PSI Dispatch
The web Dispatch List for PSI’s machine-build (ETO) shop floor — the replacement for the .NET 4.8 WPF Work Order Manager. Live in production since 2026-06-02. Built to survive the AFTEC→Business Central migration: the ERP is behind a seam, so Phase 2 is a config flag rather than a rewrite.
Since 2026-08-18 it serves both ERPs at once.
/is the AFTEC list operators use;/bcis the Business Central list, live against BC sandbox — the parallel-run surface, switched on by App Service configuration alone.
Repository: ProgressiveSurface/psi-dispatch (private)
For AI agents / new contributors: start at
CLAUDE_ONBOARDING.mdin the repo root, thendocs/ADR-0001-architecture.md.BUILD_LOG.mdis the canonical “what shipped and why” record — the repo’sREADME.mdis stale and still frames the app as pre-scaffold.
Overview
| Property | Value |
|---|---|
| Production URL | dispatch.progressivesurface.com (live since 2026-06-02) |
| Second surface | dispatch.progressivesurface.com/bc — the Business Central list (live since 2026-08-18) |
| App Service | ps-dispatch in resource group PS-WEBAPPS (Linux) |
| Network | Private endpoint 10.160.140.22; publicNetworkAccess=Disabled |
| Stack | .NET 8 Minimal-API BFF + React 19 / TypeScript / Vite / Tailwind SPA |
| Grid | PSIDataGrid (@progressivesurface/grid, a PSI-themed KendoReact Grid) |
| Auth | Entra / MSAL, token-forwarding to the PSI.UniData.API audience |
Data source — / | UniData/AFTEC via PSI.UniData.API — PS-MRPSANDBOX, not live WIP |
Data source — /bc | Business Central sandbox, company “Progressive Surface”, via the PSI Dispatch API extension |
| Deployment | Push to main → GitHub Actions → straight to production. No staging slot. |
| Replaces | PSI.All\PSI.WorkOrderManager (WPF; a ~9,800-line view-model plus XML file caches) |
Architecture
Single-origin: the SPA is published into the API’s wwwroot, so there is one app, one URL, one
origin — no CORS, no separate static host.
┌── / , /api/* ──► UniDataDispatchProvider ──► PSI.UniData.API ──► UniData / AFTEC
React SPA ──► Dispatch.Api (BFF)
└── /bc , /api/bc/* ──► BusinessCentralDispatchProvider ──► BC sandbox (dispatchLines)
(Development: FakeDispatchProvider on either stack)
psi-dispatch never talks to UniData directly. The dispatch read/write SQL lives in the PSI.UniData.API repo; the BFF is a consumer like any other.
Dual-stack hosting — two ERPs, one process
A Dispatch:Provider flip turned out to be the wrong shape for a parallel run: it moves the whole
app at once, so nobody can compare the BC list against the AFTEC list operators are actually working
from, and any BC data gap becomes an outage rather than an observation. So the app hosts two
dispatch stacks side by side (ADR-0004, “dual-stack hosting”):
| Default stack | Second (“bc”) stack | |
|---|---|---|
| SPA route | / | /bc (paints a BUSINESS CENTRAL chip) |
| API prefix | /api/* | /api/bc/* |
| Provider | UniDataDispatchProvider | BusinessCentralDispatchProvider |
| Source | AFTEC via PSI.UniData.API (PS-MRPSANDBOX) | BC sandbox |
| Writes | per-user (blocked — see below) | fail loudly, never a silent no-op |
A stack is { Provider, Cache, Warmer, EnvironmentInfo } under a route prefix. The default stack
resolves everything from the plain unkeyed DI registrations — it is the single-stack app, not a
re-implementation. The bc stack resolves the same service types by .NET 8 keyed services (key
"bc"), so the two share nothing but the host process, and a /api/bc request can never reach the
AFTEC provider. The SSE bus is process-wide, so the bc stack namespaces its channels
(bc:{workCenter}).
Inert until configured. The second stack is built only if BusinessCentral:ClientId is
present at startup; with no BC config nothing is registered — no provider, no cache, no background
timer, no HTTP client — and /bc + /api/bc/* return an honest 404 rather than falling through
to the SPA catch-all.
Where it is switched on: five application settings on App Service ps-dispatch —
BusinessCentral__TenantId, __ClientId, __ClientSecret, __Environment=sandbox,
__CompanyName. That configuration is the deployment — no repo change, no appsettings entry
and no Bicep parameter lights /bc up, and removing __ClientId turns it back off. /api/health
reports both stacks. Dispatch:Provider remains UniData, so / is untouched.
Open follow-up:
BusinessCentral__ClientSecretis currently a plain App Service setting in clear, becauseKeyVault:Uriis still empty. Acceptable for a pilot surface reading a sandbox; it must move to Key Vault (and the KV firewall must admit the app’s VNet/managed identity) before/bcpoints at anything else.
Caching on /bc is the same ADR-0003 machinery, instantiated a second time under the "bc" key:
its own InMemoryDispatchCache, refresh coordinator, DispatchCacheWarmer (single-flight,
stale-while-revalidate) and its own background refresher, all reading the same Dispatch:Cache:*
knobs (FullRefreshSeconds 900). Three deliberate differences: the bc refresher always runs,
because the BC snapshot source self-authenticates with client credentials and needs no operator
token; the bc stack is always in-memory (Redis lease coordination is deliberately single-stack);
and its SSE channels are namespaced.
The ERP seam
Everything ERP-shaped goes through IDispatchProvider
(src/Dispatch.Api/Domain/IDispatchProvider.cs) and comes back as ERP-neutral DTOs
(DispatchRow, OperatorStatusWrite, …). The seam was drawn on day one as a Phase-1 discipline,
not deferred to a Phase-2 refactor (ADR-0001 D1) — precisely because “decouple later” is how the
WPF app got coupled in the first place. The selected implementation is a config value,
Dispatch:Provider, which auto-selects Fake in Development and UniData in production.
A “thin SPA” variant that called PSI.UniData.API straight from the browser and dropped the BFF was
built, evaluated and rejected (ADR-0002 D-A1); it survives only as the git tag
archive/spa-direct-0b53b6b. Do not rebuild it.
ADR-0001 D2 also splits the data by ownership: reads (routing lines, WO status, material readiness, ERP priority) are coupled to the ERP and swap in Phase 2; MES-owned execution state (operator status events, note metadata, saved views) lives in psi-dispatch’s own store and does not move during the migration.
Auth — token forwarding, not OBO
The SPA acquires a token for the PSI.UniData.API audience and the BFF validates and forwards
that same token. There is no on-behalf-of exchange, and the BFF’s AzureAd:Audience is the API’s
app-ID-URI rather than psi-dispatch’s own. All SPA→BFF traffic goes through
web/src/api/client.ts.
Read cache (ADR-0003)
Operator GETs are served from a cache refreshed by one service-account read; writes stay
on the caller’s own UniData session so AFTEC labor attribution lands on the right person. The split
is deliberate in both directions: the service-account read bounds UniData concurrent-connection
licensing (shift start no longer spikes ~30 simultaneous logins), and per-user writes are what make
the timesheet correct.
The cache is fail-safe and additive — a cold cache or any refresh failure falls back to the
per-user live read, so behaviour degrades to “slower”, never to “wrong”. Knobs live under
Dispatch:Cache:* (Provider Memory/Redis, FullRefreshSeconds 900, WorkCenter, ApiScope).
Redis and the refresh lease are built but not yet integration-tested against a live Redis; the
pilot runs single-instance on Memory.
The SANDBOX banner is a safety control
Production currently reads PS-MRPSANDBOX via PSI.UniData.API’s /dev route
(UniDataApi:DevRoute=true), so the SPA paints an always-on SANDBOX banner, an amber viewport
ring, and a 🟧 SANDBOX tab title whenever the source is not live production UniData.
EnvironmentInfo.Resolve(...) is fail-safe: anything it does not positively recognise as live-prod
resolves to sandbox. An operator acting on sandbox data believing it is live WIP is the exact
failure this prevents — the default must never be inverted.
The /bc stack resolves its own descriptor independently, by the same rule: only a BC environment
literally named Production drops the banner, so sandbox, DEV, empty and anything unrecognised
all keep it. /bc therefore shows the SANDBOX banner and a BUSINESS CENTRAL chip in the title
band, so nobody has to read the URL to know which list they are looking at.
Current status
| Capability | State |
|---|---|
| Read-only dispatch list (grid, master/detail routing, HTG, roll-ups, cross-app broadcast) | ✅ Live in production |
| Read cache + SANDBOX banner | ✅ Live |
Floor view (#/floor) — every station’s pile, queue-aware slack, buried-piece tethers | ✅ Live since 2026-08-28 (PR #86) |
/bc — Business Central dispatch list | ✅ Live since 2026-08-18 (BC sandbox, read-only) |
BC extension surface (PSI Dispatch API v1.7) | ✅ Deployed to BC sandbox + DEV |
| Operator write-back (Claim / Start / Complete / Notes) | ⚠️ Code-complete, functionally blocked on /; fails loudly by design on /bc |
| Prod reads live MRP-PROD WIP | ❌ Still reading PS-MRPSANDBOX |
Dispatch:Provider=BusinessCentral cutover | ❌ Not made — the go-live move, gated on the migration |
| BC extension in BC Production | ❌ Held by direction |
Operator write-back is blocked on the AFTEC routing model
The write path is contract-correct but cannot function for roughly 93% of the pilot work
center: G-prefix orders (1,009 of 1,088 in WC70) have no WIPROUTE.LINE.1287 record at
all, and F-orders mismatch on the line key. There is nowhere to write the operator’s progress
to. Unblocking this needs a focused AFTEC routing-model spike — where does a G-order store
operator progress, and how does a writable routing identity get carried from read through to
write — not trial-and-error writes against the sandbox. Until then the shipped slice is read-only.
See docs/ADR-0002-writeback-and-architecture.md before touching writes.
Floor view — the question the grid can’t answer
The grid orders each operator’s work. It cannot show the piece that will be late because of what is in
front of it — a service spare due Friday sitting fifth in the Saw pile behind project ops due next month, or
the one Weld bracket an assembly bay is idle for. #/floor (live 2026-08-28, PR #86) is a Factorio-style
isometric picture of the shop built on the grid’s existing whole-shop read (workCenter=all, ADR-0003 cache,
all SSE topic) — no new data, only new arithmetic:
- Stations are the operator work areas (machine op beats cost center; unmatched rows land in Other, never hidden). Each pile is the grid’s Priority order; tile size = HTG; in-process pieces sit on the machine.
- Slack = days to due − (workable HTG ahead + own HTG) ÷ station hours/day. Red = will miss its date given the current order, even when its own due date looks fine. Capacity is an explicit, editable per-station assumption shown on the station.
- ▲n = buried n places deeper than earliest-due order warrants; the side panel shows the slack the piece would have if re-sequenced.
- Tethers run from every late/tight tile to whoever is waiting — 4-digit job → assembly bay, 5-digit job (retrofit/service) → customer truck at the dock.
- Arrange mode drags stations into the real floor plan (per-browser for now).
Next steps live in the doc: replay from persisted cache diffs, capacity simulation, tile-drag → priority write, truer need-by dates, shared layout. This is the ETO answer to “MES”: the unit of execution is the job, not the machine, so the picture is piles and crews rather than OEE — see terminology for the job-number convention the consumer tethers rely on.
Phase 2 — Business Central
Phase 2 brings Business Central in behind the same seam, ahead of the ~January 2027
AFTEC→BC go-live. BusinessCentralDispatchProvider merged 2026-08-11 (psi-dispatch PR #61); the
dual-stack hosting that serves it merged 2026-08-17 (PR #75); and it was switched on in
production 2026-08-18 at /bc, reading BC sandbox with real imported AFTEC work orders.
It is read-only and it does not displace anything: Dispatch:Provider is still UniData, so
/ is the unchanged AFTEC list. The provider flip remains the eventual go-live move — /bc is the
parallel-run surface that precedes it and makes BC’s data gaps observable instead of catastrophic.
Design decisions (docs/ADR-0004-business-central-provider.md):
- Direct BC API from the BFF, no middleware (D1). The BFF is already the integration layer; routing through the ERP Migration Tool would only couple uptimes.
- Reads are service-identity client credentials (D3) — no operator token is ever forwarded to BC, which would not accept the PSI.UniData.API audience anyway. ADR-0003’s cache model carries over unchanged, now bounding BC throttling instead of UniData licensing.
- Writes fail loudly (D4):
SetOperatorStatusAsync/SaveNoteAsyncreturn an explicit failure, never a silent no-op. AFTEC stays the labor and completion system of record until go-live. - Only the BC environment literally named
Productiondrops the SANDBOX banner (D5) —DEV,sandbox, empty and anything unrecognised all stay sandbox. - HTG and the whole legacy colour grammar stay above the seam (D7), so the grid cannot tell which ERP fed it.
The read surface: a custom AL extension (PSI Dispatch API, v1.7)
BC has no equivalent of WORK.WIPLINE.1287 — PSI’s dispatch list is a PSI-built projection, not a
vendor screen. So the projection is rebuilt in AL as dispatchLines, one row per released
Prod. Order Routing Line joined to its production order, work center and capacity ledger. What began
as that one page has grown into the extension that also creates and repairs the data it projects,
because BC ships no write API for production orders, work centers or machine centers.
| Repo | ProgressiveSurface/PSI-BC monorepo, apps/PSI.Dispatch.API |
| Version | 1.7.0.0 — deployed to BC sandbox and DEV; Production held by direction |
| API route | api/progressivesurface/dispatch/v1.0 → companies({id})/… |
| Object IDs | 53000–53049 (registered in the monorepo’s OBJECT-IDS.md) |
| Identity | PSI Dispatch BC (client) (4d1fc1cc-4589-40e2-b6fb-adeefd1237d9), client credentials — see Azure Resource Map |
Six API entities as of v1.7:
| Entity | Page | What it does |
|---|---|---|
dispatchLines | 53000 | The read surface. One row per Released Prod. Order Routing Line, joined to header, item, work center and a capacity-ledger rollup for actual hours. Read-only. |
productionOrders | 53002 | Create / list / modify released orders. One create call yields a fully routed order — a bare insert into table 5405 creates no lines and no routing, and such an order is invisible to the dispatch list forever. Since v1.6.1/v1.6.2 it accepts requestedDueDate and requestedNumber. |
routings | 53004 | Routing-header discovery (which exist, which are Certified) — plus the repairLines repair input added in v1.7. |
routingLines | 53005 | Which operations a routing has and which work/machine center each runs on (?$filter=number eq '70'). |
workCenters | 53006 | Repairs the DMT load’s missing scheduling plumbing, and creates the machine-type-area work centers of PSI’s BC capacity design. Never deletes. |
machineCenters | 53007 | The individual machines beneath those areas. Create/modify, never delete. |
Note that this is a different thing from psi-bc-dispatch,
which is a BC-native dispatch board POC (object range 70000–70199) — a parallel UI experiment that
exposes no API pages and is not a data source.
The import pilot: 24 real AFTEC work orders in BC
/bc is not showing synthetic seeds. BC sandbox holds 24 imported real AFTEC work orders = 71
dispatch lines, loaded through v1.7’s repair-then-create flow:
- discover the part’s routing (
routings/routingLines); PATCH routings({id}) { "repairLines": true }— unconditionally, because it is idempotent and because ~19 parts in 20 need it;POST productionOrderswithrequestedNumbercarrying the real AFTEC G-number andrequestedDueDatecarrying the real due date;- confirm the rows in
dispatchLines.
Steps 3’s two inputs are what make the result comparable: without them every order gets an
unrecognisable series number and a due date of workdate + 30, and the board’s due-date colours and
hours-to-go mean nothing next to the AFTEC list at /.
The migration defect census
The import doubled as the first honest audit of the DMT-migrated manufacturing data. All figures live-verified against BC sandbox on 2026-08-18:
| Defect | Scale / effect |
|---|---|
Blank derived Work Center No. on routing lines | 1,482 of 1,551 open-WO parts — 95.6%. DMT inserted routing lines unvalidated, so the derived field was never filled; BC’s order-copy trusts it and refuses to create the order. repairLines backfills it trigger-lessly (certified status and times untouched) — but that is the pilot fix; the durable ask is DMT re-validation at load time. |
| Uncertified Production BOMs | The largest remaining recreate blocker — an order needs a certified BOM as much as a certified routing. |
| Routing times that overflow BC’s scheduler | 12 parts. A Decimal18 time value that does not fit Int32 when scheduling converts it; a unit mismatch (minutes vs. hours) is the suspect. The source values need fixing, not clamping. |
Assembly-side work centers missing Efficiency | Blocks the calendar bootstrap, so those areas cannot be scheduled at all. |
M-SIM (Simulated) number series | Environment issue rather than data: Manual Nos. had to be enabled on the released order series in sandbox before a real G-number could be carried across. |
Assembly 010 lines mixed into machine routings | 340 routing masters carry an Assembly department line numbered 010 alongside machine ops numbered 1..n. BC orders routing lines alphanumerically, so "010" sorts — and chains — before op 1: assembly scheduled before sawing on every order created from them (5 of the 24 pilot orders). Numbering-scheme collision from the DMT merge; needs renumbering at load time (#82). |
Work Center Group Code = "1" on migrated work centers | dispatchLines.costCenter is the work center’s group code standing in for the AFTEC cost center — but the DMT load left every migrated work center in group "1", so the /bc Cost Center roll-up is one meaningless bucket and ccen: area filters can’t match. Fix is data: populate the group codes with the AFTEC cost centers per the work-center mapping (#83). |
Machine-op code lives in Work Center No., sequence in Operation No. | Not a defect to fix in data but a semantic to know: the migrated routings keep AFTEC’s machine-op work-area code (80=SAW, 40=lathe, 10=VF4…) in Work Center No. and a bare 1..n sequence in Operation No. The board’s Op column initially rendered the sequence (“1” on every first op) — fixed in the BFF mapper + extension v1.7.1 (#81). |
None of this is psi-dispatch code work — it is migration data work, and /bc is now the surface
that makes it visible.
What Phase 2 is waiting on
| Issue | Status |
|---|---|
#62 — grant PSI Dispatch BC (client) access to BC | ✅ Done for the environments in use. Entra API.ReadWrite.All admin-consented; registered inside BC DEV and sandbox. Any further environment needs its own in-BC registration (Azure consent alone is not enough). |
| #63 — confirm / load the manufacturing entities | ✅ Superseded by evidence. Routings and items are loaded; production orders were not, which is why productionOrders (53002) exists. The live question is no longer what loaded but how well — see the defect census above. |
#64 — build the dispatchLines AL API page | ✅ Shipped, and grown to six entities at v1.7. |
#65 — BC home for the nine WIPROUTE.LINE.1287 fields | ⚠️ Open — the biggest gap. |
| DMT re-validation of routing lines | ⚠️ Open migration ask — repairLines is the workaround standing in for it |
| Uncertified Production BOMs | ⚠️ Open — largest blocker to recreating orders at scale |
Key Vault for BusinessCentral__ClientSecret | ⚠️ Open — plain App Service setting today |
The biggest open data gap is #65. Nine WIPROUTE.LINE.1287 extension fields have no BC home —
including GPRI.LN / PPRI.LN, the priorities the grid sorts by, and OP.STAT / OP.PROG,
operator status and progress. This is a migration-design decision, not a psi-dispatch code
task; the same gap is visible from the psi-bc-dispatch POC and the Route Manager BC assessment.
Until it is settled, the BC mapper defaults those columns — so the list at /bc renders, sorts and
colours correctly on everything BC does hold, and is flat on priority and operator progress.
Reference docs (in-repo)
Architecture and UI decisions are canonical in the repo, not here:
| Path | What it decides |
|---|---|
docs/ADR-0001-architecture.md | The ERP seam, read vs. MES-owned state, optimistic concurrency, why fork PRGJSMES |
docs/ADR-0002-writeback-and-architecture.md | Why thin-SPA was rejected; the operator write-back blocker |
docs/ADR-0003-read-caching-db-impact.md | Read cache, read/write split, service-account token strategy |
docs/ADR-0004-business-central-provider.md | The Phase-2 BC provider, field mapping, prerequisites, verification runbook |
docs/PHASE-1-SPEC.md | Endpoints, DTOs, write model |
docs/dispatch-grid-behavior.md, docs/ui-audit-vs-legacy.md | Grid behaviour and the audit against the legacy WPF view |
docs/floor-view.md | The #/floor view: stations = work areas, pile = Priority order, slack = due − pile ahead ÷ capacity, displacement vs. earliest-due, consumer tethers |
docs/TEST_PLAN.md | Test layers and how to run them |
BUILD_LOG.md | What shipped and why — newest first |
Operational notes
mainis unprotected and a push to it deploys to production. There is no staging slot and no blue-green swap to fall back to (unlike PRGJSMES). The convention — enforced by humans, not the platform — is branch → PR → triage the Copilot review → merge, and treat the merge as a production deploy.- CI gate:
dotnet test(tests/Dispatch.Tests— HTG, cache, routing, environment) blocks a bad deploy. On the web side,npm run build(tsc -b && vite build) is the only check CI enforces; the Playwright E2E suite (npm run test:e2e) exists and passes but is not yet wired into the deploy workflow, and vitest has no unit tests. - No Azure SQL or Key Vault wired yet — the DbContext runs InMemory, so
StatusEvents are not durable. Fine for the read-only slice; write-back and persistence will need both. - Telerik licensing is activated in CI from the
TELERIK_LICENSEsecret. Without it the grid still builds and renders — it just carries a dev watermark.
Related
- PSI.UniData.API — where the dispatch read/write endpoints actually live
- ProgressiveSurface/PSI-BC
apps/PSI.Dispatch.API— the AL extension behind/bc(its README is canonical for the API contract and the import recipe) - ERP Migration Tool — the AFTEC→BC migration this app is designed to survive
- Deploy BC Extensions — how
apps/PSI.Dispatch.APIgets built and published - ProApps — home of the legacy WPF Work Order Manager being replaced
- deploy-to-azure · webapp-compliance-standard
Created 2026-08-11. Last updated 2026-08-18 — /bc live, dual-stack architecture, extension surface
v1.7, import pilot + migration defect census; census extended same day with the Op-column semantics
(#81), assembly 010 sequencing (#82) and work-center group codes (#83).