Spike: real P21 order acknowledgement into Zendesk
Status: research complete, and the working route is now implemented (2026-08-13) —
see What shipped. m_orderacknowledgements via
/process/pdfreport is broken on both Genfit tenants — but document generation itself
works on production, and a working, re-runnable route exists for orders that already
exist. Start at
The path that works; the option ranking
below it predates that finding. Prod build is 26.1.5910.3. See
Probe results — play and
Probe results — production.
Question asked: how do we get an order acknowledgement out of P21 so it can be
posted back onto the Zendesk ticket the order came from?
Primary source: mrwuss/p21-api-documentation
(unofficial community docs — not Epicor-endorsed; every claim below cites the file it
came from so it can be re-checked).
The ERP→Zendesk write-back pipeline already exists end to end and already accepts an arbitrary PDF. The gap is not plumbing — it is that the PDF is currently Ordermatic restating what it sent, not P21 confirming what it accepted.
P21 exposes a real Order Acknowledgement document via the report service
m_orderacknowledgements on POST {ui_server}/api/v2/process/pdfreport. Fetch that,
swap it into the existing pipe, keep the current synthesized PDF as a fallback.
Update 2026-08-11 — read this before option 1. Two findings that change the plan:
m_orderacknowledgementscannot render on EITHER Genfit tenant (play 2026-08-11, production 2026-08-12). Its criteria are now known and identical on both (probed live; two field names guessed below were wrong), but/process/pdfreportanswers with a bodyless HTTP 500 — on play for every report service including the verifiedm_reprintpurchaseorderscontrol. On prod a probe matrix locates the fault at document-generation time: the window opens, permissions are fine, an under-specified payload returns200 [], and a complete payload 500s whether or not the target order exists. Upstream hit the same symptom set on a third tenant. Our payload passes upstream’s offline validator with 0 errors. This is an Epicor bug, not something we can work around.- The print-flag path DOES work here, and produces the real document.
print_orderack = ONon a savableOrdertransaction returned a genuine 2-pageOR164921 ORDER_ACK.pdf(101,864 bytes,%PDF-1.7, producer “Powered By Crystal”) titled ORDER ACKNOWLEDGEMENT, carrying the live order number and the real bill-to / ship-to. See Print-flag path.
So the rendering engine is alive on this tenant; the report service endpoint is what is broken. That is a sharper thing to take to Epicor, and it means option 2 below — previously rejected as unnecessary risk — is currently the only route that works here.
What already exists in this repo
Section titled “What already exists in this repo”Do not rebuild any of this. The loop runs today:
| Step | Location |
|---|---|
| Fires after a successful ERP write (post-billing, failure-swallowed on purpose) | apps/ts-temporal-worker/src/workflows/erp-submission.ts:70 — acknowledgeSourceTicket() |
| Resolves the ticket, claims the work, builds + posts the PDF | apps/webapp/src/pages/api/ticketing/internal/ack.ts |
Posts the Zendesk internal note (public: false) with the PDF upload token |
packages/integrations/src/ticketing/zendesk/index.ts:393 |
Details that matter to anyone changing it:
ack.tswalksorders → extracted_orders → pdf_documentsto reachticketExternalId/ticketingConnectionId, because that is what the submission workflow actually has in hand.- Concurrency is handled by an atomic conditional claim on
orders.ticketAcknowledgedAt, plusACK_CLAIM_STALE_MS(5 min) takeover for abandoned claims, plus a duck-typedprovider.hasPostedAcknowledgement()check to disambiguate “never posted” from “posted but the response was lost”. Read the comments inack.tsbefore touching any of it — each branch exists because of a specific duplicate-note or stuck-order failure. - The provider contract already takes a PDF:
provider.postAcknowledgement(ref, { body, pdf: { bytes, filename, contentType }, tag }). Sourcing a different PDF requires no interface change. - Gated per-org on
zendesk_order_source_v1(the generic${provider}_order_source_v1convention; Chatwoot is the one exception, gating onordermatic_inbox_v1).
The actual gap
Section titled “The actual gap”buildAcknowledgementPdf() (ack.ts:317) renders a one-page PDF from our own
extracted_order_items rows. The comment at ack.ts:339 concedes the timestamp is
render-time, not ERP-accept time. Nothing in that document is authoritative — it is our
input echoed back, so it cannot confirm what P21 actually committed (which matters given
P21’s non-atomic item-row behaviour, below).
P21 options, ranked
Section titled “P21 options, ranked”1. m_orderacknowledgements report service — recommended
Section titled “1. m_orderacknowledgements report service — recommended”POST {ui_server}/api/v2/process/pdfreport
(docs/03-Transaction-API.md:1764; worked recipe at docs/recipes/generate-pick-ticket-pdf.md).
Constants for every report payload — these differ from the record-edit shape:
StatusandTypeare numeric0(not"New")- the DataElement carries
Keys: [] - only
Name, the DataElement name, and the criteriaEditschange per service
Shape, using the verified PO-reprint service as the template:
{ "Name": "m_reprintpurchaseorders", "UseCodeValues": false, "Transactions": [ { "Status": 0, "DataElements": [ { "Keys": [], "Type": 0, "Name": "TABPAGE_1.poreportcriteriadw", "Rows": [ { "Edits": [ { "Name": "company_id", "Value": "ACME" }, { "Name": "beg_po_no", "Value": "500100" }, { "Name": "end_po_no", "Value": "500100" }, { "Name": "reprint_flag", "Value": "Y" } ] } ] } ] } ]}Success response is a JSON array even for one document:
[ { "DocumentId": "PO500100 PURCHASE_ORDER", "FileName": "PO500100 PURCHASE_ORDER.pdf", "DocumentContentType": "application/pdf", "DocumentFormat": 5, "DocumentData": "JVBERi0xLjQK...", "ResponseStatus": { "StatusCode": "Success", "Message": "..." } }]Why this one: it is read-only, decoupled from the ERP write, and re-runnable against any
order number — which is exactly what the existing retry-safe ack activity needs. Unlike
m_picktickets (which creates the pick-ticket record as a side effect), an
acknowledgement reprint should have no ERP-side effect.
Caveat — resolved 2026-08-11. The DataElement name and criteria fields were unknown when this was written, and two of the names guessed above are wrong. Use the verified shape in Probe results, not the PO-reprint template. Note also that the “should have no ERP-side effect” claim just above is still an expectation, not a finding — see Open questions.
2. print_orderack = ON on the createOrder transaction — rejected for production, but it is the only route that WORKS on the playground
Section titled “2. print_orderack = ON on the createOrder transaction — rejected for production, but it is the only route that WORKS on the playground”The Order definition carries print_orderack / email_orderack / fax_orderack
(type Char, ValidValues: ["ON","OFF"]) on the TP_FRONTCOUNTER.tp_frontcounter
DataElement. Print flags on a savable transaction return PDFs at
Results.Transactions[].Documents[].DocumentData (docs/03-Transaction-API.md:2102),
and a new order is savable — so this would technically work in a single call.
Rejected anyway. createOrder already has a documented non-atomic partial-commit
failure mode (docs/incidents/2026-06-01-genfit-p21-partial-submission.md) and a tight
timeout budget: makeP21Request caps at 4 min, deliberately under the 6-min activity
startToCloseTimeout. Adding a report render inside that transaction buys one saved
round-trip at the cost of the most dangerous call in the system.
Separately, treat email_orderack as a non-goal: it makes P21 email the customer
directly, behind the CSR’s back. The point of this work is to put the document in
Zendesk where a human decides what to send. fax_orderack likewise.
Print-flag path — verified working (2026-08-11)
Section titled “Print-flag path — verified working (2026-08-11)”Run with scratch-style harness against the playground: one throwaway quote
(approved = OFF), one line, the same five header edits our working createOrder sends,
plus a third DataElement carrying the flag. POST /uiserver0/api/v2/transaction →
200, Status: "Passed", order 1013389, and one document:
Documents[0].FileName = "OR164921 ORDER_ACK.pdf"Documents[0].DocumentContentType = "application/pdf" 101,864 bytes, %PDF-1.7, 2 pagesThe flag lives on its own DataElement — not on TABPAGE_1.order:
{ "Name": "TP_FRONTCOUNTER.tp_frontcounter", "Type": "Form", "Keys": [], "Rows": [{ "Edits": [{ "Name": "print_orderack", "Value": "ON" }] }]}Re-verified with a committed harness (2026-08-12)
Section titled “Re-verified with a committed harness (2026-08-12)”The 2026-08-11 run used a throwaway script. There is now a real one:
apps/dagster/scripts/probe_p21_print_orderack.py. It is dry-run by default, writes
a quote (approved = OFF) unless --approve is passed, stamps po_no with a
findable marker, discovers a valid customer/ship-to/item triple off the tenant’s newest
order rather than guessing, and saves the PDF to --out-dir.
infisical run --env=staging --path=/runtime/webapp -- \ uv run python scripts/probe_p21_print_orderack.py \ --connection-id 566f4628-4771-4ea0-8ad2-5eeb2a69e66a --submit --out-dir /tmpSecond run, play tenant, quote 1013390 (po_no=ACK-PDF-TEST-2026-08-12):
HTTP 200, Summary {"Failed": 0, "Succeeded": 1}, Status: Passed, one document —
OR164922 ORDER_ACK.pdf, 101,858 bytes, %PDF-, application/pdf.
pdftotext confirms it is the genuine customer-facing document, not just well-formed
bytes: title ORDER ACKNOWLEDGEMENT, Genfit letterhead, Order Number 1013390, order
date, bill-to and ship-to for Humphrey Service Parts, Customer ID 10057, our PO marker,
the line (H C2480-6, 1 EA @ 4.4100), SUB-TOTAL/TAX/AMOUNT DUE, and Genfit’s terms
boilerplate. Producer is Crystal Reports.
One correction to the 2026-08-11 entry above: this document is 1 page, per its own
“Page 1 of 1” and a /Count 1 page tree. The earlier “2-page” claim was almost certainly
a miscount — the two files differ by 6 bytes, which a whole extra page would not.
Note what the document says. It reads “This acknowledgement constitutes acceptance of our terms and conditions” and carries a restocking-fee clause — on a record that is only a quote. That is fine for the current design, which posts it as an internal note for a CSR to judge. It would not be fine to send to a customer automatically off a quote.
What the P21 web client actually does (HAR capture, prod, 2026-08-12)
Section titled “What the P21 web client actually does (HAR capture, prod, 2026-08-12)”A DevTools capture of ticking Print Options → Order Acknowledgement → Print and
pressing Save on the prod Order Entry window. None of these are the documented
/api/ui/interactive/ surface — the web client drives two others, and this is the
fastest way to learn field and tool names for any window:
| Call | Purpose |
|---|---|
PUT /uiserver0/ui/full/v2/data/data?dw=tp_frontcounter&fn=print_orderack&tn=TP_FRONTCOUNTER&wn=w_order_entry_sheet&wid={guid} body {"DatawindowName":"tp_frontcounter","FieldName":"print_orderack","Value":"Y","WindowId":"…"} |
Set one field |
GET /uiserver0/api/ui/emawindow/{wid}/ribbon/ |
Enumerate the window’s toolbar tools |
PUT /uiserver0/api/ui/emawindow/{wid}/tools/Quick.Save body {"windowName":"w_order_entry_sheet"} |
Invoke the Save tool |
Note the UI sends Value: "Y", while the Transaction API declares
ValidValues: ["ON","OFF"] and accepts ON. Different layers, different encodings.
Success: true does not mean saved. The Quick.Save above returned
Success: true, State: "Normal" — and did not write anything, because
Messages[] carried [Information] "A valid customer ID must first be entered." and
[Error] "No line items have been entered." Any client on this surface must gate on
Messages[].Type == "Error", exactly as the upstream Interactive API guidance says
(“don’t trust save status alone”). Nothing was created on production by this capture.
The Order Entry ribbon has no order-acknowledgement reprint tool. Its 38 tools include
m_reprintpicktickets (“Reprint Pick Tickets”) and m_inventorycardreport, but nothing for
acknowledgements — which confirms print-on-save is the only route from this window.
Usefully, ribbon tool names are report service names, so the UI’s report launcher and
/process/pdfreport address the same namespace. That makes “run the Order Acknowledgement
report from the UI” a true apples-to-apples test of the report engine.
Unresolved, and it is the whole question: because that save never committed, the capture says nothing about whether a successful save returns the PDF on this surface. Repeat it on the play tenant against an order that actually saves.
Lead worth chasing first — EnablePdf. The Quick.Save response carries
Result.EnablePdf: false on prod. Its meaning is undocumented and this is speculation, but
it is cheap to test: open the same w_order_entry_sheet window on play, where the print
flag demonstrably does produce a PDF, and compare. If play reports true and prod false,
that is a tenant-level switch for document generation — which would explain the report
engine failing on prod while the same payload works elsewhere, and would turn the Epicor
ticket into a specific configuration question.
Constraints that shape any use of this:
- Savable only. A bare reprint errors with “Save is not enabled”, so this can only ever produce the acknowledgement for an order the same call is creating. There is no way to re-fetch it later for an order that already exists.
- One shot. The bytes exist only in that response. Our ack activity retries
independently, minutes later — so adopting this means persisting the PDF inside the
submit path and having the ack read it from there, not re-asking P21. No new
infrastructure is needed for that:
packages/integrations/src/storage/r2-storage.ts(R2StorageClient) already exists and is what the source PDFs use. - The other objections above still hold:
createOrderis the partial-commit-prone call, and rendering inside it eats into the 4-minutemakeP21Requestbudget.
Side effects — measured, not assumed. oe_hdr for 1013389 (flag ON) vs 1013388
(same customer, same day, flag absent) differs in 7 columns, and every one is an
unavoidable per-order field: order_no, oe_hdr_uid, po_no, order_date,
date_created, date_last_modified, rma_expiration_date. No ack-sent or print-date
marker appeared. Caveat: this compares two different orders rather than one order before
and after, so it proves the flag leaves no distinct oe_hdr signature — it does not prove
the absence of effects elsewhere (print queues, doclink rows, per-customer ack counters).
4. The path that works on production (2026-08-12)
Section titled “4. The path that works on production (2026-08-12)”This supersedes the ranking above. A HAR capture of the prod web client doing the thing by hand proves three things the rest of this document had wrong or open:
- Document generation works fine on Genfit production. A real 105,282-byte
%PDF-1.7acknowledgement came back,StatusCode: "Success", messageForm request '1' for Form ID OR209119 has completed.— the same response envelope/process/pdfreportis documented to return (DocumentType,DocumentId,DocumentFormat: 5,FileName,DocumentContentType,DocumentData,ResponseStatus). So “report generation isn’t available in that environment” is wrong for prod. The engine is alive; only/uiserver0/api/v2/process/pdfreportis broken. That is a much sharper Epicor ticket. - The print flag works on an order that already exists. The captured run was against order 1012793, created 2025-03-28 — not a new order. So the “savable-only, one-shot” constraint is a limit of the Transaction API, not of P21. An acknowledgement is re-fetchable for any existing order.
- The whole flow is replicable server-to-server, because the datastream arrives inline. No UNC share read is needed.
The call sequence
Section titled “The call sequence”1. PUT /uiserver0/ui/full/v2/data/data ?dw=tp_frontcounter&fn=print_orderack&r=-1&tn=TP_FRONTCOUNTER &wn=w_order_entry_sheet&wid={windowId} body {"DatawindowName":"tp_frontcounter","FieldName":"print_orderack", "Value":"Y","WindowId":"{windowId}"}
2. PUT /uiserver0/api/ui/emawindow/{windowId}/tools/Quick.Save body {"windowName":"w_order_entry_sheet"} -> Events[] contains: { Name: "formdatastreamready", Publisher: "crystaloutput", EventData: { formType: "ORDER_ACK", jobType: "PRINT", formId: "OR209119", datastreamFilename: "\\\\…\\shared\\printing\\OR209119.cfd", xmlDatastream: "<?xml …>" <-- the full ~17KB Crystal job, INLINE printerName: "", publishPath: "", prompted: "N" } } { Name: "savesucceeded", EventData: { __order_no: "1012793" } }
3. POST /api/document/generate/form <-- NOTE: not under /uiserver0 body {"ClientId":null,"RequestId":1,"Batch":null, "DocumentId":"{formId}","DocumentFormat":"PDF","CompressDocument":false, "DatastreamXml":"{base64 of xmlDatastream}"} -> { FileName:"OR209119.pdf", DocumentContentType:"application/pdf", DocumentData:"JVBERi0…", ResponseStatus:{ StatusCode:"Success" } }xmlDatastream is a complete Crystal job: REPORTNAME Acknowledgements.rpt on
\\p21us…\generationfittingFiles\Shared\Reports\, USERREPORT Y with an
Acknowledgements_user.rpt override, the logo path, and then the entire order as data —
header, every line, release schedules, and totals. P21 renders from that payload, not
from a fresh DB read, which is why the window has to load the order first; you cannot
ask this endpoint for “order X’s acknowledgement” out of nowhere.
Reassuring on side effects: ARGPRINTER = __!uiserver!__ (render to the caller, not a
physical printer), SENDPDFCOPYTODOCUMENTIMAGING = False, printerName and publishPath
empty, prompted = N. Nothing spooled or archived.
Verified server-to-server (play, 2026-08-12)
Section titled “Verified server-to-server (play, 2026-08-12)”apps/dagster/scripts/probe_p21_interactive_orderack.py runs the whole flow without a
browser. Dry-run by default: it opens the window, loads the order, proves the order is
really in the window, and stops. --submit sets the flag, saves, and renders.
infisical run --env=staging --path=/runtime/webapp -- \ uv run python scripts/probe_p21_interactive_orderack.py \ --connection-id 566f4628-4771-4ea0-8ad2-5eeb2a69e66a --order-no 1013390 --submitAgainst existing order 1013390 on play:
savesucceeded : {"datawindowname":"order","__order_no":"1013390"}formType : ORDER_ACK jobType: PRINT formId: OR164923datastream : 9,736 chars (build 26.1.5910.0)render : StatusCode "Success", Form request '1' for Form ID OR164923 has completed.saved : OR164923.pdf — 101,858 bytes, %PDF-, application/pdfpdftotext confirms it is order 1013390’s own acknowledgement: right customer (Humphrey
Service Parts, Customer ID 10057), right PO marker, right line. 101,858 bytes — the
same size as OR164922, the Transaction API print-flag output for the same order, so
both routes render the identical document. The interactive route simply also works on an
order that already exists.
Interactive session open → window → load → flag → save → render, with the session always deleted afterwards (a ghost session can only be reaped by the token that made it).
Gotchas found while building it
Section titled “Gotchas found while building it”- Two message conventions. The
emawindowsurface reportsMessages[].Typeas a string ("Error","Information"); the interactive/v2/changesurface reports it numerically (3). A client must treat both as errors, or it will sail past a real failure./v2/changealso returns noSuccess/Stateat all. - Play emits
[3] "No periods have been set up for this company."when the order loads. It looks fatal and is not — the save succeeded and the document rendered anyway. GET /v2/data?id=returns a list, not a dict. Note also the parameter inconsistency:/v2/dataand/v2/windowtake?id=,/v2/toolstakes?windowId=.- Open by
ServiceName: "Order". Openingw_order_entry_sheetbyNameorTitleis unreliable on 26.1. - Read the build from the datastream, not from session-create. Play’s session-create
returned
26.1.5910.0once and0.0.0.0on later calls in the same hour;parserApplicationVersionon the datastream was correct every time. - Confirm the order actually loaded before saving. On 26.1 a nonexistent key leaves the window empty instead of erroring, and a save then acts on nothing. The script refuses to save unless it can find the order number in the window data.
What it costs
Section titled “What it costs”- It re-saves the order. The datastream shows
DATE_LAST_MODIFIED 08/12/2026 16:41:21andLAST_MAINTAINED_BY TEST.ACCOUNT2on an order originally taken byMICHELE.NICHOLS. No business data changed, but the audit trail now attributes a modification to our service account. For orders Ordermatic created that is unremarkable; doing it to arbitrary customer orders is audit-trail pollution and a real design constraint. - More moving parts than one report call: session, window open, field set, save,
document POST — each a failure point, and the save carries the
Success: true-with-an-Error-message trap. - Undocumented endpoints. None of these three appear in the upstream docs, so they carry no version-compatibility promise.
Why it is still probably the right answer
Section titled “Why it is still probably the right answer”It is re-runnable, which the Transaction API print flag is not. That removes the
one-shot capture, removes the need to persist bytes inside the submit path, removes the R2
dependency, and decouples the acknowledgement from createOrder — the call the
2026-06-01 incident says not to add work to. The ack activity can fetch on its own retry
schedule, which is what it already wants to do.
Untested: whether the flag can be set and the document produced without a save (the
Order Entry ribbon exposes no acknowledgement reprint tool, so probably not), and whether
Quick.Save on an unmodified order is even accepted when nothing else changed — in the
captured run the flag itself was the change.
3. OData read-back for the note body — do alongside option 1
Section titled “3. OData read-back for the note body — do alongside option 1”fetchVerifiedOrderItems (apps/webapp/src/services/erp/implementations/prophet21-service.ts:2782)
already queries p21_view_oe_line for confirmed qty/price and feeds
executeOrderVerification. Extending to oe_hdr yields real ERP facts — order date,
totals, promise/ship date, approved status — to replace today’s bare note body:
Order submitted to the ERP as ${erpOrderNumber}. Acknowledgment attached.Note that string is also the idempotency marker hasPostedAcknowledgement() searches
for (ack.ts:165). It is deliberately built only from the ERP order number so a retry
finds the same comment a previously-ambiguous attempt may have left. If you enrich the
body, keep a stable order-number-only substring as the marker, or the duplicate-note
guard silently stops working.
Probe results (Genfit playground, 2026-08-11)
Section titled “Probe results (Genfit playground, 2026-08-11)”Probed with apps/dagster/scripts/probe_p21_report_service.py against
https://generationfitting-play-api.epicordistribution.com, using the P.F. Markey - Zendesk POC connection (566f4628-4771-4ea0-8ad2-5eeb2a69e66a). Read-only by default;
--render opts into the POST plus an oe_hdr before/after diff.
GET /definition/m_orderacknowledgements and GET /defaults/... both return 200.
GET /api/ui/router/v1/?urlType=external returns …/uiserver0/, so the hardcoded
/uiserver0 segment makeP21Request uses is correct for this instance — that trap is
cleared here, not in general.
Corrected criteria shape
Section titled “Corrected criteria shape”| Guessed above | Actually |
|---|---|
TABPAGE_1.poreportcriteriadw |
TABPAGE_1.tp_1_dw_1 (datawindow d_order_ack_sheetreport) |
beg_order_no / end_order_no |
order_number1 / order_number2, and they are Char, not numeric |
22 fields, only two required: company_id (Char) and print_qty (Decimal,
default 1). A second DataElement, TIMESTAMP.timestamp
(d_master_report_timestamp), carries 6 optional created/modified-date fields.
The three switches that matter:
| Field | ValidValues | Default |
|---|---|---|
print_option |
Order Acknowledgement, Delivery Ticket |
Order Acknowledgement |
bucket_3 |
Order, Quote, Both |
Order |
bypass_customer_acknowledgements_setting |
ON, OFF |
OFF |
bucket_3 settles the quote question mechanically — the report targets quotes
explicitly, so isQuote maps to a criteria value instead of needing a policy call.
bypass_customer_acknowledgements_setting probably wants ON for our use: at OFF, a
customer flagged in P21 as not receiving acknowledgements likely yields no document at
all, and the point here is to put the PDF in front of a CSR, not to email the customer.
The date default is a trap
Section titled “The date default is a trap”Every default edit carries IgnoreIfEmpty: true, and the date criteria default to
today only (beg_order_date = today 00:00, end_order_date = now). So omitting the
dates does not mean “any date” — it means “today”. A Temporal retry after midnight, or an
order placed yesterday, would render nothing and look like a malformed payload rather than
a date filter. Always send an explicit wide range. Build the payload from the
service’s own /defaults and override only what you mean to set.
company_id defaults to GENFIT here. It must come from the connection, never a
constant — upstream verified that a wrong company_id also returns a bodyless 500.
Report-service rendering is blocked on this instance
Section titled “Report-service rendering is blocked on this instance”POST /uiserver0/api/v2/process/pdfreport returns HTTP 500 with a zero-byte body for
every combination tried against order 1013386:
- both
UseCodeValuessettings, 3 retries each (6 attempts, no attempt ever differed) - the server’s own
/defaultsechoed back verbatim - first DataElement only, dropping
TIMESTAMP.timestamp - numeric
Status/Type: 0and string"New"/"Form" - order-number-only, and order number plus a wide date range
Evidence that this is the environment and not us:
- The verified control fails identically.
m_reprintpurchaseorders— documented as running at production volume elsewhere — returns the same bodyless 500 here. - The endpoint routes fine. A bogus service name returns
200 [], and so does an emptyTransactionsarray. Auth,/definitionand/defaultsall work. - Upstream reproduced it on the Play tenant at 26.1.5910.3: every report empty-500,
criteria from each service’s own
/defaults, bothUseCodeValues, sixAcceptvariants, while/definitionand/defaultsreturned 200 — “treat a blanket failure like that as an environment property, not a payload bug” (docs/03-Transaction-API.md, An empty 5xx has several causes). - Our payload is independently valid. Upstream’s offline
scripts/validate_payload.pyreports 0 errors, 0 warnings on the generated acknowledgement payload, and confirms numericStatusis the report-process shape.
Two negative results worth keeping: POST /api/v2/process/pdfreport without /uiserver0
returns 404 HTML, and POST /uiserver0/api/v2/transaction returns 200 with
Status: "Failed" and no messages — the wrong-endpoint trap, confirmed live.
The failure is scoped to report rendering, not to P21 or the Transaction API. On the
same tenant, in the same API: GET /definition/{service} and GET /defaults/{service}
return 200, POST /transaction returns 200, and OData reads work. Only
POST /process/pdfreport fails. The Interactive API is healthy too — an
api/ui/interactive/sessions create/read/delete cycle returns 200/200 with no ghost
session left behind. So the dead component is the report engine specifically.
The middleware build cannot be read from this tenant, so “same version as upstream”
stays unverified — do not repeat it as fact. P21 exposes no version endpoint; the build
rides the session-create response, and here it is zeroed: fullversion: "0.0.0.0",
shortversion: "0.0" (those are the only two version-shaped fields in the payload). The
match to upstream is a match of symptoms, not of a confirmed build number.
Superseded for production (2026-08-12). Prod’s build is now known — 26.1.5910.3 — read from
parserApplicationVersionon the Crystal datastream (see The path that works). That is exactly the build upstream reproduced the blanket report empty-500 on, so for prod this is a confirmed build match, not a symptom match. Play’s build is still unread, so the paragraph above still stands for play. A third way to read the build: anyformdatastreamreadyevent carries it.
Probe results (Genfit production, 2026-08-12)
Section titled “Probe results (Genfit production, 2026-08-12)”Same script, against the Genfit Production connection
(a0b89aa8-d4ed-42e7-8e30-7db3f6e5d05b, in the production database — every
prophet21 row in staging points at play). API base
https://generationfitting-api.epicordistribution.com; default_company_id in
extra_config is GENFIT, same as play.
Read-only half only. Rendering has NOT been attempted on production — see Open questions.
What this establishes:
- Auth works (
Bearer,expires_in=86400), andGET /definition/m_orderacknowledgementsandGET /defaults/...both return 200 — same as play. - The criteria shape is identical to play, so it is a property of the service and not
of one tenant: DataElement
TABPAGE_1.tp_1_dw_1, datawindowd_order_ack_sheetreport, 22 fields, exactly two required (company_idChar,print_qtyDecimal). Second DataElementTIMESTAMP.timestamp(d_master_report_timestamp), 6 optional fields.order_number1/order_number2areChar;customer_id/ship_to_idareDecimal. - The date trap reproduces on prod.
/defaultsreturnsbeg_order_date= today 00:00 andend_order_date= now. Omitting the dates means “today”, not “any”. company_namedefaults to'Generational Fittings, LLC'— a display string on the report, distinct fromcompany_id.- OData
p21_view_oe_hdrreads fine on prod too — 218 columns, so Data Services permissions are not a factor here either. The existence-check in client requirement 4 is available.
process_doclinks — the field to keep OFF
Section titled “process_doclinks — the field to keep OFF”Prod (and play) expose process_doclinks (Transmit Document Links, ON/OFF,
default OFF). The name suggests it transmits linked documents somewhere on render.
Nothing here establishes what it actually does, and that is the point: leave it at
OFF, and never let a payload builder set it ON “for completeness”. The related
bypass_customer_acknowledgements_setting is the other switch with outward-facing
potential — the spike’s earlier note that we “probably want ON” is about matching the
report to our CSR-facing use, but on a production tenant ON overrides a customer’s own
“do not send acknowledgements” preference, so it should stay OFF until someone
establishes what P21 does with it.
Safe render targets on prod (read-only query)
Section titled “Safe render targets on prod (read-only query)”Oldest orders that are both completed = 'Y' and cancel_flag = 'Y' — closed and
cancelled in 2020, so nothing downstream is waiting on them and a stray print-date stamp
would land on a dead record:
order_no |
order_date |
customer_id |
po_no |
|---|---|---|---|
1000002 |
2020-08-12 | 10144 | 631445 |
1000003 |
2020-08-12 | 10144 | 633264 |
1000004 |
2020-08-12 | 10144 | 633505 |
Rendering fails on production too — and the fault is now precisely located
Section titled “Rendering fails on production too — and the fault is now precisely located”POST /uiserver0/api/v2/process/pdfreport returns HTTP 500 with a zero-byte body on
production, exactly as on play. Tried both UseCodeValues settings, 3 attempts each
(6 per order), against two orders:
| Order | State | Result |
|---|---|---|
1000002 |
completed and cancelled (2020) | 6 × empty 500 |
1000005 |
completed, not cancelled (2020) | 6 × empty 500 |
The second order exists to kill a confound the first one introduced: a cancelled record
is one of the documented causes of an empty 500, and the safest order to pick was also
the most likely to be non-printable. It isn’t the cause. The harness also sends an
explicit 1990–2049 range plus order_number1/order_number2, so the date trap is
not the cause either.
A zero-side-effect probe matrix then located the fault (nothing real rendered):
| Request | Prod result | What it proves |
|---|---|---|
| Bogus service name | 400 "Window <<…>> is not available or user does not have permission to open it." |
Service names are validated, and m_orderacknowledgements is not rejected — so the window exists and this user may open it. Permission is not the problem. |
Empty Transactions |
200 [] |
The endpoint routes and parses. |
Complete criteria, nonexistent order 99999999 |
empty 500 | Identical to a real order. Criteria are never evaluated — a missing record should yield No records to print for this range. |
Criteria missing required company_id |
200 [] |
An under-specified payload short-circuits cleanly. Only a payload complete enough to attempt a render reaches the 500. |
Read together: the request is accepted, the window is permitted, the criteria shape is right, and the failure happens at document-generation time, before record selection. The report engine itself is broken on this tenant, deterministically.
Note a tenant difference worth remembering: on play a bogus service name returned
200 [], on prod it returns a 400 naming the window. Prod validates more strictly,
which is what makes the permission conclusion above available here and not there.
This kills “it’s a play-environment quirk.” Two independent tenants — a sandbox and a production system — fail identically, with upstream reporting the same symptom set on a third. This needs an Epicor ticket, and the four rows above are the ticket: they rule out payload shape, permissions, record existence, and criteria evaluation, and leave only the renderer. It does not mean the feature is unavailable in P21 generally — see Is this P21-wide, or per-instance?.
Is this P21-wide, or per-instance? — per-instance, and other tenants have it working
Section titled “Is this P21-wide, or per-instance? — per-instance, and other tenants have it working”This is settled by upstream’s own correction, not by inference. An earlier draft of their
changelog reported /process/pdfreport as simply broken, on exactly our evidence (every
attempt on a Play tenant returning an empty 500). They retracted it:
That reading was wrong: the endpoint runs at production volume — one integration emails supplier POs all day, logging 154 successes against 3 empty 500s and one dropped connection in a single afternoon, with every affected PO succeeding moments later. —
docs/10-Changelog.md
So the capability exists and is used in anger on other tenants. Their enumerated causes of
an empty 500 (03-Transaction-API.md, An empty 5xx has several causes) put ours in the
third row: “the record isn’t printable, or report generation isn’t available in that
environment” — deterministic, as opposed to the transient fault that dominates in
production.
Ruled out as explanations:
- A 26.1 version regression. 26.1’s breaking changes are documented in detail
(
14-Breaking-Changes.md) andpdfreportis not among them. The 26.1 empty-500 defect is Interactive-endpoint-specific andAccept-header-driven; our probe andprophet21-service.tsboth sendAccept: application/jsonat every call site, and upstream tested sixAcceptvariants against the same failure. Their own verification build (26.1.5910.3) is the same generation as the tenant where reports work. - Payload shape. Upstream’s offline validator passes it; an under-specified payload
returns
200 []here rather than 500. - Permissions. A bogus service name draws a
400naming the window on prod, whilem_orderacknowledgementsdoes not — the window is available and this user may open it. - A missing record. A nonexistent order number 500s identically to a real one.
What that leaves is per-tenant configuration or licensing of the report/document-generation
component behind /process/pdfreport — and one sharp piece of evidence about which:
on the play tenant, the print-flag path rendered a genuine Crystal-produced
ORDER_ACK.pdf (“Powered By Crystal”) while /process/pdfreport empty-500’d on the same
tenant, in the same API, minutes apart. The document engine itself is alive there; the
pdfreport process path is not. That asymmetry is the most useful single fact for an
Epicor ticket, because it excludes “reporting isn’t installed” and points at the
report-service/output-destination configuration for that path.
Untested and worth knowing: whether the same asymmetry holds on production. The print-flag half of it requires creating a savable order, so it has not been run there.
Practical read: there is a real prospect of this working on PF Markey’s instance, or on Genfit’s after an Epicor configuration change. It is not a dead end — but nothing we control makes it work, so the POC cannot depend on it landing.
Client requirements this surfaced
Section titled “Client requirements this surfaced”Any producer must do all four of these, or it will misreport:
- Retry a bodyless 5xx. It is usually a transient report-engine fault — upstream logged 154 successes against 3 empty 500s and a dropped connection in one afternoon, every one clearing on retry. Rendering is an idempotent read: 3 attempts, 0.5s × attempt. (This is also what proves our failure is deterministic rather than the fault.)
- Classify the
ErrorType/ErrorMessageenvelope BEFORE the status code — it can arrive on a 200 as well as a 5xx. Branching on the status alone hides the one message that explains the failure, e.g.No records to print for this range. - A 200 does not mean a usable document. Each element carries its own
ResponseStatus.StatusCodestring, a 200 can carry a failed document, and a multi-document response can mix both. RequireStatusCode == "Success"and non-emptyDocumentData, per element. - Existence-check the order over OData first. A missing record and an engine fault
produce the same unhelpful 5xx; checking first turns one of them into our own clear
error.
oe_hdrreads fine here (218 columns), so permissions are not a factor.
Also checked and not a problem for us: on 26.1, any request whose Accept header
lacks application/json returns an empty 500 — including */*, the httpx and .NET
default. prophet21-service.ts already sends Accept: application/json at every call
site.
What shipped (2026-08-13)
Section titled “What shipped (2026-08-13)”The interactive route from The path that works,
ported out of probe_p21_interactive_orderack.py into the ack pipeline. Not the report
service — that stays blocked on Epicor.
| Piece | Where |
|---|---|
generateOrderAcknowledgementPdf(connection, erpOrderNumber) — the six-call interactive flow, session always deleted |
apps/webapp/src/services/erp/implementations/prophet21-service.ts |
| Optional on the ERP contract, so other adapters simply don’t offer it | packages/integrations/src/services/ierp-service.ts |
| Connection lookup + deadline + fallback-on-anything, behind one call | apps/webapp/src/lib/ticketing/erp-acknowledgement.ts |
Uses the ERP document when it arrives, buildAcknowledgementPdf() when it doesn’t |
apps/webapp/src/pages/api/ticketing/internal/ack.ts |
Decisions baked in:
- No flag of its own. It rides the ack route’s existing order-source gate
(
${provider}_order_source_v1,ordermatic_inbox_v1for Chatwoot), which the route has already passed before it sources a PDF. Sam’s call, 2026-08-13, over the alternative of a separateerp_order_acknowledgement_pdf_v1. The consequence to remember: order-source and “may re-save orders in this ERP” are now one switch, so every organization with ticket ingestion on gets thelast_maintained_bystamp, with no per-ERP-instance rollout. Split the gate if that ever has to be two decisions. - Fallback is unconditional. No ERP connection on the order, an ERP without the
capability, a thrown error, or a 30s deadline overrun all resolve to the locally rendered
PDF. A note with our own document beats no note. The deadline exists because the calling
activity aborts its HTTP request at 60s
(
apps/ts-temporal-worker/src/activities/ticket-acknowledgement.ts), and the fallback render plus the Zendesk post still have to fit in what is left. - The load step’s messages are not fatal, because play’s benign
[3] "No periods have been set up for this company."arrives there. The real gate is the window-data check: refuse to save unless the order number is actually in the window (the empty-window trap). Success: trueis not trusted on the flag or save steps — bothMessages[].Typeconventions (string"Error", numeric3) count as failures.- The note body and filename are unchanged, so the
hasPostedAcknowledgementidempotency marker still matches.
Still untested against a live tenant: the port itself. The Python probe verified the flow
end to end on play against order 1013390; the TypeScript is covered by
prophet21-service.order-acknowledgement.test.ts (mocked HTTP) but has not yet run
against P21.
Recommended implementation
Section titled “Recommended implementation”Probe before writing code.Done 2026-08-11 — see Probe results. Re-run it against PF Markey’s own instance before production; the criteria shape should carry over, butcompany_idand whether rendering works at all will not.apps/dagster/scripts/probe_p21_report_service.py --connection-id <id>does the read-only half; add--render <order_no>for the write test.- Add
generateOrderAcknowledgementPdf(connection, erpOrderNumber)toprophet21-service.ts, routed throughmakeP21Requestto/uiserver0/api/v2/process/pdfreport. It must satisfy all four client requirements — retry the bodyless 5xx, classify the error envelope before the status code, gate on each document’s ownResponseStatus, and existence-check the order first._checkP21MessagesForErrorsdoes not parse this endpoint’s envelope; it needs its own handling. - In
ack.ts, try the P21 acknowledgement first and fall back tobuildAcknowledgementPdf()on failure. The existing error path already does the right thing (502 → release the claim → Temporal retries), so a fallback keeps notes flowing on connections where the report service isn’t enabled. - Enrich
ackBodyfrom anoe_hdrread-back — preserving the idempotency marker. - Gate the P21 path behind its own flag, separate from
zendesk_order_source_v1, so it can roll out per connection.
- Wrong-endpoint trap.
POST /api/v2/transactionaccepts anm_*report payload and returnsSucceeded— but emits nothing. The docs call this “the single biggest gotcha.” Reports must go to/api/v2/process/pdfreport. UseCodeValuesvaries per report service.m_reprintpurchaseordersworks withfalse;m_pickticketsrequirestruewith code values and returns HTTP 500 withfalse. Still unresolved form_orderacknowledgements: both settings fail identically on the playground, so this cannot be settled until an instance renders at all.- A bodyless 5xx is usually transient, and its causes are indistinguishable. Transient
engine fault, criteria that match nothing (a wrong
company_id), a non-printable record, and an environment without report generation all look the same. Retry first; treat a blanket failure across services as the environment. - Omitting a date criterion means “today”, not “any”. Every default edit is
IgnoreIfEmpty: trueand the date defaults are same-day. Build from/defaultsand override explicitly. - Different error envelope.
/process/pdfreportreturns{ ErrorType, ErrorMessage }— e.g."No records to print for this range."— not theSummary/Messagesshape/transactionuses._checkP21MessagesForErrorsinprophet21-service.tswill not parse it; needs its own handling. - An unknown
$selectcolumn returns 404, not 400 on OData. If a table you know exists starts 404ing, drop$selectand retry before chasing Data Services permissions. - OData base host vs. UI server. OData runs on the P21 base host; Transaction and
Interactive APIs use the UI server URL. Our code papers over this:
makeP21Requestconcatenatesconnection.apiUrlwith a hardcoded/uiserver0path segment, where the docs say the UI server should come fromGET /api/ui/router/v1/?urlType=external. Works today, but is instance-specific — confirm against PF Markey’s setup rather than assuming. Note the existing OData paths in our code (/data/erp/views/v1/p21_view_oe_line,/uiserver0/api/v2/odata/Order) also differ from the docs’/odataservice/odata/view/{viewname}, i.e. path layout varies by P21 version. - Data Services permissions (user-credential auth only): the P21 user needs
User Maintenance → Application Security → “Allow OData API Service” = Yes and
Role Maintenance → Dataservice Permission for each table/view. Missing either gives
"You are not authorized to access API"on a valid token. Consumer Key auth skips both.
Open questions
Section titled “Open questions”- Still open for the report service. Does
m_orderacknowledgementshave an ERP-side effect (e.g. stampingoe_hdr.print_dateor an ack-sent flag)?m_pickticketsdoes create a record. Unanswerable on the playground because no report render succeeds. It matters because the ack activity is retry-safe by design, so a side-effecting service would re-stamp on every Temporal retry. The harness diffsoe_hdrautomatically; the first successful render on any instance must be on a throwaway order. (For the print-flag path this question is largely settled — see Print-flag path.) - Which route do we actually build? The print-flag path works here today but only for the order being created, one shot, inside the riskiest call. The report path is read-only and re-runnable but needs an instance whose report endpoint works. A defensible answer is to build the report path as designed and treat the print flag as a playground-only demo mechanism — but that leaves the POC demoing something production would not use.
DoesAnswered 2026-08-12: no. Same deterministic empty 500 as play, on two orders, with the fault located at document-generation time. See Rendering fails on production too./process/pdfreportrender on Genfit production?- Who owns the Epicor ticket, and does the POC wait on it? Two tenants failing identically makes this Epicor’s bug, not a payload problem we can work around. The probe matrix is the evidence. Until it’s fixed, the report path cannot be verified end to end on any instance we have — so either the POC ships on the print-flag path (with the one-shot/persist-to-R2 consequences in Print-flag path), or it ships on the existing synthesized PDF and the real acknowledgement lands later.
- Is PF Markey’s own instance worth probing now anyway? The POC customer (P.F. Markey) and this ERP tenant (Genfit = General Fittings LLC) are different companies. A third data point would tell us whether this is Epicor-wide or specific to Genfit’s two tenants — which changes how the ticket is framed.
- Should the acknowledgement be fetched at submission time or lazily when a CSR opens the ticket? Submission time matches the existing flow; lazy would avoid a P21 call for orders nobody looks at.
- Should a quote (
isQuote: true) get an acknowledgement at all, or only an approved order?createOrderXmldefaultsisQuoteto true when undefined (prophet21-service.ts:1013), so this needs an explicit decision rather than a default.