Files
pesco-ncr/README.md
ang3l12 c1cd1bc9df Add API Q1 CAPA: root cause, corrective action gate, effectiveness verification
Implements tester feedback against API Q1 §5.9.1.2 / §6.4.2:

- Root Cause field + 6M Root Cause Category lookup (Man/Machine/Method/
  Material/Measurement/Environment), separate from Deviation Detail/Category
- "Corrective Action Required?" Yes/No gate on every NCR with a required
  justification
- Corrective action plan with owner + due date; owner is notified by email
- Effectiveness verification (result, notes, server-stamped verifier/date)
  required before an NCR can close when corrective action is required —
  costing returns 409 listing the missing pieces
- Recurring-issue flag with bidirectional NCR-to-NCR links; prior NCRs show
  a warning when later NCRs reference them
- Dashboard metrics: % root cause completed, % CAPA verified effective,
  avg CAPA close time, overdue CAPA count, NCRs by root cause category
- CAPA section in the NCR detail UI, printable PDF, CSV export, and the
  vw_ncr_full Power BI view; admin list manager for root cause categories
- Migrations 0003 (schema + seeded 6M lookup) and 0004 (view refresh);
  demo seed data exercises every metric

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 13:49:08 -06:00

320 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 |
| Corrective action assigned | the CA owner | user who assigned it |
| 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.
- The **CAPA section** (API Q1 §5.9.1.2 / §6.4.2) sits outside the stage
sequence: QC Inspectors, Disposition Authorities, and Admins can edit it at
any point before closure (`POST /ncrs/{ref}/capa`). It captures the root
cause (+ 6M root cause category), the *Corrective Action Required?* Yes/No
gate with justification, the action plan (owner + due date, owner is
notified), effectiveness verification (server-stamped verifier/date), and
the recurring-issue flag with links to prior NCRs.
- Saving **Costing** (Labor/Material/Service/Other) closes the NCR — but only
once the CAPA gate passes: the CA question must be answered, and when the
answer is Yes the plan must be complete and verified *effective* (HTTP 409
otherwise). 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`. |
| 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. |
| 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`. |