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, notWorkOrderTemplate. The latter owns data fetching, loading/error states andi18n.languagedetection — none of which apply server-side.WorkOrderMainDisplaytakes 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 toreact-i18next, andtranslateComponent(name)resolves viatranslate-functions.factory.jsto thecomponentsnamespace. So initializing i18next once before rendering is sufficient:Theawait i18next.use(initReactI18next).init({
lng: 'zh-CN', fallbackLng: 'en', interpolation: { escapeValue: false },
resources: { en: { translation: en }, 'zh-CN': { translation: zhCn } },
});'zh-CN'resource key (hyphen) matches whatadmin.router.tsregisters, while the file on disk issrc/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 resolvesreact-i18next's CommonJS build, whileadminjs(ESM) resolves the ESM build. Two module instances meansinitReactI18nextregisters the i18next instance on the copy AdminJS never sees, and rendering dies withTypeError: i18n.t is not a functionpreceded by the misleading warningreact-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.mtsfile (tsx gives it ESM + top-level await); every existing script inscripts/is.ts, so this is a new extension for the repo, needed by anything that renders React or importsadminjsESM exports. react/react-dom(18.3.1) are available but NOT declared inpackage.jsondependencies — they arrive transitively throughadminjs, andvite.config.tslists both inrollupOptions.external. Production code that importsreact-dom/serverserver-side should declare them explicitly rather than relying on a transitive hoist that a dependency bump could move.style.nameis deliberately always English on the work order.WorkOrderHeader.tsx:79hardcodesstyle.name.en ?? '', ignoringlang. Color name is the opposite —WorkOrderSizeColor(same file, ~128-135) localizes it and appends the English name in parentheses whenlang !== 'en'. So an English style name underlang: 'zh'is correct behavior, not a prop-threading bug; don't chase it.- Getting
WorkOrderDatafor a script costs nothing extra:workorderService.makeBatchInfosByPoId(poId, offset, limit, markPrinted)already takes a fourthmarkPrintedargument (defaultstrue). Passingfalseyields the exact payload the browser renders, without flipping work order status toPrinted— 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 (
makeMaterialCategorysinworkorder.service.tsfilters 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 of1123px, so a work order occupiesceil(height / 1123)pages. Replacing that withbreak-after: pageon 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
/DCTDecodeobjects 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 aUint8Array, not aBuffer(puppeteer ≥ 23).writeFileaccepts 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 inBuffer.from(...)first.archiver@8is a breaking, undocumented-in-its-own-types rewrite. v8 is ESM-only, drops thearchiver('zip', {...})factory forArchiver/ZipArchiveclasses, has no CJS entry (sorequire('archiver/package.json')throwsERR_PACKAGE_PATH_NOT_EXPORTED), and does not match the published@types/archiver. Pinarchiver@^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-cjkis 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-domare 18.3.1 and available, but undeclared inpackage.json— they arrive transitively viaadminjs(both are already invite.config.ts'sexternal). Anything importingreact-dom/serverin 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) andscripts/spike-export-wo-fixture.mts. Findings:docs/infra-616-spike-findings.md. .mtsis the extension to reach for when a script renders React or importsadminjsESM exports;scripts/lib/types.d.ts'sArgOptionwas widened totype: '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)
WorkOrderPdfExportJobhas afailedStep WorkOrderPdfExportJobStatus?column — only set while the job isBlocked;updateStatus(workOrderPdfExportJob.service.ts:155) clears it tonullon every other transition. It records which pipeline step threw soregeneratecan resume from that exact step.- Job statuses:
WaitingPrereqs → Queued → Rendering → (Partial | Packaging) → Notified, withBlockedreachable fromWaitingPrereqs/Queued/Rendering/Packaging.Partialmeans "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 inQueuedby a process crash — without it the compare-and-setupdateStatuswould miss and throw P2025 (surfaced as 400). - Chunk statuses:
Pending → Rendering → (Stored | Failed | MaxRetriesExceeded). - Status updates are compare-and-set, not read-then-write.
updateStatusbuildswhere: { id, status: { in: ALLOWED_PREVIOUS_STATUSES[newStatus] } }(allowedPreviousStatuses.ts:40derives the reverse map from aVALID_TRANSITIONStable). A concurrent sweep / regenerate / double-promote then throws P2025 (surfaced as 400) instead of silently overwriting — the same optimistic-concurrency shape as the POCreated → Openguard inshipment-create.md. createdBywas historically alwaysNULL—autoUpdateInfoExtension(models/prismaClient.ts:7) only interceptsupdate/updateMany/updateManyAndReturn/upsert, nevercreate, andcreateAndMakePrefixdoes acreatefollowed by anupdate(to setobjectPrefix), so onlyupdatedByever got stamped. INFRA-655 fills it increateAndMakePrefix(workOrderPdfExportJob.service.ts:124):params.data.createdBy ??= requestContextStorage.getStore()?.adminUser?.email ?? null—'N8N'for the x-api-key shipment-create path (apiAuth.ts:36sets the session email), the admin's email for a manual regenerate,nullfor a pure background run with no request context. Existing rows stayNULL(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: anEventEmitterthe caller passes intoupdateBarcodesWhenInit(shipment.controller.ts:169-186→runPostCreateFlow→workorderService.updateBarcodesWhenInit, which emitsbarcodeChunkDone/barcodeChunkFailedper chunk). The listener accumulates counts until every work order has a barcode.settleDelayElapsed: a baresetTimeout(SETTLE_DELAY_MS)(10 min) atworkOrderPdfGeneration.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:
createAndMakePrefixadds the row right aftercreate(workOrderPdfExportJob.service.ts:132) — the job starts inWaitingPrereqs, which is already "in flight". - Sync on every transition:
updateStatuscalls_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 aBlocked/Partialjob 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), sogetJobStatuscallsupdateStatus({ status: Blocked, failedStep: <stranded status> })and reports the job asBlocked. The export page then offers Regenerate.
Three things are easy to get wrong here:
getJobStatusis 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
getJobStatusblocks it. There is deliberately no backfill sweep: recovery runs only when someone actually polls the PO's status. Queued → Blockedhad to be added toVALID_TRANSITIONS(allowedPreviousStatuses.ts:9). Without it, a job stranded inQueuedcould not be blocked — the compare-and-setwhere: { 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.errorwithpoRecName,jobId,failedStep,pipelineError(the original failure) anderror(the handler's own failure) — a Sentry issue viapino-sentry-transport; - deletes the job from
workOrderPdfExportRunningJobs._trackRunningJobonly runs after a successfulupdateStatuswrite, so without this the dead job would look "running" forever:getJobStatus's stuck-job recovery skips it and the admineditguard refuses it until the process restarts. Dropping the entry lets the nextgetJobStatuspoll retry theBlockedwrite; - 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 defaultonUnhandledRejectionIntegrationinmode: 'warn', which registers aprocess.on('unhandledRejection')listener; Node only crashes when no listener exists. The rejection becomes a Sentry issue withmechanism: onunhandledrejection(searcherror.mechanism:onunhandledrejectionin themanufacturingproject). The integration is only set up when the client is enabled (@sentry/coreclient.init()→_isEnabled());instrument.tspasses anintegrationsarray, which merges with the defaults. - Local dev had no listener.
instrument.tssetsenabled: falsewhenNODE_ENV === 'development', so an unhandled rejection exited the process withERR_UNHANDLED_REJECTION— how the INFRA-706 local run crashed. registerUnhandledRejectionHandler()(called insrc/index.tsright after Sentry init) now logs every unhandled rejection throughlogPinoSentry.errorand 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
seqis 0-based; the PDF file name is 1-based (makeWoPdfFileNameWithoutPathsdoesString(seq + 1).padStart(3, '0')), sochunk_000holdsBG_..._001.pdf. index.json'stask2Seqmaps Cloud Run task index → chunk seq. It exists becauseRunJobenv overrides are per-execution, not per-task (see the design §3); a retry round rewrites it covering only the failed seqs.result.json/zipResult.jsonare 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
-PO23264suffix (the admin list showsobjectPrefix, which covers this). Old rows and their GCS objects are untouched (no backfill, no rename): every read goes through the storedobjectPrefix, so old and new naming coexist. Only the folder is prefixed — the zip filename staysBG_<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).enqueueChunksthrows on an empty list rather than creating zero chunks, which would strand the job inRenderingforever (nothing to claim, nothing to finalize). runJobs(runJobs.ts:75) →_launchChunks(writeindex.jsononce, then one Cloud Run execution withtaskCount = 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 asetTimeoutloop, notsetInterval, so a slow poll round can't overlap the next tick. Chunks that never reach a terminal status byMAX_WAIT_MSare treated asfailed(a crashed Cloud Run task that never wrote its result must not be polled forever)._relaunchFailedChunks(runJobs.ts:369) bumpsindex.json.retryCount(job becomesPartialpastMAX_CHUNK_RETRY), remaps task indices to the failed seqs, resets thoseresult.jsonfiles to'processing'(so the next poll doesn't read stale'failed'), and launches a new execution withtaskCount = failed count.- A polling infrastructure error (launch/poll throw) propagates to
_startGeneration's catch, which marks the jobBlockedwithfailedStep = Rendering— distinct from chunk-level failure, which lands inPartial.
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 ownid/prefix); generation runs fire-and-forget. The create path iscreateByNewJob(:336, formerly_regenerateByNewJob— exported in INFRA-678 so the adminnewhandler can reuse it).Blocked→_regenerateByFailedStep(:417): resume fromfailedStep. Verifies prior steps' artifacts are intact (_verifyPriorArtifacts,:473), cleans the failed step's + later steps' products (_cleanupFromStep,:512), then re-runs from that step.failedStepnull/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, allsessionAuth(). TheJobStatusResponsejob payload carriescreatedAt(ISO string) andupdatedAt(ISO string,nulluntil the first update) —schemas/workOrderPdfExport/index.ts:81-88, served fromgetJobStatus(workOrderPdfExportJob.service.ts:257-258).updatedAtisnullfor a freshly created job becauseautoUpdateInfoExtensionstampsupdatedAtonupdate/upsert, nevercreate(same reasoncreatedBywas historicallyNULL, see the data-model section above).- The zip download lives on the shipment router:
GET /shipment/v1/downloadWorkordersZipandGET /shipment/v1/hasNotifiedWorkordersZipJob(shipment.router.ts:71,81).downloadWorkordersZip(shipment.service.ts:811) signs a 7-day URL against the latestNotifiedjob, marks the whole POOpen → Printedon 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), butnew/editare enabled as of INFRA-678; onlydelete/bulkDeletestayisAccessible: false.editProperties: ['status']restricts the edit form tostatus;newProperties: ['poId']makes create take just apoId. Default sortcreatedAt desc; filters onstatus,failedStep,triggeredBy,poId,createdAt. newuses a custom handler (workOrderPdfExportJobNewHandler,workOrderPdfExportJob.ts:240) because the default AdminJS create would build a job from the bare{ poId }payload, missing the non-nullobjectPrefix/triggeredBy/languagecolumns. It callscreateByNewJob(workOrderPdfExportJob.service.ts:336), which kicks off background generation. Twobeforeguards gate it:workOrderPdfExportJobNewBeforeHook(workOrderPdfExportJob.ts:192) refuses a PO already running in this process and a PO younger thanPO_ALLOW_NEW_JOB_MIN = 60minutes (workOrderPdfExportJob.ts:22).editonly gates the write, it does not re-route it:workOrderPdfExportJobEditBeforeHook(workOrderPdfExportJob.ts:171) refuses the edit whileworkOrderPdfExportRunningJobs.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 itsstatus). The default edit handler writes through@adminjs/prisma, which bypassesupdateStatus's compare-and-setALLOWED_PREVIOUS_STATUSESguard — the admin can force any status, makingeditthe manual-override path for a genuinely stuck job.poIdis a relation FK, so@adminjs/prismarenders the raw id without aninclude. A listafterhook (listActionFindPoRecNameAfter,workOrderPdfExportJob.ts:109) batch-queries the page'spoIds and injects a virtualpoRecNamecolumn — one query per page, not an N+1 (mirrorsshipment.ts'slistActionFindRelationsAfter). Enrichment failure degrades to aresponse.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'spoRecNamerenders empty when the admin'sselectedFactoryIddiffers 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.zipPdfsstampsgeneratedAtonly on zip success (zipPdfs.ts:67-71), so "no zip" ===generatedAt IS NULL. This also excludes the notify-onlyPackagingfailure (zip exists → no alert) and theWaitingPrereqsgate (a failed shipment creation already alerts the same channel viapostFulfilFailureAlert). - Two disjoint fire sites, so each failure alerts exactly once:
updateJobFailedAndLog(workOrderPdfGeneration/utils.ts:32) covers theBlockedpath (throw path);_pollUntilAllDone(runJobs.ts:281) coversPartial(return path — aPartialjob never reachedzipPdfs, 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 threeSLACK_*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).