Skip to main content

Work Order PDF Export (Server-Side Rendering)

Rendering the Work Order template outside the browser with react-dom/server, headless-Chromium PDF generation, page-break parity with the existing print flow, image dedupe, and the Chromium image-size cost.

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.

Related: docs/infra-616-spike-findings.md (full spike write-up) and docs/work-order-pdf-export-design.md (INFRA-617/618 design).

Server-Side Rendering the Work Order Template (INFRA-616 spike findings)​

The Work Order template components can be rendered outside the browser with renderToStaticMarkup from react-dom/server, with zero changes to the components themselves. Verified against the real WorkOrderMainDisplay tree: translations resolve, the stored barcode SVG passes through, product-image <img> URLs are emitted, ~2.6 KB of HTML for a Dress work order with one measurement row and one material row.

  • Entry point is WorkOrderMainDisplay, not WorkOrderTemplate. The latter owns data fetching, loading/error states and i18n.language detection — none of which apply server-side. WorkOrderMainDisplay takes everything as props (data, lang, printMode, skuImages), so SSR bypasses the fetch layer entirely.
  • Translations need only an i18next init, no shim module and no aliasing. AdminJS's useTranslation (node_modules/adminjs/lib/frontend/hooks/use-translation.js) delegates to react-i18next, and translateComponent(name) resolves via translate-functions.factory.js to the components namespace. So initializing i18next once before rendering is sufficient:
    await i18next.use(initReactI18next).init({
    lng: 'zh-CN', fallbackLng: 'en', interpolation: { escapeValue: false },
    resources: { en: { translation: en }, 'zh-CN': { translation: zhCn } },
    });
    The 'zh-CN' resource key (hyphen) matches what admin.router.ts registers, while the file on disk is src/locales/zh_CN.json (underscore) — don't "fix" one to match the other.
  • ⚠️ Must be a real ESM module — this is the trap. tsx -e '<inline>' transpiles to CJS, which resolves react-i18next's CommonJS build, while adminjs (ESM) resolves the ESM build. Two module instances means initReactI18next registers the i18next instance on the copy AdminJS never sees, and rendering dies with TypeError: i18n.t is not a function preceded by the misleading warning react-i18next:: You will need to pass in an i18next instance by using initReactI18next — which reads like the init was forgotten rather than duplicated. Use a .mts file (tsx gives it ESM + top-level await); every existing script in scripts/ is .ts, so this is a new extension for the repo, needed by anything that renders React or imports adminjs ESM exports.
  • react / react-dom (18.3.1) are available but NOT declared in package.json dependencies — they arrive transitively through adminjs, and vite.config.ts lists both in rollupOptions.external. Production code that imports react-dom/server server-side should declare them explicitly rather than relying on a transitive hoist that a dependency bump could move.
  • style.name is deliberately always English on the work order. WorkOrderHeader.tsx:79 hardcodes style.name.en ?? '', ignoring lang. Color name is the opposite — WorkOrderSizeColor (same file, ~128-135) localizes it and appends the English name in parentheses when lang !== 'en'. So an English style name under lang: 'zh' is correct behavior, not a prop-threading bug; don't chase it.
  • Getting WorkOrderData for a script costs nothing extra: workorderService.makeBatchInfosByPoId(poId, offset, limit, markPrinted) already takes a fourth markPrinted argument (defaults true). Passing false yields the exact payload the browser renders, without flipping work order status to Printed — the only supported way to read this data without side effects. (One latent write survives: it calls _makeBatchBarcodeAndSave, which early-returns when every work order already has a valid barcode but generates and persists any that are missing.)

PDF generation via headless Chromium (INFRA-616 spike, measured)​

  • ⚠️ An Aolong work order renders 1107px tall against a 1123px A4 page — 16px of headroom. Measured at production densities (median 7 populated measurement rows per work order; 54 material library rows across 7 categories, queried 2026-07-29). Consequences that apply to the existing browser print flow as much as to any server-side renderer: a style at the observed measurement maximum (10 rows) reaches 1124px and already spills to a second page today, and adding just ~4 rows to the global Material library pushes every Aolong work order to 2 pages — doubling page count, file size and render time. Every library material renders on every work order (makeMaterialCategorys in workorder.service.ts filters nothing, it pushes the whole library into categories), so the Material library is a shared lever on Aolong's print volume. Don't write code that assumes one page per work order.
  • Page-break parity is provable, not a judgement call. Today's browser flow pads each [data-id="workorderContainer"] up to a multiple of 1123px, so a work order occupies ceil(height / 1123) pages. Replacing that with break-after: page on a per-work-order wrapper reproduces it exactly, verified against real Chromium page counts at 1, 2 and 3 pages per work order. Note the measurements table splits into two columns, so it takes ~150 measurement rows before a work order needs a second page — well above the real library size.
  • Chromium dedupes identical images inside one PDF, including repeated data URIs. Measured: 500 work orders × 2 images drawing on 110 distinct sources produced exactly 110 /DCTDecode objects and byte-identical output whether the <img src> was a repeated data URI or a shared URL. So inlining downscaled data URIs is safe — no need to serve image bytes over a local URL to get dedupe.
  • page.pdf() resolves a Uint8Array, not a Buffer (puppeteer ≥ 23). writeFile accepts it, so a size check passes and the file is valid — but any Buffer-only API silently misbehaves: pdf.toString('latin1') yields "37,80,68,70,…" rather than PDF bytes, so byte-level inspection returns nothing with no error. Wrap in Buffer.from(...) first.
  • archiver@8 is a breaking, undocumented-in-its-own-types rewrite. v8 is ESM-only, drops the archiver('zip', {...}) factory for Archiver/ZipArchive classes, has no CJS entry (so require('archiver/package.json') throws ERR_PACKAGE_PATH_NOT_EXPORTED), and does not match the published @types/archiver. Pin archiver@^7 + @types/archiver@^6.
  • Chromium costs ~821 MB of image layer on node:24-alpine (chromium nss freetype harfbuzz ttf-freefont font-noto-cjk), taking the whole image from ~225 MB to ~1.05 GB. Relevant to build/push time and the registry GC constraints. If only a worker component needs Chromium, a separate image keeps the web service slim — "same image, different run command" means the web service carries the 821 MB too.
  • font-noto-cjk is mandatory, not defensive: Alpine ships no CJK glyphs, so without it every Chinese style/colour name renders as tofu boxes. The package installs ~30 font files.
  • react/react-dom are 18.3.1 and available, but undeclared in package.json — they arrive transitively via adminjs (both are already in vite.config.ts's external). Anything importing react-dom/server in production code should declare them explicitly rather than rely on the transitive hoist.
  • Spike harness for re-measuring any of the above: scripts/spike-render-wo-pdf.mts (--synthetic/--fixture, --unit, --images inline|url, --measurement-rows, --zip) and scripts/spike-export-wo-fixture.mts. Findings: docs/infra-616-spike-findings.md.
  • .mts is the extension to reach for when a script renders React or imports adminjs ESM exports; scripts/lib/types.d.ts's ArgOption was widened to type: 'boolean' | 'string' for these (every earlier script only needed boolean flags).

Runtime pipeline (INFRA-617/618/632/651 — implemented)​

The spike findings above answer "can we render server-side". This section is the implemented pipeline that ships it — and where it deliberately diverges from the agreed design (work-order-pdf-export-design.md). Read the design for the why (PO-created trigger, one click for the factory, Cloud Run over an always-on worker); read this for the as-built behaviour and its gotchas. Three divergences matter most and are called out inline: promotion is in-process, not a sweep; polling reads result.json only, never Cloud Run executions; the GCS dir is chunk_000 (underscore), not chunk-000.

shipment create (controller)
→ workOrderPdfGeneration (creates Job, arms EventEmitter gates)
→ _startGeneration: initChunks → uploadFixtures → runJobs (Cloud Run render) → zipPdfs (Cloud Run zip) → notifyFactory
→ GCS (index.json / chunk_NNN / zipResult.json) + Postgres (Job, Chunk) + Gmail

Data model & status machine (prisma/schema.prisma:61-77, 581-626)​

  • WorkOrderPdfExportJob has a failedStep WorkOrderPdfExportJobStatus? column — only set while the job is Blocked; updateStatus (workOrderPdfExportJob.service.ts:155) clears it to null on every other transition. It records which pipeline step threw so regenerate can resume from that exact step.
  • Job statuses: WaitingPrereqs → Queued → Rendering → (Partial | Packaging) → Notified, with Blocked reachable from WaitingPrereqs/Queued/Rendering/Packaging. Partial means "some chunks failed after the retry budget; the successful PDFs are kept for a Partial resume". Queued → Blocked (allowedPreviousStatuses.ts:9) exists specifically so the stuck-job recovery (below) can block a job that was stranded in Queued by a process crash — without it the compare-and-set updateStatus would miss and throw P2025 (surfaced as 400).
  • Chunk statuses: Pending → Rendering → (Stored | Failed | MaxRetriesExceeded).
  • Status updates are compare-and-set, not read-then-write. updateStatus builds where: { id, status: { in: ALLOWED_PREVIOUS_STATUSES[newStatus] } } (allowedPreviousStatuses.ts:40 derives the reverse map from a VALID_TRANSITIONS table). A concurrent sweep / regenerate / double-promote then throws P2025 (surfaced as 400) instead of silently overwriting — the same optimistic-concurrency shape as the PO Created → Open guard in shipment-create.md.
  • createdBy was historically always NULL — autoUpdateInfoExtension (models/prismaClient.ts:7) only intercepts update/updateMany/updateManyAndReturn/upsert, never create, and createAndMakePrefix does a create followed by an update (to set objectPrefix), so only updatedBy ever got stamped. INFRA-655 fills it in createAndMakePrefix (workOrderPdfExportJob.service.ts:124): params.data.createdBy ??= requestContextStorage.getStore()?.adminUser?.email ?? null — 'N8N' for the x-api-key shipment-create path (apiAuth.ts:36 sets the session email), the admin's email for a manual regenerate, null for a pure background run with no request context. Existing rows stay NULL (no backfill, by decision).

Promotion is in-process, not a sweep — ⚠️ the restart gotcha​

The design specified eligibleAt persisted on the job plus an n8n/Cloud-Scheduler sweep that promoted waiting_prereqs → queued. Neither was implemented. Instead, workOrderPdfGeneration.ts:87 builds a PrerequisiteResult (two gates — allBarcodesExist, settleDelayElapsed) driven by:

  • allBarcodesExist: an EventEmitter the caller passes into updateBarcodesWhenInit (shipment.controller.ts:169-186 → runPostCreateFlow → workorderService.updateBarcodesWhenInit, which emits barcodeChunkDone/barcodeChunkFailed per chunk). The listener accumulates counts until every work order has a barcode.
  • settleDelayElapsed: a bare setTimeout(SETTLE_DELAY_MS) (10 min) at workOrderPdfGeneration.ts:121.

PrerequisiteResult.setGateResult (PrerequisiteResult.ts:21) fires allSuccessCb/failedCb on the first terminal state and then latches (shouldTryStart), so it is single-shot.

Gotcha — a process restart strands the job in WaitingPrereqs. Both the settle setTimeout and the barcode emitter live in the web process's memory; a restart loses the timer and the updateBarcodesWhenInit fire-and-forget is not re-run. Because findActiveByPoId (workOrderPdfExportJob.service.ts:148) treats anything != Notified as active and blocks a duplicate job, and regenerate refuses WaitingPrereqs ("still in flight", workOrderPdfExportJob.service.ts:333), the job has no automatic path forward on its own. The design's proposed sweep is still absent — but the stuck-job recovery below now gives the job a lazy path forward: the next getJobStatus read marks it Blocked, which regenerate can then resume.

Stuck-job recovery — in-memory running-jobs registry (INFRA-678)​

The restart gotcha above is closed lazily, on read, not by a startup sweep. runningJobs.ts holds a process-local workOrderPdfExportRunningJobs: Map<jobId, { jobId, poId }> (runningJobs.ts:20), whose membership means "this Node process is actively advancing this job":

  • Register on creation: createAndMakePrefix adds the row right after create (workOrderPdfExportJob.service.ts:132) — the job starts in WaitingPrereqs, which is already "in flight".
  • Sync on every transition: updateStatus calls _trackRunningJob (workOrderPdfExportJob.service.ts:187) after the DB write — a terminal status (Notified/Partial/Blocked) deletes the entry, any other status (re)adds it (:176). The re-add matters for resume flows, where a Blocked/Partial job re-enters an intermediate status and must be tracked again.
  • Recover on read: getJobStatus (workOrderPdfExportJob.service.ts:215-232) inspects the latest job — if its status is intermediate but its id is not in the registry, the process that was advancing it is gone (crash/restart), so getJobStatus calls updateStatus({ status: Blocked, failedStep: <stranded status> }) and reports the job as Blocked. The export page then offers Regenerate.

Three things are easy to get wrong here:

  • getJobStatus is a side-effecting GET. The status endpoint writes (blocks) a stranded job on read. It is a no-op for a healthy job (present in the registry) and for a terminal job — but don't assume a read-only status probe.
  • The registry is process-local and empty after a restart — that is the entire mechanism. A job in an intermediate status that survives a restart is, by definition, not in the fresh registry, so the first getJobStatus blocks it. There is deliberately no backfill sweep: recovery runs only when someone actually polls the PO's status.
  • Queued → Blocked had to be added to VALID_TRANSITIONS (allowedPreviousStatuses.ts:9). Without it, a job stranded in Queued could not be blocked — the compare-and-set where: { status: { in: ALLOWED_PREVIOUS_STATUSES[Blocked] } } would miss and throw P2025, leaving the job permanently stuck.

The failure handler never rejects (INFRA-707)​

updateJobFailedAndLog (workOrderPdfGeneration/utils.ts) is contractually non-rejecting: two of its four callers fire it with a bare void (workOrderPdfGeneration.ts prereq failedCb and the _startGeneration catch block), so any rejection would be unhandled. Its updateStatus({ status: Blocked }) write is wrapped in a try/catch; on failure it:

  • logs one logger.error with poRecName, jobId, failedStep, pipelineError (the original failure) and error (the handler's own failure) — a Sentry issue via pino-sentry-transport;
  • deletes the job from workOrderPdfExportRunningJobs. _trackRunningJob only runs after a successful updateStatus write, so without this the dead job would look "running" forever: getJobStatus's stuck-job recovery skips it and the admin edit guard refuses it until the process restarts. Dropping the entry lets the next getJobStatus poll retry the Blocked write;
  • skips the Slack alert (the error log already raises the Sentry issue).

When the Blocked write can actually fail: ALLOWED_PREVIOUS_STATUSES[Blocked] includes Blocked itself and every intermediate status, so a job another process already blocked does not fail (Blocked → Blocked is allowed). It fails only on a DB error, or when the row is missing or already Notified/Partial.

Don't add a .catch at the void call sites or make the function throw again — keep it non-rejecting. utils.spec.ts covers the contract; workOrderPdfGeneration.spec.ts runs the real handler through a void call site and asserts mock.settledResults[0].type === 'fulfilled' (a vi.fn wrapper attaches its own .then, which hides the rejection from an unhandledRejection listener — assert on the settled result instead).

Unhandled rejections: prod survives, local dev used to crash (src/utils/unhandledRejection.ts)​

  • Prod / staging (Sentry enabled) has always survived. @sentry/node (9.x) installs its default onUnhandledRejectionIntegration in mode: 'warn', which registers a process.on('unhandledRejection') listener; Node only crashes when no listener exists. The rejection becomes a Sentry issue with mechanism: onunhandledrejection (search error.mechanism:onunhandledrejection in the manufacturing project). The integration is only set up when the client is enabled (@sentry/core client.init() → _isEnabled()); instrument.ts passes an integrations array, which merges with the defaults.
  • Local dev had no listener. instrument.ts sets enabled: false when NODE_ENV === 'development', so an unhandled rejection exited the process with ERR_UNHANDLED_REJECTION — how the INFRA-706 local run crashed.
  • registerUnhandledRejectionHandler() (called in src/index.ts right after Sentry init) now logs every unhandled rejection through logPinoSentry.error and keeps the process alive in every environment. It is a Sentry log, not an issue, so it doesn't duplicate the issue Sentry's integration creates. Keeping alive matches what prod already did, and exiting would restart the container and strand every in-flight PDF job (prereq timers + the running-jobs registry are in memory).

The same registry doubles as the guard for the admin new/edit actions (below): workOrderPdfExportRunningJobs.has(id) refuses a manual edit of a running job, and [...values()].some(job => job.poId === poId) refuses a manual create for a PO already being processed.

GCS layout (as-built)​

objectPrefix = ${env.JOB_PDF_OBJECT_PREFIX}/${factoryNameEn}/${makePoFolderName(poRecName, record.createdAt)}/${jobId} (built in createAndMakePrefix, workOrderPdfExportJob.service.ts). makePoFolderName prefixes the PO name with the job row's own createdAt in 24-hour UTC (2026-08-26-14-58-PO23264), so a factory's folder list reads chronologically instead of PO-number order. The timestamp comes from the row's createdAt (not a second new Date()), keeping the folder name and the row in agreement.

<prefix>/
index.json { retryCount, task2Seq: { taskIndex: seq }, totalCount, poRecName }
BG_<poRecName>.zip
zipResult.json { status: 'processing'|'success'|'failed', retryCount }
chunk_000/ ⚠️ underscore, not the design's chunk-000
payload.json WorkOrderFixture (workOrderDatas + skuImages)
BG_<poRecName>_001.pdf
result.json { status: 'processing'|'success'|'failed', tryCount }
  • makeChunkDirName (utils/woPdfGenerationUtils.ts:25) → chunk_${String(seq).padStart(3, '0')}.
  • Chunk seq is 0-based; the PDF file name is 1-based (makeWoPdfFileNameWithoutPaths does String(seq + 1).padStart(3, '0')), so chunk_000 holds BG_..._001.pdf.
  • index.json's task2Seq maps Cloud Run task index → chunk seq. It exists because RunJob env overrides are per-execution, not per-task (see the design §3); a retry round rewrites it covering only the failed seqs.
  • result.json / zipResult.json are the only interface the renderer/zip job writes back; they are the source of truth for success, never the child-process/Cloud-Run exit code (the dev launcher logs a non-zero exit and ignores it).
  • ⚠️ Trade-off (INFRA-655): the timestamp prefix means a factory's folders no longer sort by PO number — locating one PO means filtering on the -PO23264 suffix (the admin list shows objectPrefix, which covers this). Old rows and their GCS objects are untouched (no backfill, no rename): every read goes through the stored objectPrefix, so old and new naming coexist. Only the folder is prefixed — the zip filename stays BG_<poRecName>.zip, so the factory email link and downloaded filename are unaffected.

Chunking & chunk retry (runJobs.ts)​

  • Chunks are 500 work orders each (workOrderPdfExportChunk.service.ts:12, enqueueChunks). enqueueChunks throws on an empty list rather than creating zero chunks, which would strand the job in Rendering forever (nothing to claim, nothing to finalize).
  • runJobs (runJobs.ts:75) → _launchChunks (write index.json once, then one Cloud Run execution with taskCount = chunk count) → _pollUntilAllDone (runJobs.ts:241).
  • Poll constants: POLL_INTERVAL_MS = 10s, MAX_CHUNK_RETRY = 3, MAX_WAIT_MS = 30 min (runJobs.ts:43-47).
  • _waitAllChunksTerminal (runJobs.ts:278) is a setTimeout loop, not setInterval, so a slow poll round can't overlap the next tick. Chunks that never reach a terminal status by MAX_WAIT_MS are treated as failed (a crashed Cloud Run task that never wrote its result must not be polled forever).
  • _relaunchFailedChunks (runJobs.ts:369) bumps index.json.retryCount (job becomes Partial past MAX_CHUNK_RETRY), remaps task indices to the failed seqs, resets those result.json files to 'processing' (so the next poll doesn't read stale 'failed'), and launches a new execution with taskCount = failed count.
  • A polling infrastructure error (launch/poll throw) propagates to _startGeneration's catch, which marks the job Blocked with failedStep = Rendering — distinct from chunk-level failure, which lands in Partial.

Zip is a second Cloud Run job (zipPdfs.ts)​

The zip step is not done in MES — it's a second Cloud Run execution of the same job image with JOB_PDF_ZIP=true (zipPdfs.ts:97 → main.ts:20 branches on it). MES orchestrates and polls zipResult.json (_pollUntilTerminal, zipPdfs.ts:160), with MAX_ZIP_RETRY = 3 and the same 30-min deadline. This matches the design's "zip built by a second run" decision — the render tasks run in parallel so no single task holds all PDFs, and zipping inside MES would push ~20 MB × N through the OOM-prone web service.

Polling reads result.json only — the execution-poll code is dead​

cloudRunClient.ts still defines getExecutionTasksInfo / checkExecutionRunning / checkExecutionInitRunning (cloudRunClient.ts:49-93) — the design's "poll executions.get plus result objects". Nothing calls them: runJobs and zipPdfs poll GCS result.json/zipResult.json exclusively. Treat those methods as dead design leftovers; if a future change needs execution-level visibility (e.g. to distinguish "task crashed" from "task slow"), that is the seam to wire up.

Regenerate strategies (workOrderPdfExportJob.service.ts:287)​

POST /workOrderPdfExport/v1/regenerate decides by the latest job's status:

  • Notified → keep history, create a fresh job (its own id/prefix); generation runs fire-and-forget. The create path is createByNewJob (:336, formerly _regenerateByNewJob — exported in INFRA-678 so the admin new handler can reuse it).
  • Blocked → _regenerateByFailedStep (:417): resume from failedStep. Verifies prior steps' artifacts are intact (_verifyPriorArtifacts, :473), cleans the failed step's + later steps' products (_cleanupFromStep, :512), then re-runs from that step. failedStep null/WaitingPrereqs (legacy jobs) → full reset.
  • Partial → _regenerateFromPartial (:564): re-render only the failed chunks, keep the Stored ones.
  • Anything else (WaitingPrereqs/Queued/Rendering/Packaging) → refuse ("still in flight").

Every resume path falls back to _fullResetAndRegenerate (:398) when the resume base is broken (missing index.json, missing fixtures/PDFs/result.json, inconsistent chunk states) rather than rejecting the regeneration — e.g. fixtures pruned by the bucket's 30-day lifecycle. The full reset deletes every GCS object under the prefix (GcsClient.deleteByPrefix, note the trailing-slash guard at gcsClient.ts:82), deletes the chunk rows (no unique constraint on (jobId, seq), so re-enqueue is safe), and restarts from scratch.

Feature flag, endpoints, and the zip download​

  • Gated by gcp-wo-pdf-generation (constants/featureFlagKeys.ts:9). The flag seed is create-only (INFRA-651) so it never resets a live toggle.
  • src/routers/api/workOrderPdfExport/v1/workOrderPdfExport.router.ts — GET /jobStatus, POST /regenerate, GET /checkEnabled, all sessionAuth(). The JobStatusResponse job payload carries createdAt (ISO string) and updatedAt (ISO string, null until the first update) — schemas/workOrderPdfExport/index.ts:81-88, served from getJobStatus (workOrderPdfExportJob.service.ts:257-258). updatedAt is null for a freshly created job because autoUpdateInfoExtension stamps updatedAt on update/upsert, never create (same reason createdBy was historically NULL, see the data-model section above).
  • The zip download lives on the shipment router: GET /shipment/v1/downloadWorkordersZip and GET /shipment/v1/hasNotifiedWorkordersZipJob (shipment.router.ts:71,81). downloadWorkordersZip (shipment.service.ts:811) signs a 7-day URL against the latest Notified job, marks the whole PO Open → Printed on click (log-only on failure), and returns { url: null, expired: true } if the object was already pruned by the 30-day lifecycle — so the export page offers Regenerate instead of a dead link.

Observability + admin manual controls (INFRA-655 / INFRA-678): admin page + Slack failure alert​

Three gaps were closed in INFRA-655: an admin page for WorkOrderPdfExportJob (before this, diagnosing a failed export meant querying the DB directly), a populated createdBy (see the data-model section above), and a proactive Slack alert when a run ends with no zip produced. INFRA-678 later added manual create/edit to that page (it is no longer read-only), reusing the running-jobs registry as its guard.

AdminJS page — src/routers/admin/resources/workOrderPdfExportJob/workOrderPdfExportJob.ts, registered in admin.router.ts beside transactionErrorResource:

  • Admin-only (all actions adminRoleAuth), but new/edit are enabled as of INFRA-678; only delete/bulkDelete stay isAccessible: false. editProperties: ['status'] restricts the edit form to status; newProperties: ['poId'] makes create take just a poId. Default sort createdAt desc; filters on status, failedStep, triggeredBy, poId, createdAt.
  • new uses a custom handler (workOrderPdfExportJobNewHandler, workOrderPdfExportJob.ts:240) because the default AdminJS create would build a job from the bare { poId } payload, missing the non-null objectPrefix/triggeredBy/language columns. It calls createByNewJob (workOrderPdfExportJob.service.ts:336), which kicks off background generation. Two before guards gate it: workOrderPdfExportJobNewBeforeHook (workOrderPdfExportJob.ts:192) refuses a PO already running in this process and a PO younger than PO_ALLOW_NEW_JOB_MIN = 60 minutes (workOrderPdfExportJob.ts:22).
  • edit only gates the write, it does not re-route it: workOrderPdfExportJobEditBeforeHook (workOrderPdfExportJob.ts:171) refuses the edit while workOrderPdfExportRunningJobs.has(recordId). This is a process-local check — a job stranded after a restart is not in the registry, so it stays editable (the operator can manually fix its status). The default edit handler writes through @adminjs/prisma, which bypasses updateStatus's compare-and-set ALLOWED_PREVIOUS_STATUSES guard — the admin can force any status, making edit the manual-override path for a genuinely stuck job.
  • poId is a relation FK, so @adminjs/prisma renders the raw id without an include. A list after hook (listActionFindPoRecNameAfter, workOrderPdfExportJob.ts:109) batch-queries the page's poIds and injects a virtual poRecName column — one query per page, not an N+1 (mirrors shipment.ts's listActionFindRelationsAfter). Enrichment failure degrades to a response.notice, never breaks the list.
  • The resource is not in selectedFactoryExtension, so the list is cross-factory (acceptable — admin-only). But the after hook's PO lookup does go through that extension, so a row's poRecName renders empty when the admin's selectedFactoryId differs from the job's PO factory — the same accepted behaviour as the shipment list.

Slack alert on "no zip" — postWorkOrderPdfFailureAlert (slackClient.ts:278), reusing the Fulfil alert's isEnabled()/_postMessage()/_makeAlertBlocks() helpers; no new env var (channel is SLACK_CHANNEL_ID, prod = eng-mes-po-creation).

  • One condition collapses every case: failedStep !== WaitingPrereqs && !updatedJob.generatedAt. zipPdfs stamps generatedAt only on zip success (zipPdfs.ts:67-71), so "no zip" === generatedAt IS NULL. This also excludes the notify-only Packaging failure (zip exists → no alert) and the WaitingPrereqs gate (a failed shipment creation already alerts the same channel via postFulfilFailureAlert).
  • Two disjoint fire sites, so each failure alerts exactly once: updateJobFailedAndLog (workOrderPdfGeneration/utils.ts:32) covers the Blocked path (throw path); _pollUntilAllDone (runJobs.ts:281) covers Partial (return path — a Partial job never reached zipPdfs, so it is unconditional there).
  • Fire-and-forget (void … .catch(logger.error)) — a Slack outage never blocks PDF generation or the shipment-create response; isEnabled() (all three SLACK_* set) keeps it a no-op locally and in CI.

See also: shipment-create.md (where the post-create kickoff hooks in), work-order-pdf-export-design.md (the agreed design and its decisions), infra-621-cloud-run-hosting-decision.md (Cloud Run hosting, the renderer image build, and deploy pipeline — Dockerfile.gcpJobs + GCP GitHub workflows).