Skip to content

ERP Quirks & Integration Notes

Order tiers: P21 has a 3-tier order fallback chain for order retrieval. See apps/dagster/erp_pipeline/sources/prophet21/order_service.py and the tier-hierarchy README in the P21 docs directory.

Custom source migration: There’s a get_p21_custom_source_enabled() flag in config.py that controls whether to use the new custom source path. This was added as part of a migration — don’t remove the flag gate until the migration is complete for all customers.

Inventory routing: The PDF extraction workflow runs a P21-specific enrichment step (enrich_order_with_inventory_routing) that annotates each order item with the best source location. This is P21-only — a no-op for other ERPs.

Auth: Prophet21AuthHandler handles session management. P21 sessions expire and need to be refreshed — the handler manages this transparently.

Web client order links use the menu name, not the window class: the inbox conversation’s Order entered line links a P21 job to https://<web host>/prophet21/#/window/m_orderentry?order_no=<n> (apps/webapp/src/lib/erp-order-link.ts), the same URL P21’s own Enterprise Search opens. P21 26.1 refuses to open the window class w_order_entry_sheet from that handler. The web host is the connection’s api_url host with -api dropped from its first label, so only an Epicor-hosted <tenant>-api.epicordistribution.com connection gets a link; an on-prem P21, or a job number that is not all digits (our OM-<uuid> placeholder), stays copy-only.

REST API via httpx (wherefour_fetch.py): WhereFour sync and order operations use direct httpx REST API calls (apps/dagster/erp_pipeline/sources/wherefour_fetch.py and apps/webapp/src/services/erp/implementations/wherefour-service.ts), authenticating via REST session/token endpoints without Playwright browser automation.

Sequential SO numbering (SO<n>-OM): when extraConfig.numbering.sequentialSo is on for a wherefour (or mock-wherefour) connection AND erp_connections.last_so_number is seeded (non-NULL), submission mints the WhereFour order_number as SO<n>-OM from that counter and claims it onto orders.erp_order_ref before the workflow runs (apps/webapp/src/services/erp/wherefour-order-number.ts). Flag on but counter unseeded silently falls back to the legacy OM-<orderId> ref until the re-base job or the seed script runs. The OM-<orderId> fallback is a synthetic internal ref, not a real WhereFour label — don’t hand it to operators.

SO-counter re-base job: last_so_number is continuously re-based against WhereFour’s own advancing native counter (orders arrive independently of us, e.g. via SPS) by the ts-temporal-worker’s WherefourSoRebaseWorkflow on a 6-hour Temporal schedule. It seeds opted-in NULL counters and only ever bumps upward. The schedule (wherefour-so-rebase) is created automatically by the Python temporal-worker’s boot-time schedule reconciliation (worker.py) — Temporal schedules can target the TS queue by workflow-type string, so no manual temporal schedule create step is needed.

The scan is bounded (last 14 days, max 10 pages per connection): a connection with no native SO<n> in that window logs scan_empty and its counter is left untouched — a scan_empty streak means the scan is dead (credential rot, API change), not that the counter is verified. scripts/wherefour-seed-so-counter.ts remains the manual seeding path and the only way to LOWER an already-seeded counter (--force).

429 rate limiting: the TypeScript WherefourService retries once on HTTP 429 for GET/HEAD requests, honoring Retry-After capped at 30s — the TS twin of the Dagster-side 429 handling.

submitOverride fails closed: a connection submitOverride with enabled: true but no customerId refuses submission instead of silently routing the order to the real customer untagged.

Order links key on the internal id, not the SO number: the inbox conversation’s Order entered line links a WhereFour job to https://<tenant>.wherefour.com/customers/orders/<orders.erp_order_id> (apps/webapp/src/lib/erp-order-link.ts). The internal id survives WhereFour renumbering the SO; the SO number does not. The tenant comes from the connection’s api_url host, so a connection on the shared my.wherefour.com host gets no link.

Split-order submissions and the regular submit route must stay in sync: generate-quote.ts (the Split-tab submission route) and [id]/submit.ts both ultimately call the same eclipseE4Service.createOrder(), but historically only submit.ts resolved eclipse_e4_component_item_codes_v1, eclipse_e4_ship_to_v1, and ordermaticOrderRef before calling it — splits silently fell back to legacy XML with no TemplateCode/FinGoodCode, no quantity, and no SellPrice. Both routes now thread the same three things through the same helpers (apps/webapp/src/services/erp/eclipse-e4-ship-to-mapper.ts factors out the shared Typesense-doc-to-ErpOrderShipToAddress mapping so this doesn’t drift again). If you add a new Eclipse E4 field gated by a connection-level flag, wire it into both routes or it’s inert on splits.

Only [id]/submit.ts checks bound ids against the catalog. Before it calls the ERP, revalidateOrderLineProducts in apps/webapp/src/services/erp/submit-revalidation.ts looks up each line’s erpItemId in the Typesense products index and blocks the submit if any is missing. A line counts as present when its raw id or its bare stem (baseTemplateCode) is live. On finished-goods-only Eclipse E4 connections (e4Rules.finishedGoodsOnly) only the raw id counts, because the submit books that exact code as a finished good — unless eclipse_e4_fg_base_code_match_v1 is also on, which drops back to raw-id-or-stem (resolveEclipseExactRevalidation in apps/webapp/src/services/erp/eclipse-e4-order-fields.ts ends in && !baseCodeMatch). That relaxation is required, not incidental: the bare-stem rung below binds through dbo.Material.UserDefined5, which the Typesense catalog cannot see, so an exact-id gate would refuse the very lines the rung exists to rescue. One override moves two gates. generate-quote.ts has no such check, so a line submit.ts would refuse still reaches E4 through a split. The review-v2 TemplateRevisionPicker is built around that gap. Its live GET /api/erp/e4/template-lookup always degrades on Cloud Run, which has no route to epmsdb.vmsinc.com, so it asks GET /api/erp/products/exists instead. By default that endpoint counts an id present when it, or its bare code without a trailing revision suffix, is in the index; exact=1 counts only ids the index holds verbatim. The picker labels a code the catalog lacks “Not in catalog, submit may refuse” and keeps it pickable, because disabling it would also block the split route. If generate-quote.ts ever gains the check, the picker can disable those codes.

On finished-goods-only connections the picker checks finished goods, not templates. Under e4Rules.finishedGoodsOnly (VMS), submit books a dbo.Material finished good and sends the revision separately in <UserDefinedField1>, never a TemplateCode. So the picker skips the TemplateHeader lookup and its auto-apply, and always asks the catalog with exact=1: without it, A007SL-10025 Rev VMS 5 reads as present because A007SL-10025 is. It offers the PO’s code only once that check confirms it is a finished good (AL-69552 Rev AJ is one). Before this, picking the PO side wrote a template code over a correct bare finished-good bind and submit refused the line (7 VMS orders, 2026-09-16 to 09-21).

eclipse_e4_fg_base_code_match_v1 (default off, per-connection; ON for conn_vms_e4 since 2026-09-22): one extra identity rung in resolveSubmitComponentIdentities, finished-goods-only connections. VMS stores most finished goods as the bare MaterialCode with the revision in dbo.Material.UserDefined5, but a bound id can arrive rev-suffixed (the picker’s rev-from-PO composition, or a rev-suffixed code picked out of the search catalog). After the exact-revision and bare-id rungs both miss, this rung binds the family row whose code is the bound id’s stripped stem (0517-0126-01-0 Rev A → 0517-0126-01-0), and only when that row’s UserDefined5 states the revision the line asked for. It compares codes, never stems, because a stem-keyed match is the 13-collision wrong-item bug this resolver exists to prevent (AL-69552 and AL-69552 Rev AJ are two different live finished goods), and a bare code whose UserDefined5 is blank or disagrees still refuses. It never fires on a kept line — a keep rejects the PO revision, and this rung keys on it. The code shipped 2026-09-18 but was inert until 2026-09-22: the flag was not declared in flags.json, and getFlagForConnection resolves an unknown flag to the caller’s default (false), so every X Rev A line kept refusing with “E4 has no finished good by this code” while the finished good sat under the bare stem. Verify after a flip with the submit log line “Eclipse E4 finished-goods-only routed a line to FinGoodCode” whose code is rev-suffixed while finGoodCode is bare.

There is no review-time E4 existence check, and eclipse_e4_presubmit_existence_check_v1 is undeclared on purpose. POST /api/erp/orders/existence-check runs in the webapp (Cloud Run), which has no route to epmsdb.vmsinc.com, so its lookup degrades and the check fail-opens — declaring the flag would buy a doomed MSSQL round trip per order load and zero warnings. The real probe (E4_NO_FINISHED_GOOD) lives in the worker’s persistAutoPricedMatches pre-review pass, which runs once, so a line a CSR rebinds during review stays unchecked until submit.

eclipse_e4_keep_bound_identity_v1 (default off, per-connection): makes the revision card’s “ERP · current match” click count at submit. The click writes a field_decisions row with reason keep_bound, and the route refuses one that does not keep the line’s current erp_item_id. On finished-goods-only connections both submit routes read the newest revision decision per line (apps/webapp/src/services/erp/kept-bound-identity.ts) and mark kept lines keepBoundIdentity, so identity resolution books the bound finished good instead of re-selecting the <id> Rev <revision> one (VMS’s bare/Rev pairs, such as AL-69552 and AL-69552 Rev AJ). A keep counts only while it answers the line’s current conflict: it chose the bind being submitted, against the code the line prints now. A later revision edit, rebind or re-extraction voids it, and the card applies the same check. A keep means the PO revision is wrong for that item, so <UserDefinedField1> carries only the kept finished good’s own revision: its dbo.Material.UserDefined5, else its code’s revision suffix, else nothing. It only matters with e4Rules.finishedGoodsOnly and eclipse_e4_template_resolution_v1 on; with it off, submit ignores the click. One known gap is open, recorded in its description in infrastructure/flagsmith/configs/flags.json: a finished-goods-only line whose PO revision matches no finished good never shows the card, so it has no keep button.

Suffixed PO numbers on splits: each split of one PO used to reach the ERP under the identical bare PO number. generate-quote.ts now computes a server-authoritative suffixed PO number (123-1, 123-2, 123-1-3 for a merged split) from the persisted quote_splits.item_positions row, never from the client. A minted-when-absent base PO is persisted back onto extractedOrders the first time it’s needed, so sequential splits of a PO-less order converge on the same base instead of each minting an unrelated one.

eclipse_e4_shipment_package_v1 (default off, per-connection): gates Shipment.ShipVia/ShipViaService, an inline Shipment.Packages block, and WebJobHeader.DeliveryDate/Component.DeliveryDate in the SOAP builder (eclipse-e4-service.ts, buildShipmentsXml/buildPackagesXml/ buildComponentsXml). Shipment.Residence (a required WSDL field) is NOT gated by this flag — it’s now always sent as false whenever a Shipment block is emitted at all, fixing a pre-existing gap where it was never sent.

E4 does not auto-propagate due dates to shipment/package ship dates: live-probed 2026-09-03 (job TST-000079-26, direct MSSQL readback) — contrary to VMS’s own description, header DueDate/DeliveryDate do NOT cascade to Shipment.ScheduledShipDate (stays E4’s 1900-01-01 sentinel) or Package.ShipDate (silently defaults to the order’s OrderDate, i.e. today). An inline Package on SubmitOrder’s Shipment block IS accepted and persisted correctly — no SubmitPackage fallback call is needed. E4 normalizes ShipViaService to its own internal code rather than rejecting or corrupting it (e.g. "Ground" is stored as "GRND").

Ship-via has no real data source yet: the plumbing above accepts a ship-via code, but nothing in the extraction pipeline captures one — it’s structurally inert until a data source exists. See TODOS.md (“Eclipse E4 ShipVia has no real data source anywhere in the extraction pipeline”) for the live-probed finding on why a synced CustomerShipTo.DefaultShipVia default isn’t a reliable substitute.

GetSummaryJobStatus answers an unknown job with the literal Job not found.: not the empty result the vendor doc implies (live-probed 2026-09-09 against VMS’s endpoint). Both decoders in eclipse-e4-service.ts (probeJobNumberExists behind eclipse_e4_test_job_number_precheck_v1, and getOrderDetails) treat it as “no such job”. Before that fix the pre-check read it as “taken”, so turning the flag on would have failed every submit without a SubmitOrder call. Any status outside E4’s known vocabulary (In Process, Completed, History, ON HOLD) is “unknown” and submits anyway. Every TST- job we submit is ON HOLD (we send <JobStatus>On Hold</JobStatus>), which maps to quote_generated.

VMS staff hand-enter test jobs in the same TST-######-26 sequence we mint from: our counter (erp_connections.extra_config.eclipseE4TestLastJobNumber) cannot see those, so it sits behind E4 (13 numbers on 2026-09-09; the 2026-09-08 91/92 collisions were this, not EDI). Since 2026-09-09 conn_vms_e4 runs with eclipse_e4_test_job_number_om_suffix_v1 ON (our jobs mint as TST-000105-26-OM, a namespace no hand-entered job collides with; E4 stores the 16-char string verbatim) and eclipse_e4_test_job_number_precheck_v1 ON (a read-only walk past held numbers, bounded by MAX_PRECHECK_PROBES and a wall-clock deadline, separate from the 4-attempt SubmitOrder retry). A submit that exhausts the walk still lands quote_failed because the Temporal submission path treats every failure as final; resubmit and it starts past the held run. The counter is hand-bumped in practice, and a stored label that does not parse refuses to mint rather than restarting the sequence at the seed.

Turning eclipse_e4_test_job_numbering_v1 OFF does not mean “send nothing”: with no other flag on, createOrder sends order.poNumber || 'NEW' as the JobNumber, a shape no E4 job has ever had (0 of ~32.5k live OrderHeader rows start with a non-letter, 2026-09-11). eclipse_e4_om_job_numbering_v1 is the replacement: it mints OM-######-YY (EDI-style, like E4’s own EDI-004467-26) from its own counter key, extra_config.eclipseE4OmLastJobNumber, seeded so the first number is OM-000001-YY. TST- numbering keeps precedence while its flag is on, so the OM override can be armed ahead of time; setting the eclipse_e4_test_job_numbering_v1 / conn_vms_e4 entry in segment-overrides.json to "enabled": false and deploying is what takes real OM- jobs live. Deleting that entry does not turn it off — set-segment-overrides.py only creates and patches the entries it finds in the file and has no delete path, so a deleted row leaves the live override enabled. The precedence is fail-safe, not merely ordered: the OM gate re-reads the TST flag defaulting true, because getFlagForConnection returns the caller’s default on a timeout and negative-caches it for 30s, so one Flagsmith blip would otherwise read as “TST off” and mint a real job early. A minted OM- job keeps <JobStatus>On Hold</JobStatus> for the first real batch, carries the PO’s own due date (the 12/31 override is TST-only) and sends no PrevJobNumber; the pre-check and the SubmitOrder retry both still apply.

Retry idempotency is a separate flag, and a precondition for that switch. createOrder used to mint a fresh number on every call, so a Temporal retry after a SubmitOrder that timed out but landed created a duplicate job — one throwaway On Hold job on the TST- path, a duplicate REAL production job under OM-, up to the submission activity’s maximumAttempts: 7. eclipse_e4_job_number_claim_v1 closes it: the number is claimed against the worker’s ${workflowId}:createOrder key in eclipse_e4_job_number_claims (after the pre-submit walk settles, before the SOAP call) and every later attempt resubmits under it, the same claim-once shape WhereFour uses for SO<n>-OM. A collision then splits by sequence — a reused OM- number E4 already holds is reported as our own earlier write, since nothing else mints that string, while a reused TST- number walks forward, since VMS staff hand-enter jobs in that sequence. A human resubmit is a new workflow, so it carries a new key and correctly gets a fresh number.

E4 marks every component sent with a <SellPrice> as a Manual Price Override, and SubmitOrder has no input element that clears it. So every line we submitted used to land with OrderComponent.ManualPriceOverride=1, which VMS only wants on lines a CSR repriced. eclipse_e4_erp_priced_lines_v1 is default off, on for conn_vms_e4, and requires eclipse_e4_full_payload_v1. With it on, createOrder leaves <SellPrice> off a line when all of these hold, as decided in apps/webapp/src/services/pricing/erp-priced-lines.ts: the CSR did not override the price; the connection-wide dbo.MaterialUnitCost gold tier at the submitted quantity gives the same extended total to the cent; the schedule’s fetch watermark is under 26 hours old and the tier row was rebuilt after it; and the component is a finished good whose FinGoodCode is the item that was priced. Customer-scoped gold rows never count, because for E4 they are mined from order history, past manual overrides included. The check runs at the write, so a retry re-checks it. When any line goes unpriced, header <TotalSellPrice> is dropped too, since E4 sums its own line prices. Live-probed 2026-09-16 on TST-999992-26-OM: unpriced finished-good lines landed with ManualPriceOverride 0, priced from dbo.MaterialUnitCost at the right quantity break, and <UserDefinedField1> still bound. A mixed order probed the same day on TST-999989-26-OM behaved the same way per line: the unpriced line landed at the exact quantity break it sat on with the override off, the CSR-priced line kept its <SellPrice> with the override on, and E4 set the header total to the sum of both. UpdateOrder still always sends <SellPrice>, because leaving it off there silently re-prices the line. The split route, generate-quote.ts, now passes overrideUnitPrice on E4 lines as well, and counts a line with no matching extracted row as overridden so a CSR price is never dropped.

ERP credentials are encrypted with AES-256-GCM. TypeScript (webapp) encrypts them, Python (Dagster/temporal-worker) decrypts them. The encryption key is shared via env var. Do not store plaintext credentials anywhere.