Skip to content

Inventory Stock

1. Tổng quan

Thuộc tínhGiá trị
IDFEAT-INV-STOCK
StatusStable
Ownerinventory-team
Phụ thuộcMaterial / 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ườngKiểuBắt buộcMô tả
merchantIdtextOwner
itemTypetextMATERIAL / PRODUCT_VARIANT
itemIdtextFK target id (đa hình, không có DB FK)
identifiertextTự động, tiền tố INI
statustextInventoryItemStatuses (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ườngKiểuBắt buộcMô tả
inventoryItemIdtextFK
inventoryLocationIdtextFK
merchantIdtextDenormalize
quantityOnHanddecimal(15,4)Tổng stock vật lý
quantityReserveddecimal(15,4)Đã reserve cho đơn
quantityAvailabledecimal(15,4)Trường lưu trữ; service duy trì = onHand − reserved
lotNumbertextMở rộng bucket-key
serialNumbertextMở rộng bucket-key
expiryDatetimestamptzHỗ trợ FEFO (chỉ data)
manufactureDatetimestamptz
lastCountedAttimestamptzCycle count gần nhất
lastStockedAttimestamptzLần nhận hàng gần nhất
averageCostdecimal(15,4)Snapshot AVCO
costingMethodtextMặc định AVERAGE
metadatajsonbConfig per-bucket — IInventoryStockMetadata: { allowOversell?: boolean; lowStockThreshold?: number }. lowStockThresholdoverride 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ườngKiểuBắt buộcMô tả
principalTypetextINVENTORY_ITEM / INVENTORY_STOCK
principalIdtextFK target id
schemetextSKU / BARCODE / QRCODE / IMEI / SERIAL
valuetextChuỗ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ứcNguồnMục đích
find / findByIdInventoryStockService (CRUD)Read merchant-scoped
Bulk-load theo item + locationNội bộ serviceDùng bởi worker; tránh N+1

Write — atomic

Phương thứcSignatureMụ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

sql
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ốngHành vi
Guard thỏa mãn → row được updateTrả về { quantityOnHand, quantityAvailable, quantityReserved }
Guard fail → 0 row updateTrả về null — caller ghi tracking note OVERSELL_BLOCKED và bỏ qua
forceNonNegative=falseLuôn thành công; cho phép số âm

Xem ADR-0003 để hiểu lý do.

5. REST Endpoints

VerbPathAuthPermissionHandler
6× CRUD/inventory-itemsJWT/BASICInventoryItem.<crud>merchant-scoped
GET/inventory-items/listJWT/BASICInventoryItem.findInventoryItemService.getList — danh sách slim + summary (left pane của Stock Edit)
GET/inventory-items/list/countJWT/BASICInventoryItem.countInventoryItemService.getCount — chỉ total, giống /products/list/count
GET/inventory-items/{id}/stocksJWT/BASICInventoryItem.findInventoryItemService.getStocks — chi tiết per-bucket cho right pane
PATCH/inventory-items/{id}/stocks/{inventoryStockId}JWT/BASICInventoryStock.updateByIdInventoryItemService.updateStock — patch một bucket atomic
6× CRUD/inventory-stocksJWT/BASICInventoryStock.<crud>merchant-scoped
GET/inventory-stocks/overviewJWT/BASICInventoryStock.findInventoryStockService.getOverview
6× CRUD/inventory-identifiersJWT/BASICInventoryIdentifier.<crud>merchant-scoped

Không có endpoint mutation cho adjustStock — thay đổi stock chỉ xảy ra qua PurchaseOrder.receive, Kafka sale-payment, Kafka kitchen-ticket, hoặc InventoryTicket.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ênBắt buộcMặc địnhMô tả
merchantIdPhải nằm trong merchant scope của user theo Casbin (admin / always-allowed bỏ qua).
inventoryLocationIdGiới hạn stockneedAttention 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ên InventoryItem.metadata.lowStockThreshold, fallback hệ thống 5. Reader per-bucket resolve COALESCE(stock override, item default, 5). Seed lúc tạo item từ inventory.lowStockThreshold của aggregate; default sửa live qua PATCH /inventory-items/{id} ({ metadata: { lowStockThreshold } }), override qua stock PATCH bên dưới.

Response — 4 block tương ứng 4 card

CardFieldNguồn dữ liệu
Storable VariantstorableVariant.totalCOUNT(*) ProductVariant WHERE type ∈ {STORABLE, MANUFACTURED} (theo merchant).
Storable Variant ("tracked")storableVariant.trackedNhư trên + metadata.inventory.isInventoryTracked = true.
Locationlocation.total / physical / simulationCOUNT(*) InventoryLocation với FILTER theo type. total = physical + simulation.
Stockstock.totalOnHandSUM(quantity_on_hand) trên mọi InventoryStock live trong scope. Trả về dạng chuỗi numeric.
Stockstock.totalValueSUM(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 AttentionneedAttention.outquantity_available ≤ 0 — hết sạch hoặc âm.
Need AttentionneedAttention.oversellquantity_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 AttentionneedAttention.low0 < 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 AttentionneedAttention.totalout + 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 chung stock-posture.sql. Khác biệt duy nhất là cách rollup: overview đếm bucket, list item dùng BOOL_OR theo từng item.

Authorization: dùng lại InventoryStock.find vì đây là read-only aggregate trên cùng rows. Service trả 403 khi merchant nằm ngoài scope.

Caching: response được cache trong cache-redis 60 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ênMô 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 (400 nếu thiếu) và được kiểm tra theo scope của caller (403 nếu ngoài scope; admin / always-allowed đọc được mọi merchant). /inventory-items/list/count cũng yêu cầu where[merchantId] tương tự.

Response — InventoryItemListResponse (mảng InventoryItemListRowResponse)

FieldNguồn
id / identifier / status / merchantIdCột của InventoryItem
itemId / itemTypePrincipal polymorphic — ProductVariant.id hoặc Material.id
itemNameCOALESCE(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
needAttentionBoolean { 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.sqlout = available ≤ 0, oversell = available < 0out, 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-dataResponse 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)

FieldNguồn
stock.idInventoryStock.id — FE cần để build URL PATCH /inventory-items/{id}/stocks/{stockId}
location.id / identifier / type / name / isDefaultInventoryLocation JOIN trên stock.inventory_location_id
uomCOALESCE(ProductVariant.uom, Material.uom){ base, purchase, sale }
allowOversellCOALESCE((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).
averageCostInventoryStock.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:

json
{
  "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. allowOverselllowStockThreshold shallow-merge vào metadata jsonb qua ||, nên các key metadata khác không bị clobber. lowStockThresholdoverride per-location (cho phép số thập phân); thắng default của InventoryItem trong reader low-stock per-bucket:

sql
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:

FieldUpdate qua
Base UoMPATCH /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 (lowStockThreshold trong body). Thắng cho bucket nó nhắm tới.
  • Default item-levelPATCH /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ạoinventory.lowStockThreshold củ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 5 khi 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):

TopicWorkerTác dụng stock
payment.successInventoryWorkerService.handlePaymentSuccess−quantity cho product items; reserve materials theo recipe
kitchen-ticket-item.status-changedMaterialWorkerService.handleKitchenTicketItemStatusChangedTiêu thụ material khi READY; restore khi VOIDED
material.transferredInventoryWorkerService.handleMaterialTransferred ở source, + ở destination
CDC nx.seller.public.product_variantInventoryWorkerService.handleProductVariantCDCUpsert ensureInventoryItem

Outbound:

TopicKhi nào
material.stock-changedSau khi tiêu thụ/transfer material
WebSocket observation/inventory/inventory-stockSau 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 effectKhi nào
Insert row InventoryTracking (referenceType + referenceId)Luôn — mọi thay đổi stock đều ghi audit
Broadcast WebSocket trên 3 roomSau khi adjustStock trả về non-null
Có thể emit Kafka (MATERIAL_STOCK_CHANGED)Nếu là material
Cập nhật InventoryStock.lastStockedAt / lastCountedAtTheo loại operation

9. Idempotency

CallerKey
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 InventoryTracking trước khi ghi — xem ADR-0004.

10. Trang liên quan

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