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:84) 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/Rendering/Packaging. Partial means "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. 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.

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-186runPostCreateFlowworkorderService.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: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 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).

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:97main.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 own id/prefix); generation runs fire-and-forget.
  • Blocked_regenerateByFailedStep (:295): resume from failedStep. Verifies prior steps' artifacts are intact (_verifyPriorArtifacts, :351), cleans the failed step's + later steps' products (_cleanupFromStep, :390), then re-runs from that step. failedStep null/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.tsGET /jobStatus, POST /regenerate, GET /checkEnabled, all sessionAuth().
  • 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.

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).