Skip to content

PRD: Init Inventory — Opening Balance Import

ModuleCORE-06StatusDraft
Date2026-05-10AuthorPhat Nguyen

Owner team: inventory + BO · Stakeholders: inventory, BO frontend, signal, ops, onboarding

1. Problem

The data model already supports recording opening stock (a STOCK_IN ticket without a PO reference). But there is no UX, no Excel importer, no flow to put 200 SKUs in fast. Today the only path is creating individual STOCK_IN tickets per-line through the existing CRUD UI, which is unusable for migrations.

Symptoms:

  • Migration teams from POS365 spend hours hand-typing.
  • Some operators fake a "ghost vendor" + ghost PO to lean on the existing PO→STOCK_IN flow → vendor list polluted.
  • Many merchants skip the workaround → first month of reports wrong.

2. Why now

SignalDetail
Real merchants migrating from POS365First wave WK20-22
POS365 parity gap"Khởi tạo tồn kho" is a flagship POS365 flow; missing in BANA blocks every migration
Data model readyInventoryTicket + STOCK_IN + originReferenceType already cover the primitive — only UX/import work needed
WK19 dependencies landedMaterial aggregate (BANA-1040), merchant scope (BANA-1041), zod hardening (BANA-1042) — base shape is solid

3. Goals

  • Onboarding ops or merchant uploads opening balance for ≤500 SKUs in ≤30 min.
  • Result is a single auditable InventoryTicket(type=STOCK_IN, originReferenceType='OpeningBalance') per location.
  • No ghost vendors, no fake POs.
  • Migration team can paste POS365 export with ≤5 column-mapping edits.

4. Non-goals

  • New InventoryTicket type (we reuse STOCK_IN + originReferenceType discriminator).
  • Auto-pull from POS365 API (manual xlsx only v1).
  • Material creation (covered by separate carry-over: BANA-890–894).
  • Cost basis / pricing fields (PriceList is separate).
  • Periodic stock count / cycle count (existing CYCLE_COUNT type covers that).
  • Lot / serial / expiry tracking on init (use ADJUSTMENT post-init).

5. Success metrics

MetricTargetSourceWindow
Merchants with opening balance posted before first SaleOrder100%onboarding checklistper-merchant
Init duration (≤200 SKUs, single location)<30 min P95upload-start → confirm telemetry30d post-GA
First-try validation pass rate≥70%upload error logsweekly
Re-init / correction tickets within 24h<10%InventoryTicket count by originReferenceType30d
Onboarding ops qualitative"easier than POS365"onboarding retroevery onboarding

6. Users & scenarios

UserScenario
Onboarding ops (BO, internal)Receives xlsx from migrating merchant → uploads via BO /inventory/opening-balance → fixes 5–15 row errors → confirms
Merchant owner (BO, self-service)Counts shop stock manually → fills downloadable template → uploads via merchant settings → confirms
Migration teamOnboards 5 POS365 merchants in a week → wants identical flow each time
Auditor (post-fact)Filters tickets where originReferenceType='OpeningBalance' to see opening balance as one row

7. Solution

7.1 Reuse, don't expand

  • Ticket type: InventoryTicketTypes.STOCK_IN (no new enum).
  • Discriminator: originReferenceType = 'OpeningBalance', originReferenceId = null.
  • Status flow: standard DRAFT → IN_PROGRESS → COMPLETED (skip submit/approve — opening balance is operator-confirmed, not approval-gated).
  • Lines: one InventoryTicketItem per (material, location, qty, uom).

7.2 Excel template (downloadable from BO)

ColumnRequiredNotes
material_codeYMust exist for merchant
location_codeYMust exist for merchant
qtyYfloat(value, 4) precision; must be > 0
uom_codeYConvertible to material's base UoM
noteNFree text per row

7.3 Flow

StepAPIBehaviour
1 UploadPOST /v1/api/inventory/opening-balance/uploadStreamed parse, validates rows, creates InventoryTicket in DRAFT
2 PreviewGET /v1/api/inventory/opening-balance/:ticketIdReturns parsed lines + per-row errors
3 ConfirmPOST /v1/api/inventory/opening-balance/:ticketId/confirmTransitions DRAFT → IN_PROGRESS → COMPLETED, posts InventoryTracking. Body: { skipBadRows: boolean }
4 ReceiptUI shows ticket identifier (ITI-…), line count, link to ticket detail

7.4 Validation rules

CheckError level
material_code exists in merchant scoperow error
location_code exists for merchantrow error
uom_code convertible to material base UoMrow error (with auto-convert hint)
qty > 0, ≤ float(4)row error
Duplicate (material, location) in uploadmerge with warning, take last value
Merchant has any SaleOrderupload-level block ("use ADJUSTMENT instead")
Already-posted STOCK_IN ticket with originReferenceType='OpeningBalance' for same (merchantId, locationId)upload-level block unless prior ticket cancelled

8. UX

StateBehaviour
Empty"Get template" CTA + drop zone + "Why am I locked?" link
ParsingSpinner + row count
Preview cleanTable, green banner "Ready to post", Confirm CTA
Preview with errorsPer-row tooltip; toggle "Skip bad rows" enables Confirm
PostingProgress bar, step labels (validate → write tracking → finalize)
SuccessReceipt card: ticket identifier, X lines posted, link to detail
LockedMuted card explaining first SaleOrder already exists, deep link to ADJUSTMENT flow

Wireframes: WK20 design pass with Thuong (Figma).

9. Technical

9.1 Schema

  • Zero new tables, zero new columns. Reuses InventoryTicket + InventoryTicketItem.
  • New constant in inventory package: OPENING_BALANCE_ORIGIN_TYPE = 'OpeningBalance' (used for the discriminator).

9.2 Service layer

  • New service: OpeningBalanceService in packages/inventory/src/services/opening-balance.service.ts.
  • Inherits MerchantScopedService (BANA-1041).
  • Delegates to existing InventoryTicketService for ticket lifecycle — no parallel implementation.
  • Posting writes through the same InventoryTrackingService.complete path as a normal STOCK_IN.

9.3 Worker / parser

  • xlsx parsing via streamed reader to handle 10k rows without OOM.
  • Validation: batch-fetch material codes + location codes by IN (...) queries (existing repos).
  • Transactional: validation outside TX, posting inside one TX (ticket update + N tracking inserts). Partial post is impossible.

9.4 Idempotency

  • Upload key: opening_balance:{merchantId}:{sha256(file)} → re-uploading same file returns same ticket.
  • Confirm key: idempotent — second confirm returns the existing COMPLETED ticket.

9.5 Observability

  • Log merchantId, ticketId, rowCount, errorCount, durationMs (per IGNIS key: %s).
  • Emit Kafka inventory.opening-balance.posted post-commit; downstream listeners: signal, reporting.
  • BO subscribes to observation/inventory/inventory-ticket (existing topic) to live-update preview state.

9.6 Performance target

  • 1k rows → <5 s end-to-end on staging.
  • 10k rows → <30 s with stream parser.

9.7 Security

  • Casbin permission: Inventory.openingBalance (new fine-grained perm, attach to OPERATOR role by default).
  • Endpoint scoped via MerchantScopedService — no cross-merchant init.
  • Audit: createdBy / updatedBy recorded by generateUserAuditColumnDefs (already on schema).

10. Rollout

StageWindowAudienceGate
InternalWK20NEXPANDO sandbox merchants, flag ONOnboarding ops complete a 100-row init in <15 min
PilotWK211 VNPAY pilot merchant0 critical bugs after 1 week
GAWK22+Default ON for new merchants; opt-in for existingStockout / re-init metrics in target

Feature flag: inventory.openingBalance (per-merchant boolean in merchant config).

11. Open questions

  • Lock policy: block init after first SaleOrder or after any non-INIT InventoryTracking row? (current draft = first SaleOrder)
  • Allow re-init pre-sale (cancel old + post new) or strict one-shot? (current draft = re-init OK pre-sale)
  • Multi-warehouse: one upload covering all locations, or one per location? (current draft = one upload, location_code column)
  • POS365 export format — column-for-column match, or do we provide a converter?
  • Mixed UoM in same row: accept iff convertible, or force base UoM only? (current draft = accept iff convertible)
  • Should opening balance be visible as a separate filter on the InventoryTicket list, or only via originReferenceType query?

12. Appendix

  • Existing schema: packages/core/src/models/schemas/inventory/inventory-ticket/{schema,constants}.ts
  • Existing service: packages/inventory/src/services/inventory-ticket.service.ts
  • Tracking layer: packages/inventory/src/services/inventory-tracking.service.ts
  • Related WK19 work: BANA-1040 (material aggregate), BANA-1041 (merchant scope), BANA-1042 (zod hardening)
  • POS365 reference: "Khởi tạo tồn kho" (Operations → Inventory)

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