Architecture
1. System Context (C4 L1)
2. Container View (C4 L2)
3. Component View (C4 L3) — Internal Layering
| Layer | Responsibility |
|---|---|
| Routes | /inventory-stocks, /purchase-orders, … (15 in RestPaths) |
| Controllers | Auth gate (JWT / BASIC), permission check, DTO mapping |
| Services | Business logic, transactions, event emission, idempotency guards |
| Workers | InventoryWorkerService, MaterialWorkerService — stateless Kafka handlers |
| Repositories | Drizzle queries, soft-delete, atomic adjusters (adjustStock) |
| Components | Kafka producer/consumer, WebSocket, Redis cache |
4. State Machines Index
| Entity | States | Diagram |
|---|---|---|
PurchaseOrder | DRAFT, PROCESSING, RECEIVED, COMPLETED, CLOSED, CANCELLED | → |
MaterialRecipe | DRAFT, ACTIVATED, DEACTIVATED | → |
Vendor / VendorItem | ACTIVATED, DEACTIVATED, ARCHIVED | → |
InventoryLocation | NEW, ACTIVATED, DEACTIVATED, ARCHIVED | → |
InventoryTicket | DRAFT, SUBMITTED, APPROVED, IN_PROGRESS, COMPLETED, CANCELLED | → |
PurchaseOrder
| From | Event | To | Guards |
|---|---|---|---|
DRAFT | confirm | PROCESSING | items length ≥ 1 |
PROCESSING | receive(items) | RECEIVED | partial fill |
PROCESSING | receive(items) | COMPLETED | all items receivedQuantity ≥ quantity |
RECEIVED | receive(items) | RECEIVED / COMPLETED | re-receive remaining |
RECEIVED / COMPLETED | complete | COMPLETED | idempotent |
RECEIVED / COMPLETED | close | CLOSED | terminal |
PROCESSING | revert | DRAFT | no items received yet |
| any non-terminal | cancel | CANCELLED | terminal |
MaterialRecipe
Vendor / VendorItem
InventoryLocation
InventoryTicket
5. Runtime Scenarios
5.1 Sale Payment → Stock Deduct
5.2 Kitchen Ticket Item READY → Material Consume
5.3 PO Confirm → Receive → Complete
5.4 Merchant CDC → Default Location Seed
5.5 ProductVariant CDC seed — COMBO is auto-skipped
InventoryWorkerService.handleProductVariantCDC only seeds InventoryItem for stockable variant types. ProductVariantTypes.COMBO is not in STOCKABLE_SET, so the existing guard
ts
if (!ProductVariantTypes.isStockable(productVariant.type)) return;already excludes combo variants — no category lookup, no extra branch. A combo variant is virtual; its components carry stock.
Combo deduction flows through the existing per-item path at payment-success because the cart-add layer (@nx/sale) already inserts the leaf components as SaleOrderItem children with leadItemId. The worker sees those as ordinary PRODUCT_VARIANT items and deducts them normally.
ADR: ./decisions/0006-combo-explosion-at-cart-add.
6. Crosscutting Concerns
| Concern | How this service handles it |
|---|---|
| AuthN | JWT (ES256, JWKS pulled from identity); falls back to BASIC for service-to-service |
| AuthZ | Casbin via PolicyDefinitionService (cached in Redis) — <Resource>.<action> codes from controllers/permissions.ts |
| i18n | jsonb columns with { en, vi } shape; locale resolved from Accept-Language |
| Logging | IGNIS structured logger; key: %s format; topic/partition/offset on Kafka |
| Idempotency | Per (referenceType, referenceId, inventoryStockId) lookup in InventoryTracking before write |
| Atomicity | InventoryStockRepository.adjustStock — single SQL UPDATE with optional forceNonNegative |
| Soft-delete | SoftDeletableRepository (deletedAt); audit trail (InventoryTracking) NOT soft-deletable |
| IDs | Snowflake via IdGenerator, worker 5 |
| Transactions | Service methods accept optional transaction param; aggregate ops scoped via _withTransaction helper |
| Merchant scoping | MerchantScopedService injects merchantId filter into all reads/writes for non-system roles |