Skip to content

PRD: Opening Balance Import

ModuleInventory (CORE-06)PRD IDPRD-STK-002
StatusDraftOwnerPhat Nguyen (Inventory + BO)
Date2026-05-10Versionv0.1
Packages@nx/inventory · apps/bo · @nx/signalURDSTK · 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_IN ticket 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_IN flow, 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 InventoryTicket type - opening balance reuses STOCK_IN + the originReferenceType discriminator.
  • 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_COUNT ticket type and Inventory Tickets.
  • Lot / serial / expiry tracking at init - use an ADJUSTMENT ticket post-init.

3. Success Metrics

MetricTarget / signal
Merchants with opening balance posted before first SaleOrder100% (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

PersonaGoal 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 teamOnboard 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

#RequirementURD ref
FR-1Opening balance posts as InventoryTicket(type=STOCK_IN) with originReferenceType='OpeningBalance', originReferenceId=null - no new ticket typeURD-STK-004 · URD-TRK-003
FR-2One InventoryTicketItem per (material, location, qty, uom); ticket flows DRAFT → IN_PROGRESS → COMPLETED (no submit/approve gate)URD-TKT-002..003
FR-3A downloadable spreadsheet template with columns material_code, location_code, qty, uom_code, noteURD-STK-004
FR-4Upload streams and parses rows, validates them, and creates the ticket in DRAFT; preview returns parsed lines plus per-row errorsURD-STK-006
FR-5Confirm transitions DRAFT → IN_PROGRESS → COMPLETED and posts InventoryTracking; body carries { skipBadRows: boolean }URD-STK-004 · URD-TRK-001
FR-6Posting increments stock per line and writes an immutable tracking entry, idempotent per (referenceType, referenceId)URD-TRK-001..004
FR-7Quantities use decimal precision (4 places) and must be > 0URD-STK-003
FR-8uom_code must be convertible to the material's base unit; duplicate (material, location) rows merge with a last-value warningURD-STK-003
FR-9Upload 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 cancelledURD-STK-004 · URD-CON-004
FR-10The success receipt shows the ticket identifier (ITI-…), line count, and a link to ticket detailURD-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

AreaRequirement
Data integrityPosting is transactional - ticket update + N tracking inserts in one transaction; a partial post is impossible. Validation runs outside the transaction
ImmutabilityPosting writes through the same tracking path as a normal STOCK_IN; tracking entries are append-only
IdempotencyUpload key opening_balance:{merchantId}:{sha256(file)} returns the same ticket on re-upload; a second confirm returns the existing COMPLETED ticket
Tenancy & authzEndpoints are merchant-scoped (no cross-merchant init); gated by a fine-grained Inventory.openingBalance permission attached to OPERATOR by default
Performance / scale1k rows < 5 s end-to-end on staging; 10k rows < 30 s via a streamed parser that avoids OOM
ObservabilityLog merchantId, ticketId, rowCount, errorCount, durationMs (IGNIS key: %s); emit Kafka inventory.opening-balance.posted post-commit for signal + reporting
i18nUser-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

EntityRole
InventoryTicketThe opening-balance document - type=STOCK_IN, originReferenceType='OpeningBalance', one per location
InventoryTicketItemA line - (material, location, qty, uom, note)
InventoryTrackingThe immutable movement entry written per line on confirm
InventoryStockThe stock record incremented at the line's location
OPENING_BALANCE_ORIGIN_TYPEA 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 InventoryTicketService for lifecycle; no parallel implementation.
  • Inventory locations (URD-LOC) - each line lands at a merchant location.
  • Materials (URD-MAT) - material_code must 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 to observation/inventory/inventory-ticket for 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 / questionMitigation / 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

AspectPlan
PhaseP1 (foundation) - see URD feature catalog
RolloutInternal sandbox (WK20) → 1 VNPAY pilot merchant (WK21) → GA default-on for new merchants, opt-in for existing (WK22+); behind per-merchant flag inventory.openingBalance
MigrationNone - zero new tables/columns; reuses InventoryTicket + InventoryTicketItem
Launch criteriaA 100-row init completes in <15 min for onboarding ops; 0 critical bugs after a 1-week pilot; re-init / stockout metrics within target
MonitoringInit 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

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.