ProcureOps Procurement & Receiving Control — Answer Builder
You are turning a task prompt.txt plus a local memo/payload into a single JSON
answer that matches a provided answer_template.json, using a live ProcureOps ERP
API as the system of record. The grader compares your JSON field-by-field, so
precision on field names, enums, rounding, set membership, and date scoping is what
matters — not prose.
Golden rules (the whole skill in five lines)
- The API is the source of truth. Local memos/exports only name the anchors
(which ids to look at) and supply task-only numbers (tax rates, chargeback basis
quantities, opening balances). When a memo value disagrees with the API, the
API wins — except for values the API genuinely does not contain (see "Source
precedence").
- Match the template literally. Output exactly the keys it lists, with the
enum values it allows, the rounding it specifies, and the list ordering it
specifies. Do not add or rename keys.
- Scope to the as_of / cutoff / close date. Records dated after the scoping
date do not exist for your answer. This silently excludes "newer" rows.
- Treat list fields as sets (dedupe, then sort ascending) unless the template
says otherwise. Most templates say "sorted ascending" or "evaluator sorts";
either way, emit ascending-sorted, de-duplicated lists.
- Money rounds to cents (2 dp). Ratios/percentages use the precision the
template names (often 4 dp for ratios, 1 dp for percentages). Round only at
the end.
Step-by-step SOP
1. Read the three inputs first
input/prompt.txt — the business ask, the base URL, and any scoping date.
input/payloads/answer_template.json — the contract for your output. Read it
twice: once for the key skeleton, once for every allowed_values, ordering,
precision, required_value, and inline note ("as of as_of_date",
"use 0.00 when no receipt exists", etc.).
input/payloads/<memo> — names the anchor ids (programs, POs, receipts, invoices,
suppliers, contracts) and any task-only constants. Note its filename; some
templates ask you to echo it in a task_payloads_reviewed / supporting_only
field.
Pull out: the task_id (templates often hardcode the exact required value, e.g.
required_value: "train_NNN" — copy it verbatim), the program id, the scoping date,
and the explicit list of target ids to restrict the answer to.
2. Find the base URL and confirm the API is live
The base URL is in the prompt (commonly http://127.0.0.1:8006; a mirror at
http://127.0.0.1:8056 serves identical data — either works). Confirm with
GET /health and skim GET /manifest for record counts and anchor ids. Use curl
or Python urllib/requests; all calls are read-only GETs.
3. Pull every anchor record, then expand outward
For each anchor id named in the memo, fetch the record by id, then follow its
foreign keys (a PO points to program/supplier/contract/requisition; an invoice
points to PO/receipt/supplier; a receipt points to PO/supplier). See
references/api_field_map.md for the exact field on each record type and the
filter keys that actually work (notably: approval events filter on object_id, not
requisition_id; POs on a contract filter on contract_id).
Prefer id lookups (/collection/<id>) for known anchors and query filters
for "all rows matching X" (/ap/invoices?supplier_id=SUP-LUMA). Filters match a
field exactly, case-insensitively, including fields nested inside list values. List
responses are shaped {"count": n, "results": [...]} — iterate results.
4. Apply date scoping
Filter every derived list to records dated on/before the as_of/cutoff date. Use the
collection's primary date field (receipts.receipt_date, ap_invoices.invoice_date,
payments.scheduled_date, vendor_risk_events.event_date,
approval_events.event_date). You can pre-filter on the server with start=/end=,
but always re-check the boundary yourself — a record dated after as_of must be
dropped from "as of" lists (e.g. an on-hold invoice dated after the as_of date is
NOT an exception for that date).
5. Compute the derived values
Use the formulas in "Business rules" below. Round only at the very end.
6. Assemble and validate the JSON
- Every template key present, no extras, enums exactly as spelled.
- Lists de-duplicated and sorted ascending (string sort is fine for these ids).
- Numbers rounded per the template; integers stay integers (e.g.
quantity fields).
- Output only the JSON object — no markdown fence, no commentary.
A reusable helper is in scripts/procureops.py (a tiny GET client + rounding/sort
helpers); import it or copy the snippet. It is optional — plain curl works too.
Source precedence (API vs. local memo)
| Need |
Authoritative source |
| Program/supplier/contract/PO/receipt/invoice/payment/budget/approval/risk facts |
API record |
| Which ids are in scope |
Memo names them; verify each exists in the API |
| Tax rate, freight inclusion rule, opening AP balance, "good" approval actions |
Memo (task-only business controls) |
| Chargeback basis quantity / unit cost / approval status |
Local chargeback register in the payload (the API has no chargeback table) |
| Requester comments, "release this please" notes, stale PO-alias notes |
Supporting only — context, never a decision driver |
When a memo says e.g. "use the generated PO/receipt ids named here because the
PO-73xx ids aren't in the shared data", treat the named ids as the in-scope ids and
treat the stale-alias note as a supporting_only source, not authoritative.
Business rules and derived values
Budget / program headroom
remaining_budget (a.k.a. budget headroom) = budget_cap - committed_amount,
read from the program record or the matching budget_snapshot (they agree; the
snapshot also carries snapshot_id and snapshot_date). Do not subtract
pending_invoice_amount for headroom.
requested_subtotal = requested_quantity * contract_unit_price.
requested_tax = requested_subtotal * tax_rate (tax rate comes from the memo,
e.g. 7.25% → multiply by 0.0725). requested_total = subtotal + tax (+ freight
only if the memo supplies freight).
budget_after_change = remaining_budget - requested_total; budget_ok is
budget_after_change >= 0.
max_quantity_with_current_budget = floor(remaining_budget / (unit_price * (1 + tax_rate)))
— i.e. the largest integer quantity whose tax-loaded subtotal still fits the
remaining budget.
Contract ceiling / headroom
noncancelled_subtotal = sum of subtotal over all POs on the contract whose
status is not cancelled. Filter /purchase_orders?contract_id=<id>. Keep
the included PO ids (sorted) and the excluded cancelled PO ids (sorted)
separately when the template asks.
headroom_before_change = ceiling_amount - noncancelled_subtotal.
headroom_after_change = headroom_before_change - requested_subtotal.
ceiling_ok is headroom_after_change >= 0.
Quantity reconciliation (received vs ordered vs billed)
For a PO line with ordered_qty (PO line quantity), received_qty (sum of accepted
receipt-line quantity_received), rejected_qty (sum of quantity_rejected), and
billed_qty (invoice-line quantity_billed):
short_qty_vs_po = ordered_qty - received_qty.
unreceived_billed_qty = billed_qty - received_qty (floor at 0 conceptually).
receipt_completion_ratio = received_qty / ordered_qty (4 dp).
quantity_variance = billed_qty - received_qty.
quantity_variance_pct = quantity_variance / ordered_qty * 100 (1 dp; PO quantity
is the denominator, not billed quantity).
received_goods_value = received_qty * unit_price;
unreceived_goods_value = (billed_qty - received_qty) * unit_price.
Price reconciliation / three-way match
po_unit_price from the PO line, contract_unit_price from the contract,
invoice_unit_price from the invoice line. contract_price_match is true iff
invoice price equals contract price.
- A clean three-way match = invoice
status is approved/no hold, billed == received,
and price matches → release. Anything else holds.
Invoice financial totals
Use the API invoice fields directly: invoice_subtotal = subtotal,
invoice_freight = freight, invoice_tax = tax, invoice_total = total
(total = subtotal + freight + tax). Note the PO total excludes freight while
the invoice total includes it — don't cross them up.
AP hold / release decisions
Drive the decision from the invoice's own status and hold_code, then map to the
template's enum:
status = approved, hold_code = null, receipt present, qty matches → RELEASE /
release_to_payment=true; reason like APPROVED_THREE_WAY_MATCH.
status = on_hold with hold_code = QTY_VARIANCE → HOLD, reason QTY_VARIANCE.
status = pending_receipt or hold_code = NO_RECEIPT or receipt_id = null →
HOLD, reason NO_RECEIPT.
PRICE_VARIANCE, SUPPLIER_REVIEW are other real hold codes.
- Add
SCHEDULED_PAYMENT_FOUND when a matching scheduled payment exists (below).
Scheduled payments (close balance)
A payment reduces the close/vendor balance only if it matches a target invoice
(payments.invoice_id) AND its scheduled_date is on/before the cutoff the memo
gives (e.g. "scheduled through 2026-06-30"). Sum amount of those.
net_balance_impact = invoice_total - scheduled_payment_amount.
close_balance = opening_balance + invoice_total - scheduled_payments (opening
balance is the memo-stated slice opening, often 0.00).
balance_status: FULLY_SCHEDULED when scheduled covers the total and nothing is
held; OPEN_HELD when the invoice is held; OPEN_APPROVED when approved but not
yet fully scheduled.
Vendor-risk scoping ("open or monitoring as of date")
vendor_risk_events.status is one of open / monitoring / closed. An event
counts as an active/open supplier-risk event iff its status is open or
monitoring and its event_date is on/before the as_of date. Exclude closed.
severe/high-severity open events = those above plus severity = high (used for
fields like severe_open_event_ids; a watch-rated supplier with only
medium/low open events is usually "risk ok" / context-only).
- Supplier
risk_rating (low|watch|high) and status (active|...) come from the
supplier record; a watch rating alone is context, not a hard blocker, unless the
template/memo says otherwise.
Chargeback netting (AP release files)
Chargeback rows live in the local register payload, not the API. Each row has a
reason_code, basis_quantity, unit_cost, and status.
- chargeback amount
= basis_quantity * unit_cost.
- If
status = approved → it is an approved chargeback; net_release_amount = invoice_total - approved_chargeback_amount; decision
release_net_after_approved_chargeback.
- If
status = pending_quality_review (or the receipt is on inspection hold) → it is
a pending chargeback; hold the invoice, net_release_amount = 0, decision
hold_pending_quality_chargeback.
- If the invoice's PO has no receipt (receipt_id null / none on the PO) → decision
hold_missing_receipt, reason no_receipt_on_po, net 0. Represent a missing
receipt explicitly when the template expects a row (e.g. "MISSING:<po_id>").
- When one PO has multiple receipts, the in-scope receipt is the one the chargeback
register/invoice points at; the other receipt(s) on that PO go in
excluded_same_po_receipt_ids (a possible duplicate-receipt to hold for a separate
invoice).
Receiving exception codes (set per receipt)
Derive from receipt + PO + register, e.g.:
Underage Quantity: received < ordered.
Severe Unmatched Quantity: a large received-vs-ordered gap (a big short).
Inspection Hold: receipt status = inspection_hold (or a failed inspection line).
AP Quantity Variance: the register flags an AP qty variance for that receipt.
Map the receipt's chargeback status and resolution to the template enums
(net_release_ready, hold_for_quality_review, accepted_no_receiving_exception,
missing_receipt).
Nomination readiness (sourcing packets)
Per package line, choose the selected supplier (the contract/PO supplier for the
sku), then collect, as of the as_of date:
package_po_ids (the line's POs), receipt_evidence_ids (accepted receipts on
those POs), invoice_exception_ids (that supplier's on-hold / hold-coded invoices
relevant to the line, dated ≤ as_of), risk_event_ids (open/monitoring events
≤ as_of), and commercial_basis_id (the governing contract id, or null if none).
blocker_codes (sorted set) from: missing_contract (no active contract),
supplier_watch (supplier risk_rating = watch), open_supplier_risk (an
open/monitoring risk event), ap_hold (an on-hold invoice), pending_receipt
(billed/ordered but not yet received), late_due_date (PO due date passed with no
receipt). Use none only when there are zero blockers.
nomination_decision: nominate (no blockers), conditional_nomination (only
soft blockers like supplier_watch / a clearable ap_hold), hold (a hard blocker
like missing_contract, pending_receipt, or open_supplier_risk).
- Roll the line decisions up into the committee buckets and
send_to_committee
(no if any line is on hold). overall_readiness is not_ready if any line is
not_ready, else at_risk if any is at_risk, else ready.
Common misjudgments to avoid
- Trusting the memo's numbers over the API. Re-derive subtotals/totals/quantities
from API records; the memo's prose is often rounded or stale.
- Forgetting the as_of cutoff, so you include an invoice/receipt/risk event dated
after the scoping date. Drop anything later than the cutoff.
- Including closed risk events or cancelled POs. Exclude
closed events and
cancelled POs from active/headroom rollups (but list cancelled POs in the
dedicated "excluded" field when asked).
- Using billed quantity as the variance denominator. Percentage variance is
against PO ordered quantity.
- Mixing PO total (no freight) with invoice total (with freight).
- Subtracting pending_invoice_amount when computing headroom. Headroom is
cap - committed only.
- Emitting unsorted or duplicated id lists. Always dedupe + sort ascending unless
a template explicitly asks for a different order (e.g. "sort by po_line_id").
- Letting a requester's "please release" note drive a release. Notes are
supporting-only; the receipt/PO/AP records and the chargeback status decide.
- Adding narrative or extra keys. Return only the JSON the template defines.
Reference files
references/api_field_map.md — every endpoint, the fields it returns, which filter
keys work, and the enum value sets (PO/invoice/receipt/risk statuses, hold codes).
Read it before writing API calls so you query the right field names.
scripts/procureops.py — optional helper: get(path), get_list(path, **filters),
money(x), as_set(ids). Saves re-writing a GET client per task.
1---2name: procureops-control-answers3description: Produce the required JSON answer for ProcureOps ERP "Procurement Supplier and Receiving Control" tasks: sourcing nomination readiness packets, receiving/AP three-way reconciliation, AP close hold/release decisions, contract change-control decision files, budget/contract headroom checks, vendor-risk scoping, and AP release/chargeback netting. Use this skill WHENEVER a task references the ProcureOps API (a localhost service exposing /programs, /suppliers, /contracts, /purchase_orders, /receipts, /ap/invoices, /ap/payments, /approval_events, /budget_snapshots, /vendor_risk_events), asks you to fill an answer_template.json against ERP records, or mentions nomination, receiving control, AP hold/release, three-way match, chargeback, budget headroom, or supplier-risk-as-of-date — even if the words "ProcureOps" or "ERP" are not used but a localhost procurement API plus a JSON answer template are involved.4---56# ProcureOps Procurement & Receiving Control — Answer Builder78You are turning a task `prompt.txt` plus a local memo/payload into a single JSON9answer that matches a provided `answer_template.json`, using a live ProcureOps ERP10API as the system of record. The grader compares your JSON field-by-field, so11precision on field names, enums, rounding, set membership, and date scoping is what12matters — not prose.1314## Golden rules (the whole skill in five lines)15161. **The API is the source of truth. Local memos/exports only name the anchors17 (which ids to look at) and supply task-only numbers (tax rates, chargeback basis18 quantities, opening balances).** When a memo value disagrees with the API, the19 API wins — except for values the API genuinely does not contain (see "Source20 precedence").212. **Match the template literally.** Output exactly the keys it lists, with the22 enum values it allows, the rounding it specifies, and the list ordering it23 specifies. Do not add or rename keys.243. **Scope to the as_of / cutoff / close date.** Records dated *after* the scoping25 date do not exist for your answer. This silently excludes "newer" rows.264. **Treat list fields as sets (dedupe, then sort ascending) unless the template27 says otherwise.** Most templates say "sorted ascending" or "evaluator sorts";28 either way, emit ascending-sorted, de-duplicated lists.295. **Money rounds to cents (2 dp). Ratios/percentages use the precision the30 template names (often 4 dp for ratios, 1 dp for percentages).** Round only at31 the end.3233## Step-by-step SOP3435### 1. Read the three inputs first36- `input/prompt.txt` — the business ask, the base URL, and any scoping date.37- `input/payloads/answer_template.json` — the **contract for your output**. Read it38 twice: once for the key skeleton, once for every `allowed_values`, `ordering`,39 `precision`, `required_value`, and inline note (`"as of as_of_date"`,40 `"use 0.00 when no receipt exists"`, etc.).41- `input/payloads/<memo>` — names the anchor ids (programs, POs, receipts, invoices,42 suppliers, contracts) and any task-only constants. Note its filename; some43 templates ask you to echo it in a `task_payloads_reviewed` / `supporting_only`44 field.4546Pull out: the `task_id` (templates often hardcode the exact required value, e.g.47`required_value: "train_NNN"` — copy it verbatim), the program id, the scoping date,48and the explicit list of target ids to restrict the answer to.4950### 2. Find the base URL and confirm the API is live51The base URL is in the prompt (commonly `http://127.0.0.1:8006`; a mirror at52`http://127.0.0.1:8056` serves identical data — either works). Confirm with53`GET /health` and skim `GET /manifest` for record counts and anchor ids. Use `curl`54or Python `urllib`/`requests`; all calls are read-only GETs.5556### 3. Pull every anchor record, then expand outward57For each anchor id named in the memo, fetch the record by id, then follow its58foreign keys (a PO points to program/supplier/contract/requisition; an invoice59points to PO/receipt/supplier; a receipt points to PO/supplier). See60`references/api_field_map.md` for the exact field on each record type and the61filter keys that actually work (notably: approval events filter on `object_id`, not62`requisition_id`; POs on a contract filter on `contract_id`).6364Prefer **id lookups** (`/collection/<id>`) for known anchors and **query filters**65for "all rows matching X" (`/ap/invoices?supplier_id=SUP-LUMA`). Filters match a66field exactly, case-insensitively, including fields nested inside list values. List67responses are shaped `{"count": n, "results": [...]}` — iterate `results`.6869### 4. Apply date scoping70Filter every derived list to records dated on/before the as_of/cutoff date. Use the71collection's primary date field (`receipts.receipt_date`, `ap_invoices.invoice_date`,72`payments.scheduled_date`, `vendor_risk_events.event_date`,73`approval_events.event_date`). You can pre-filter on the server with `start=`/`end=`,74but always re-check the boundary yourself — a record dated *after* as_of must be75dropped from "as of" lists (e.g. an on-hold invoice dated after the as_of date is76NOT an exception for that date).7778### 5. Compute the derived values79Use the formulas in "Business rules" below. Round only at the very end.8081### 6. Assemble and validate the JSON82- Every template key present, no extras, enums exactly as spelled.83- Lists de-duplicated and sorted ascending (string sort is fine for these ids).84- Numbers rounded per the template; integers stay integers (e.g. `quantity` fields).85- Output **only** the JSON object — no markdown fence, no commentary.8687A reusable helper is in `scripts/procureops.py` (a tiny GET client + rounding/sort88helpers); import it or copy the snippet. It is optional — plain curl works too.8990## Source precedence (API vs. local memo)9192| Need | Authoritative source |93| --- | --- |94| Program/supplier/contract/PO/receipt/invoice/payment/budget/approval/risk facts | **API record** |95| Which ids are in scope | Memo names them; verify each exists in the API |96| Tax rate, freight inclusion rule, opening AP balance, "good" approval actions | Memo (task-only business controls) |97| Chargeback basis quantity / unit cost / approval status | **Local chargeback register** in the payload (the API has no chargeback table) |98| Requester comments, "release this please" notes, stale PO-alias notes | **Supporting only** — context, never a decision driver |99100When a memo says e.g. "use the generated PO/receipt ids named here because the101PO-73xx ids aren't in the shared data", treat the named ids as the in-scope ids and102treat the stale-alias note as a *supporting_only* source, not authoritative.103104## Business rules and derived values105106### Budget / program headroom107- `remaining_budget` (a.k.a. budget headroom) `= budget_cap - committed_amount`,108 read from the program record or the matching `budget_snapshot` (they agree; the109 snapshot also carries `snapshot_id` and `snapshot_date`). Do **not** subtract110 `pending_invoice_amount` for headroom.111- `requested_subtotal = requested_quantity * contract_unit_price`.112- `requested_tax = requested_subtotal * tax_rate` (tax rate comes from the memo,113 e.g. 7.25% → multiply by 0.0725). `requested_total = subtotal + tax` (+ freight114 only if the memo supplies freight).115- `budget_after_change = remaining_budget - requested_total`; `budget_ok` is116 `budget_after_change >= 0`.117- `max_quantity_with_current_budget = floor(remaining_budget / (unit_price * (1 + tax_rate)))`118 — i.e. the largest integer quantity whose tax-loaded subtotal still fits the119 remaining budget.120121### Contract ceiling / headroom122- `noncancelled_subtotal` = sum of `subtotal` over all POs on the contract whose123 status is **not** `cancelled`. Filter `/purchase_orders?contract_id=<id>`. Keep124 the included PO ids (sorted) and the excluded `cancelled` PO ids (sorted)125 separately when the template asks.126- `headroom_before_change = ceiling_amount - noncancelled_subtotal`.127- `headroom_after_change = headroom_before_change - requested_subtotal`.128- `ceiling_ok` is `headroom_after_change >= 0`.129130### Quantity reconciliation (received vs ordered vs billed)131For a PO line with `ordered_qty` (PO line quantity), `received_qty` (sum of accepted132receipt-line `quantity_received`), `rejected_qty` (sum of `quantity_rejected`), and133`billed_qty` (invoice-line `quantity_billed`):134- `short_qty_vs_po = ordered_qty - received_qty`.135- `unreceived_billed_qty = billed_qty - received_qty` (floor at 0 conceptually).136- `receipt_completion_ratio = received_qty / ordered_qty` (4 dp).137- `quantity_variance = billed_qty - received_qty`.138- `quantity_variance_pct = quantity_variance / ordered_qty * 100` (1 dp; PO quantity139 is the denominator, not billed quantity).140- `received_goods_value = received_qty * unit_price`;141 `unreceived_goods_value = (billed_qty - received_qty) * unit_price`.142143### Price reconciliation / three-way match144- `po_unit_price` from the PO line, `contract_unit_price` from the contract,145 `invoice_unit_price` from the invoice line. `contract_price_match` is true iff146 invoice price equals contract price.147- A clean three-way match = invoice `status` is `approved`/no hold, billed == received,148 and price matches → release. Anything else holds.149150### Invoice financial totals151Use the API invoice fields directly: `invoice_subtotal = subtotal`,152`invoice_freight = freight`, `invoice_tax = tax`, `invoice_total = total`153(`total = subtotal + freight + tax`). Note the **PO** `total` excludes freight while154the **invoice** `total` includes it — don't cross them up.155156### AP hold / release decisions157Drive the decision from the invoice's own `status` and `hold_code`, then map to the158template's enum:159- `status = approved`, `hold_code = null`, receipt present, qty matches → RELEASE /160 `release_to_payment=true`; reason like `APPROVED_THREE_WAY_MATCH`.161- `status = on_hold` with `hold_code = QTY_VARIANCE` → HOLD, reason `QTY_VARIANCE`.162- `status = pending_receipt` or `hold_code = NO_RECEIPT` or `receipt_id = null` →163 HOLD, reason `NO_RECEIPT`.164- `PRICE_VARIANCE`, `SUPPLIER_REVIEW` are other real hold codes.165- Add `SCHEDULED_PAYMENT_FOUND` when a matching scheduled payment exists (below).166167### Scheduled payments (close balance)168A payment reduces the close/vendor balance only if it matches a target invoice169(`payments.invoice_id`) AND its `scheduled_date` is on/before the cutoff the memo170gives (e.g. "scheduled through 2026-06-30"). Sum `amount` of those.171- `net_balance_impact = invoice_total - scheduled_payment_amount`.172- `close_balance = opening_balance + invoice_total - scheduled_payments` (opening173 balance is the memo-stated slice opening, often 0.00).174- `balance_status`: `FULLY_SCHEDULED` when scheduled covers the total and nothing is175 held; `OPEN_HELD` when the invoice is held; `OPEN_APPROVED` when approved but not176 yet fully scheduled.177178### Vendor-risk scoping ("open or monitoring as of date")179`vendor_risk_events.status` is one of `open` / `monitoring` / `closed`. An event180counts as an active/open supplier-risk event iff its status is `open` **or**181`monitoring` and its `event_date` is on/before the as_of date. Exclude `closed`.182- `severe`/high-severity open events = those above plus `severity = high` (used for183 fields like `severe_open_event_ids`; a `watch`-rated supplier with only184 medium/low open events is usually "risk ok" / context-only).185- Supplier `risk_rating` (`low|watch|high`) and `status` (`active|...`) come from the186 supplier record; a `watch` rating alone is context, not a hard blocker, unless the187 template/memo says otherwise.188189### Chargeback netting (AP release files)190Chargeback rows live in the **local register** payload, not the API. Each row has a191`reason_code`, `basis_quantity`, `unit_cost`, and `status`.192- chargeback amount `= basis_quantity * unit_cost`.193- If `status = approved` → it is an approved chargeback; `net_release_amount =194 invoice_total - approved_chargeback_amount`; decision195 `release_net_after_approved_chargeback`.196- If `status = pending_quality_review` (or the receipt is on inspection hold) → it is197 a pending chargeback; hold the invoice, `net_release_amount = 0`, decision198 `hold_pending_quality_chargeback`.199- If the invoice's PO has **no receipt** (receipt_id null / none on the PO) → decision200 `hold_missing_receipt`, reason `no_receipt_on_po`, net 0. Represent a missing201 receipt explicitly when the template expects a row (e.g. `"MISSING:<po_id>"`).202- When one PO has multiple receipts, the in-scope receipt is the one the chargeback203 register/invoice points at; the other receipt(s) on that PO go in204 `excluded_same_po_receipt_ids` (a possible duplicate-receipt to hold for a separate205 invoice).206207### Receiving exception codes (set per receipt)208Derive from receipt + PO + register, e.g.:209- `Underage Quantity`: received < ordered.210- `Severe Unmatched Quantity`: a large received-vs-ordered gap (a big short).211- `Inspection Hold`: receipt `status = inspection_hold` (or a failed inspection line).212- `AP Quantity Variance`: the register flags an AP qty variance for that receipt.213Map the receipt's chargeback status and resolution to the template enums214(`net_release_ready`, `hold_for_quality_review`, `accepted_no_receiving_exception`,215`missing_receipt`).216217### Nomination readiness (sourcing packets)218Per package line, choose the selected supplier (the contract/PO supplier for the219sku), then collect, **as of the as_of date**:220- `package_po_ids` (the line's POs), `receipt_evidence_ids` (accepted receipts on221 those POs), `invoice_exception_ids` (that supplier's on-hold / hold-coded invoices222 relevant to the line, dated ≤ as_of), `risk_event_ids` (open/monitoring events223 ≤ as_of), and `commercial_basis_id` (the governing contract id, or null if none).224- `blocker_codes` (sorted set) from: `missing_contract` (no active contract),225 `supplier_watch` (supplier risk_rating = watch), `open_supplier_risk` (an226 open/monitoring risk event), `ap_hold` (an on-hold invoice), `pending_receipt`227 (billed/ordered but not yet received), `late_due_date` (PO due date passed with no228 receipt). Use `none` only when there are zero blockers.229- `nomination_decision`: `nominate` (no blockers), `conditional_nomination` (only230 soft blockers like supplier_watch / a clearable ap_hold), `hold` (a hard blocker231 like missing_contract, pending_receipt, or open_supplier_risk).232- Roll the line decisions up into the committee buckets and `send_to_committee`233 (`no` if any line is on hold). `overall_readiness` is `not_ready` if any line is234 not_ready, else `at_risk` if any is at_risk, else `ready`.235236## Common misjudgments to avoid237- **Trusting the memo's numbers over the API.** Re-derive subtotals/totals/quantities238 from API records; the memo's prose is often rounded or stale.239- **Forgetting the as_of cutoff**, so you include an invoice/receipt/risk event dated240 after the scoping date. Drop anything later than the cutoff.241- **Including closed risk events** or **cancelled POs**. Exclude `closed` events and242 `cancelled` POs from active/headroom rollups (but list cancelled POs in the243 dedicated "excluded" field when asked).244- **Using billed quantity as the variance denominator.** Percentage variance is245 against PO ordered quantity.246- **Mixing PO total (no freight) with invoice total (with freight).**247- **Subtracting pending_invoice_amount when computing headroom.** Headroom is248 `cap - committed` only.249- **Emitting unsorted or duplicated id lists.** Always dedupe + sort ascending unless250 a template explicitly asks for a different order (e.g. "sort by po_line_id").251- **Letting a requester's "please release" note drive a release.** Notes are252 supporting-only; the receipt/PO/AP records and the chargeback status decide.253- **Adding narrative or extra keys.** Return only the JSON the template defines.254255## Reference files256- `references/api_field_map.md` — every endpoint, the fields it returns, which filter257 keys work, and the enum value sets (PO/invoice/receipt/risk statuses, hold codes).258 Read it before writing API calls so you query the right field names.259- `scripts/procureops.py` — optional helper: `get(path)`, `get_list(path, **filters)`,260 `money(x)`, `as_set(ids)`. Saves re-writing a GET client per task.