sup_order_product — Full Lifecycle Flow

Line item inside a supplier document (sup_order: Order · Packing Slip · Invoice). Reverse-engineered from canopy/sup/, canopy/ai/, canopy/sync/, canopy/cat/ — Grow / TC framework. Audience: developers.

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.

CORE IDEA

Two identities per row

Every row carries both:

  • The supplier's viewsupplier_name, sup_pcn, price (what the invoice/PDF literally said).
  • The catalog linkproductcat_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.

KEY FIELDS

At a glance

  • sup_document → parent sup_order
  • productcat_product (required)
  • product_name_search — caption field, inline search
  • quantity · received_quantity · price · total_price (calc)
  • order_status — mirror of sup_document::status
  • ai_detected_status — NEW / UPDATE / VARIANT / ∅
  • manually_inserted · linked_order_product · ai_run_guid
Registration: canopy/sup/sup-orders.php:461-577. The row is a child of 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_typeMeaningRow editable whenStock effect
ORDERPO sent to suppliersup_document::status ∈ {OPEN}Affects available_stock only (pending)
PACKING_SLIPGoods receiptorder_status ∈ SUP_ORDER_IS_EDITABLE = {OPEN, SENT}On status→DELIVERED: stock movement cat-stock.php:126-135
INVOICEBill to payorder_status ∈ {OPEN, SENT}Same DELIVERED rule

Data Model

Fields of sup_order_productcanopy/sup/sup-orders.php:495-570. Permission helper cy_set_sup_order_product_permissions() at :452-458.

FieldTypeDefault / CalcPermission & notes
sup_documentsup_orderREADABLE. Parent document. item_go.
ordersup_orderNONE / "TODO DELETE". Only set for Order-typed parents & linked siblings.
linked_order_productsup_order_productBack-link from a packing-slip/invoice row to the originating Order row.
suppliersup_suppliersup_document::supplierRequired.
branchpos_branchsup_document::branchREADABLE.
order_statussup_order_statussup_document::statusREADABLE; mirror, kept in sync by document status changes & an update-now backfill :1671-1672.
supplier_namestringproduct::nameSupplier's label for the product (from invoice text).
product_name_searchsearchCaption field. search_keep, table_edit, channel sup-products; extra action navigate_cat_product. Editable when status ∈ {SENT,OPEN}.
productcat_productRequired, READABLE (changed only via resolver code). item_go.
sup_pcnstringproduct::sup_pcnSupplier catalogue number from the document.
sup_pcn_regexstringproduct::sup_pcn_regexREADABLE. PCN after supplier regex — the AI match key.
quantityintegerreq.table_edit. Writeable only while document OPEN (Order) / order_status OPEN.
received_quantityintegerHidden until status ≥ relevant; writeable on packing-slip/invoice when status OPEN.
pricefloatproduct::sup_price_after_discounttable_edit; editable in SUP_ORDER_IS_EDITABLE.
total_pricefloatcalc price × quantityREADABLE. Rolls up to sup_document.total_price via recalc :1278.
descriptionstringWriteable in editable states. Presence is used as a "manually distinct row" marker in dedup queries.
stock_movementcat_stock_movementREADABLE link to the movement generated on delivery.
ai_detected_statusai_sup_order_detected_statusREADABLE. NEW / UPDATE / VARIANT — see Status tab.
manually_insertedgrow_yes_no_answerYES when added via search/scan/+; drives full-row replacement on product change.
ai_run_guidstringPer-extraction GUID; prevents re-matching rows created in the same AI run.
Calc & recalc: a quantity/price change → 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.

MANUAL

1 · Search bar / quick-list / barcode scan

Inside a sup_order creator/single, the cashier types in product_name_search or scans a barcode.

  1. Trigger. + or pick from quick-list → action sup_order_search_product_name; barcode → sup_product_scan sup-orders-methods.php:1243-1314.
  2. Resolve to a cat_product. Exact pos_display_name MATCH on hierarchy ∈ {SINGLE, PARENT}, ACTIVE, GOODS. Exactly one hit → that product; otherwise open sup_search_product_dialog :1288-1311.
  3. Filter gate. add_product_to_order() calls tc_apply_filters('tc_sup_add_order_product',…). The hierarchy resolver runs here — see the dedicated card below.
  4. Insert or increment. If an identical (product, sup_document, no description) row exists → increment_meta('quantity',1); else tc_insert_item('sup_order_product',…) with manually_inserted = YES, quantity = 1 :622-637.
  5. Fan-out to linked docs. For every related sup_order whose order = this document, a mirror row is added with quantity 0 and its linked_order_product set :638-645.
  6. Post-insert hook. tc_item_inserted may open the product lightbox if pcn/price missing; recalcs document total; refund docs negate the quantity canopy/sup/sup-orders.php:843-864.
Also: 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.
FILTER

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 hierarchyAction
PARENT, ≥1 same-supplier variantAdd that variant. If exactly 1 → add it; if >1 → TODO/unimplemented (no-op).
PARENT, only other-supplier variantsOpen sup_order_create_child_product_dialog to make a child for this supplier.
SINGLE, no supplierStamp the order's supplier onto the product, add it.
SINGLE, different supplierOpen create-child dialog (becomes a variant).
CHILD, wrong supplierReject (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.

AI

2 · Invoice / Packing-slip PDF extraction

User uploads a PDF to a sup_order and runs action ai_analyze_receiptcat_ai_analyze_receipt()ai_analyze_receipt_update_order() canopy/ai/ai-analyze-invoice.php:135-305. Runs in background; no reqres.

  1. Header. Detect/normalise invoice number, date, subtotal; resolve supplier (business_number → name → sender email); read supplier pcn_regex :135-172.
  2. Per product line (loop :184-300) compute price_after_discount (from total ÷ qty if TC_CAT_AI_PRICE_FROM_TOTAL), apply VAT correction, regex the item number → pcn_after_regex.
  3. Dedup vs the underlying document. If a row with same sup_pcn_regex (or same supplier_name/description) already exists and is from a different ai_run_guid → just update its price/qty and continue (no new row) :210-227.
  4. Match to catalog. By sup_pcn_regex+supplier → product; else by name → product (then the hierarchy walk: SINGLE may absorb supplier; CHILD→its parent; PARENT→search same-supplier child, miss ⇒ keep parent + VARIANT); else fall back to ai_default_product + NEW :229-280.
  5. Status. If no status forced yet, sup_order_is_product_change() compares price/pcn/name → UPDATE or ∅ :283-285.
  6. 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.
Matching is done against the underlying order if one exists, so re-analysing an invoice for an existing PO updates rows in place rather than duplicating.
AI

3 · Email reader (inbound supplier emails)

Module email_reader_module. canopy/sync/sync-email-sup-orders.php:141-238.

  1. IMAP fetcher uploads message attachments → one sup_order_email_attachment per file, status PENDING :184-190.
  2. PDF/image attachments: create (or reuse, on retry) a sup_order, then call cat_ai_analyze_receipt($sup_document,'invoice_file',false,true,true) :196-216 — i.e. the same path as card 2, which creates the sup_order_product rows.
  3. Roll attachment status up to PROCESSED/FAILED; non-doc attachments → SKIPPED :225-238.
Net effect on sup_order_product: identical to PDF extraction — the email layer only orchestrates document creation and retry idempotency.
COPY

4 · Copied / duplicated between documents

  • Duplicate document — action duplicate_sup_order: every row tc_duplicate_item'd into the new sup_order with linked_order_product/received_quantity cleared, under a global $sup_order_products_auto_creation guard 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_product set) via the recursion in add_product_to_order() :638-645. The reverse reconcile is check_for_new_products_to_update_in_sup_order() canopy/sup/sup-orders.php:1630-1649.
  • Invoice from packing slipssup_documents_connection_dialog_submit() creates an Invoice sup_order + sup_order_connection rows 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.
DIALOG

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.

ManualDialogCopy / Duplicate AIThe row (hub)Update trigger Terminal effect
reconciled row re-enters (∅) Manual search / + / barcode scan Creator / create-child dialog sup_order_product creator Duplicate doc / connections duplicate_sup_order · invoice←slips AI: invoice / slip PDF ai_analyze_receipt AI: inbound email (IMAP) sync-email-sup-orders sup-orders-methods.php:1268-1314 — exact pos_display_name MATCH; 0/multi → sup_search_product_dialog Resolve cat_product exact name match · else dialog dialog submit → add_product_to_order() sup-orders-methods.php:1208-1225 — $sup_order_products_auto_creation guard suppresses side effects tc_duplicate_item auto-creation guard canopy/ai/ai-analyze-invoice.php:135-305 — background; no reqres cat_ai_analyze_receipt → ai_analyze_receipt_update_order() sync-email-sup-orders.php:196-216 — reuses sup_order on retry (idempotent) create / reuse sup_order per attachment → AI pipeline add_product_to_order() sup-orders-methods.php:618-653 · filter :1568-1628 Row insertion manual/dialog → add_product_to_order() → tc_sup_add_order_product (hierarchy resolver) duplicate/connections → tc_duplicate_item (side-effects suppressed) → tc_insert_item(sup_order_product) | or increment qty on identical row ai-analyze-invoice.php:210-298 AI extraction pipeline dedup vs underlying doc (ai_run_guid) match cascade: sup_pcn_regex → name → ai_default set ai_detected_status → tc_insert_item sup_order_product — ROW EXISTS ai_detected_status ∈ { ∅ · NEW · UPDATE · VARIANT } · product → cat_product · supplier view: sup_pcn / supplier_name / price tc_item_inserted — open product lightbox if pcn/price missing · recalc document total · fan-out linked rows (qty 0) to sibling docs · refund document negates quantity ▼ UPDATE / RECONCILIATION PATHS Field edit tc_meta_changed → tc_sup_orders_meta_changed Explicit reconcile update_sup_order_product · update_sup_order_products Document status change sup_order.status sup-orders.php:922-1024 product → 6-step resolver (PARENT/CHILD/SINGLE) quantity / price → recalc total product_name_search → rebind product sup_pcn → regex · supplier_name sup-orders-methods.php:703-739 AI-validation dialog buckets: NEW · UPDATE · VARIANT stacked dialogs deleted first cat-stock.php:126-176 · sup-orders.php:1085-1086 order_status mirror → all child rows → DELIVERED: cat_create_stock_movement leave DELIVERED → reverse movement open ORDER qty → available_stock recalc set / clear ai_detected_status tc_table_row_classes → recolor cells recalc_sup_document_total_price sup-orders-methods.php:509-579 update_sup_order_product_execute() applies resolution per status cat_create_stock_movement · available_stock order_status pushed onto every row NEW create / link cat_product UPDATE patch catalog pcn/name/price VARIANT create child & bind clear ai_detected_status → ∅ Document total recalc_sup_document_total_price Row colours tc_table_row_classes (cell highlights) Stock stock_movement · available_stock
The diagram is a topological map, not a strict call graph: the duplicate/connections path inserts rows directly (bypassing 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 changedEffectRef
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 / pricesup_order_recalc_total_price(); on price also set_ai_detected_status_update() + reclass + refresh methods.:956-964
sup_pcnupdate_cat_product_sup_pcn_regex() then fall through to ↓.:965-967
supplier_nameset_ai_detected_status_update() + reclass.:968-971
productThe big resolver — see below.:972-1022

The product change resolver :972-1022

  1. 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.
  2. New product = ai_default? → mark NEW.
  3. New product is PARENT? → find child with parent+supplier=row.supplier: hit ⇒ bind to child & clear status; miss ⇒ VARIANT.
  4. CHILD with matching supplier → set product_name_search to parent's display name, clear status.
  5. SINGLE no supplier → stamp row's supplier, clear status. SINGLE other supplierVARIANT. SINGLE same supplier → clear.
  6. Finally, if status still empty, sup_order_is_product_change() may set UPDATE; reclass.

Stock side-effects

EventEffectRef
parent sup_order.status → DELIVEREDupdate_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 DELIVEREDReverse movement (sign −1).cat-stock.php:131-133
row qty/handled changes on an open ORDERrecalc_cat_product_available_stock() (Order rows reduce available_stock, not stock).cat-stock.php:164-173
parent sup_order.status changesupdate_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.

OPEN (Draft) SENT DELIVERED CLOSED …any…CANCELLED |IN_PROGRESS / PAID = deprecated

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().

created NEW∅ (new cat_product created / linked) created UPDATE∅ (catalog price/pcn/name patched) created VARIANT∅ (child variant created & bound)
StatusMeansResolution (update_sup_order_product_execute)
NEWNo 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.
UPDATEMatched, but invoice price / sup_pcn / name differs from catalog.Push sup_pcn, name, recomputed sup_price onto the cat_product :534-543.
VARIANTMatched 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.

ai_detected_status
tc-sup-order-product-ai-newNEW row · #e3fce3
ai_detected_status
tc-sup-order-product-ai-updateUPDATE · also the divergence trio below · lightyellow
ai_detected_status
tc-sup-order-product-ai-variantVARIANT / open-order name · #fce5cd
price
tc-sup-product-different-supplier-price — invoice price ≠ product::sup_price_after_discount
sup_pcn
tc-sup-product-different-supplier-pcn — row pcn ≠ catalog pcn
supplier_name
tc-sup-product-different-supplier-name — row name ≠ product::name
received_quantity
tc-sup-order-product-different-quantityquantity ≠ received_quantity · #F0B27A
product_name_search
tc-sup-order-product-ai-product — bound to ai_default_product (also via tc_item_field_defs :1261-1275)
The divergence trio (price/pcn/name) is suppressed while the row is VARIANT — a variant is expected to differ. Quantity-mismatch colour is independent of AI status.

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.

MethodWhat it doesAvailable whenRef
update_sup_order_productOpens 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_pricesOpens the product (or its parent) lightbox with prices_edit_status=ON.FORBIDDEN when status = NEW.:879-882, 581-598
sup_order_product deleterDeletes the row (recalcs totals).FORBIDDEN once order_status ∈ {DELIVERED,PAID,CANCELLED,CLOSED}.:874-877
sup_order_search_product_nameChannel action: bind clicked product → set product + name-search.Editable states.:1154-1169
navigate_cat_productExtra 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_productsOperate 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.

  1. Caller: per-row update_sup_order_product method (1 row) or document update_sup_order_products (all rows). Existing dialogs are deleted first to avoid stacking.
  2. Rows with no status but real drift get a late UPDATE via sup_order_is_product_change() :716-722.
  3. Buckets: update_products / variant_products / new_products link collections, each rendered as a links table.
  4. On submit, every bucketed row runs update_sup_order_product_execute() :655-673 — applying the resolutions in the Status tab and clearing ai_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.

HookHandlerRole for sup_order_productRef
tc_item_insertedtc_sup_orders_item_insertedOpen lightbox if pcn/price missing; refund qty negate; recalc total; methods refresh on first row.sup-orders.php:843-866
tc_item_insertedtc_cat_stock_item_insertedSupply docs → stock movement; open ORDER → available_stock recalc.cat-stock.php:180-212
tc_item_deletedtc_sup_orders_item_deletedRecalc document total; refresh product; methods refresh when last row gone.sup-orders.php:868-884
tc_item_deletedtc_cat_stock_item_deletedOpen ORDER row deleted → available_stock recalc.cat-stock.php:214-232
tc_meta_changedtc_sup_orders_meta_changedField resolvers (product/name-search/qty/price/pcn/supplier_name).sup-orders.php:922-1024
tc_meta_changedtc_cat_stock_meta_changedsup_order status→stock; ORDER row qty/handled→available_stock.cat-stock.php:126-176
tc_table_row_classestc_sup_orders_table_row_classesDivergence & AI-status cell colours.sup-orders.php:1168-1229
tc_item_field_defstc_sup_orders_item_field_defsai-product class on name-search when bound to default.sup-orders.php:1261-1275
tc_item_permissiontc_sup_orders_item_permissionForce READABLE outside editable order_status.sup-orders.php:1408-1424
tc_item_method_defstc_sup_orders_methods_item_method_defsGate deleter / update methods by status.sup-orders-methods.php:869-913
tc_method_executetc_sup_orders_methods_method_executeExecute row/document actions.sup-orders-methods.php:1149-1351
tc_sup_add_order_producttc_sup_orders_add_order_productHierarchy resolver gate before insert.sup-orders-methods.php:1568-1628
recalcrecalc_sup_document_total_priceSum rows → document total.sup-orders.php:1278+

Edge Cases & Gotchas

Assumptions made while authoring: technical/developer audience; tasteful client-side-only interactivity (no external libraries); line numbers reflect the working copy at the time of review and should be treated as anchors, not contracts.