Purchase Order
1. Overview
| Property | Value |
|---|---|
| ID | FEAT-INV-PO |
| Status | Stable |
| Owner | inventory-team |
| Depends on | Vendor, VendorItem, Material / ProductVariant, InventoryLocation, UnitOfMeasure |
Purchase Orders represent goods receipt from vendors. The aggregate API (POST /aggregate, PATCH /:id/aggregate) creates/updates a PO + its items in one transaction. Goods receipt mutates InventoryStock and writes to InventoryTracking. Optional payment legs in the receive call drive a Kafka emit consumed by @nx/finance.
2. Entity Model
PurchaseOrder fields
| Field | Type | Required | Notes |
|---|---|---|---|
id | text | ✓ | Snowflake |
purchaseOrderNumber | text | ✓ | Unique; default <YYYYMMDDHHmmss>-<snowflake> |
name | text | Default PurchaseOrder-<snowflake> | |
slug | text | Unique; same default pattern | |
merchantId | text | ✓ | Owner |
vendorId | text | ✓ | FK |
inventoryLocationId | text | ✓ | Receive destination |
status | text | ✓ | See §3; default DRAFT |
orderDate | timestamptz | ✓ | Default now() |
expectedDeliveryDate / actualDeliveryDate | timestamptz | — | |
draftAt / processingAt / confirmedAt / receivedAt / completedAt / closedAt / cancelledAt | timestamptz | Per-status timestamps | |
currency | text | ✓ | Default VND |
exchangeRate | decimal(12,6) | Default 1 | |
subtotal / discount / tax / total | decimal(15,4) | ✓ | Maintained by updateSummaryFromItems |
metadata | jsonb | IPurchaseOrderMetadata |
PurchaseOrderItem fields
| Field | Type | Required | Notes |
|---|---|---|---|
purchaseOrderId | text | ✓ | FK |
itemType | text | ✓ | MATERIAL / PRODUCT_VARIANT |
itemId | text | ✓ | FK target id (polymorphic) |
uomId | text | Soft ref | |
multiplier | decimal(15,4) | UoM-to-base conversion | |
quantity | decimal(15,4) | ✓ | Ordered |
receivedQuantity | decimal(15,4) | ✓ | Cumulative |
unitPrice | decimal(15,4) | ✓ | Per UoM |
discount / tax | decimal(15,4) | Per-line |
Idempotency on add: same (purchaseOrderId, itemType, itemId, uomId) increments quantity instead of duplicating.
3. Lifecycle
| From | Event | To | Guard |
|---|---|---|---|
DRAFT | confirmPurchaseOrder | PROCESSING | items.length ≥ 1 |
PROCESSING | revertPurchaseOrder | DRAFT | no items received yet |
PROCESSING / RECEIVED | receivePurchaseOrder(items) | RECEIVED | partial fill |
PROCESSING / RECEIVED | receivePurchaseOrder(items) | COMPLETED | all items receivedQuantity ≥ quantity |
RECEIVED / COMPLETED | completePurchaseOrder | COMPLETED | idempotent |
RECEIVED / COMPLETED | closePurchaseOrder | CLOSED | terminal |
| any non-terminal | cancelPurchaseOrder | CANCELLED | terminal |
| Status code (DB value) | Display |
|---|---|
001_DRAFT | DRAFT |
203_PROCESSING | PROCESSING |
205_RECEIVED | RECEIVED |
303_COMPLETED | COMPLETED |
404_CLOSED | CLOSED |
505_CANCELLED | CANCELLED |
4. Receive Modes
Defined in
inventory/src/common/constants.ts:20(ReceivePurchaseOrderItemModes).
| Mode | Behavior |
|---|---|
OVERRIDE (default) | newReceivedQuantity = receivedQuantity (caller computes the absolute target) |
ACCUMULATIVE | newReceivedQuantity = currentReceivedQuantity + receivedQuantity |
InventoryService.receiveInventoryFromPurchaseOrderItem()always operates on a delta — the caller resolves the delta from the chosen mode before passing it down.
Auto-complete rule: if all items satisfy receivedQuantity ≥ quantity after a receive call, PurchaseOrder.status transitions to COMPLETED in the same operation.
5. Operations
| Method | Signature | Purpose | Line |
|---|---|---|---|
createAggregate | { context, data: TCreatePurchaseOrderAggregateRequest } | PO + items in one TX; idempotent item merge | 212 |
updateByIdAggregate | { context, id, data: TUpdatePurchaseOrderAggregateRequest } | Patch metadata + items via id-presence discriminator | 277 |
addItemToPurchaseOrder | { purchaseOrderId, item: TAddPurchaseOrderItemRequest } | Append to DRAFT; merges if same (itemType, itemId, uomId) | 338 |
clearPurchaseOrderItems | { purchaseOrderId } | Soft-delete all items; only DRAFT | 379 |
confirmPurchaseOrder | { purchaseOrderId } | DRAFT → PROCESSING | 412 |
receivePurchaseOrder | { purchaseOrderId, inventoryLocationId?, items, transaction? } | PROCESSING/RECEIVED → RECEIVED/COMPLETED; mutate stock; write tracking; emit Kafka | 436 |
revertPurchaseOrder | { purchaseOrderId } | PROCESSING → DRAFT | 522 |
cancelPurchaseOrder | { purchaseOrderId, reason? } | any non-terminal → CANCELLED | 534 |
completePurchaseOrder | { purchaseOrderId } | RECEIVED → COMPLETED (idempotent) | 564 |
closePurchaseOrder | { purchaseOrderId } | RECEIVED/COMPLETED → CLOSED | 591 |
Source:
inventory/src/services/purchase-order.service.ts(1227 lines).
updateByIdAggregate items semantics
| Shape | Interpretation |
|---|---|
{ id } only | DELETE this item |
{ id, ...fields } | UPDATE existing |
{ mode, itemId, quantity, ... } (no id) | CREATE new |
Receive flow detail
effectiveQuantity = adjustedQuantity * multiplier— base-unit conversion from line UoM.
6. REST Endpoints
| Verb | Path | Auth | Permission | Handler |
|---|---|---|---|---|
POST | /purchase-orders/aggregate | JWT/BASIC | PurchaseOrder.createAggregate | createAggregate |
PATCH | /purchase-orders/:id/aggregate | JWT/BASIC | PurchaseOrder.updateAggregate | updateByIdAggregate |
POST | /purchase-orders/:id/items | JWT/BASIC | PurchaseOrder.addItem | addItemToPurchaseOrder |
DELETE | /purchase-orders/:id/items | JWT/BASIC | PurchaseOrder.clearItems | clearPurchaseOrderItems |
POST | /purchase-orders/:id/confirm | JWT/BASIC | PurchaseOrder.confirm | confirmPurchaseOrder |
POST | /purchase-orders/:id/revert | JWT/BASIC | PurchaseOrder.revert | revertPurchaseOrder |
POST | /purchase-orders/:id/receive | JWT/BASIC | PurchaseOrder.receive | receivePurchaseOrder |
POST | /purchase-orders/:id/cancel | JWT/BASIC | PurchaseOrder.cancel | cancelPurchaseOrder |
POST | /purchase-orders/:id/complete | JWT/BASIC | PurchaseOrder.complete | completePurchaseOrder |
POST | /purchase-orders/:id/close | JWT/BASIC | PurchaseOrder.close | closePurchaseOrder |
| 6× CRUD | /purchase-orders | JWT/BASIC | PurchaseOrder.<crud> | CRUD |
7. Events
Inbound: none directly. PO is mutated only via REST.
Outbound:
| Topic | When | Payload |
|---|---|---|
purchase-order.received (KafkaTopics.PURCHASE_ORDER_RECEIVED) | After receivePurchaseOrder commits, only when payments[] non-empty | { purchaseOrderId, merchantId, payments[], items[] (with inventoryItemId, quantity, unitCost, uomId, multiplier, effectiveAt) } |
Consumer:
@nx/finance(records EXPENSE / COGS legs).
8. Side effects on receive
| Side effect | Where |
|---|---|
InventoryStock.adjustStock(+effectiveQuantity) | Per item, atomic |
InventoryTracking row insert (type=PURCHASE, referenceType=PURCHASE_ORDER, referenceId=<poId>) | Per item, after stock adjust |
VendorItem.lastInvoiced snapshot update | Per item via VendorItemService.recordPurchase |
PurchaseOrder.subtotal/tax/total recalc | After all items processed |
Status transition to RECEIVED or COMPLETED | Based on cumulative receivedQuantity |
Kafka emit PURCHASE_ORDER_RECEIVED | Post-commit, only when payments[] non-empty |
9. Related Pages
- Vendor & VendorItem —
recordPurchasesnapshot details - Inventory Stock —
adjustStocksemantics - Inventory Tracking — audit trail format
- Decisions