Shipment Creation & Fulfil Acknowledge
Chunked shipment/work-order creation, PO status transitions, infra-error fail-fast, acknowledge flow.
Deep-dive doc split out of
.claude/rules/architecture.md(which is now an index). Append new findings about this area here, not to the index.
Fulfil Shipment Acknowledge Flow
The acknowledge orchestration lives in src/services/shipment/shipment.service.ts:
runPostCreateFlow(line 734) — invoked after shipments are created; callsmakeShipmentsAcknowledgethenmonitorPendingShipmentsAfterCreateas a safety alert.makeShipmentsAcknowledge(line 406) — looks upPendingshipments, calls Fulfil, then updates MES status for the successful ones. Also callsmakeRTSShipmentsPacked(INFRA-439) for RTS shipments after a successful ack._makeFulfilShipmentAcknowledge(line 547) — wraps the actual Fulfil HTTP call. Two response paths:- HTTP succeeded: iterates
fulfilRes.data.shipments; per-itemerrorscontaining only"already acknowledge"strings count as success (Fulfil returns"This shipment is already acknowledged"for prior acks). All other error strings →failedFulfilShipments. - HTTP failed (timeout/network): catch block sets
err; both success/failed arrays are empty, so_makeMESShipmentsAcknowledgeshort-circuits (no DB write). Shipments remainPendingeven though Fulfil may have processed them server-side.
- HTTP succeeded: iterates
monitorPendingShipmentsAfterCreate(line 769) — the only existing safety net for stuck-Pending shipments; it emitscapturePendingShipmentsAlertto Sentry but does not retry or reconcile.
Chunked Shipment Creation (INFRA-599)
POST /api/shipment/v1/create (src/controllers/shipment/shipment.controller.ts#create) no longer wraps the whole PO's shipments/work-orders in one prisma.$transaction — a prior version did, and a large-enough batch (~7,500 work orders, a real Milly PO) OOM'd the production Postgres node. The current shape:
- PO
Created→Opentransition is its own small transaction (timeout: 10_000), usingPurchaseOrderService.findUniqueByPoRecNameForUpdate(SELECT ... FOR UPDATEraw query inpurchaseorder.model.ts) to lock the row, then a conditionalPurchaseOrderService.update({ where: { purchaseOrderId, poStatus: PoStatus.Created }, data: { poStatus: PoStatus.Open } }, tx). TheWHERE poStatus = 'Created'guard gives optimistic-concurrency safety — a concurrent writer changing the status first makes this throw P2025 (surfaced as 400 viahandleDbErr). This transition happens before any shipment/work-order chunk is attempted. ShipmentService.batchCreateChunked(src/services/shipment/shipment.service.ts) splitsshipmentsinto chunks ofenv.SHIPMENT_CREATE_TRANSACTION_CHUNK(default 1000), each chunk processed in its ownprisma.$transaction(isolationLevel: ReadCommitted, timeout: 60_000) that doesShipmentService.batchCreateByTransaction(CustomerShipment rows) thenworkorderService.batchCreateByTransaction(WorkOrder rows). The loop is sequential — a chunk's transaction failure is caught and recorded inresults.chunks[](status: 'failed',error). It continues to the next chunk for ordinary chunk-scoped/data errors (unique-constraint violations, bad SKUs, etc.) but stops early on an infra-level error (DB connectivity/timeout signatures — see the INFRA-602 gotcha below), recording every remaining unattempted chunk as'skipped'. Already-committed chunks stay committed either way (no whole-PO atomicity anymore — deliberate trade-off for bounded per-transaction memory).- Response shape (
ShipmentCreateResponse):totalShipmentCount/totalWorkOrderCount/successfulShipmentCount/failedShipmentCount/failedShipmentRecNames/errors— callers must checkfailedShipmentCount/errors, since the endpoint always returns HTTP 201 (successCreatedJson) even when every chunk failed (see gotcha below). SHIPMENTS_ARRAY_MAX = 50_000(src/schemas/shipment/index.ts) is a generous abuse-guard cap on theshipmentsarray, not a functional limit — grounded on historical max shipments-per-PO (~10,000) × safety factor 5.- Sentry:
captureShipmentChunkedCreatereports the full per-chunk breakdown (levelwarnif any chunk failed, elseinfo).
Gotcha (INFRA-601, found in production immediately after INFRA-599 shipped) — a fully-failed batch leaves the PO permanently stuck. Because step 1 flips the PO to Open before any chunk is attempted, and the chunk loop never rethrows, a batch where every chunk fails still returns HTTP 201 with totalWorkOrderCount: 0 — and the PO is left in Open with zero shipments/work orders. _validatePoRecord hard-requires poStatus === Created, so the same poRecName can never be retried through this endpoint again without a manual AdminJS fix. The n8n workflow calling this endpoint has no response-body error check (only a non-2xx interrupts it), so this reads as false success downstream. Fix: after chunkedResult is returned, if chunkedResult.number2WorkOrderIdNumMap.size === 0 (safe "every chunk failed" signal — the schema's quantity.gt(0) + skus.min(1) guarantee any successful chunk contributes ≥1 work order), revert the PO back to Created via the same conditional-update pattern as step 1, guarded by WHERE poStatus = 'Open'.
Gotcha (INFRA-619, found 2026-07-29 during INFRA-613 local e2e; FIXED — see the implementation note below) — the INFRA-601 revert didn't cover the SKU-limit rejection path, so a rejected shipment batch also stranded the PO in Open. Pre-fix, in shipment.controller.ts#create the Created → Open transition happened at lines 56-71, before ShipmentService.validateShipmentSkuLimitsByCategory at lines 77-81 (all line numbers in this paragraph describe the pre-fix layout). That validator throws (makeBadRequestError) rather than returning a per-shipment failure, so control never reached the INFRA-601 revert block at line 94 — it sat after the validate call, not around it. Reproduced end-to-end against a local server: posting a 13-SKU scarf shipment to a Created PO returns the expected 400 (13 SKUs exceeds category 'scarf' limit of 12), and the PO is left poStatus = 'Open' with zero shipments; re-posting a valid 12-SKU payload to the same poRecName then fails with PO613002 is not in Created status. forever, since _validatePoRecord hard-requires Created. Same failure shape as INFRA-601 (false-success/unretryable PO needing a manual AdminJS fix), reached through a different door — and more likely to fire in practice, since an oversized batch is an ordinary n8n data condition rather than a DB failure. Note the n8n caller only branches on non-2xx, so it does see this 400 — the damage is that the retry it would naturally attempt can never succeed. (Note: validateShipmentUniqueFields is already above the transition at line 50 — an earlier draft of this note wrongly listed it as a co-candidate to move; only the SKU-limit check and its FactoryService.findUnique sat in the danger window.) Not introduced by INFRA-613, and not gated on it either — 20260616084818_add_factory_configuration_sku_limit seeded max-scarf-sku-limit = 12 active for every factory, so Milly Scarf has been on the identical limit since it launched and this path is already reachable in production today. Any category's limit can trigger it (dress = 2 is the tightest and applies to every factory), so it is not scarf-specific at all. INFRA-613 merely surfaced it — the fix belongs on its own ticket, prioritized on current production behavior rather than on Aolong Scarf's go-live.
INFRA-619 implementation — SKU-limit validation moved ahead of the transition, plus a shared revert helper. Three changes in src/controllers/shipment/shipment.controller.ts, and the ordering of create() is now load-bearing in a way it wasn't before:
- The SKU-limit check runs INSIDE the
Created → Opentransaction, between_validatePoRecordand the statusUPDATE. A throw therefore rolls the transaction back and the flip never persists — no pre-check query, no wasted write, and no guard needed on PO existence/status, because_validatePoRecordalready ran first and its canonicalwas not found in PurchaseOrder table./is not in Created status.error naturally wins. Prisma rethrows the original error out of$transaction, so the 400 shape is unchanged. Keep_validateSkuLimitsForPoahead of theUPDATEif this block is ever reordered — moving it after would restore the original bug in a form no test outside the e2e run would catch (see the mock caveat below).- Superseded first attempt (worth knowing, since a reviewer may suggest reverting to it): the fix originally did a read-only
PurchaseOrderService.findUnique({ where: { poRecName } })before the transaction, gatedif (preTransitionPo?.poStatus === PoStatus.Created). That works and is safe (PurchaseorderModel.findUniqueis uncached — see below), but costs one extra query on everycreate()call and needs the?.+=== Createdguard purely to keep error precedence. The in-transaction version is strictly less code for the same semantics; the only trade-off is thatFactoryService.findUnique+getSkuLimitnow execute while the PO row isFOR UPDATE-locked (two indexed reads against a 10s transaction timeout, and that lock exists precisely to serialize concurrentcreate()on the same PO).
- Superseded first attempt (worth knowing, since a reviewer may suggest reverting to it): the fix originally did a read-only
_validateSkuLimitsForPo(shipments, poRecord)— module-private, wraps the relocatedFactoryService.findUnique+ShipmentService.validateShipmentSkuLimitsByCategoryas a unit (the factory lookup exists only to feed the limit check, so the two always move together). Deliberately takes notx: both are reads of reference tables (Factory,FactoryConfiguration) this transaction never mutates, andFactoryModel.findUnique(params)hardcodesprisma.factory.findUniquewith no optional-client parameter — so threadingtxwould be partial and pointless without also changing that model signature andvalidateShipmentSkuLimitsByCategory's._revertPoToCreated({ poRecName, purchaseOrderId })— module-private, best-effort (WHERE poStatus = Open,captureShipmentPoRevertFailedon failure, never rethrows). Shared by two call sites: the INFRA-601 all-chunks-failed branch, and a newtry/catcharoundbatchCreateChunkedthat reverts then rethrows. Sharing one helper is not just style — it keepsPurchaseOrderService.updateat exactly 2 calls on the INFRA-601 path, which the existing "revert update itself fails" spec depends on positionally (see the ordering trap below).
The try/catch deliberately wraps only the batchCreateChunked call — never the response-building/sending below it. Two long-standing specs (should handle response.json throwing error, should handle response.status throwing error) throw after shipments have committed; widening the try to cover them would revert a PO that legitimately owns shipments. Residual trade-off accepted and commented in-code: if batchCreateChunked ever threw after a chunk committed, the revert would touch a PO with shipments — unreachable today (every chunk failure is absorbed by the per-chunk try inside the loop), and bounded if it happened, since a retry's duplicate shipmentRecName/shipmentId hits the unique constraints and surfaces as failed shipments like any other partial-commit retry.
⚠️ The unit tests verify ORDERING, not rollback. src/models/mock/index.ts mocks prisma.$transaction as async (fn) => fn() — there is no real transaction, so nothing actually rolls back in any spec. The INFRA-619 unit tests pass because a throw from _validateSkuLimitsForPo propagates out of the callback before PurchaseOrderService.update is reached, i.e. they assert the call ordering that makes rollback possible. Only the end-to-end run against real Postgres proves the rollback itself. Consequence: a future edit that moves the validation after the UPDATE inside the transaction would still pass the whole unit suite while reintroducing the bug — so treat the e2e check below as load-bearing for this area, not optional.
Verification that the regression tests have teeth: with the controller reverted to main and the new spec block kept, 4 of the 5 new tests fail (both test.each limit cases, the re-post case, and the batchCreateChunked-throws case) while all 37 pre-existing tests still pass — confirming both that the tests catch the bug and that the try/catch is behavior-neutral. The 5th new test (non-Created PO skips validation) passes in both states by design: it's a precedence guard against a future regression, not a reproduction of this bug. Re-verified unchanged after the in-transaction refactor. Worth re-running that stash-and-compare check if this area is refactored again — note the fix was committed before the refactor, so git stash push -- <file> finds nothing to save on a clean tree and silently exits 0; use git checkout origin/main -- <file> and restore with git checkout HEAD -- <file>.
End-to-end recipe for this area (used to validate both the original and refactored fix; see also the /verify skill): seed a fresh Created PO with category='dress' on a factory with no max-dress-sku-limit row (hardcoded default 2 applies), then POST /api/shipment/v1/create with X-API-Key: $API_KEY three times — 3 SKUs (expect 400 + PO still Created), 2 SKUs × qty 2 (expect 400 on the total-quantity branch + still Created), then 2 SKUs × qty 1 (expect 201 + PO Open with shipment and work orders). Pick a PO with zero existing work orders: reusing one that already has them collides on workOrderNumber (PO…-S0001-0001 is derived from a per-PO sequence), which fails every chunk and makes step 3 exercise INFRA-601's revert instead of the intended success path — an easy false negative to misread as the fix not working.
Facts that constrain any fix in the create() pre-chunk window (gathered while scoping INFRA-619). Relevant whether the fix moves validation earlier or wraps the window in try/catch:
PurchaseorderModel.findUniqueis NOT cached — unlike the Style/Color/Size readers described under Model Caching Pattern above (which wrapcacheGet/cacheSet), it's a plainhandleDbErr(() => finalClient.purchaseOrder.findUnique(params), ...)passthrough. So a read-only PO preload before the transaction cannot serve a stale cached row, and needs nocacheInvalidatecoordination with theCreated → Openwrite that follows it. Don't assume the documented caching pattern applies to every model — it's per-model, and PurchaseOrder opted out.PurchaseOrder.poRecNameis@unique(prisma/schema.prisma:63,VarChar(25)), sofindUnique({ where: { poRecName } })is a valid unique lookup — no need to route a by-name read throughfindFirst. The authoritative in-transaction load is a different method,findUniqueByPoRecNameForUpdate(poRecName, tx), which is a rawSELECT * FROM "PurchaseOrder" WHERE "poRecName" = $1 AND "isDeleted" = false FOR UPDATE(row lock — the whole point of it, and why it takes a requiredtx).- The global mock already supports both shapes.
src/models/mock/index.ts'svi.mock('models/purchaseorder', ...)stubsfindUnique({ where: { poRecName } })andfindUniqueByPoRecNameForUpdate(poRecName), each resolving tomockDatawhen the name matches andnullotherwise — so adding a read-only preload bypoRecNameneeds no new mock wiring. The fixture (src/models/mock/purchaseorder.mock.ts) is frozen withpurchaseOrderId: 1, factoryId: 1, poRecName: 'PO123456', poStatus: 'Created', category: 'dress'— which is what makes the controller spec'swhere: { purchaseOrderId: 1, ... }revert assertions and thecategory-driven SKU-limit path work without per-test setup. FactoryService.findUnique's result is used only to feed the SKU-limit check — nothing after the validate call readsfactory. So the lookup can move with the validation as a unit;poRecord(from the transaction) is the one thing genuinely needed downstream, bybatchCreateChunkedandrunPostCreateFlow.validateShipmentSkuLimitsByCategorycompares one limit value against two different quantities.getSkuLimit(factoryCode, category)returns a single number, and the validator pushes an error when eithershipment.skus.length > skuLimitorshipment.skus.reduce((s, sku) => s + sku.quantity, 0) > skuLimit. Somax-dress-sku-limit = 2caps a dress shipment at 2 SKUs and at total quantity 2 — the key name says "sku limit" but it doubles as a per-shipment quantity cap. Both branches accumulate into oneerrors[]and throw a singlemakeBadRequestErrorlisting every offendingshipmentRecName, which is why the two AC rows ("exceeding SKU limit" / "exceeding total quantity") are the same code path with different messages.
Spec conventions in shipment.controller.spec.ts that a change to create() must respect:
ShipmentService.batchCreateChunkedandPurchaseOrderService.updateare notvi.fn()-wrapped by the module-levelvi.mockfactories, so tests reassign the static prop directly per test (ShipmentService.batchCreateChunked = vi.fn().mockResolvedValueOnce(...),PurchaseOrderService.update = updateSpy) rather than usingvi.mocked(...). Matches the file's pre-existing pattern forShipmentService.updateStatus.- The INFRA-601 "does not revert" test deliberately asserts by call content —
updateSpy.mock.calls.some((call) => call[0]?.data?.poStatus === PoStatus.Created)— not by call count or argument arity, so the assertion keeps working if the revert call ever gains an extra argument. Follow this when adding revert assertions. - ⚠️ Ordering trap: the "revert update itself fails" test drives
PurchaseOrderService.updatewith positionalmockResolvedValueOnce(undefined)(theCreated → Opentransition) followed bymockRejectedValueOnce(...)(the revert). Adding any newPurchaseOrderService.updatecall tocreate()silently shifts that sequence and breaks the test — so a fix that introduces a second revert site must either share one revert helper (keeping the call count at 2 on that path) or update theOncechain. afterEachin the INFRA-619 block is NOT redundant with itsbeforeEach(a review bot flagged it as such). That block is the last one inside theCreate Shipment Controllerdescribe, but three more top-level describes follow it in the file (Shipment Update Status,Shipment Force Acknowledge,Shipment Force Acknowledge By Po).beforeEachonly re-seeds statics for tests within the block;afterEachis what stops the block's overrides leaking into those later describes — i.e. it avoids propagating the exact hazard the INFRA-601 block creates by reassigning statics and never restoring them. Currently defensive rather than load-bearing (none of the trailing describes callcreate()), which is why removing it doesn't fail anything — don't mistake that for redundancy.- All test declarations in this file use
test()as of INFRA-619; the 27 pre-existingit()calls were migrated in a dedicated commit to satisfy the CLAUDE.md /pr-review.mdconvention. Theitimport was dropped, so a newit()here won't compile — matching the convention by construction. vi.mock('utils/sentryUtils', importOriginal)spreads the real module and replaces onlycaptureShipmentPoRevertFailed, so other capture functions increate()run for real in specs — keep new captures side-effect-free or add them to that factory.
Gotcha (INFRA-602) — the chunk loop used to always attempt every remaining chunk, even after an infra-level (DB-degraded) failure. A local repro (3-chunk/3000-shipment payload, chunk 2's insert rigged via a Postgres statement-level trigger + pg_sleep(65) to hang) proved total request time (67.4s) blew past n8n's 60s client timeout even though only 1 of 3 chunks stalled — and revealed Prisma's timeout: 60_000 on prisma.$transaction is reactive, not preemptive: it did not cancel the hanging query at 60s, it only noticed the overrun after the query naturally returned (surfaced as PrismaClientKnownRequestError code P2028, "Transaction already closed... however 65160 ms passed since the start of the transaction"). Fixed with two mechanisms, both in src/services/shipment/shipment.service.ts:
export function isInfraLevelChunkError(err, durationMs)— a hybrid classifier, OR of two independent signals: (1) explicit Prisma/pg connectivity signatures —instanceof Prisma.PrismaClientInitializationError | PrismaClientRustPanicError | PrismaClientUnknownRequestError, orPrismaClientKnownRequestErrorwithcodein{P1008, P1017, P2024, P2028}, or a raw error with.codein{ECONNREFUSED, ECONNRESET, ETIMEDOUT, EHOSTUNREACH, EPIPE}; (2) a duration fallback —durationMs >= env.SHIPMENT_CREATE_CHUNK_STATEMENT_TIMEOUT_MScounts as infra-level regardless of error shape, since a healthy chunk transaction is fast (the real PO23225 payload's un-chunked DB write time was ~2s total per the INFRA-599 postmortem) and this closes the gap where an unanticipated Prisma wrapper around the new statement_timeout cancellation (below) would otherwise slip past pure code-matching. Everything else (P2002/P2003/P2000/P2025, plainmakeBadRequestError-shaped application errors like "shipmentRecName ... was not found in table") stays classified as chunk-scoped/continue — unchanged from pre-INFRA-602 behavior, matching the AC. Exported as a top-levelexport function(not aShipmentService.*static prop) — same convention asresolveFulfilFactory/makeRTSShipmentsPacked— so it's directly unit-testable with real constructedPrisma.PrismaClient*Errorinstances.- On a classified infra-level error, the loop marks every remaining, unattempted chunk with a new
'skipped'status (added toresults.chunks/failedShipmentRecNames— necessary so the controller'ssuccessfulShipmentCount = shipments.length - failedCountmath stays correct — each carrying a syntheticErrorso it still surfaces through the existingerrorsresponse field) andbreaks. Already-committed chunks stay committed — no change to the partial-commit design. - DB-side
statement_timeout:ShipmentModel.setChunkStatementTimeout(client, timeoutMs)(src/models/shipment/shipment.model.ts) issuesclient.$executeRawUnsafe(`SET LOCAL statement_timeout = ${timeoutMs}`)as the first statement inside each chunk's transaction callback, using the newenv.SHIPMENT_CREATE_CHUNK_STATEMENT_TIMEOUT_MS(default50_000, kept below the Prisma transaction's own 60s so Postgres cancels first and throws a classifiable error).SET LOCALdoesn't accept bind parameters in Postgres, so the value must be inlined — safe here since it's always a trusted, server-controlled config number, never user input. This was deliberately built as a Model method, not an inlinetx.$executeRawUnsafecall in the Service's transaction callback — see the testing gotcha below for why that distinction mattered in practice, not just for style. - Why 50s, not 55s: the dual-worktree verification below (initially run at 55s) measured total request time at 57.3s for a batch with one stalled chunk — only ~2.7s of margin under n8n's 60s client timeout. Lowered to 50s post-verification for more headroom against real network variance and per-request overhead (SKU-lookup phase, etc.) beyond the stalled chunk itself; not re-verified end-to-end at 50s (the mechanism itself — Postgres cancelling the query, the loop skipping remaining chunks — doesn't change with the threshold value, only the total elapsed time scales down accordingly).
- Sentry:
captureShipmentChunkedCreategained achunkedCreateFailFastevent type (SENTRY_CREATE_SHIPMENT_EVENT_TYPEinsrc/constants/sentryTags.ts), fired (atwarnlevel) whenever any chunk is'skipped'— distinct from the existingchunkedCreatePartial(loop ran every chunk; some had ordinary recoverable/data errors).
Testing gotcha, corrected: an earlier pass of this doc claimed prisma.$transaction "is not mocked anywhere in this suite" for shipment.controller.spec.ts — that was wrong. It is globally mocked: src/models/mock/index.ts (wired into every spec file via vitest.config.ts's setupFiles) does vi.mock('models/prismaClient', ...) with $transaction: async (fn) => fn() — note the callback is invoked with no argument, so tx is undefined inside every prisma.$transaction(async (tx) => {...}) callback in any spec file that doesn't locally re-mock models/prismaClient. This was harmless pre-INFRA-602 because nothing dereferenced tx directly — it was only ever passed through as an opaque argument to already-mocked Model methods (which ignore it). INFRA-602 hit this directly: an early draft called tx.$executeRawUnsafe(...) inline inside batchCreateChunked's transaction callback, which crashed with TypeError: Cannot read properties of undefined (reading '$executeRawUnsafe') in any spec relying on the global mock without locally overriding models/prismaClient (e.g. shipment.controller.spec.ts's "Jewelry image sync trigger" test, which exercises the real batchCreateChunked rather than reassigning ShipmentService.batchCreateChunked). Fixed by moving the raw SQL behind ShipmentModel.setChunkStatementTimeout(tx, timeoutMs) instead — a normal Model method call, which is itself covered by the other global mock (vi.mock('models/shipment', ...), also in src/models/mock/index.ts) and so never touches the real/undefined tx. Lesson: never call a raw tx.$executeRaw*/tx.$queryRaw* method inline inside a Service-layer prisma.$transaction callback — always wrap it in a Model method (matching the existing handleDbErr-wrapped raw-SQL convention, e.g. PurchaseorderModel.findUniqueByPoRecNameForUpdate, ShipmentModel.updateTrackingNumberCarriers), both for the architectural reason (raw Prisma calls belong in the Model layer) and this very concrete testing reason (the global mock only makes tx safe to use through an already-mocked Model class, not as a bare object with real $-prefixed methods).
ShipmentService.batchCreateByTransaction/workorderService.batchCreateByTransaction are called as direct function references inside batchCreateChunked, not via ShipmentService.*/property lookup — so the established "reassign the static prop directly" mocking trick (used elsewhere in this file, e.g. ShipmentService.updateStatus = vi.fn()...) does not intercept them. shipment.service.spec.ts's own tests for batchCreateChunked (added INFRA-602) instead mock at the prisma.$transaction boundary itself — a local vi.mock('models/prismaClient', () => ({ prisma: { $transaction: vi.fn() } })) for that file, with vi.mocked(prisma.$transaction).mockResolvedValueOnce(...)/mockRejectedValueOnce(...) per chunk — bypassing the real inner insert logic entirely and testing only the loop's orchestration (classify/skip/break). This is a different, file-local override of models/prismaClient layered on top of the global one described above (a local vi.mock in a spec file takes precedence over the global setupFiles one for that file only).
Post-create: Work Order PDF export kickoff (INFRA-617/632)
After the create() response is sent, shipment.controller.ts:167-186 fires the server-side Work Order PDF
pipeline when the gcp-wo-pdf-generation feature flag is on:
ShipmentService.workOrderPdfGeneration(poRecord, factoryNameEn, shipmentCreateResponse)returns aprerequisiteEmitter(EventEmitter). This is the same emitter thatrunPostCreateFlow(shipment.service.ts:1089) threads intoworkorderService.updateBarcodesWhenInit, which emitsbarcodeChunkDone/barcodeChunkFailedper barcode chunk. The PDF pipeline listens on it to satisfy itsallBarcodesExistgate.- The second gate (
settleDelayElapsed) is a bare in-processsetTimeout, so both gates are process-local — a restart strands the job (seework-order-pdf-export.md"Promotion is in-process" gotcha). runPostCreateFlowonly runs for_successfulShipments(the barcode emitter and the rest of the post-flow skip failed shipment chunks), but the PDF job is enqueued from the fullshipmentCreateResponse— an enqueued job with failed shipments is flippedBlockedinsideworkOrderPdfGenerationbefore the gates are armed.
The full pipeline (chunking, Cloud Run rendering, zip, retry, regenerate) is documented in
work-order-pdf-export.md.