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; /bc is 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.md in the repo root, then docs/ADR-0001-architecture.md. BUILD_LOG.md is the canonical “what shipped and why” record — the repo’s README.md is stale and still frames the app as pre-scaffold.


Overview

PropertyValue
Production URLdispatch.progressivesurface.com (live since 2026-06-02)
Second surfacedispatch.progressivesurface.com/bc — the Business Central list (live since 2026-08-18)
App Serviceps-dispatch in resource group PS-WEBAPPS (Linux)
NetworkPrivate endpoint 10.160.140.22; publicNetworkAccess=Disabled
Stack.NET 8 Minimal-API BFF + React 19 / TypeScript / Vite / Tailwind SPA
GridPSIDataGrid (@progressivesurface/grid, a PSI-themed KendoReact Grid)
AuthEntra / MSAL, token-forwarding to the PSI.UniData.API audience
Data source — /UniData/AFTEC via PSI.UniData.API — PS-MRPSANDBOX, not live WIP
Data source — /bcBusiness Central sandbox, company “Progressive Surface”, via the PSI Dispatch API extension
DeploymentPush to main → GitHub Actions → straight to production. No staging slot.
ReplacesPSI.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 stackSecond (“bc”) stack
SPA route//bc (paints a BUSINESS CENTRAL chip)
API prefix/api/*/api/bc/*
ProviderUniDataDispatchProviderBusinessCentralDispatchProvider
SourceAFTEC via PSI.UniData.API (PS-MRPSANDBOX)BC sandbox
Writesper-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-dispatchBusinessCentral__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__ClientSecret is currently a plain App Service setting in clear, because KeyVault:Uri is 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 /bc points 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

CapabilityState
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 tethersLive since 2026-08-28 (PR #86)
/bc — Business Central dispatch listLive 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 / SaveNoteAsync return 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 Production drops 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.

RepoProgressiveSurface/PSI-BC monorepo, apps/PSI.Dispatch.API
Version1.7.0.0 — deployed to BC sandbox and DEV; Production held by direction
API routeapi/progressivesurface/dispatch/v1.0companies({id})/…
Object IDs53000–53049 (registered in the monorepo’s OBJECT-IDS.md)
IdentityPSI Dispatch BC (client) (4d1fc1cc-4589-40e2-b6fb-adeefd1237d9), client credentials — see Azure Resource Map

Six API entities as of v1.7:

EntityPageWhat it does
dispatchLines53000The 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.
productionOrders53002Create / 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.
routings53004Routing-header discovery (which exist, which are Certified) — plus the repairLines repair input added in v1.7.
routingLines53005Which operations a routing has and which work/machine center each runs on (?$filter=number eq '70').
workCenters53006Repairs the DMT load’s missing scheduling plumbing, and creates the machine-type-area work centers of PSI’s BC capacity design. Never deletes.
machineCenters53007The 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:

  1. discover the part’s routing (routings / routingLines);
  2. PATCH routings({id}) { "repairLines": true } — unconditionally, because it is idempotent and because ~19 parts in 20 need it;
  3. POST productionOrders with requestedNumber carrying the real AFTEC G-number and requestedDueDate carrying the real due date;
  4. 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:

DefectScale / effect
Blank derived Work Center No. on routing lines1,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 BOMsThe largest remaining recreate blocker — an order needs a certified BOM as much as a certified routing.
Routing times that overflow BC’s scheduler12 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 EfficiencyBlocks the calendar bootstrap, so those areas cannot be scheduled at all.
M-SIM (Simulated) number seriesEnvironment 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 routings340 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 centersdispatchLines.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

IssueStatus
#62 — grant PSI Dispatch BC (client) access to BCDone 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 entitiesSuperseded 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 pageShipped, 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:

PathWhat it decides
docs/ADR-0001-architecture.mdThe ERP seam, read vs. MES-owned state, optimistic concurrency, why fork PRGJSMES
docs/ADR-0002-writeback-and-architecture.mdWhy thin-SPA was rejected; the operator write-back blocker
docs/ADR-0003-read-caching-db-impact.mdRead cache, read/write split, service-account token strategy
docs/ADR-0004-business-central-provider.mdThe Phase-2 BC provider, field mapping, prerequisites, verification runbook
docs/PHASE-1-SPEC.mdEndpoints, DTOs, write model
docs/dispatch-grid-behavior.md, docs/ui-audit-vs-legacy.mdGrid behaviour and the audit against the legacy WPF view
docs/floor-view.mdThe #/floor view: stations = work areas, pile = Priority order, slack = due − pile ahead ÷ capacity, displacement vs. earliest-due, consumer tethers
docs/TEST_PLAN.mdTest layers and how to run them
BUILD_LOG.mdWhat shipped and why — newest first

Operational notes

  • main is 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_LICENSE secret. Without it the grid still builds and renders — it just carries a dev watermark.


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