PRD: Khởi tạo tồn kho — Import số dư tồn đầu kỳ
| Module | CORE-06 | Trạng thái | Draft |
|---|---|---|---|
| Ngày | 2026-05-10 | Tác giả | Phat Nguyen |
Team chủ trì: inventory + BO · Stakeholder: inventory, BO frontend, signal, ops, onboarding
1. Vấn đề
Data model đã hỗ trợ ghi nhận tồn kho ban đầu (một ticket STOCK_IN không kèm tham chiếu PO). Nhưng chưa có UX, chưa có Excel importer, chưa có flow để nhập 200 SKU nhanh chóng. Hiện tại con đường duy nhất là tạo từng ticket STOCK_IN theo dòng qua UI CRUD có sẵn, vốn không thể dùng cho migration.
Triệu chứng:
- Team migration từ POS365 mất hàng giờ gõ tay.
- Một số operator tạo "vendor ảo" + PO ảo để mượn flow PO→STOCK_IN có sẵn → vendor list bị bẩn.
- Nhiều merchant bỏ qua workaround → tháng đầu báo cáo sai.
2. Tại sao bây giờ
| Tín hiệu | Chi tiết |
|---|---|
| Merchant thật migrate từ POS365 | Lứa đầu WK20-22 |
| Khoảng cách parity với POS365 | "Khởi tạo tồn kho" là flow chủ lực ở POS365; thiếu ở BANA chặn mọi migration |
| Data model đã sẵn | InventoryTicket + STOCK_IN + originReferenceType đã đủ primitive — chỉ cần công việc UX/import |
| Dependency WK19 đã land | Material aggregate (BANA-1040), merchant scope (BANA-1041), zod hardening (BANA-1042) — nền tảng vững chắc |
3. Mục tiêu
- Onboarding ops hoặc merchant upload số dư đầu kỳ cho ≤500 SKU trong ≤30 phút.
- Kết quả là một
InventoryTicket(type=STOCK_IN, originReferenceType='OpeningBalance')duy nhất, audit được, mỗi địa điểm. - Không có vendor ảo, không có PO ảo.
- Team migration paste được export POS365 với ≤5 chỉnh sửa ánh xạ cột.
4. Không-mục-tiêu
- Type
InventoryTicketmới (tái sử dụng discriminatorSTOCK_IN+originReferenceType). - Auto-pull từ API POS365 (chỉ xlsx thủ công ở v1).
- Tạo material (đã được phủ bởi carry-over riêng: BANA-890–894).
- Trường cost basis / pricing (PriceList là feature riêng).
- Stock count định kỳ / cycle count (type
CYCLE_COUNTcó sẵn đã phủ). - Lot / serial / expiry tracking lúc init (dùng ADJUSTMENT sau init).
5. Chỉ số thành công
| Chỉ số | Mục tiêu | Nguồn | Cửa sổ |
|---|---|---|---|
| Merchant có số dư đầu kỳ được post trước SaleOrder đầu tiên | 100% | onboarding checklist | mỗi merchant |
| Thời gian init (≤200 SKU, một địa điểm) | <30 phút P95 | telemetry upload-start → confirm | 30 ngày sau GA |
| Tỉ lệ pass validation lần đầu | ≥70% | upload error logs | hàng tuần |
| Re-init / correction ticket trong 24h | <10% | đếm InventoryTicket theo originReferenceType | 30 ngày |
| Đánh giá định tính của onboarding ops | "dễ hơn POS365" | retro onboarding | mỗi đợt onboarding |
6. Người dùng & kịch bản
| User | Kịch bản |
|---|---|
| Onboarding ops (BO, nội bộ) | Nhận xlsx từ merchant đang migrate → upload qua BO /inventory/opening-balance → fix 5–15 dòng lỗi → confirm |
| Chủ merchant (BO, self-service) | Đếm tồn shop thủ công → điền template tải về → upload qua merchant settings → confirm |
| Team migration | Onboard 5 merchant POS365 trong một tuần → muốn flow giống nhau mỗi lần |
| Kiểm toán (về sau) | Lọc ticket có originReferenceType='OpeningBalance' để xem số dư đầu kỳ như một dòng |
7. Giải pháp
7.1 Tái sử dụng, không mở rộng
- Ticket type:
InventoryTicketTypes.STOCK_IN(không thêm enum mới). - Discriminator:
originReferenceType = 'OpeningBalance',originReferenceId = null. - Status flow: chuẩn
DRAFT → IN_PROGRESS → COMPLETED(bỏ qua submit/approve — số dư đầu kỳ do operator xác nhận, không qua cổng duyệt). - Lines: mỗi
InventoryTicketItemcho một (material, location, qty, uom).
7.2 Excel template (tải về từ BO)
| Cột | Bắt buộc | Ghi chú |
|---|---|---|
material_code | Y | Phải tồn tại với merchant |
location_code | Y | Phải tồn tại với merchant |
qty | Y | Độ chính xác float(value, 4); phải > 0 |
uom_code | Y | Convert được về base UoM của material |
note | N | Free text mỗi dòng |
7.3 Flow
| Bước | API | Hành vi |
|---|---|---|
| 1 Upload | POST /v1/api/inventory/opening-balance/upload | Stream parse, validate dòng, tạo InventoryTicket ở DRAFT |
| 2 Preview | GET /v1/api/inventory/opening-balance/:ticketId | Trả về dòng đã parse + lỗi từng dòng |
| 3 Confirm | POST /v1/api/inventory/opening-balance/:ticketId/confirm | Chuyển DRAFT → IN_PROGRESS → COMPLETED, post InventoryTracking. Body: { skipBadRows: boolean } |
| 4 Receipt | UI hiện ticket identifier (ITI-…), số dòng, link tới ticket detail |
7.4 Quy tắc validation
| Kiểm tra | Cấp độ lỗi |
|---|---|
material_code tồn tại trong merchant scope | row error |
location_code tồn tại với merchant | row error |
uom_code convert được về base UoM của material | row error (kèm gợi ý auto-convert) |
qty > 0, ≤ float(4) | row error |
Trùng (material, location) trong upload | merge với warning, lấy giá trị cuối |
Merchant đã có SaleOrder bất kỳ | block cấp upload ("dùng ADJUSTMENT thay vì") |
Đã có ticket STOCK_IN với originReferenceType='OpeningBalance' cho cùng (merchantId, locationId) | block cấp upload trừ khi ticket trước đã bị cancel |
8. UX
| Trạng thái | Hành vi |
|---|---|
| Empty | CTA "Lấy template" + drop zone + link "Vì sao tôi bị khóa?" |
| Đang parse | Spinner + đếm dòng |
| Preview sạch | Table, banner xanh "Sẵn sàng để post", CTA Confirm |
| Preview có lỗi | Tooltip mỗi dòng; toggle "Bỏ qua dòng lỗi" mở nút Confirm |
| Đang post | Progress bar, label theo bước (validate → write tracking → finalize) |
| Thành công | Card receipt: ticket identifier, X dòng đã post, link tới detail |
| Locked | Card mờ giải thích SaleOrder đầu tiên đã tồn tại, deep link sang flow ADJUSTMENT |
Wireframes: design pass với Thuong trong WK20 (Figma).
9. Kỹ thuật
9.1 Schema
- Zero bảng mới, zero cột mới. Tái sử dụng
InventoryTicket+InventoryTicketItem. - Constant mới trong inventory package:
OPENING_BALANCE_ORIGIN_TYPE = 'OpeningBalance'(dùng cho discriminator).
9.2 Service layer
- Service mới:
OpeningBalanceServiceởpackages/inventory/src/services/opening-balance.service.ts. - Kế thừa
MerchantScopedService(BANA-1041). - Delegate sang
InventoryTicketServicecó sẵn cho lifecycle ticket — không implement song song. - Posting ghi qua đúng đường
InventoryTrackingService.completenhư một STOCK_IN bình thường.
9.3 Worker / parser
- Parse xlsx bằng stream reader để xử lý 10k dòng không OOM.
- Validation: batch-fetch material code + location code bằng query
IN (...)(repo có sẵn). - Transactional: validate ngoài TX, post trong một TX (cập nhật ticket + N tracking insert). Partial post là không thể.
9.4 Idempotency
- Upload key:
opening_balance:{merchantId}:{sha256(file)}→ upload lại cùng file trả về cùng ticket. - Confirm key: idempotent — confirm lần hai trả về ticket
COMPLETEDđã có.
9.5 Observability
- Log
merchantId,ticketId,rowCount,errorCount,durationMs(theokey: %scủa IGNIS). - Emit Kafka
inventory.opening-balance.postedpost-commit; downstream listener: signal, reporting. - BO subscribe
observation/inventory/inventory-ticket(topic có sẵn) để live-update trạng thái preview.
9.6 Mục tiêu performance
- 1k dòng → <5 giây end-to-end trên staging.
- 10k dòng → <30 giây với stream parser.
9.7 Security
- Casbin permission:
Inventory.openingBalance(perm fine-grained mới, gán mặc định cho roleOPERATOR). - Endpoint scoped qua
MerchantScopedService— không có init cross-merchant. - Audit:
createdBy/updatedByghi bởigenerateUserAuditColumnDefs(đã có trên schema).
10. Rollout
| Stage | Cửa sổ | Đối tượng | Gate |
|---|---|---|---|
| Internal | WK20 | Sandbox merchant NEXPANDO, flag ON | Onboarding ops làm xong init 100 dòng trong <15 phút |
| Pilot | WK21 | 1 merchant pilot VNPAY | 0 bug critical sau 1 tuần |
| GA | WK22+ | Default ON cho merchant mới; opt-in cho merchant cũ | Stockout / re-init metric đạt mục tiêu |
Feature flag: inventory.openingBalance (boolean per-merchant trong merchant config).
11. Câu hỏi mở
- Chính sách khóa: block init sau
SaleOrderđầu tiên hay sau bất kỳ dòngInventoryTrackingnon-INIT nào? (draft hiện tại = SaleOrder đầu tiên) - Cho phép re-init trước sale (cancel cũ + post mới) hay strict one-shot? (draft hiện tại = re-init OK trước sale)
- Multi-warehouse: một upload bao tất cả địa điểm, hay một upload mỗi địa điểm? (draft hiện tại = một upload, cột
location_code) - Format export POS365 — match cột với cột, hay chúng ta cung cấp converter?
- Trộn UoM trong cùng dòng: chấp nhận nếu convert được, hay bắt buộc base UoM? (draft hiện tại = chấp nhận nếu convert được)
- Số dư đầu kỳ nên hiển thị như một filter riêng trên list InventoryTicket, hay chỉ qua query
originReferenceType?
12. Phụ lục
- Schema có sẵn:
packages/core/src/models/schemas/inventory/inventory-ticket/{schema,constants}.ts - Service có sẵn:
packages/inventory/src/services/inventory-ticket.service.ts - Tracking layer:
packages/inventory/src/services/inventory-tracking.service.ts - Công việc WK19 liên quan: BANA-1040 (material aggregate), BANA-1041 (merchant scope), BANA-1042 (zod hardening)
- Tham khảo POS365: "Khởi tạo tồn kho" (Operations → Inventory)