Initial commit: PESCO NCR system
Complete Non-Conformance Report system replacing the PowerApps/SharePoint
prototype: FastAPI + SQLAlchemy 2 (async) + Alembic + MySQL 8 backend,
React 18 + Vite + TypeScript + MUI frontend, Entra ID auth (MSAL / JWKS,
group-gated), Microsoft Graph delegated Mail.Send notifications (OBO),
six-stage workflow state machine with server-side enforcement, atomic
NCR-YYYY-NNNN numbering, attachments with camera capture, immutable
field-level audit trail, admin reopen, reports + CSV export, WeasyPrint
PDF traveler, Power BI reporting views + read-only DB user, documented
VISUAL ERP job-lookup stub, pytest suite (26 tests), docker-compose
deployment.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 11:41:22 -06:00
# 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 ](#architecture )
- [Quick start (local demo, no Entra required) ](#quick-start-local-demo-no-entra-required )
- [Production setup ](#production-setup )
- [Entra ID app registration ](#entra-id-app-registration )
- [Email notifications (delegated Graph send) ](#email-notifications-delegated-graph-send )
- [Workflow & roles ](#workflow--roles )
- [Migrations & seed data ](#migrations--seed-data )
- [Power BI ](#power-bi )
- [Future VISUAL ERP integration ](#future-visual-erp-integration )
- [Backend tests ](#backend-tests )
- [Development outside Docker ](#development-outside-docker )
- [Troubleshooting ](#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 |
| Email | Microsoft Graph **delegated ** `Mail.Send` via the On-Behalf-Of flow |
| PDF | 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 transitions
- `backend/app/services/workflow.py` — the state machine (all transitions validated server-side)
- `backend/app/services/numbering.py` — atomic `NCR-YYYY-NNNN` allocation (per-year row lock)
- `backend/app/services/notifications.py` — fault-tolerant Graph notifications
- `backend/app/services/job_lookup.py` — `JobLookupService` seam for the future VISUAL integration
- `backend/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):
```bash
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
1. Complete the [Entra ID app registration ](#entra-id-app-registration ) below.
2. `cp .env.example .env` and fill in everything marked `__LIKE_THIS__` :
- `AUTH_MODE=entra`
- `ENTRA_TENANT_ID` , `ENTRA_CLIENT_ID` , `ENTRA_CLIENT_SECRET`
- `ENTRA_ALLOWED_GROUP_ID` — object ID of the `NCR-Users` security group
- `INITIAL_ADMIN_EMAILS` — who gets the Admin role on first sign-in
- `APP_BASE_URL` — the URL users browse to (used in email links)
- strong MySQL + Power BI passwords
3. `docker compose up -d --build` — migrations run automatically on API start.
4. `docker compose exec api python -m app.seed` — seeds departments/categories
(set `SEED_DEMO_DATA=false` first to skip demo users/NCRs).
5. Sign in with an `INITIAL_ADMIN_EMAILS` account, 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.
1. **Create the registration ** — Azure portal → Entra ID → App registrations →
* New registration * . Name: `PESCO NCR` . Supported account types: *single
tenant*.
2. **SPA redirect URIs ** — Authentication → * Add a platform * →
**Single-page application ** → add:
- `http://localhost:8080` (or your `APP_BASE_URL` )
- your production URL, e.g. `https://ncr.pescoinc.biz`
3. **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"
4. **API permissions ** — * Add a permission * → Microsoft Graph → **Delegated ** :
- `User.Read` (usually present already)
- `Mail.Send` — required for stage-transition emails
- `GroupMember.Read.All` — optional; only needed for the group-overage
fallback (users in >200 groups)
Then click **Grant admin consent ** .
5. **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.)
6. **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-Users` to the app (Enterprise application → Users and groups) to avoid
claim overage.
7. **Access-token version ** — the API accepts both v1 and v2 issuers. For
clean v2 tokens set `"accessTokenAcceptedVersion": 2` in the app manifest.
8. **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 ** in
`ENTRA_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_transitions` row (timestamp + acting
user) — the basis for the aging and cycle-time reports — and every field
change writes an immutable `audit_log` row (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
```bash
# 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:
1. MySQL is published on `MYSQL_PUBLISHED_PORT` (default 3306). Firewall it so
only the Power BI gateway host can reach it.
2. On the gateway machine install the MySQL .NET connector
(Connector/NET 8.x — required for `caching_sha2_password` ).
3. In Power BI Desktop: * Get data → MySQL database * → server
`<docker-host>:3306` , database `pesco_ncr` , user `powerbi_ro` .
4. 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.py` defines the `JobLookupService`
protocol. The default * * `NullJobLookupService` ** returns no enrichment.
* * `VisualJobLookupService` ** is a documented stub containing the verified
VISUAL 10 query plan (WORK_ORDER composite key `TYPE/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_info` row
(part, description, customer, WO status) any provider can populate.
- The SPA's job-number field already calls `GET /api/jobs/{job}/lookup` while
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.
```bash
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:
```bash
DATABASE_URL="mysql+aiomysql://user:pass@host/pesco_ncr_test ?charset=utf8mb4" .venv/bin/pytest
```
## Development outside Docker
```bash
# 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` . |
2026-07-13 13:54:39 -06:00
| mysql dies instantly: "Fatal glibc error: CPU does not support x86-64-v2" | You overrode `MYSQL_IMAGE` to an Oracle Linux build (e.g. `mysql:8.4` ) on a host without x86-64-v2. Remove the override — the default `mysql:8.0-debian` runs on any x86-64 CPU. |
Initial commit: PESCO NCR system
Complete Non-Conformance Report system replacing the PowerApps/SharePoint
prototype: FastAPI + SQLAlchemy 2 (async) + Alembic + MySQL 8 backend,
React 18 + Vite + TypeScript + MUI frontend, Entra ID auth (MSAL / JWKS,
group-gated), Microsoft Graph delegated Mail.Send notifications (OBO),
six-stage workflow state machine with server-side enforcement, atomic
NCR-YYYY-NNNN numbering, attachments with camera capture, immutable
field-level audit trail, admin reopen, reports + CSV export, WeasyPrint
PDF traveler, Power BI reporting views + read-only DB user, documented
VISUAL ERP job-lookup stub, pytest suite (26 tests), docker-compose
deployment.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 11:41:22 -06:00
| 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` . |