Supplier integrations
Three suppliers, three completely different channels, one uniform interface inside the system. Every
supplier is a suppliers row with a type, and the type selects a transport handler inside the
supplier-dispatch job processor. Adding a fourth supplier is a new type value, a new handler and a
config schema — nothing above the handler changes.
| Supplier | type | Channel | Outbound artefact | Dispatch reference | Inbound |
|---|---|---|---|---|---|
| Sysco | sysco | SFTP file drop | X12 850 EDI file | The EDI file name | X12 855 and 997, polled every 15 minutes by the sysco-poll CronJob |
| Costco Business Center | costco | SMTP email | Structured HTML order table | The SMTP messageId | None in the proof of concept |
| Central Kitchen | central_kitchen | SMTP email with attachment | Puppeteer-rendered PDF purchase order | The SMTP messageId | None in the proof of concept |
Sysco is the non-negotiable integration and the only one with a return path. The two email channels log that the message was accepted by the relay and stop there; there is no inbox monitoring in scope.
Detail pages: Sysco EDI, Costco email, Central Kitchen PDF.
How a dispatch is selected
Two separate decisions, made in two different places, and it is worth keeping them apart.
Which supplier gets a line is decided by the API at order placement, in
resolveOrderLines and commitOrder (apps/api/src/domain/orders.ts). Each catalog item has at most
one mapping row, so the lookup is a single findMany over the requested catalog items with the mapping
and its primary supplier joined in. There is no supplier logic in the ordering path beyond that read.
Which transport runs is decided by the worker, in processSupplierDispatch
(apps/worker/src/handlers/supplier-dispatch.ts), with a switch on the job payload's supplierType
that calls dispatchToSysco, dispatchToCostco or dispatchToCentralKitchen. The switch reads the
type from the payload, not from the freshly loaded row; the suppliers row is still loaded, and it is
where the handler gets the name, the active flag and the config it validates.
At order placement
resolveOrderLines(input):
one findMany over every requested catalog item, mapping + primary supplier joined
unknown catalog item id → 404, offending ids echoed back
inactive catalog item → 409, offending items echoed back
no mapping, mapping inactive, or primary
supplier inactive → 422, offending items echoed back
otherwise → line.supplierId / supplierType / supplierSku / supplierProductCode
are copied off the mapping
commitOrder(tx): # one transaction
orders row, order_line_items rows (status pending)
groupBySupplier(lines) # the fan-out
one supplier_dispatches row per group (status pending)
supplier_dispatch_line_items join rows
ORDER_PLACED + one SUPPLIER_DISPATCH_CREATED per group
after the commit:
for each group → supplierDispatchJobSchema.parse(...) → enqueueSupplierDispatch(payload)
order.status = 'dispatching', or 'failed' if no job reached the queue at all
Four properties of that sequence are load-bearing:
- All or nothing. A single unmapped line rejects the whole order rather than silently dropping the line. A chef who thinks they ordered eight items must not receive seven. The three rejection reasons have distinct status codes so the UI can say which one happened.
- Grouping is by supplier, not by line. One Sysco job carries every Sysco line on the order, so Sysco receives one purchase order rather than eight.
- The mapping is read at placement time and copied onto the dispatch payload. Re-mapping an item tomorrow does not retroactively re-route an order placed today, and no handler re-reads the catalog.
- Enqueuing happens after the commit, and a failed enqueue keeps the order. That dispatch is marked
failedwith aSUPPLIER_DISPATCH_FAILEDevent and an administrator re-dispatches it; the chef is not told an order vanished. Only if every enqueue fails does the order itself go tofailed.
At back order
Back-order rerouting is a separate queue and a separate handler
(apps/worker/src/handlers/backorder-reroute.ts), triggered from the inbound 855 path.
855 line disposition ∈ { backordered (ACK01 IB, BP), rejected (ACK01 IR) }
→ order_line_items.status = 'backordered', LINE_ITEM_BACKORDERED, enqueue backorder-reroute
processBackorderReroute:
a reroute dispatch already exists for this line → discard, outcome 'discarded'
a BACKORDER_NO_ALTERNATE event already exists for
(lineItemId, originalSupplierId) → discard
candidate = job.alternateSupplierId ?? mapping.alt_supplier_id
candidate missing, inactive, or equal to the original → line 'backorder_no_alternate',
BACKORDER_NO_ALTERNATE, admin alert,
job COMPLETES successfully
otherwise → new supplier_dispatches row (isReroute = true,
rerouteOfDispatchId = original), line 'rerouted',
BACKORDER_REROUTED, enqueue supplier-dispatch
with dedupSuffix `reroute:{lineItemId}`
IC, IQ, IP, SP and DR classify as changed: recorded on the line and in the audit trail, and
deliberately not rerouted, because rerouting a line the supplier is still shipping double-orders it.
Two details that are easy to get wrong. "No alternate supplier" is a business outcome, not a job
failure — the handler completes successfully, so the failed set stays a list of real problems. And the
reroute is a new dispatch, never a mutation of the original: the original row remains the record of
what Sysco was sent and how they answered, with isReroute and rerouteOfDispatchId linking the two.
The fallback is one hop deep. If the alternate supplier also back-orders the line there is no second
alternate to try. Because the POC allows exactly one mapping per catalog item, the alternate is sent the
same supplier_sku as the primary.
What is common and what is per type
| Concern | Owner |
|---|---|
| Idempotency check, loading, routing, state transitions | handlers/supplier-dispatch.ts (shared) |
| Retry classification, metrics, audit events, admin alert | handlers/supplier-dispatch.ts (shared) |
Reading and validating suppliers.config | supplier-config.ts (shared, per-type schema) |
| Building the artefact and moving the bytes | handlers/sysco.ts, costco.ts, central-kitchen.ts |
The three transport handlers know nothing about the database. Each receives the same common object —
orderId, orderNumber, supplierId, restaurantName, restaurantLocation, placedByEmail,
deliveryNotes, lineItems, now — plus its own resolved config, and returns a reference (and, for
Central Kitchen, a pdfUrl and pdfKey). deliveryNotes falls back to orders.notes when the payload
omits it, so the chef's note travels to every supplier on the order.
Before any transport runs, the shared handler intersects the payload's line items with the rows actually linked to the dispatch and fails permanently if the intersection is empty, and fails permanently if the supplier row is inactive.
Where supplier configuration lives
| Value | Stored in | Read by |
|---|---|---|
| Channel type | suppliers.type (Prisma enum supplier_type) | The dispatch switch |
emailAddress, ccAddresses, facilityName | suppliers.config (JSONB) | Costco and Central Kitchen handlers |
| SFTP directories | suppliers.config, falling back to SYSCO_SFTP_*_DIR | Sysco handler and inbound poll |
EDI interchange identifiers (ISA05–ISA08, version, product ID qualifier) | suppliers.config, falling back to EDI_* | X12 850 serialiser |
| Names of the SFTP credential secrets | suppliers.config (credentialsSecretName, sftpHostSecretRef) | Resolved from the environment by sftp/client.ts |
| SMTP host, port, credentials, from-address | Environment (Vault-injected in the cluster) | Both email handlers |
resolveSupplierConfig layers the process configuration underneath the stored object and then parses
the result with the per-type schema from packages/types/src/supplier.ts, which also supplies that
schema's defaults. A freshly created supplier with config: {} is therefore usable in development for
Sysco, but not for the email types: there is deliberately no environment fallback for
emailAddress, because quietly substituting ADMIN_ALERT_EMAIL would send a restaurant's purchase
order to the operations mailbox and report success. suppliers.config never holds a credential — only
non-secret settings and the names of secrets.
Nothing partner-specific is compiled in. That is decision D3 in IMPLEMENTATION.md: because Sysco's
Trading Partner Implementation Guide was not available during the build, every value the guide will
specify is driven from configuration so the guide can be applied without a code change.
Failure and retry, uniformly
| Aspect | Behaviour |
|---|---|
| Attempts | DISPATCH_ATTEMPTS, default 3 |
| Backoff | BullMQ exponential with base DISPATCH_BACKOFF_MS, default 1000 ms — so roughly 1 s then 2 s |
| Enqueue dedup | BullMQ Simple-Mode deduplication: { id }, keyed dispatch:{orderId}:{supplierType}; reroutes add a reroute:{lineItemId} suffix to avoid a collision |
| Execution dedup | The handler reads supplier_dispatches.status first and returns { skipped: true, reason: 'already_submitted' } when it is submitted or confirmed |
| Terminal failure | Dispatch failed, its lines dispatch_failed, order recomputed, SUPPLIER_DISPATCH_FAILED appended, alert to ADMIN_ALERT_EMAIL, Sentry event |
| Job retention | removeOnComplete: { age: 3600, count: 1000 }, removeOnFail: { age: 86400 } |
| Partial order | Some suppliers succeed and some fail → recomputeOrderStatus rolls the order up to partial; the chef sees exactly which lines failed |
| Metrics | supplier_dispatch_duration_seconds{supplier_type,outcome} and supplier_dispatch_failures_total{supplier_type,terminal} |
The execution-level check matters because deduplication only prevents duplicate enqueues. A worker that crashes mid-execution has its job re-queued by the stall detector, and that path bypasses dedup entirely — so the status read is what makes re-execution of an irreversible SFTP drop or email harmless.
Permanent versus retryable
isPermanentFailure classifies, and a permanent failure calls job.discard() so BullMQ does not retry
something that cannot succeed. Permanent: PermanentDispatchError (invalid job payload, missing
dispatch row, no line items in common with the payload, inactive supplier), SupplierConfigError,
PermanentMailError (5xx or every recipient rejected), SftpConfigError, a ZodError,
ChromiumUnavailableError (a deployment fault — every retry in this pod fails identically), and any
error carrying permanent === true. Everything else is retryable, deliberately: over-classifying as
permanent loses an order that a second attempt would have delivered.
Channel-specific notes:
- Concurrency is 1 for all three types. There is a single
supplier-dispatchWorkeratconcurrency: 1withlockDuration: 120_000, because Sysco's SSH server enforces a session limit and the producer writes every supplier type to one queue.EMAIL_WORKER_CONCURRENCY(default 4) applies only tobackorder-reroute. The trade-off is stated in the header comment ofworker.ts: at ~25 dispatches a day, serialising email and PDF work alongside Sysco is fine, and it is correct. - Costco and Central Kitchen — SMTP transient errors (
ECONNRESET,ETIMEDOUT, a 4xx reply) retry; a 5xx, or a relay that accepts no recipient at all, fails immediately. - Central Kitchen only — a Chromium crash mid-render is retryable; a missing Chromium binary is not.
Automated test coverage
apps/worker/tests/ contains edi/, email/, pdf/, sftp/ and handlers/. The handler suites drive
the real processSupplierDispatch / processBackorderReroute against a seeded database and real
infrastructure — an in-process SFTP server, MinIO, Mailpit, Redis and real Chromium. Nothing in the
transport chain is stubbed.
Suite in apps/worker/tests/handlers/ | What it proves |
|---|---|
sysco.test.ts | A valid 850 lands on the SFTP server, ISA13 increases per interchange, the session is closed |
costco.test.ts | The email as a receiving MTA parsed it, and a config: {} supplier fails permanently |
central-kitchen.test.ts | A real render and upload; the stored object and the decoded attachment are byte-identical |
idempotency.test.ts | Running the same job twice drops one file and sends one email; submitted and confirmed short-circuit, a failed attempt does not |
failure.test.ts | A refused SFTP port stays retryable until the final attempt then transitions terminally; a 550 recipient fails on attempt 1; no line items and a deactivated supplier fail immediately; a failed admin alert does not fail the job |
backorder-reroute.test.ts | The reroute dispatch is created and linked, the job is read back out of Redis with the right deduplication id, and every "no alternate" branch parks the line and alerts once |
The API side adds apps/api/tests/orders.backorder.test.ts, orders.place.test.ts and
orders.query.test.ts (which covers the presigned PDF link endpoint). See testing.
Adding a fourth supplier
- Add the value to
supplierTypeSchemainpackages/types/src/enums.tsand to theSupplierTypePrisma enum via a migration. - Add a config schema in
packages/types/src/supplier.tsand register it insupplierConfigSchemaFor. - Add a resolver branch in
apps/worker/src/supplier-config.tsand acasein the dispatch switch. - Add the connection secrets to Vault and reference them by name from
suppliers.config. - Create the supplier and its mappings through the admin API.
Nothing in the API's ordering path, the frontend, or the data model needs to change.
Where to go next
- Sysco EDI — the X12 850 and 855 detail, segment by segment.
- Costco email — the HTML order email.
- Central Kitchen PDF — Chromium, PDF and object storage.
- Queues and jobs — the queue definitions, deduplication and retention.
- Data model —
suppliers,supplier_dispatchesand the mapping table.