Skip to main content

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; calls makeShipmentsAcknowledge then monitorPendingShipmentsAfterCreate as a safety alert.
  • makeShipmentsAcknowledge (line 406) — looks up Pending shipments, calls Fulfil, then updates MES status for the successful ones. Also calls makeRTSShipmentsPacked (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-item errors containing 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 _makeMESShipmentsAcknowledge short-circuits (no DB write). Shipments remain Pending even though Fulfil may have processed them server-side.
  • monitorPendingShipmentsAfterCreate (line 769) — the only existing safety net for stuck-Pending shipments; it emits capturePendingShipmentsAlert to 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:

  1. PO CreatedOpen transition is its own small transaction (timeout: 10_000), using PurchaseOrderService.findUniqueByPoRecNameForUpdate (SELECT ... FOR UPDATE raw query in purchaseorder.model.ts) to lock the row, then a conditional PurchaseOrderService.update({ where: { purchaseOrderId, poStatus: PoStatus.Created }, data: { poStatus: PoStatus.Open } }, tx). The WHERE poStatus = 'Created' guard gives optimistic-concurrency safety — a concurrent writer changing the status first makes this throw P2025 (surfaced as 400 via handleDbErr). This transition happens before any shipment/work-order chunk is attempted.
  2. ShipmentService.batchCreateChunked (src/services/shipment/shipment.service.ts) splits shipments into chunks of env.SHIPMENT_CREATE_TRANSACTION_CHUNK (default 1000), each chunk processed in its own prisma.$transaction (isolationLevel: ReadCommitted, timeout: 60_000) that does ShipmentService.batchCreateByTransaction (CustomerShipment rows) then workorderService.batchCreateByTransaction (WorkOrder rows). The loop is sequential — a chunk's transaction failure is caught and recorded in results.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).
  3. Response shape (ShipmentCreateResponse): totalShipmentCount/totalWorkOrderCount/successfulShipmentCount/failedShipmentCount/failedShipmentRecNames/errors — callers must check failedShipmentCount/errors, since the endpoint always returns HTTP 201 (successCreatedJson) even when every chunk failed (see gotcha below).
  4. SHIPMENTS_ARRAY_MAX = 50_000 (src/schemas/shipment/index.ts) is a generous abuse-guard cap on the shipments array, not a functional limit — grounded on historical max shipments-per-PO (~10,000) × safety factor 5.
  5. Sentry: captureShipmentChunkedCreate reports the full per-chunk breakdown (level warn if any chunk failed, else info).

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 either20260616084818_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:

  1. The SKU-limit check runs INSIDE the Created → Open transaction, between _validatePoRecord and the status UPDATE. 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 _validatePoRecord already ran first and its canonical was 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 _validateSkuLimitsForPo ahead of the UPDATE if 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, gated if (preTransitionPo?.poStatus === PoStatus.Created). That works and is safe (PurchaseorderModel.findUnique is uncached — see below), but costs one extra query on every create() call and needs the ?. + === Created guard purely to keep error precedence. The in-transaction version is strictly less code for the same semantics; the only trade-off is that FactoryService.findUnique + getSkuLimit now execute while the PO row is FOR UPDATE-locked (two indexed reads against a 10s transaction timeout, and that lock exists precisely to serialize concurrent create() on the same PO).
  2. _validateSkuLimitsForPo(shipments, poRecord) — module-private, wraps the relocated FactoryService.findUnique + ShipmentService.validateShipmentSkuLimitsByCategory as a unit (the factory lookup exists only to feed the limit check, so the two always move together). Deliberately takes no tx: both are reads of reference tables (Factory, FactoryConfiguration) this transaction never mutates, and FactoryModel.findUnique(params) hardcodes prisma.factory.findUnique with no optional-client parameter — so threading tx would be partial and pointless without also changing that model signature and validateShipmentSkuLimitsByCategory's.
  3. _revertPoToCreated({ poRecName, purchaseOrderId }) — module-private, best-effort (WHERE poStatus = Open, captureShipmentPoRevertFailed on failure, never rethrows). Shared by two call sites: the INFRA-601 all-chunks-failed branch, and a new try/catch around batchCreateChunked that reverts then rethrows. Sharing one helper is not just style — it keeps PurchaseOrderService.update at 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.findUnique is NOT cached — unlike the Style/Color/Size readers described under Model Caching Pattern above (which wrap cacheGet/cacheSet), it's a plain handleDbErr(() => finalClient.purchaseOrder.findUnique(params), ...) passthrough. So a read-only PO preload before the transaction cannot serve a stale cached row, and needs no cacheInvalidate coordination with the Created → Open write that follows it. Don't assume the documented caching pattern applies to every model — it's per-model, and PurchaseOrder opted out.
  • PurchaseOrder.poRecName is @unique (prisma/schema.prisma:63, VarChar(25)), so findUnique({ where: { poRecName } }) is a valid unique lookup — no need to route a by-name read through findFirst. The authoritative in-transaction load is a different method, findUniqueByPoRecNameForUpdate(poRecName, tx), which is a raw SELECT * FROM "PurchaseOrder" WHERE "poRecName" = $1 AND "isDeleted" = false FOR UPDATE (row lock — the whole point of it, and why it takes a required tx).
  • The global mock already supports both shapes. src/models/mock/index.ts's vi.mock('models/purchaseorder', ...) stubs findUnique({ where: { poRecName } }) and findUniqueByPoRecNameForUpdate(poRecName), each resolving to mockData when the name matches and null otherwise — so adding a read-only preload by poRecName needs no new mock wiring. The fixture (src/models/mock/purchaseorder.mock.ts) is frozen with purchaseOrderId: 1, factoryId: 1, poRecName: 'PO123456', poStatus: 'Created', category: 'dress' — which is what makes the controller spec's where: { purchaseOrderId: 1, ... } revert assertions and the category-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 reads factory. So the lookup can move with the validation as a unit; poRecord (from the transaction) is the one thing genuinely needed downstream, by batchCreateChunked and runPostCreateFlow.
  • validateShipmentSkuLimitsByCategory compares one limit value against two different quantities. getSkuLimit(factoryCode, category) returns a single number, and the validator pushes an error when either shipment.skus.length > skuLimit or shipment.skus.reduce((s, sku) => s + sku.quantity, 0) > skuLimit. So max-dress-sku-limit = 2 caps 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 one errors[] and throw a single makeBadRequestError listing every offending shipmentRecName, 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.batchCreateChunked and PurchaseOrderService.update are not vi.fn()-wrapped by the module-level vi.mock factories, so tests reassign the static prop directly per test (ShipmentService.batchCreateChunked = vi.fn().mockResolvedValueOnce(...), PurchaseOrderService.update = updateSpy) rather than using vi.mocked(...). Matches the file's pre-existing pattern for ShipmentService.updateStatus.
  • The INFRA-601 "does not revert" test deliberately asserts by call contentupdateSpy.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.update with positional mockResolvedValueOnce(undefined) (the Created → Open transition) followed by mockRejectedValueOnce(...) (the revert). Adding any new PurchaseOrderService.update call to create() 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 the Once chain.
  • afterEach in the INFRA-619 block is NOT redundant with its beforeEach (a review bot flagged it as such). That block is the last one inside the Create Shipment Controller describe, but three more top-level describes follow it in the file (Shipment Update Status, Shipment Force Acknowledge, Shipment Force Acknowledge By Po). beforeEach only re-seeds statics for tests within the block; afterEach is 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 call create()), 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-existing it() calls were migrated in a dedicated commit to satisfy the CLAUDE.md / pr-review.md convention. The it import was dropped, so a new it() here won't compile — matching the convention by construction.
  • vi.mock('utils/sentryUtils', importOriginal) spreads the real module and replaces only captureShipmentPoRevertFailed, so other capture functions in create() 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, or PrismaClientKnownRequestError with code in {P1008, P1017, P2024, P2028}, or a raw error with .code in {ECONNREFUSED, ECONNRESET, ETIMEDOUT, EHOSTUNREACH, EPIPE}; (2) a duration fallback — durationMs >= env.SHIPMENT_CREATE_CHUNK_STATEMENT_TIMEOUT_MS counts 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, plain makeBadRequestError-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-level export function (not a ShipmentService.* static prop) — same convention as resolveFulfilFactory/makeRTSShipmentsPacked — so it's directly unit-testable with real constructed Prisma.PrismaClient*Error instances.
  • On a classified infra-level error, the loop marks every remaining, unattempted chunk with a new 'skipped' status (added to results.chunks/failedShipmentRecNames — necessary so the controller's successfulShipmentCount = shipments.length - failedCount math stays correct — each carrying a synthetic Error so it still surfaces through the existing errors response field) and breaks. 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) issues client.$executeRawUnsafe(`SET LOCAL statement_timeout = ${timeoutMs}`) as the first statement inside each chunk's transaction callback, using the new env.SHIPMENT_CREATE_CHUNK_STATEMENT_TIMEOUT_MS (default 50_000, kept below the Prisma transaction's own 60s so Postgres cancels first and throws a classifiable error). SET LOCAL doesn'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 inline tx.$executeRawUnsafe call 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: captureShipmentChunkedCreate gained a chunkedCreateFailFast event type (SENTRY_CREATE_SHIPMENT_EVENT_TYPE in src/constants/sentryTags.ts), fired (at warn level) whenever any chunk is 'skipped' — distinct from the existing chunkedCreatePartial (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 a prerequisiteEmitter (EventEmitter). This is the same emitter that runPostCreateFlow (shipment.service.ts:1089) threads into workorderService.updateBarcodesWhenInit, which emits barcodeChunkDone/barcodeChunkFailed per barcode chunk. The PDF pipeline listens on it to satisfy its allBarcodesExist gate.
  • The second gate (settleDelayElapsed) is a bare in-process setTimeout, so both gates are process-local — a restart strands the job (see work-order-pdf-export.md "Promotion is in-process" gotcha).
  • runPostCreateFlow only 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 full shipmentCreateResponse — an enqueued job with failed shipments is flipped Blocked inside workOrderPdfGeneration before the gates are armed.

The full pipeline (chunking, Cloud Run rendering, zip, retry, regenerate) is documented in work-order-pdf-export.md.