purchase_orders — overview
Procurement purchase orders: the execution layer for the supplier suite (#227). Places orders against supplier contacts, tracks a status lifecycle, receives partial deliveries with quality checks, and exports PDFs.
What it is
Authenticated endpoints under /api/v1/purchase_orders/ (JWT + RBAC). A clinic places an order for inventory items, moves it through draft -> sent -> confirmed -> received|cancelled, and receives delivery batches where only good lines move stock.
Routes:
GET /api/v1/purchase_orders— list, filterable byorder_status/supplier_idGET /api/v1/purchase_orders/{id}— order + lines + supplier/item namesPOST /api/v1/purchase_orders— create adraftorder (201)PATCH /api/v1/purchase_orders/{id}— editexpected_date/notes(409 once received)POST /api/v1/purchase_orders/{id}/status— explicit status transition (409 on invalid moves)POST /api/v1/purchase_orders/{id}/receive— batch receive; onlygoodlines move stockGET /api/v1/purchase_orders/{id}/receipts— receipt batchesGET /api/v1/purchase_orders/{id}/receipts/{rid}— one batch with line qualityGET /api/v1/purchase_orders/{id}/pdf— WeasyPrint PDF (en/es, clinic currency)
Status lifecycle
draft ──> sent ──> confirmed ──> received (implicit: receipt completes all lines)
│ │ │
└─────────┴─────> cancelledExplicit transitions go through the allowed matrix; received can only be reached through a receive batch that fulfils every line — there is no manual "mark received" escape hatch.
Data model
purchase_orders— header:supplier_id(FK tocontacts.id, typesupplier),status,expected_date,notes,created_by,received_at.purchase_order_lines— one row per item:purchase_order_id,inventory_item_id,quantity_ordered,quantity_received,unit_pricesnapshot.UNIQUE (purchase_order_id, inventory_item_id).purchase_receipts— a delivery batch against a PO (partial receives supported;received_atserver-stamped,received_by).purchase_receipt_lines— per-linequantity_received+quality(good|rejected). Rejected units never touch the inventory ledger and do not fulfil the PO line: the order stays open for the replacement.
Migration: po_0001_initial on own Alembic branch (purchase_orders), depending on contacts@con_0001 + inventory@inv_0002.
Stock integration
Receiving calls InventoryService._apply_movement (the single quantity-change write path in inventory) with reason='purchase_receipt', reference_type='purchase_receipt', reference_id=<receipt.id>. Only quality='good' quantities are applied. The whole batch is one transaction: receipt + lines + stock movements + the auto received transition commit or roll back together (ADR 0019).
Service layer
PurchaseOrderService encapsulates the lifecycle: clinic-scoped supplier/item validation at creation, duplicate-item guard, explicit transitions with an allowed-matrix (409 on invalid moves), batch receiving with over-receipt guard, and a lock on editing received orders.
Agent tools
Five tools: list_purchase_orders, get_purchase_order, create_purchase_order, transition_purchase_order, receive_purchase_order. Each wraps the corresponding service method, filters by ctx.clinic_id, gated by the matching RBAC string, and returns native UUID/Decimal values (jsonify at the registry). All are marked exposes_free_text=True (item/supplier names and notes are user-entered prose kept off the cloud LLM path).
Tenancy
Every query filters by clinic_id; a cross-clinic supplier or item during creation 404s/400s and never leaks another clinic's rows.
Pricing note / follow-up (#227-2 integration)
PO lines carry a unit_price snapshot supplied at creation (or left null), and the create tool does not source it from anywhere — it is independent of supplier_items' per-vendor SKU/price (@#368). This keeps purchase_orders uncoupled from supplier_items for v1. Follow-up: once supplier_items (#227-2) ships, wire PO-line creation to source the per-vendor unit price automatically from the supplier-item link (supplier_id + inventory_item_id), with the manual snapshot as a fallback. That change touches PurchaseOrderService.create_order + the create_purchase_order tool only; no schema migration needed (unit_price already exists on purchase_order_lines).
Constraints
Own Alembic branch (purchase_orders), depending on contacts@con_0001 + inventory@inv_0002 — FKs only into contacts, inventory_items, and users. manifest.depends = ["contacts", "inventory", "suppliers"] (suppliers is declared for domain semantics; the code references suppliers purely as Contact(contact_type='supplier')). No hard DELETE — POs use the status lifecycle (L7): a cancelled/received order is never removed.
See ./permissions.md and ./events.md for full detail.
Frontend
One shared Nuxt layer owned here (backend/app/modules/purchase_orders/frontend/) covers the whole suite (#227): /procurement/suppliers, /procurement/items, /procurement/orders, /procurement/reorder, /procurement/ratings, with nav entries gated on each area's read permission and en/es locales under frontend/i18n/locales/. Screen docs live under docs/user-manual/{en,es}/purchase_orders/.