PRD: Init Inventory — Opening Balance Import
| Module | CORE-06 | Status | Draft |
|---|---|---|---|
| Date | 2026-05-10 | Author | Phat 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
| Signal | Detail |
|---|---|
| Real merchants migrating from POS365 | First 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 ready | InventoryTicket + STOCK_IN + originReferenceType already cover the primitive — only UX/import work needed |
| WK19 dependencies landed | Material 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
InventoryTickettype (we reuseSTOCK_IN+originReferenceTypediscriminator). - 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_COUNTtype covers that). - Lot / serial / expiry tracking on init (use ADJUSTMENT post-init).
5. Success metrics
| Metric | Target | Source | Window |
|---|---|---|---|
| Merchants with opening balance posted before first SaleOrder | 100% | onboarding checklist | per-merchant |
| Init duration (≤200 SKUs, single location) | <30 min P95 | upload-start → confirm telemetry | 30d post-GA |
| First-try validation pass rate | ≥70% | upload error logs | weekly |
| Re-init / correction tickets within 24h | <10% | InventoryTicket count by originReferenceType | 30d |
| Onboarding ops qualitative | "easier than POS365" | onboarding retro | every onboarding |
6. Users & scenarios
| User | Scenario |
|---|---|
| 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 team | Onboards 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
InventoryTicketItemper (material, location, qty, uom).
7.2 Excel template (downloadable from BO)
| Column | Required | Notes |
|---|---|---|
material_code | Y | Must exist for merchant |
location_code | Y | Must exist for merchant |
qty | Y | float(value, 4) precision; must be > 0 |
uom_code | Y | Convertible to material's base UoM |
note | N | Free text per row |
7.3 Flow
| Step | API | Behaviour |
|---|---|---|
| 1 Upload | POST /v1/api/inventory/opening-balance/upload | Streamed parse, validates rows, creates InventoryTicket in DRAFT |
| 2 Preview | GET /v1/api/inventory/opening-balance/:ticketId | Returns parsed lines + per-row errors |
| 3 Confirm | POST /v1/api/inventory/opening-balance/:ticketId/confirm | Transitions DRAFT → IN_PROGRESS → COMPLETED, posts InventoryTracking. Body: { skipBadRows: boolean } |
| 4 Receipt | UI shows ticket identifier (ITI-…), line count, link to ticket detail |
7.4 Validation rules
| Check | Error level |
|---|---|
material_code exists in merchant scope | row error |
location_code exists for merchant | row error |
uom_code convertible to material base UoM | row error (with auto-convert hint) |
qty > 0, ≤ float(4) | row error |
Duplicate (material, location) in upload | merge with warning, take last value |
Merchant has any SaleOrder | upload-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
| State | Behaviour |
|---|---|
| Empty | "Get template" CTA + drop zone + "Why am I locked?" link |
| Parsing | Spinner + row count |
| Preview clean | Table, green banner "Ready to post", Confirm CTA |
| Preview with errors | Per-row tooltip; toggle "Skip bad rows" enables Confirm |
| Posting | Progress bar, step labels (validate → write tracking → finalize) |
| Success | Receipt card: ticket identifier, X lines posted, link to detail |
| Locked | Muted 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:
OpeningBalanceServiceinpackages/inventory/src/services/opening-balance.service.ts. - Inherits
MerchantScopedService(BANA-1041). - Delegates to existing
InventoryTicketServicefor ticket lifecycle — no parallel implementation. - Posting writes through the same
InventoryTrackingService.completepath 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
COMPLETEDticket.
9.5 Observability
- Log
merchantId,ticketId,rowCount,errorCount,durationMs(per IGNISkey: %s). - Emit Kafka
inventory.opening-balance.postedpost-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 toOPERATORrole by default). - Endpoint scoped via
MerchantScopedService— no cross-merchant init. - Audit:
createdBy/updatedByrecorded bygenerateUserAuditColumnDefs(already on schema).
10. Rollout
| Stage | Window | Audience | Gate |
|---|---|---|---|
| Internal | WK20 | NEXPANDO sandbox merchants, flag ON | Onboarding ops complete a 100-row init in <15 min |
| Pilot | WK21 | 1 VNPAY pilot merchant | 0 critical bugs after 1 week |
| GA | WK22+ | Default ON for new merchants; opt-in for existing | Stockout / re-init metrics in target |
Feature flag: inventory.openingBalance (per-merchant boolean in merchant config).
11. Open questions
- Lock policy: block init after first
SaleOrderor after any non-INITInventoryTrackingrow? (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_codecolumn) - 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
originReferenceTypequery?
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)