Skip to content

Purchase Order

1. Overview

PropertyValue
IDFEAT-INV-PO
StatusStable
Ownerinventory-team
Depends onVendor, 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

FieldTypeRequiredNotes
idtextSnowflake
purchaseOrderNumbertextUnique; default <YYYYMMDDHHmmss>-<snowflake>
nametextDefault PurchaseOrder-<snowflake>
slugtextUnique; same default pattern
merchantIdtextOwner
vendorIdtextFK
inventoryLocationIdtextReceive destination
statustextSee §3; default DRAFT
orderDatetimestamptzDefault now()
expectedDeliveryDate / actualDeliveryDatetimestamptz
draftAt / processingAt / confirmedAt / receivedAt / completedAt / closedAt / cancelledAttimestamptzPer-status timestamps
currencytextDefault VND
exchangeRatedecimal(12,6)Default 1
subtotal / discount / tax / totaldecimal(15,4)Maintained by updateSummaryFromItems
metadatajsonbIPurchaseOrderMetadata

PurchaseOrderItem fields

FieldTypeRequiredNotes
purchaseOrderIdtextFK
itemTypetextMATERIAL / PRODUCT_VARIANT
itemIdtextFK target id (polymorphic)
uomIdtextSoft ref
multiplierdecimal(15,4)UoM-to-base conversion
quantitydecimal(15,4)Ordered
receivedQuantitydecimal(15,4)Cumulative
unitPricedecimal(15,4)Per UoM
discount / taxdecimal(15,4)Per-line

Idempotency on add: same (purchaseOrderId, itemType, itemId, uomId) increments quantity instead of duplicating.

3. Lifecycle

FromEventToGuard
DRAFTconfirmPurchaseOrderPROCESSINGitems.length ≥ 1
PROCESSINGrevertPurchaseOrderDRAFTno items received yet
PROCESSING / RECEIVEDreceivePurchaseOrder(items)RECEIVEDpartial fill
PROCESSING / RECEIVEDreceivePurchaseOrder(items)COMPLETEDall items receivedQuantity ≥ quantity
RECEIVED / COMPLETEDcompletePurchaseOrderCOMPLETEDidempotent
RECEIVED / COMPLETEDclosePurchaseOrderCLOSEDterminal
any non-terminalcancelPurchaseOrderCANCELLEDterminal
Status code (DB value)Display
001_DRAFTDRAFT
203_PROCESSINGPROCESSING
205_RECEIVEDRECEIVED
303_COMPLETEDCOMPLETED
404_CLOSEDCLOSED
505_CANCELLEDCANCELLED

4. Receive Modes

Defined in inventory/src/common/constants.ts:20 (ReceivePurchaseOrderItemModes).

ModeBehavior
OVERRIDE (default)newReceivedQuantity = receivedQuantity (caller computes the absolute target)
ACCUMULATIVEnewReceivedQuantity = 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

MethodSignaturePurposeLine
createAggregate{ context, data: TCreatePurchaseOrderAggregateRequest }PO + items in one TX; idempotent item merge212
updateByIdAggregate{ context, id, data: TUpdatePurchaseOrderAggregateRequest }Patch metadata + items via id-presence discriminator277
addItemToPurchaseOrder{ purchaseOrderId, item: TAddPurchaseOrderItemRequest }Append to DRAFT; merges if same (itemType, itemId, uomId)338
clearPurchaseOrderItems{ purchaseOrderId }Soft-delete all items; only DRAFT379
confirmPurchaseOrder{ purchaseOrderId }DRAFT → PROCESSING412
receivePurchaseOrder{ purchaseOrderId, inventoryLocationId?, items, transaction? }PROCESSING/RECEIVED → RECEIVED/COMPLETED; mutate stock; write tracking; emit Kafka436
revertPurchaseOrder{ purchaseOrderId }PROCESSING → DRAFT522
cancelPurchaseOrder{ purchaseOrderId, reason? }any non-terminal → CANCELLED534
completePurchaseOrder{ purchaseOrderId }RECEIVED → COMPLETED (idempotent)564
closePurchaseOrder{ purchaseOrderId }RECEIVED/COMPLETED → CLOSED591

Source: inventory/src/services/purchase-order.service.ts (1227 lines).

updateByIdAggregate items semantics

ShapeInterpretation
{ id } onlyDELETE 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

VerbPathAuthPermissionHandler
POST/purchase-orders/aggregateJWT/BASICPurchaseOrder.createAggregatecreateAggregate
PATCH/purchase-orders/:id/aggregateJWT/BASICPurchaseOrder.updateAggregateupdateByIdAggregate
POST/purchase-orders/:id/itemsJWT/BASICPurchaseOrder.addItemaddItemToPurchaseOrder
DELETE/purchase-orders/:id/itemsJWT/BASICPurchaseOrder.clearItemsclearPurchaseOrderItems
POST/purchase-orders/:id/confirmJWT/BASICPurchaseOrder.confirmconfirmPurchaseOrder
POST/purchase-orders/:id/revertJWT/BASICPurchaseOrder.revertrevertPurchaseOrder
POST/purchase-orders/:id/receiveJWT/BASICPurchaseOrder.receivereceivePurchaseOrder
POST/purchase-orders/:id/cancelJWT/BASICPurchaseOrder.cancelcancelPurchaseOrder
POST/purchase-orders/:id/completeJWT/BASICPurchaseOrder.completecompletePurchaseOrder
POST/purchase-orders/:id/closeJWT/BASICPurchaseOrder.closeclosePurchaseOrder
6× CRUD/purchase-ordersJWT/BASICPurchaseOrder.<crud>CRUD

7. Events

Inbound: none directly. PO is mutated only via REST.

Outbound:

TopicWhenPayload
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 effectWhere
InventoryStock.adjustStock(+effectiveQuantity)Per item, atomic
InventoryTracking row insert (type=PURCHASE, referenceType=PURCHASE_ORDER, referenceId=<poId>)Per item, after stock adjust
VendorItem.lastInvoiced snapshot updatePer item via VendorItemService.recordPurchase
PurchaseOrder.subtotal/tax/total recalcAfter all items processed
Status transition to RECEIVED or COMPLETEDBased on cumulative receivedQuantity
Kafka emit PURCHASE_ORDER_RECEIVEDPost-commit, only when payments[] non-empty

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