Inventory Stock
1. Tổng quan
| Thuộc tính | Giá trị |
|---|---|
| ID | FEAT-INV-STOCK |
| Status | Stable |
| Owner | inventory-team |
| Phụ thuộc | Material / ProductVariant (principal đa hình), InventoryLocation |
Mô hình stock có ba lớp — InventoryItem (principal đa hình × merchant), InventoryStock (bucket per-location), InventoryIdentifier (SKU / barcode / serial / v.v.). Mutator atomic InventoryStockRepository.adjustStock thực hiện đọc+ghi trong một SQL UPDATE duy nhất với guard forceNonNegative tùy chọn.
2. Mô hình Entity
InventoryItem
| Trường | Kiểu | Bắt buộc | Mô tả |
|---|---|---|---|
merchantId | text | ✓ | Owner |
itemType | text | ✓ | MATERIAL / PRODUCT_VARIANT |
itemId | text | ✓ | FK target id (đa hình, không có DB FK) |
identifier | text | ✓ | Tự động, tiền tố INI |
status | text | ✓ | InventoryItemStatuses (mặc định ACTIVATED) |
Upsert idempotent: InventoryItemRepository.ensureInventoryItem({ merchantId, itemType, itemId }) — key theo partial unique index (merchantId, itemType, itemId) WHERE deletedAt IS NULL.
InventoryStock — bucket theo (item × location × lot × serial)
| Trường | Kiểu | Bắt buộc | Mô tả |
|---|---|---|---|
inventoryItemId | text | ✓ | FK |
inventoryLocationId | text | ✓ | FK |
merchantId | text | ✓ | Denormalize |
quantityOnHand | decimal(15,4) | ✓ | Tổng stock vật lý |
quantityReserved | decimal(15,4) | ✓ | Đã reserve cho đơn |
quantityAvailable | decimal(15,4) | ✓ | Trường lưu trữ; service duy trì = onHand − reserved |
lotNumber | text | Mở rộng bucket-key | |
serialNumber | text | Mở rộng bucket-key | |
expiryDate | timestamptz | Hỗ trợ FEFO (chỉ data) | |
manufactureDate | timestamptz | — | |
lastCountedAt | timestamptz | Cycle count gần nhất | |
lastStockedAt | timestamptz | Lần nhận hàng gần nhất | |
averageCost | decimal(15,4) | Snapshot AVCO | |
costingMethod | text | ✓ | Mặc định AVERAGE |
metadata | jsonb | ✓ | Config per-bucket — IInventoryStockMetadata: { allowOversell?: boolean; lowStockThreshold?: number }. lowStockThreshold là override per-location của default trên InventoryItem.metadata. |
Bucket key: unique (inventoryItemId, inventoryLocationId, lotNumber, serialNumber) với NULLS NOT DISTINCT — row có lot/serial NULL vẫn được tính là duplicate.
InventoryIdentifier — tag đa hình
| Trường | Kiểu | Bắt buộc | Mô tả |
|---|---|---|---|
principalType | text | ✓ | INVENTORY_ITEM / INVENTORY_STOCK |
principalId | text | ✓ | FK target id |
scheme | text | ✓ | SKU / BARCODE / QRCODE / IMEI / SERIAL |
value | text | ✓ | Chuỗi identifier |
Constraint: unique (scheme, value) mỗi principal.
3. Vòng đời
Row InventoryStock không có status — sự tồn tại ngụ ý bucket đang hoạt động. Row có thể soft-delete nhưng thường chỉ bị xóa khi merchant offboard.
InventoryItem cũng stateless — vòng đời phản chiếu principal (Material / ProductVariant).
4. Vận hành
Read
| Phương thức | Nguồn | Mục đích |
|---|---|---|
find / findById | InventoryStockService (CRUD) | Read merchant-scoped |
| Bulk-load theo item + location | Nội bộ service | Dùng bởi worker; tránh N+1 |
Write — atomic
| Phương thức | Signature | Mục đích |
|---|---|---|
InventoryStockRepository.adjustStock | { stockId, adjustOnHand?, adjustAvailable?, adjustReserved?, forceNonNegative? } | Một SQL UPDATE; trả về post-state hoặc null nếu guard fail |
InventoryService.seedInventoryAcrossLocations | { merchantId, itemType, itemId, baseUomId, locations?, ... } | Seed multi-location idempotent (dùng khi merchant onboard, ProductVariant create) |
InventoryService.receiveInventoryFromPurchaseOrderItem | { purchaseOrderId, merchantId, inventoryLocationId, item, transaction } | Tăng stock từ PO receive; ghi tracking với effectivePrice |
Ngữ nghĩa SQL của adjustStock
UPDATE "InventoryStock"
SET quantity_on_hand = quantity_on_hand + :adjustOnHand,
quantity_available = quantity_available + :adjustAvailable,
quantity_reserved = quantity_reserved + :adjustReserved
WHERE id = :stockId
AND ( :forceNonNegative = false
OR (
quantity_on_hand + :adjustOnHand >= 0
AND quantity_available + :adjustAvailable >= 0
AND quantity_reserved + :adjustReserved >= 0
) )
RETURNING quantity_on_hand, quantity_available, quantity_reserved;| Tình huống | Hành vi |
|---|---|
| Guard thỏa mãn → row được update | Trả về { quantityOnHand, quantityAvailable, quantityReserved } |
| Guard fail → 0 row update | Trả về null — caller ghi tracking note OVERSELL_BLOCKED và bỏ qua |
forceNonNegative=false | Luôn thành công; cho phép số âm |
Xem ADR-0003 để hiểu lý do.
5. REST Endpoints
| Verb | Path | Auth | Permission | Handler |
|---|---|---|---|---|
| 6× CRUD | /inventory-items | JWT/BASIC | InventoryItem.<crud> | merchant-scoped |
GET | /inventory-items/list | JWT/BASIC | InventoryItem.find | InventoryItemService.getList — danh sách slim + summary (left pane của Stock Edit) |
GET | /inventory-items/list/count | JWT/BASIC | InventoryItem.count | InventoryItemService.getCount — chỉ total, giống /products/list/count |
GET | /inventory-items/{id}/stocks | JWT/BASIC | InventoryItem.find | InventoryItemService.getStocks — chi tiết per-bucket cho right pane |
PATCH | /inventory-items/{id}/stocks/{inventoryStockId} | JWT/BASIC | InventoryStock.updateById | InventoryItemService.updateStock — patch một bucket atomic |
| 6× CRUD | /inventory-stocks | JWT/BASIC | InventoryStock.<crud> | merchant-scoped |
GET | /inventory-stocks/overview | JWT/BASIC | InventoryStock.find | InventoryStockService.getOverview |
| 6× CRUD | /inventory-identifiers | JWT/BASIC | InventoryIdentifier.<crud> | merchant-scoped |
Không có endpoint mutation cho
adjustStock— thay đổi stock chỉ xảy ra quaPurchaseOrder.receive, Kafka sale-payment, Kafka kitchen-ticket, hoặcInventoryTicket.complete.
GET /inventory-stocks/overview — Dashboard cho màn hình Stock List
Cấp số liệu cho 4 thẻ KPI ở màn hình Inventory > Stock List. Bốn metric được fan-out song song từ repository.
Query
| Tên | Bắt buộc | Mặc định | Mô tả |
|---|---|---|---|
merchantId | ✓ | — | Phải nằm trong merchant scope của user theo Casbin (admin / always-allowed bỏ qua). |
inventoryLocationId | — | Giới hạn stock và needAttention về 1 location. Counter variant + location bỏ qua tham số này. |
Low-stock threshold không phải query input. Đây là config inventory-domain hai tầng: override per-location trên
InventoryStock.metadata.lowStockThreshold, default item-level trênInventoryItem.metadata.lowStockThreshold, fallback hệ thống5. Reader per-bucket resolveCOALESCE(stock override, item default, 5). Seed lúc tạo item từinventory.lowStockThresholdcủa aggregate; default sửa live quaPATCH /inventory-items/{id}({ metadata: { lowStockThreshold } }), override qua stock PATCH bên dưới.
Response — 4 block tương ứng 4 card
| Card | Field | Nguồn dữ liệu |
|---|---|---|
| Storable Variant | storableVariant.total | COUNT(*) ProductVariant WHERE type ∈ {STORABLE, MANUFACTURED} (theo merchant). |
| Storable Variant ("tracked") | storableVariant.tracked | Như trên + metadata.inventory.isInventoryTracked = true. |
| Location | location.total / physical / simulation | COUNT(*) InventoryLocation với FILTER theo type. total = physical + simulation. |
| Stock | stock.totalOnHand | SUM(quantity_on_hand) trên mọi InventoryStock live trong scope. Trả về dạng chuỗi numeric. |
| Stock | stock.totalValue | SUM(quantity_on_hand × COALESCE(average_cost, 0)) — giá trị tồn kho (tiền) trên mọi bucket live trong scope. average_cost NULL được coi như 0 (chưa cấu hình giá vốn). Trả về dạng chuỗi numeric. |
| Need Attention | needAttention.out | quantity_available ≤ 0 — hết sạch hoặc âm. |
| Need Attention | needAttention.oversell | quantity_available < 0 — âm; là tập con cụ thể hơn của out. Xảy ra khi variant cho phép oversell và sale / reservation đẩy stock xuống dưới 0. |
| Need Attention | needAttention.low | 0 < quantity_available <= threshold, với threshold = COALESCE((stock.metadata->>lowStockThreshold)::numeric, (inv_item.metadata->>lowStockThreshold)::numeric, 5). Per-bucket — override của stock row thắng, sau đó default của InventoryItem, cuối cùng 5. |
| Need Attention | needAttention.total | out + low — số bucket riêng biệt cần chú ý. oversell là tập con của out nên không cộng lại. |
Các điều kiện
out/oversell/lowở đây giống hệt các flag per-item của/inventory-items/list— cùng đến từ các fragment dùng chungstock-posture.sql. Khác biệt duy nhất là cách rollup: overview đếm bucket, list item dùngBOOL_ORtheo từng item.
Authorization: dùng lại
InventoryStock.findvì đây là read-only aggregate trên cùng rows. Service trả403khi merchant nằm ngoài scope.
Caching: response được cache trong
cache-redis60 giây, key theo(merchantId, inventoryLocationId). Scope check chạy trước cache lookup nên không bao giờ trả cache entry cho caller ngoài scope. Lỗi Redis fall-through sang query trực tiếp — Redis là best-effort, không phải hard dependency.
GET /inventory-items/list — Left pane của Stock Edit
Danh sách phân trang. Slim row + summary tổng hợp; chi tiết per-bucket được fetch lazy qua endpoint /stocks bên dưới.
Query (theo style CRUD find)
| Tên | Mô tả |
|---|---|
filter[where][merchantId] | Bắt buộc — merchant picker trên màn hình. Query scope đúng merchant này. |
filter[where][itemType] | ProductVariant / Material — bỏ qua để lấy cả hai. |
filter[order] | Whitelist: name · id · identifier · status · itemType · createdAt · modifiedAt. Mặc định: name ASC NULLS LAST, id ASC (tiebreaker ổn định). |
filter[limit] / filter[offset] | Page size (tối đa 250) / offset. |
where[merchantId]bắt buộc (400nếu thiếu) và được kiểm tra theo scope của caller (403nếu ngoài scope; admin / always-allowed đọc được mọi merchant)./inventory-items/list/countcũng yêu cầuwhere[merchantId]tương tự.
Response — InventoryItemListResponse (mảng InventoryItemListRowResponse)
| Field | Nguồn |
|---|---|
id / identifier / status / merchantId | Cột của InventoryItem |
itemId / itemType | Principal polymorphic — ProductVariant.id hoặc Material.id |
itemName | COALESCE(ProductInfo.name, Material.name) — jsonb i18n. PV lấy từ ProductInfo(principalType=ProductVariant), Material lấy từ Material.name trực tiếp. |
identifiers[] | Chip { scheme, identifier } — từ ProductIdentifier (PV) hoặc MaterialIdentifier (Material). SQL đã flatten nguồn polymorphic; FE không cần branch. |
summary.total | { location: COUNT(DISTINCT inventory_location_id), quantity: SUM(onHand), value: SUM(onHand × averageCost) } |
summary.onHand / summary.reserved | { quantity, value } các slice |
needAttention | Boolean { out, low, oversell } — per-location (BOOL_OR trên các bucket của item): true nếu BẤT KỲ bucket nào thỏa điều kiện. out = ∃ bucket available ≤ 0 (hết hoặc âm); oversell = ∃ bucket available < 0 (tập con cụ thể hơn của out); low = ∃ bucket 0 < available ≤ COALESCE(stock override, item default, 5) (cùng cascade threshold per-bucket như low của overview). Các flag độc lập / có thể chồng nhau (bucket âm set cả out lẫn oversell; một item có thể low ở location này và oversell ở location khác); tất cả false = healthy. Item không có stock row → tất cả false.Lưu ý: các điều kiện này giống hệt card của /inventory-stocks/overview (cùng đến từ các fragment dùng chung stock-posture.sql — out = available ≤ 0, oversell = available < 0 ⊂ out, low trên cùng cascade threshold). Khác biệt duy nhất là cách rollup: overview đếm bucket, list này dùng BOOL_OR theo từng item. |
Total phân trang nằm ở header HTTP Content-Range (records 0-49/123). Shape body tuân theo toggle x-request-count-data (cùng convention với CRUD find):
Header x-request-count-data | Response body |
|---|---|
bỏ qua / true | { data: [...], count: <số-row-trong-page> } |
false | [...] (mảng thuần) |
Query list + count chạy chung một transaction nên thấy cùng snapshot — range.total luôn khớp với rows trong page.
GET /inventory-items/list/count — Total companion
Cùng shape where filter như CRUD count. Trả về { count: number }. FE pagination footer đọc từ đây khi chỉ cần total tươi mà không cần fetch lại rows.
GET /inventory-items/{id}/stocks — Right pane của Stock Edit
Một row per bucket InventoryStock × InventoryLocation cho một InventoryItem. Mỗi row self-contained (location nested + uom của principal + allowOversell per-bucket + các tầng lowStockThreshold đã resolve) để FE render edit pane không phải lookup thêm.
Response — InventoryStocksResponse (mảng InventoryStockLocationRowResponse)
| Field | Nguồn |
|---|---|
stock.id | InventoryStock.id — FE cần để build URL PATCH /inventory-items/{id}/stocks/{stockId} |
location.id / identifier / type / name / isDefault | InventoryLocation JOIN trên stock.inventory_location_id |
uom | COALESCE(ProductVariant.uom, Material.uom) — { base, purchase, sale } |
allowOversell | COALESCE((stock.metadata→>allowOversell)::boolean, false) — per-bucket |
lowStockThreshold | { default, byItem, byStock } chuỗi numeric: default = mặc định hệ thống 5; byItem = COALESCE(item default, 5) (item-level hiệu lực); byStock = COALESCE(stock override, item default, 5) (threshold đang áp dụng cho bucket này). FE hiển thị byStock là giá trị active, so với byItem để biết bucket có override item default không. Mọi tầng đều jsonb_typeof = 'number' guard (giá trị không phải số → fallback tầng tiếp theo). |
averageCost | InventoryStock.average_cost (chuỗi) |
onHand / reserved / available | { quantity, value } với value = quantity × averageCost |
Rows order: location mặc định trước, sau đó theo location id. Merchant scope enforce qua InventoryItem.merchant_id của parent — caller ngoài scope không đọc được stock của merchant khác bằng cách đoán id.
PATCH /inventory-items/{id}/stocks/{inventoryStockId} — Patch một bucket atomic
Body — tất cả field optional, chỉ field cung cấp mới đổi:
{
"onHand": "12.5",
"reserved": "3",
"averageCost": "40000",
"allowOversell": true,
"lowStockThreshold": 4.5
}Một SQL statement duy nhất — không race read-then-write. quantity_available được tính lại ngay trong UPDATE = quantity_on_hand - quantity_reserved để 3 counter luôn nhất quán bất kể caller đổi field nào. allowOversell và lowStockThreshold shallow-merge vào metadata jsonb qua ||, nên các key metadata khác không bị clobber. lowStockThreshold là override per-location (cho phép số thập phân); thắng default của InventoryItem trong reader low-stock per-bucket:
UPDATE inventory."InventoryStock"
SET quantity_on_hand = COALESCE($onHand, quantity_on_hand),
quantity_reserved = COALESCE($reserved, quantity_reserved),
quantity_available = COALESCE($onHand, quantity_on_hand)
- COALESCE($reserved, quantity_reserved),
average_cost = COALESCE($averageCost, average_cost),
metadata = COALESCE(metadata, '{}'::jsonb)
|| COALESCE($metadataPatch::jsonb, '{}'::jsonb)
WHERE id = $stockId
AND inventory_item_id = $itemId -- enforce stock-thuộc-về-item
AND merchant_id IN ($scope) -- scope guard server-side
AND deleted_at IS NULL
RETURNING *;Service translate allowOversell / lowStockThreshold top-level thành metadata-patch object (chỉ field có mặt mới đưa vào), ví dụ { allowOversell: true } → metadata = metadata || '{"allowOversell": true}'. Bỏ field ra khỏi body → giá trị cũ giữ nguyên.
Strict-mode pre-check. Trước khi UPDATE chạy, service gọi findOne load row hiện tại để tiên đoán state sau patch. Nếu allowOversell sẽ thành false sau patch (do body set hoặc kế thừa từ metadata hiện tại) VÀ một trong các giá trị tiên đoán onHand, reserved, available = onHand − reserved < 0, request bị reject HTTP 409 với messageCode: server.inventory.inventory_stock.update.oversell_disable_requires_non_negative. Caller phải hoặc fix quantity trong cùng PATCH (ví dụ gửi onHand không âm cùng allowOversell: false), hoặc giữ allowOversell: true. Tránh để bucket rơi vào state mà guard sale-deduct không bao giờ thoát ra được.
Nếu RETURNING trả về 0 rows → HTTP 404 với messageCode: server.inventory.inventory_stock.find.not_found (sai stockId, itemId không khớp, hoặc merchant ngoài scope — đều gộp về cùng not-found response).
Vẫn nằm trên principal, không trên stock row:
| Field | Update qua |
|---|---|
| Base UoM | PATCH /product-variants/{id}/aggregate (hoặc material aggregate) — uom.base của principal share giữa mọi bucket. |
Nguồn truth cho allowOversell
Flag này trước đây nằm trên ProductVariant.metadata.inventory.allowOversell / Material.metadata.inventory.allowOversell. Từ migration 0009_charming_armor, flag đã chuyển sang InventoryStock.metadata.allowOversell (per-location) — runtime read trên hot-path deduct/reserve lấy từ stock row.
metadata.inventory.allowOversell của principal giờ chỉ là seed: đọc duy nhất lúc tạo stock lần đầu (CDC handler / Material aggregate create), không đọc lại nữa. Sửa nó sau khi stock đã seed sẽ KHÔNG ảnh hưởng runtime — dùng PATCH endpoint này per location thay vì.
Sửa lowStockThreshold (hai tầng)
- Override per-location → stock PATCH này (
lowStockThresholdtrong body). Thắng cho bucket nó nhắm tới. - Default item-level →
PATCH /inventory-items/{id}với{ "metadata": { "lowStockThreshold": 30 } }(CRUD update InventoryItem chuẩn, merchant-scoped). Áp dụng cho mọi bucket không có override. - Seed lúc tạo →
inventory.lowStockThresholdcủa aggregate (PV qua CDC, Material in-process) ghi default InventoryItem một lần lúc tạo; sửa catalog sau đó không propagate (dùng item update ở trên). - Fallback hệ thống
5khi cả hai tầng đều trống.
(Từ migration 0010_mellow_threshold, threshold không còn nằm trên metadata của ProductVariant/Material ở runtime.)
6. Sự kiện
Topic Kafka inbound thay đổi stock (xử lý bởi worker, không qua HTTP):
| Topic | Worker | Tác dụng stock |
|---|---|---|
payment.success | InventoryWorkerService.handlePaymentSuccess | −quantity cho product items; reserve materials theo recipe |
kitchen-ticket-item.status-changed | MaterialWorkerService.handleKitchenTicketItemStatusChanged | Tiêu thụ material khi READY; restore khi VOIDED |
material.transferred | InventoryWorkerService.handleMaterialTransferred | − ở source, + ở destination |
CDC nx.seller.public.product_variant | InventoryWorkerService.handleProductVariantCDC | Upsert ensureInventoryItem |
Outbound:
| Topic | Khi nào |
|---|---|
material.stock-changed | Sau khi tiêu thụ/transfer material |
WebSocket observation/inventory/inventory-stock | Sau bất kỳ ghi adjustStock nào |
7. Luồng Trừ Stock khi Sale
8. Side effects mỗi lần thay đổi stock
| Side effect | Khi nào |
|---|---|
Insert row InventoryTracking (referenceType + referenceId) | Luôn — mọi thay đổi stock đều ghi audit |
| Broadcast WebSocket trên 3 room | Sau khi adjustStock trả về non-null |
Có thể emit Kafka (MATERIAL_STOCK_CHANGED) | Nếu là material |
Cập nhật InventoryStock.lastStockedAt / lastCountedAt | Theo loại operation |
9. Idempotency
| Caller | Key |
|---|---|
| Sale payment | (SALE_ORDER, saleOrderId, stockId) |
| Kitchen READY | (KITCHEN_TICKET_ITEM, kitchenTicketItemId, materialStockId) |
| PO receive | (PURCHASE_ORDER, purchaseOrderId, stockId) |
| Material transfer | (transferId, fromStockId) + (transferId, toStockId) |
Tất cả key được query trong
InventoryTrackingtrước khi ghi — xem ADR-0004.
10. Trang liên quan
- Inventory Tracking — định dạng audit trail + pattern truy vấn
- Inventory Location — vòng đời location
- Purchase Order — caller của luồng receive
- Material & BOM — reservation + tiêu thụ material
- ADR-0001 InventoryItem đa hình
- ADR-0003 adjustStock atomic