Without the exec bit the MySQL entrypoint sources the script instead of executing it, leaking set -euo pipefail into MySQL's own startup shell and aborting first-time initialization (container exits -> unhealthy -> compose dependency failure). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PESCO NCR — Non-Conformance Report System
A web-based Non-Conformance Report (NCR / "QN") system for PESCO, replacing the PowerApps/SharePoint prototype. Shop-floor and office users log nonconformance issues on jobs, route them through disposition review, operations rework, QC inspection, and costing, then close them — with full audit history, email notifications, reporting, and Power BI access.
Contents
- Architecture
- Quick start (local demo, no Entra required)
- Production setup
- Entra ID app registration
- Email notifications (delegated Graph send)
- Workflow & roles
- Migrations & seed data
- Power BI
- Future VISUAL ERP integration
- Backend tests
- Development outside Docker
- Troubleshooting
Architecture
┌────────────┐ HTTPS ┌─────────────────────┐ ┌──────────────┐
│ Browser │ ─────────▶ │ frontend (nginx) │ │ Entra ID │
│ React SPA │ │ - serves built SPA │ │ (OIDC/JWKS) │
│ MSAL │ │ - proxies /api ────┼──┐ └──────▲───────┘
└────────────┘ └─────────────────────┘ │ │ token
▼ │ validation,
┌─────────────────────────────┐ │ OBO exchange
│ api (FastAPI, SQLAlchemy 2) │ ──────┘
│ - state machine, RBAC │ ──▶ Microsoft Graph
│ - audit trail, numbering │ (delegated
│ - WeasyPrint PDF, reports │ Mail.Send)
└───────┬──────────────┬───────┘
│ │
┌─────────▼───────┐ ┌───▼──────────────┐
│ MySQL 8 (volume)│ │ attachments │
│ + reporting │ │ (named volume) │
│ views for BI │ └──────────────────┘
└─────────────────┘
| Piece | Tech |
|---|---|
| Backend | Python 3.12, FastAPI, SQLAlchemy 2 (async, aiomysql), Alembic, Pydantic v2 |
| Frontend | React 18 + Vite + TypeScript, MUI, React Router, TanStack Query, TipTap, Recharts |
| Auth | Entra ID (OIDC) — @azure/msal-react in the SPA, python-jose/JWKS validation in the API |
Microsoft Graph delegated Mail.Send via the On-Behalf-Of flow |
|
| WeasyPrint (HTML → PDF) | |
| Database | MySQL 8 (utf8mb4), named volume; SQLite used only by the test suite |
Key backend modules:
backend/app/domain.py— roles, stages, allowed transitionsbackend/app/services/workflow.py— the state machine (all transitions validated server-side)backend/app/services/numbering.py— atomicNCR-YYYY-NNNNallocation (per-year row lock)backend/app/services/notifications.py— fault-tolerant Graph notificationsbackend/app/services/job_lookup.py—JobLookupServiceseam for the future VISUAL integrationbackend/app/routers/ncrs.py— NCR endpoints (create, queues, stage actions, attachments, audit, CSV, PDF)
API docs (OpenAPI/Swagger) are served at /api/docs.
Quick start (local demo, no Entra required)
Runs the full stack with dev auth (a user switcher instead of Entra — never use outside a lab):
cp .env.example .env
# In .env set:
# AUTH_MODE=dev
# MYSQL_PASSWORD / MYSQL_ROOT_PASSWORD / POWERBI_RO_PASSWORD → anything
docker compose up -d --build
# seed departments/categories, demo users, and sample NCRs (SEED_DEMO_DATA=true)
docker compose exec api python -m app.seed
Open http://localhost:8080. The switcher in the top bar signs you in as any
demo user (admin@, dispo@, second@, ops@, qc@, cost@,
req@pescoinc.biz) so you can walk an NCR through the whole workflow.
Production setup
- Complete the Entra ID app registration below.
cp .env.example .envand fill in everything marked__LIKE_THIS__:AUTH_MODE=entraENTRA_TENANT_ID,ENTRA_CLIENT_ID,ENTRA_CLIENT_SECRETENTRA_ALLOWED_GROUP_ID— object ID of theNCR-Userssecurity groupINITIAL_ADMIN_EMAILS— who gets the Admin role on first sign-inAPP_BASE_URL— the URL users browse to (used in email links)- strong MySQL + Power BI passwords
docker compose up -d --build— migrations run automatically on API start.docker compose exec api python -m app.seed— seeds departments/categories (setSEED_DEMO_DATA=falsefirst to skip demo users/NCRs).- Sign in with an
INITIAL_ADMIN_EMAILSaccount, open Admin → Users & Roles, and assign Disposition Authority / Operations / QC Inspector / Costing roles.
Put TLS in front of the frontend service (reverse proxy or load balancer) and
update the Entra redirect URI + APP_BASE_URL to the HTTPS URL.
Entra ID app registration
One app registration serves both the SPA and the API.
- Create the registration — Azure portal → Entra ID → App registrations →
New registration. Name:
PESCO NCR. Supported account types: single tenant. - SPA redirect URIs — Authentication → Add a platform →
Single-page application → add:
http://localhost:8080(or yourAPP_BASE_URL)- your production URL, e.g.
https://ncr.pescoinc.biz
- Expose the API — Expose an API → Set the Application ID URI to the
default
api://<client-id>→ Add a scope:- Scope name:
access_as_user - Who can consent: Admins and users
- Display name/description: "Access the PESCO NCR API as the signed-in user"
- Scope name:
- API permissions — Add a permission → Microsoft Graph → Delegated:
User.Read(usually present already)Mail.Send— required for stage-transition emailsGroupMember.Read.All— optional; only needed for the group-overage fallback (users in >200 groups) Then click Grant admin consent.
- Client secret — Certificates & secrets → New client secret → put the
value in
ENTRA_CLIENT_SECRET. (Used by the API for the OBO exchange and overage checks; the SPA never sees it.) - Groups claim — Token configuration → Add groups claim → select
Security groups (for both ID and access tokens). If your users belong to
many groups, prefer Groups assigned to the application and assign
NCR-Usersto the app (Enterprise application → Users and groups) to avoid claim overage. - Access-token version — the API accepts both v1 and v2 issuers. For
clean v2 tokens set
"accessTokenAcceptedVersion": 2in the app manifest. - Front-door group — create (or reuse) a security group such as
NCR-Users, add everyone who may use the app, and put its object ID inENTRA_ALLOWED_GROUP_ID. Users outside the group get "Access denied" even with a valid token.
Users are auto-provisioned in the local DB on first sign-in (OID, name, email, employee ID when present in the token) with the default Requester role.
Email notifications (delegated Graph send)
Notifications are sent from the mailbox of the user who performed the
action, using the On-Behalf-Of (OBO) flow — chosen over passing
frontend-acquired Graph tokens because it keeps Graph scopes and token plumbing
entirely server-side: the SPA only ever requests the API scope, and the API
exchanges the incoming access token for a delegated Graph token when it needs
to send mail (backend/app/services/graph.py).
| Event | Recipients | Sent from |
|---|---|---|
| New request submitted | selected Disposition Authority | requester |
| Secondary review assigned | "Notify These People" | initial reviewer |
| Released to Operations | all Operations users | releasing reviewer |
| Operations complete | all QC Inspectors | operations user |
| QC closed | all Costing users | QC inspector |
| NCR closed | original requester | costing user |
| Admin reopen | owners of the target stage + requester | admin |
Fault tolerance: a Graph failure never blocks a workflow transition — the
transition is already committed; the failure is logged and returned in the
response warnings array, which the UI shows as a toast. The Admin → Settings
screen has a global on/off toggle (handy during testing).
Workflow & roles
Stages (enforced server-side; invalid transitions are rejected with HTTP 409):
New Request ──(initial disposition)──┬── needs secondary review ──▶ Secondary Disposition ─┐
└───────────── no ──────────────────▶ Operations ◀────┘
Operations ─▶ QC Inspection ─▶ Costing ─▶ Closed (locked; Admin-only reopen with reason)
Notes:
- "Initial Disposition" is the review a Disposition Authority performs on an NCR sitting in the New Request queue (QC authority, work order, rich-text disposition notes, secondary-review decision).
- Secondary Disposition is visible only to the assigned "Notify These People" users (their personal queue) and Admins; they may update disposition fields and release to Operations.
- QC Inspection can be saved repeatedly until QC Closed advances it.
- Saving Costing (Labor/Material/Service/Other) closes the NCR. Closed NCRs are fully read-only — including attachments — until an Admin reopens them (required reason, recorded in the audit trail).
- Every stage change writes a
stage_transitionsrow (timestamp + acting user) — the basis for the aging and cycle-time reports — and every field change writes an immutableaudit_logrow (before/after values). The app exposes no way to edit or delete audit rows.
Roles (assigned in Admin → Users & Roles; a user may hold several): Requester (default), Disposition Authority, Secondary Disposition Authority, Operations, QC Inspector, Costing, Admin.
Migrations & seed data
# run migrations manually (they also run on every api container start)
docker compose exec api alembic upgrade head
# seed lookups (+ demo data when SEED_DEMO_DATA=true)
docker compose exec api python -m app.seed
# create a new migration after model changes
docker compose exec api alembic revision --autogenerate -m "describe change"
Power BI
The schema ships three read-only flattened views for external reporting:
| View | Grain |
|---|---|
vw_ncr_full |
one row per NCR — all stage data, costs, computed total_cost, days_in_stage, ERP enrichment |
vw_ncr_stage_history |
one row per stage transition (for cycle-time analysis) |
vw_ncr_costs |
one row per costed NCR (cost of nonconformance) |
A dedicated MySQL account powerbi_ro is created on first startup
(db/init/01-powerbi-user.sh) with SELECT on exactly those views and nothing
else. If your MySQL volume was initialized before you set
POWERBI_RO_PASSWORD, run scripts/powerbi_grants.sql (instructions inside).
Pointing the gateway at it:
- MySQL is published on
MYSQL_PUBLISHED_PORT(default 3306). Firewall it so only the Power BI gateway host can reach it. - On the gateway machine install the MySQL .NET connector
(Connector/NET 8.x — required for
caching_sha2_password). - In Power BI Desktop: Get data → MySQL database → server
<docker-host>:3306, databasepesco_ncr, userpowerbi_ro. - Import (or DirectQuery) the three
vw_*views; schedule refresh through the gateway.
Future VISUAL ERP integration
Job Number is free text today. The integration seam is already in place:
backend/app/services/job_lookup.pydefines theJobLookupServiceprotocol. The defaultNullJobLookupServicereturns no enrichment.VisualJobLookupServiceis a documented stub containing the verified VISUAL 10 query plan (WORK_ORDER composite keyTYPE/BASE_ID/LOT_ID/ SPLIT_ID/SUB_ID, PART join, and the DEMAND_SUPPLY_LINK → CUST_ORDER_LINE → CUSTOMER_ORDER → CUSTOMER customer linkage).- The schema stores the job number as entered plus a nullable
job_inforow (part, description, customer, WO status) any provider can populate. - The SPA's job-number field already calls
GET /api/jobs/{job}/lookupwhile typing and displays whatever enrichment returns — validation/autocomplete light up without a redesign.
To enable later: implement the stub (read-only SQL Server access — never write
to VISUAL tables), set JOB_LOOKUP_PROVIDER=visual plus the VISUAL_DB_*
variables, and restart the API.
Backend tests
Covers the state machine (happy paths, invalid transitions, closure locking, reopen), per-stage permission enforcement, NCR numbering (format, year rollover, 12-way concurrent submission), attachments, and rich-text sanitization.
cd backend
python -m venv .venv && .venv/bin/pip install -r requirements-dev.txt
.venv/bin/pytest
Tests run against SQLite by default (same models/state machine/numbering code paths); to exercise real MySQL locking:
DATABASE_URL="mysql+aiomysql://user:pass@host/pesco_ncr_test?charset=utf8mb4" .venv/bin/pytest
Development outside Docker
# API (SQLite works fine for dev; export the vars or put them in backend/.env)
cd backend
export DATABASE_URL="sqlite+aiosqlite:///dev.db" AUTH_MODE=dev ATTACHMENTS_DIR=./attachments
.venv/bin/python -m app.dev_init # create tables (models → SQLite)
SEED_DEMO_DATA=true .venv/bin/python -m app.seed # demo users + sample NCRs
.venv/bin/uvicorn app.main:app --reload --port 8000
# SPA (proxies /api to :8000; public/config.js defaults to dev auth)
cd frontend
npm install
npm run dev # http://localhost:5173
Note: generating PDFs locally requires WeasyPrint's system libraries (Pango/
Cairo — brew install pango on macOS). The Docker image includes them.
Troubleshooting
| Symptom | Likely cause / fix |
|---|---|
| "Access token has no groups claim" | Add the groups claim in Token configuration (step 6 above). |
| "Could not verify group membership (group overage)" | Grant delegated GroupMember.Read.All + admin consent, or scope the group claim to "Groups assigned to the application". |
| Notification warning "OBO token exchange failed" | Check ENTRA_CLIENT_SECRET, and that Mail.Send has admin consent. |
api container restarts at boot |
MySQL still initializing; the entrypoint retries migrations 12×. Check docker compose logs mysql. |
| Power BI can't authenticate | Update Connector/NET (needs caching_sha2_password), verify the powerbi_ro grants (scripts/powerbi_grants.sql). |
| Uploads fail at ~25 MB | Raise MAX_UPLOAD_MB (API) — nginx client_max_body_size is 50 MB in frontend/nginx.conf. |