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 Serviceps-bom-coverage (RG PS-WEBAPPS, plan ps-bom-coverage-plan, B1 Linux)
URLhttps://bom-coverage.progressivesurface.com
RepoProgressiveSurface/psi-bc-part-checker
Stack.NET 10 · ASP.NET Core Razor Pages · psi-design-system v1.3.1 · no database
AuthEasyAuth, Entra-only (PSI BOM Coverage, e7fe3261-12c0-4ab5-aefd-9b40ce985c79)
Private endpointps-bom-coverage-pe10.160.140.33
App Insightsps-bom-coverage-insights
StatusLive 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 UseFake flags are false. Verified end-to-end: Unidata part 328401 returns 1342 items (wasTruncated: false, no warnings), and the BC sandbox items call returns 200 for company Progressive 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.

  1. Never write Graph permission GUIDs from memory. profile and email were declared with plausible-but-wrong IDs (correct prefix, invented tail). Entra does not reject them — it renders them as bare GUIDs with no name or description in the portal’s API-permissions list, and admin consent silently grants only the valid ones. If a permission row shows a GUID instead of a name, that ID is wrong. Read them off the Graph SP: az ad sp show --id 00000003-0000-0000-c000-000000000000 --query "oauth2PermissionScopes[?value=='profile'].id".
  2. EasyAuth needs enableIdTokenIssuance: true. It signs in with response_type=code+id_token; without ID-token issuance Entra fails the sign-in with AADSTS700054 no matter how complete the consent is. az ad app create does not set it.

The repo’s verify.yml workflow catches both — it hits the Entra authorize endpoint and fails on any AADSTS code. Worth copying to any new app; one runner minute, no browser needed.

How it works

  1. GET /api/bom/part/{partNumber} against PSI.UniData.API.
  2. 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.
  3. One bulk fetch of BC item numbers into a case-insensitive HashSet, then membership-check in memory. A per-item OData $filter path exists behind BusinessCentral:Strategy=PerItemFilter.
  4. 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.

  1. 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 single items[] array — there is no children array. Do not recurse client-side; assemble the flat list into a tree.
  2. Key nodes on wbsNumber, never partNumber. 2107 items / 2107 distinct wbsNumber / only 1472 distinct partNumber — one part appeared 81 times. Keying on part number collapses the BOM.
  3. level and maxLevel are unreliable. A live response reported maxLevel: 2 when the real depth was 9. Compute depth yourself from parentPartNumber / wbsNumber nesting.
  4. An unknown part returns HTTP 200, not 404. The subroutine echoes the requested part back as row 1, so totalItems is never 0 on the /part/ route — the status code cannot validate a part. Discriminator: a single-item response whose description, gtCode and mrpCode are 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.

SettingValue
BusinessCentral:TenantIda83ae943-0a50-49cc-83c3-479b7a44b7fb
BusinessCentral:Environmentsandbox
BusinessCentral:CompanyNameProgressive Surface
Unidata:BaseUrlhttps://api.progressivesurface.com
RateLimit:PermitsPerMinute60 (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 DEV and 401 Authentication_InvalidCredentials against sandbox until 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 curl against DEV localised 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.Web

Runs with zero credentialsappsettings.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.


Created 2026-08-10.