BOM Coverage Checker
Enter a parent part number, and the app walks the full Unidata BOM, checks every distinct item against the Business Central items API, and renders a report of what exists in BC and what is missing — so the gaps can be closed before a demo or a go-live.
The missing-items list is the actionable output; it pairs directly with the add-bc-item flow.
| App Service | ps-bom-coverage (RG PS-WEBAPPS, plan ps-bom-coverage-plan, B1 Linux) |
| URL | https://bom-coverage.progressivesurface.com |
| Repo | ProgressiveSurface/psi-bc-part-checker |
| Stack | .NET 10 · ASP.NET Core Razor Pages · psi-design-system v1.3.1 · no database |
| Auth | EasyAuth, Entra-only (PSI BOM Coverage, e7fe3261-12c0-4ab5-aefd-9b40ce985c79) |
| Private endpoint | ps-bom-coverage-pe → 10.160.140.33 |
| App Insights | ps-bom-coverage-insights |
| Status | Live on real data — live AFTEC BOMs via unidata-api, checked against the BC sandbox item master. |
Live since 2026-08-13
Deployed 2026-08-12, auth verified and both integrations switched to real data 2026-08-13. Both
UseFakeflags arefalse. Verified end-to-end: Unidata part328401returns 1342 items (wasTruncated: false, no warnings), and the BC sandboxitemscall returns 200 for companyProgressive Surface(5c223602-b34f-f111-a820-000d3aec53ac).
Two app-registration traps this app hit — check these on any new EasyAuth app
Both were invisible until something exercised the sign-in path, and neither produced an obvious error.
- Never write Graph permission GUIDs from memory.
profileandaz ad sp show --id 00000003-0000-0000-c000-000000000000 --query "oauth2PermissionScopes[?value=='profile'].id".- EasyAuth needs
enableIdTokenIssuance: true. It signs in withresponse_type=code+id_token; without ID-token issuance Entra fails the sign-in withAADSTS700054no matter how complete the consent is.az ad app createdoes not set it.The repo’s
verify.ymlworkflow catches both — it hits the Entra authorize endpoint and fails on anyAADSTScode. Worth copying to any new app; one runner minute, no browser needed.
How it works
GET /api/bom/part/{partNumber}against PSI.UniData.API.- Flatten to distinct part numbers — PSI reuses a large share of PHYS parts across BOMs, so one real job produced 2107 rows but only 1472 distinct numbers. De-duping first turns N lookups into one.
- One bulk fetch of BC item numbers into a case-insensitive
HashSet, then membership-check in memory. A per-item OData$filterpath exists behindBusinessCentral:Strategy=PerItemFilter. - Render: header band → KPI row → missing-items table first → full annotated tree.
Four Unidata API behaviours that will bite the next integrator
These were verified against live production while building this app. They are not documented in the API repo, and each one silently produces a wrong answer rather than an error.
- The BOM comes back already exploded, flat, in one call.
/api/bom/part/{n}recurses server-side and returns every node at every depth in a singleitems[]array — there is nochildrenarray. Do not recurse client-side; assemble the flat list into a tree. - Key nodes on
wbsNumber, neverpartNumber. 2107 items / 2107 distinctwbsNumber/ only 1472 distinctpartNumber— one part appeared 81 times. Keying on part number collapses the BOM. levelandmaxLevelare unreliable. A live response reportedmaxLevel: 2when the real depth was 9. Compute depth yourself fromparentPartNumber/wbsNumbernesting.- An unknown part returns HTTP 200, not 404. The subroutine echoes the requested part back as
row 1, so
totalItemsis never 0 on the/part/route — the status code cannot validate a part. Discriminator: a single-item response whosedescription,gtCodeandmrpCodeare all empty.
Also: gtCode is free text, not an enum — live values include PHYS. with a trailing period,
plus HHCS, 10GA, WIREWAY, and "" on 464 of 2107 rows. Detect leaves from hasChildren, never
from a GT-code allowlist. And /api/parts/{n}’s gtCode is actually the MRP code (parser bug),
so the BOM response is the only reliable source of a real GT code.
wasTruncated, truncationReason and a non-empty warnings[] all mean the tree is partial —
which would understate the missing-parts list — so the report surfaces them rather than swallowing them.
Configuration
Non-secret settings live in appsettings.json; the app resolves the BC company GUID at runtime
from its name, so there is no GUID to copy.
| Setting | Value |
|---|---|
BusinessCentral:TenantId | a83ae943-0a50-49cc-83c3-479b7a44b7fb |
BusinessCentral:Environment | sandbox |
BusinessCentral:CompanyName | Progressive Surface |
Unidata:BaseUrl | https://api.progressivesurface.com |
RateLimit:PermitsPerMinute | 60 (per IP, /Report only) |
Both APIs need one Entra identity holding the UniData.ServiceRead app role and BC
permission. As of 2026-08-11 one exists: PSI Dispatch BC (client)
(4d1fc1cc-4589-40e2-b6fb-adeefd1237d9) was granted the Business Central API.ReadWrite.All
application permission with admin consent, and it already held UniData.ServiceRead — making it the
tenant’s first registration holding both. It was registered for PSI Dispatch’s
Phase-2 BC reads, so BOM Coverage would be a second consumer of the same identity rather than the
reason it exists.
Done — the in-BC registration was completed in sandbox on 2026-08-13 and both UseFake flags
are now false. The secret is appended to that registration (not reset), so psi-dispatch’s own
two credentials are untouched; it lives in ps-certificates-kv as ps-bom-coverage--api-client-secret.
BC per-environment registration: measured, not theoretical
The identical credential returned 200 against
DEVand 401Authentication_InvalidCredentialsagainstsandboxuntil the app was added inside the sandbox environment. The Entra app-role grant (API.ReadWrite.All, admin-consented) is necessary but not sufficient — each BC environment keeps its own allowlist (BC → Microsoft Entra Applications → add client ID → State=Enabled → permission set → Grant Consent).A 401 from one BC environment while another works is this, every time. Don’t go re-minting secrets chasing it — test the same token against a known-good environment first to tell a credential problem from a registration gap. Two minutes of
curlagainstDEVlocalised this immediately.
Shared identity — deliberate trade-off
This app and PSI Dispatch present the same service principal, so BC and Unidata audit logs cannot distinguish them, and rotating that secret affects both. Accepted to avoid a second admin-consent cycle. Split into a dedicated registration if either app’s access needs to change independently.
Local development
dotnet run --project src/BomBcChecker.WebRuns with zero credentials — appsettings.Development.json defaults both clients to fakes, so a
clean clone renders a populated report immediately. Base appsettings.json keeps UseFake: false,
so real APIs remain the default everywhere else.
Deployment
GitHub Actions on the self-hosted runner (runs-on: [self-hosted, psi-internal], az login --identity).
Async az webapp deploy + Kudu polling, serialised via a concurrency group with
cancel-in-progress: false.
The health gate compares the deployed commit SHA rather than accepting a 200: the build stamps
build-info.json, and /api/health serves it. That endpoint is the single entry in EasyAuth’s
globalValidation.excludedPaths — everything else requires Entra sign-in. It returns build metadata
only: no config, no secrets, no BOM or BC data.
Related
- unidata-api · deploy-to-azure · dns-standards
- webapp-compliance-standard · azure-security
- design-system — vendored at
b3873ea(v1.3.1); this is the first Razor consumer of the design system, which is plain CSS with no build step
Created 2026-08-10.