PRD: Opening Balance Import
| Module | Inventory (CORE-06) | PRD ID | PRD-STK-002 |
| Status | Draft | Owner | Phat Nguyen (Inventory + BO) |
| Date | 2026-05-10 | Version | v0.1 |
| Packages | @nx/inventory · apps/bo · @nx/signal | URD | STK · TRK · LOC |
TL;DR
Lets onboarding ops or a merchant bulk-load day-1 stock from a spreadsheet, posting it as a single auditable
STOCK_INticket per location in under 30 minutes for ≤500 SKUs. The outcome: every migrating merchant starts with correct stock - no hand-typed tickets, no ghost vendors, no fake POs polluting the catalog.
1. Context & Problem
The inventory data model already supports recording opening stock - a STOCK_IN InventoryTicket with no PO reference. What is missing is the way to put it in fast: there is no UX, no spreadsheet importer, and no guided flow. Today the only path is creating individual STOCK_IN tickets line-by-line through the CRUD UI, which is unusable for a migration of hundreds of SKUs.
The pain is concrete and current:
- Migration teams from POS365 spend hours hand-typing stock.
- Some operators fake a "ghost vendor" plus a ghost PO to lean on the existing PO →
STOCK_INflow, polluting the vendor list. - Many merchants skip the workaround entirely, so their first month of reports is wrong.
Why now: real merchants are migrating from POS365 in the WK20-22 wave. "Khởi tạo tồn kho" is a flagship POS365 flow, and its absence in KICKO blocks every migration. The data primitive (InventoryTicket + STOCK_IN + originReferenceType) is already in place, and the WK19 foundations (Material aggregate, merchant scope, zod hardening) have landed - only the import UX and flow remain.
2. Goals & Non-Goals
Goals
- Onboarding ops or a merchant uploads opening balance for ≤500 SKUs in ≤30 minutes.
- Each upload produces a single auditable
InventoryTicket(type=STOCK_IN, originReferenceType='OpeningBalance')per location. - No ghost vendors and no fake POs in the flow.
- The migration team can paste a POS365 export with ≤5 column-mapping edits.
Non-Goals
- A new
InventoryTickettype - opening balance reusesSTOCK_IN+ theoriginReferenceTypediscriminator. - Auto-pull from a POS365 API - v1 is manual spreadsheet upload only.
- Material creation - owned by the material carry-over work, not this feature.
- Cost basis / pricing fields - owned by PriceList, separate.
- Periodic stock count / cycle count - covered by the existing
CYCLE_COUNTticket type and Inventory Tickets. - Lot / serial / expiry tracking at init - use an ADJUSTMENT ticket post-init.
3. Success Metrics
| Metric | Target / signal |
|---|---|
| Merchants with opening balance posted before first SaleOrder | 100% (onboarding checklist) |
| Init duration (≤200 SKUs, single location) | <30 min P95 (upload-start → confirm telemetry) |
| First-try validation pass rate | ≥70% (upload error logs, weekly) |
| Re-init / correction tickets within 24h | <10% (InventoryTicket count by originReferenceType) |
| Onboarding ops qualitative | "easier than POS365" (onboarding retro) |
4. Personas & Use Cases
| Persona | Goal in this feature |
|---|---|
| Onboarding ops (BO, internal) | Upload a migrating merchant's spreadsheet, fix a handful of row errors, confirm |
| Merchant owner (BO, self-service) | Count shop stock manually, fill the downloadable template, upload, confirm |
| Migration team | Onboard several POS365 merchants in a week with an identical, repeatable flow |
| Auditor (post-fact) | Filter tickets where originReferenceType='OpeningBalance' to see opening balance as one row |
Core scenarios: download template → fill / paste POS365 export → upload → preview parsed lines with per-row errors → confirm → a single STOCK_IN ticket posts and stock rises with an audit trail.
5. User Stories
- As onboarding ops, I want to upload a spreadsheet of opening balances, so that I can initialize a merchant's stock in minutes instead of hours.
- As a merchant owner, I want a downloadable template to fill in, so that I can self-serve my own opening count.
- As onboarding ops, I want to preview parsed rows with per-row errors before posting, so that I can fix data without a failed post.
- As onboarding ops, I want to skip bad rows and post the rest, so that one bad line doesn't block the whole upload.
- As the migration team, I want re-uploading the same file to return the same ticket, so that retries don't double-post stock.
- As an auditor, I want opening balance to be one identifiable ticket per location, so that I can see day-1 stock as a single traceable entry.
6. Functional Requirements
| # | Requirement | URD ref |
|---|---|---|
| FR-1 | Opening balance posts as InventoryTicket(type=STOCK_IN) with originReferenceType='OpeningBalance', originReferenceId=null - no new ticket type | URD-STK-004 · URD-TRK-003 |
| FR-2 | One InventoryTicketItem per (material, location, qty, uom); ticket flows DRAFT → IN_PROGRESS → COMPLETED (no submit/approve gate) | URD-TKT-002..003 |
| FR-3 | A downloadable spreadsheet template with columns material_code, location_code, qty, uom_code, note | URD-STK-004 |
| FR-4 | Upload streams and parses rows, validates them, and creates the ticket in DRAFT; preview returns parsed lines plus per-row errors | URD-STK-006 |
| FR-5 | Confirm transitions DRAFT → IN_PROGRESS → COMPLETED and posts InventoryTracking; body carries { skipBadRows: boolean } | URD-STK-004 · URD-TRK-001 |
| FR-6 | Posting increments stock per line and writes an immutable tracking entry, idempotent per (referenceType, referenceId) | URD-TRK-001..004 |
| FR-7 | Quantities use decimal precision (4 places) and must be > 0 | URD-STK-003 |
| FR-8 | uom_code must be convertible to the material's base unit; duplicate (material, location) rows merge with a last-value warning | URD-STK-003 |
| FR-9 | Upload is blocked when the merchant has any SaleOrder (directs to ADJUSTMENT), or when a posted opening-balance STOCK_IN already exists for the same (merchantId, locationId) unless the prior ticket is cancelled | URD-STK-004 · URD-CON-004 |
| FR-10 | The success receipt shows the ticket identifier (ITI-…), line count, and a link to ticket detail | URD-STK-004 |
Full requirement text and acceptance criteria live in the Inventory URD. This PRD references them rather than restating them.
7. Non-Functional Requirements
| Area | Requirement |
|---|---|
| Data integrity | Posting is transactional - ticket update + N tracking inserts in one transaction; a partial post is impossible. Validation runs outside the transaction |
| Immutability | Posting writes through the same tracking path as a normal STOCK_IN; tracking entries are append-only |
| Idempotency | Upload key opening_balance:{merchantId}:{sha256(file)} returns the same ticket on re-upload; a second confirm returns the existing COMPLETED ticket |
| Tenancy & authz | Endpoints are merchant-scoped (no cross-merchant init); gated by a fine-grained Inventory.openingBalance permission attached to OPERATOR by default |
| Performance / scale | 1k rows < 5 s end-to-end on staging; 10k rows < 30 s via a streamed parser that avoids OOM |
| Observability | Log merchantId, ticketId, rowCount, errorCount, durationMs (IGNIS key: %s); emit Kafka inventory.opening-balance.posted post-commit for signal + reporting |
| i18n | User-facing labels/statuses are bilingual ({ en, vi }) |
8. UX & Flows
Key screens (in apps/bo, at /inventory/opening-balance): an empty state with a "Get template" CTA, drop zone, and "Why am I locked?" link; a parsing spinner; a clean preview with a "Ready to post" banner; an error preview with per-row tooltips and a "Skip bad rows" toggle; a posting progress bar; a success receipt card; and a locked card (when a SaleOrder exists) deep-linking to the ADJUSTMENT flow.
9. Data & Domain
| Entity | Role |
|---|---|
InventoryTicket | The opening-balance document - type=STOCK_IN, originReferenceType='OpeningBalance', one per location |
InventoryTicketItem | A line - (material, location, qty, uom, note) |
InventoryTracking | The immutable movement entry written per line on confirm |
InventoryStock | The stock record incremented at the line's location |
OPENING_BALANCE_ORIGIN_TYPE | A new inventory constant ('OpeningBalance') used as the ticket discriminator - no new tables, no new columns |
Conceptual only - full schema and invariants in the inventory domain model.
10. Dependencies & Assumptions
Depends on
- Stock levels & movement audit (URD-STK · URD-TRK) - posting builds on the stock and tracking primitives and their idempotency.
- Inventory tickets (URD-TKT) - reuses
InventoryTicketServicefor lifecycle; no parallel implementation. - Inventory locations (URD-LOC) - each line lands at a merchant location.
- Materials (URD-MAT) -
material_codemust already exist for the merchant; this feature does not create materials. - Signal (
@nx/signal) - emits the post-commit Kafka event consumed by signal and reporting; BO subscribes toobservation/inventory/inventory-ticketfor live preview state.
Assumptions
- Materials and locations referenced by the upload already exist for the merchant.
- The merchant has not yet placed its first
SaleOrder(opening balance is a day-1 operation). - Onboarding has the merchant's stock count in a spreadsheet (or a POS365 export).
11. Risks & Open Questions
| Risk / question | Mitigation / status |
|---|---|
Lock policy - block after first SaleOrder, or after any non-INIT tracking row? | Current draft: block after first SaleOrder |
| Re-init pre-sale (cancel old + post new) vs. strict one-shot? | Current draft: re-init allowed pre-sale |
| Multi-warehouse - one upload across all locations vs. one per location? | Current draft: one upload with a location_code column |
| POS365 export format - column-for-column match vs. a converter? | Open - decide during the WK20 migration pass |
| Mixed UoM in one row - accept iff convertible vs. force base unit? | Current draft: accept iff convertible to base UoM |
Opening balance as a separate InventoryTicket list filter vs. originReferenceType query only? | Open |
12. Release Plan & Launch Criteria
| Aspect | Plan |
|---|---|
| Phase | P1 (foundation) - see URD feature catalog |
| Rollout | Internal sandbox (WK20) → 1 VNPAY pilot merchant (WK21) → GA default-on for new merchants, opt-in for existing (WK22+); behind per-merchant flag inventory.openingBalance |
| Migration | None - zero new tables/columns; reuses InventoryTicket + InventoryTicketItem |
| Launch criteria | A 100-row init completes in <15 min for onboarding ops; 0 critical bugs after a 1-week pilot; re-init / stockout metrics within target |
| Monitoring | Init duration, first-try validation pass rate, re-init rate by originReferenceType, posted-ticket counts; Kafka inventory.opening-balance.posted lag |
13. FAQ
Does this add a new inventory ticket type? No - opening balance reuses STOCK_IN with originReferenceType='OpeningBalance' as the discriminator. Zero new tables, zero new columns.
Can I import opening stock through a purchase order? No - there is no ghost-vendor/ghost-PO workaround. Opening balance is its own flow; purchases go through Purchase Orders.
What happens if a row is bad? The preview shows per-row errors. You can fix the spreadsheet and re-upload, or toggle "Skip bad rows" and post the valid ones.
What if I upload the same file twice? Idempotent - the same file (by SHA-256) returns the same ticket, and a second confirm returns the existing COMPLETED ticket. Stock is not double-posted.
Why am I locked out? The merchant already has a SaleOrder, so opening balance is no longer the right tool - use an ADJUSTMENT ticket instead. The locked card deep-links to that flow.
Does it create materials? No - material_code must already exist for the merchant. Material creation is a separate carry-over.
References
- URD: Inventory - Stock Levels · Movement Audit Trail · Inventory Tickets
- Builds on: Inventory Locations · Materials
- Related PRD: Purchase Orders
- Module: Inventory - overview + traceability
- Developer: @nx/inventory · domain model