Overview
A sup_order_product is one product row inside a sup_order (the "supplier document" — its slug is legacy; document_type distinguishes Order / Packing Slip / Invoice). It records what a supplier is being asked for / shipped / billed for, at what quantity and price, and how that line maps onto a catalog cat_product.
Two identities per row
▶Every row carries both:
- The supplier's view —
supplier_name,sup_pcn,price(what the invoice/PDF literally said). - The catalog link —
product→cat_product(what it resolved to internally).
Most lifecycle complexity is reconciling these two: detecting drift (ai_detected_status), colouring the divergence, and offering one-click "apply to catalog" actions.
At a glance
▶sup_document→ parentsup_orderproduct→cat_product(required)product_name_search— caption field, inline searchquantity·received_quantity·price·total_price(calc)order_status— mirror ofsup_document::statusai_detected_status— NEW / UPDATE / VARIANT / ∅manually_inserted·linked_order_product·ai_run_guid
sup_order via sup_document; order is a deprecated parallel link kept only for Order-typed documents.The three documents a row can live in
| document_type | Meaning | Row editable when | Stock effect |
|---|---|---|---|
| ORDER | PO sent to supplier | sup_document::status ∈ {OPEN} | Affects available_stock only (pending) |
| PACKING_SLIP | Goods receipt | order_status ∈ SUP_ORDER_IS_EDITABLE = {OPEN, SENT} | On status→DELIVERED: stock movement cat-stock.php:126-135 |
| INVOICE | Bill to pay | order_status ∈ {OPEN, SENT} | Same DELIVERED rule |
Data Model
Fields of sup_order_product — canopy/sup/sup-orders.php:495-570. Permission helper cy_set_sup_order_product_permissions() at :452-458.
| Field | Type | Default / Calc | Permission & notes |
|---|---|---|---|
sup_document | sup_order | — | READABLE. Parent document. item_go. |
order | sup_order | — | NONE / "TODO DELETE". Only set for Order-typed parents & linked siblings. |
linked_order_product | sup_order_product | — | Back-link from a packing-slip/invoice row to the originating Order row. |
supplier | sup_supplier | sup_document::supplier | Required. |
branch | pos_branch | sup_document::branch | READABLE. |
order_status | sup_order_status | sup_document::status | READABLE; mirror, kept in sync by document status changes & an update-now backfill :1671-1672. |
supplier_name | string | product::name | Supplier's label for the product (from invoice text). |
product_name_search | search | — | Caption field. search_keep, table_edit, channel sup-products; extra action navigate_cat_product. Editable when status ∈ {SENT,OPEN}. |
product | cat_product | — | Required, READABLE (changed only via resolver code). item_go. |
sup_pcn | string | product::sup_pcn | Supplier catalogue number from the document. |
sup_pcn_regex | string | product::sup_pcn_regex | READABLE. PCN after supplier regex — the AI match key. |
quantity | integer | req. | table_edit. Writeable only while document OPEN (Order) / order_status OPEN. |
received_quantity | integer | — | Hidden until status ≥ relevant; writeable on packing-slip/invoice when status OPEN. |
price | float | product::sup_price_after_discount | table_edit; editable in SUP_ORDER_IS_EDITABLE. |
total_price | float | calc price × quantity | READABLE. Rolls up to sup_document.total_price via recalc :1278. |
description | string | — | Writeable in editable states. Presence is used as a "manually distinct row" marker in dedup queries. |
stock_movement | cat_stock_movement | — | READABLE link to the movement generated on delivery. |
ai_detected_status | ai_sup_order_detected_status | ∅ | READABLE. NEW / UPDATE / VARIANT — see Status tab. |
manually_inserted | grow_yes_no_answer | — | YES when added via search/scan/+; drives full-row replacement on product change. |
ai_run_guid | string | — | Per-extraction GUID; prevents re-matching rows created in the same AI run. |
sup_order_recalc_total_price() → recalc_sup_document_total_price sums all rows into the document total canopy/sup/sup-orders.php:628-633, 1278+.Creation Paths
Five ways a row comes into existence. Click a card to expand. All manual paths funnel through add_product_to_order() canopy/sup/sup-orders-methods.php:618-653, which is gated by the tc_sup_add_order_product filter :1568-1628.
1 · Search bar / quick-list / barcode scan
▶Inside a sup_order creator/single, the cashier types in product_name_search or scans a barcode.
- Trigger.
+or pick from quick-list → actionsup_order_search_product_name; barcode →sup_product_scansup-orders-methods.php:1243-1314. - Resolve to a cat_product. Exact
pos_display_nameMATCH on hierarchy ∈ {SINGLE, PARENT}, ACTIVE, GOODS. Exactly one hit → that product; otherwise opensup_search_product_dialog:1288-1311. - Filter gate.
add_product_to_order()callstc_apply_filters('tc_sup_add_order_product',…). The hierarchy resolver runs here — see the dedicated card below. - Insert or increment. If an identical (
product,sup_document, nodescription) row exists →increment_meta('quantity',1); elsetc_insert_item('sup_order_product',…)withmanually_inserted = YES,quantity = 1:622-637. - Fan-out to linked docs. For every related
sup_orderwhoseorder= this document, a mirror row is added withquantity 0and itslinked_order_productset :638-645. - Post-insert hook.
tc_item_insertedmay open the product lightbox if pcn/price missing; recalcs document total; refund docs negate the quantity canopy/sup/sup-orders.php:843-864.
tc_add_sup_product_to_sup_order() (from a catalog product's "add to order" button) finds/creates the supplier's OPEN Order then opens a creator dialog sup-orders-methods.php:675-698.1b · The hierarchy resolver (tc_sup_add_order_product)
▶sup-orders-methods.php:1568-1628. Runs before every manual add; decides whether the chosen product is added as-is, swapped to a same-supplier child, or sent to the "create child" dialog.
| Chosen product hierarchy | Action |
|---|---|
| PARENT, ≥1 same-supplier variant | Add that variant. If exactly 1 → add it; if >1 → TODO/unimplemented (no-op). |
| PARENT, only other-supplier variants | Open sup_order_create_child_product_dialog to make a child for this supplier. |
| SINGLE, no supplier | Stamp the order's supplier onto the product, add it. |
| SINGLE, different supplier | Open create-child dialog (becomes a variant). |
| CHILD, wrong supplier | Reject (return false). |
The create-child dialog submit calls sup_order_create_child_product() then add_product_to_order() with the new child canopy/sup/sup-orders-hierarchy.php:118-183.
2 · Invoice / Packing-slip PDF extraction
▶User uploads a PDF to a sup_order and runs action ai_analyze_receipt → cat_ai_analyze_receipt() → ai_analyze_receipt_update_order() canopy/ai/ai-analyze-invoice.php:135-305. Runs in background; no reqres.
- Header. Detect/normalise invoice number, date, subtotal; resolve supplier (
business_number → name → sender email); read supplierpcn_regex:135-172. - Per product line (loop :184-300) compute
price_after_discount(from total ÷ qty ifTC_CAT_AI_PRICE_FROM_TOTAL), apply VAT correction, regex the item number →pcn_after_regex. - Dedup vs the underlying document. If a row with same
sup_pcn_regex(or samesupplier_name/description) already exists and is from a differentai_run_guid→ just update its price/qty andcontinue(no new row) :210-227. - Match to catalog. By
sup_pcn_regex+supplier → product; else byname→ product (then the hierarchy walk: SINGLE may absorb supplier; CHILD→its parent; PARENT→search same-supplier child, miss ⇒ keep parent + VARIANT); else fall back toai_default_product+ NEW :229-280. - Status. If no status forced yet,
sup_order_is_product_change()compares price/pcn/name → UPDATE or ∅ :283-285. - Insert row with
product,quantity,price,product_name_search = description,supplier_name,sup_pcn,sup_pcn_regex,ai_detected_status,ai_run_guid:287-298.
3 · Email reader (inbound supplier emails)
▶Module email_reader_module. canopy/sync/sync-email-sup-orders.php:141-238.
- IMAP fetcher uploads message attachments → one
sup_order_email_attachmentper file, statusPENDING:184-190. - PDF/image attachments: create (or reuse, on retry) a
sup_order, then callcat_ai_analyze_receipt($sup_document,'invoice_file',false,true,true):196-216 — i.e. the same path as card 2, which creates thesup_order_productrows. - Roll attachment status up to
PROCESSED/FAILED; non-doc attachments →SKIPPED:225-238.
sup_order_product: identical to PDF extraction — the email layer only orchestrates document creation and retry idempotency.4 · Copied / duplicated between documents
▶- Duplicate document — action
duplicate_sup_order: every rowtc_duplicate_item'd into the newsup_orderwithlinked_order_product/received_quantitycleared, under a global$sup_order_products_auto_creationguard that suppresses the post-insert side effects sup-orders-methods.php:1208-1225. - Order → Packing-slip / Invoice fan-out — adding a product to an Order also seeds linked rows in its child documents (qty 0,
linked_order_productset) via the recursion inadd_product_to_order():638-645. The reverse reconcile ischeck_for_new_products_to_update_in_sup_order()canopy/sup/sup-orders.php:1630-1649. - Invoice from packing slips —
sup_documents_connection_dialog_submit()creates an Invoicesup_order+sup_order_connectionrows and closes the slips; the line totals are carried on the connection, not re-created as product rows canopy/sup/sup-order-connections.php:341-375.
5 · Creator dialog & create-child variants
▶The standard tc_creator dialog (tc_creator_dialog_fields = product, quantity, price, total_price) and the hierarchy sup_order_create_child_product_dialog both end in add_product_to_order(). The AI-validation dialog (card on Methods tab) can also create rows indirectly by re-running update_sup_order_product_execute().
Flow Diagram — Creation & Update Paths
End-to-end map: five creation sources at the top converge on the live row; below it, the three update / reconciliation mechanisms and their terminal effects. Click any block to open a sub-diagram of its internal logic (or focus it with Tab and press Enter). Pan with scroll on narrow screens.
add_product_to_order()) but lands in the same row-exists state, so it is grouped under "Row insertion". The green dashed feedback edge marks rows whose reconciliation mutated product/catalog and effectively re-enter the row state cleared (∅).Change Handling
All field edits route through tc_meta_changed → tc_sup_orders_meta_changed() canopy/sup/sup-orders.php:922-1024 (plus stock hooks in cat-stock.php:137-176).
Field-by-field
| Field changed | Effect | Ref |
|---|---|---|
product_name_search (free text, not a pick) | Try exact pos_display_name match on SINGLE/PARENT ACTIVE → set product; else fall back to ai_default_product. Refresh price-list linked fields. | :924-955 |
quantity / price | sup_order_recalc_total_price(); on price also set_ai_detected_status_update() + reclass + refresh methods. | :956-964 |
sup_pcn | update_cat_product_sup_pcn_regex() then fall through to ↓. | :965-967 |
supplier_name | set_ai_detected_status_update() + reclass. | :968-971 |
product | The big resolver — see below. | :972-1022 |
The product change resolver :972-1022
- Manually inserted? →
cy_sup_order_product_change_linked_product()does a full row replacement (product, supplier_name, name-search, pcn, price) :975-976, 1388-1406. - New product = ai_default? → mark NEW.
- New product is PARENT? → find child with
parent+supplier=row.supplier: hit ⇒ bind to child & clear status; miss ⇒ VARIANT. - CHILD with matching supplier → set
product_name_searchto parent's display name, clear status. - SINGLE no supplier → stamp row's supplier, clear status. SINGLE other supplier → VARIANT. SINGLE same supplier → clear.
- Finally, if status still empty,
sup_order_is_product_change()may set UPDATE; reclass.
Stock side-effects
| Event | Effect | Ref |
|---|---|---|
parent sup_order.status → DELIVERED | update_sup_order_products_stock() → cat_create_stock_movement() per row (sign by qty & direction); manufacturing plans diverted. | cat-stock.php:50-68, 126-135 |
sup_order.status leaves DELIVERED | Reverse movement (sign −1). | cat-stock.php:131-133 |
| row qty/handled changes on an open ORDER | recalc_cat_product_available_stock() (Order rows reduce available_stock, not stock). | cat-stock.php:164-173 |
parent sup_order.status changes | update_sup_document_order_products_field(order_status) pushes the new status onto every child row. | sup-orders.php:1085-1086 |
Status & Colors
order_status — mirror of the document
Enum sup_order_status canopy/sup/sup.php:11-19. The row's order_status is a denormalised copy of sup_document::status, propagated on document status change and backfilled by an update-now walk sup-orders.php:1671-1672.
Editable window: SUP_ORDER_IS_EDITABLE = {OPEN, SENT} for packing-slip/invoice rows; Order rows editable only while the document is OPEN cy_set_sup_order_product_permissions, sup-orders.php:452-458. Outside that window the whole row is forced READABLE tc_sup_orders_item_permission :1408-1424; the deleter is FORBIDDEN once DELIVERED/PAID/CANCELLED/CLOSED sup-orders-methods.php:874-877.
ai_detected_status — reconciliation state machine
Enum ai_sup_order_detected_status sup-orders.php:14-17. Set by extraction/resolvers; cleared (delete_meta) once reconciled by update_sup_order_product_execute().
| Status | Means | Resolution (update_sup_order_product_execute) |
|---|---|---|
| NEW | No catalog match — bound to ai_default_product. | If a real product now exists by (supplier,sup_pcn) or (supplier,name) → link it & patch prices; else create_cat_product_from_ai_product() sup-orders-methods.php:511-533. |
| UPDATE | Matched, but invoice price / sup_pcn / name differs from catalog. | Push sup_pcn, name, recomputed sup_price onto the cat_product :534-543. |
| VARIANT | Matched a parent/other-supplier single — needs a same-supplier child. | SINGLE→create child; PARENT→clone a sibling as new child; CHILD→log "should not happen" :544-575. |
| ∅ | Reconciled / no drift. | None. update_sup_order_product method is FORBIDDEN :884-887. |
Row colours — tc_table_row_classes sup-orders.php:1168-1229 · CSS canopy/sup/sup.css:34-53
Colour is applied to the specific cell, not the whole row. Computed live whenever the row's product/price/pcn/name/qty diverge.
#e3fce3lightyellow#fce5cdproduct::sup_price_after_discountproduct::namequantity ≠ received_quantity · #F0B27Aai_default_product (also via tc_item_field_defs :1261-1275)Methods & Actions
Declared on the item type: tc_methods = [deleter, update_sup_order_product, update_cat_product_prices]; tc_multiple_methods/tc_context_methods = [update_sup_order_product] sup-orders.php:476-478. Gating in tc_sup_orders_methods_item_method_defs() sup-orders-methods.php:869-913; execution in tc_method_execute :1149-1351.
| Method | What it does | Available when | Ref |
|---|---|---|---|
update_sup_order_product | Opens the AI-validation dialog for this row (also multiple/context). | Row has an ai_detected_status (else FORBIDDEN). | :884-887, 1170-1173 |
update_cat_product_prices | Opens the product (or its parent) lightbox with prices_edit_status=ON. | FORBIDDEN when status = NEW. | :879-882, 581-598 |
sup_order_product deleter | Deletes the row (recalcs totals). | FORBIDDEN once order_status ∈ {DELIVERED,PAID,CANCELLED,CLOSED}. | :874-877 |
sup_order_search_product_name | Channel action: bind clicked product → set product + name-search. | Editable states. | :1154-1169 |
navigate_cat_product | Extra action on the name-search field — jump to the catalog product. | Always (link). | field def :525 |
document-level: ai_analyze_receipt, duplicate_sup_order, sup_product_scan, status changers, update_sup_order_products | Operate on the parent sup_order but create/mutate its rows. | Various — e.g. analyze needs invoice_file & editable status. | :1206-1351, :891-913 |
The AI-validation dialog flow
insert_sup_order_products_validate_ai_updates_dialog() sup-orders-methods.php:703-739 buckets rows by ai_detected_status into three link lists on sup_order_products_validate_ai_updates_dialog :244-285.
- Caller: per-row
update_sup_order_productmethod (1 row) or documentupdate_sup_order_products(all rows). Existing dialogs are deleted first to avoid stacking. - Rows with no status but real drift get a late UPDATE via
sup_order_is_product_change():716-722. - Buckets:
update_products/variant_products/new_productslink collections, each rendered as a links table. - On submit, every bucketed row runs
update_sup_order_product_execute():655-673 — applying the resolutions in the Status tab and clearingai_detected_status.
cy_sup_order_product_change_linked_product() is the shared "rebind row to a product" primitive: with $use_order_product_prices=true it pushes row → catalog (NEW reconciled into existing); false pulls catalog → row sup-orders.php:1388-1406.Hooks Reference
Every filter/action that touches sup_order_product, consolidated.
| Hook | Handler | Role for sup_order_product | Ref |
|---|---|---|---|
| tc_item_inserted | tc_sup_orders_item_inserted | Open lightbox if pcn/price missing; refund qty negate; recalc total; methods refresh on first row. | sup-orders.php:843-866 |
| tc_item_inserted | tc_cat_stock_item_inserted | Supply docs → stock movement; open ORDER → available_stock recalc. | cat-stock.php:180-212 |
| tc_item_deleted | tc_sup_orders_item_deleted | Recalc document total; refresh product; methods refresh when last row gone. | sup-orders.php:868-884 |
| tc_item_deleted | tc_cat_stock_item_deleted | Open ORDER row deleted → available_stock recalc. | cat-stock.php:214-232 |
| tc_meta_changed | tc_sup_orders_meta_changed | Field resolvers (product/name-search/qty/price/pcn/supplier_name). | sup-orders.php:922-1024 |
| tc_meta_changed | tc_cat_stock_meta_changed | sup_order status→stock; ORDER row qty/handled→available_stock. | cat-stock.php:126-176 |
| tc_table_row_classes | tc_sup_orders_table_row_classes | Divergence & AI-status cell colours. | sup-orders.php:1168-1229 |
| tc_item_field_defs | tc_sup_orders_item_field_defs | ai-product class on name-search when bound to default. | sup-orders.php:1261-1275 |
| tc_item_permission | tc_sup_orders_item_permission | Force READABLE outside editable order_status. | sup-orders.php:1408-1424 |
| tc_item_method_defs | tc_sup_orders_methods_item_method_defs | Gate deleter / update methods by status. | sup-orders-methods.php:869-913 |
| tc_method_execute | tc_sup_orders_methods_method_execute | Execute row/document actions. | sup-orders-methods.php:1149-1351 |
| tc_sup_add_order_product | tc_sup_orders_add_order_product | Hierarchy resolver gate before insert. | sup-orders-methods.php:1568-1628 |
| recalc | recalc_sup_document_total_price | Sum rows → document total. | sup-orders.php:1278+ |
Edge Cases & Gotchas
ordervssup_document.sup_documentis the real parent;orderis a deprecated link only meaningful for Order-typed documents and the linked-row fan-out. New code should never key offorder.descriptionas a dedup discriminator. Increment-vs-insert logic inadd_product_to_order()only collapses rows that have nodescription. A described row is always kept distinct.$sup_order_products_auto_creationguard. Bulk duplication sets this global sotc_item_insertedskips lightbox/recalc side effects — forgetting it during programmatic copies causes lightbox storms.- Same-supplier >1 variant is unimplemented. The PARENT branch of the hierarchy resolver no-ops when a parent has multiple same-supplier children (see the sibling plan
cat_product_same_supplier_variants.md). - AI runs in background.
ai_analyze_receipt_update_order()cannot usetc_reqres_*; UI only refreshes on the next request.ai_run_guidis the only guard against re-matching rows from the same extraction. - VARIANT suppresses divergence colours. Don't read "no yellow price cell" as "price matches" on a VARIANT row.
order_statusis denormalised. It can lag if a document status change path bypassesupdate_sup_document_order_products_field(); the update-now walk :1671-1672 is the repair.- Stock only moves on DELIVERED. Editing quantities on a non-Order document before it is DELIVERED has no stock effect; the movement is computed from row quantity at the moment of the
status→DELIVEREDtransition.