ERP Quirks & Integration Notes
Prophet21 (P21)
Section titled “Prophet21 (P21)”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.
WhereFour
Section titled “WhereFour”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.
Acumatica
Section titled “Acumatica”NetSuite
Section titled “NetSuite”Eclipse E4 (VMS EPMS)
Section titled “Eclipse E4 (VMS EPMS)”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.
General ERP credential storage
Section titled “General ERP credential storage”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.