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:84) 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/Rendering/Packaging.Partialmeans "some chunks failed after the retry budget; the successful PDFs are kept for a Partial resume". - 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.
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:77) treats
anything != Notified as active and blocks a duplicate job, and regenerate refuses WaitingPrereqs ("still
in flight", workOrderPdfExportJob.service.ts:240), the job has no automatic path forward until a manual
intervention. This is the main reason the design's sweep was proposed — it is still absent.
GCS layout (as-built)
objectPrefix = ${env.JOB_PDF_OBJECT_PREFIX}/${factoryNameEn}/${poRecName}/${jobId} (built in
createAndMakePrefix, workOrderPdfExportJob.service.ts:45):
<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).
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:175)
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.Blocked→_regenerateByFailedStep(:295): resume fromfailedStep. Verifies prior steps' artifacts are intact (_verifyPriorArtifacts,:351), cleans the failed step's + later steps' products (_cleanupFromStep,:390), then re-runs from that step.failedStepnull/WaitingPrereqs(legacy jobs) → full reset.Partial→_regenerateFromPartial(:442): 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 (:276) 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().- 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.
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).