Quality — manufacturing quality inspection (pass/fail, non-conformance disposition: scrap/rework)
The whole quality subsystem reduces to one idea: a
QualityInspectionis a single record of "we inspected N units of an item (usually tied to a production order / operation), P passed, F failed", with aresult(PASS / FAIL / CONDITIONAL_PASS / PENDING) and — for the failures — adisposition(REWORK / SCRAP / ACCEPT_AS_IS / RETURN_TO_VENDOR). It is a single flat document with full CRUD; there is no inspection-plan / characteristic / sampling-rule sub-model, and recording an inspection has no side effects on stock, production orders, or the GL — it is a logbook that downstream reports aggregate.
Source: BE src/modules/manufacturing/quality · Admin src/modules/manufacturing/quality
⚠️ Scope note. Quality is record-keeping only. Marking an inspection FAIL with
disposition: SCRAPandscrapQuantity: 10does not write a scrapStockOUT row, does not adjust the production order's good/scrap quantities, and does not post a scrap-cost GL entry — those numbers live solely on the inspection document. The only consumer of inspection data is the manufacturing reportsqualitySummaryReport(pass/fail rates). If you need scrap to actually move inventory or hit COGS, that integration does not exist in this module.
1. Purpose & scope
The quality module records inspections of manufactured (or incoming) goods. It sits beside the production module: an inspection optionally references a production order and a routing operation, plus the item being inspected.
Responsible for:
- Recording an inspection event: type (in-process / final / incoming), inspector, date, quantities (inspected / passed / failed).
- Capturing the result (PASS / FAIL / CONDITIONAL_PASS / PENDING) and defect / corrective-action notes.
- Tracking non-conformance disposition for failed units: rework, scrap, accept-as-is, return-to-vendor, plus rework/scrap quantities and quarantine warehouse.
- An open/closed inspection status to mark a record as resolved.
Explicitly does NOT:
- Move stock — no scrap/rework
Stockrows are written (see inventory). - Update the linked production order's completed/scrapped quantities.
- Post any GL legs (no scrap-cost, no rework-cost — see manufacturing accounting).
- Model inspection plans / quality characteristics / sampling rules / acceptance limits — the schema has no characteristic sub-collection; pass/fail is the inspector's manual judgement entered as quantities.
- Enforce
quantityPassed + quantityFailed = quantityInspected(no such invariant; the inspector enters all three freely). - Auto-derive
resultfrom quantities —resultis set manually (defaultsPENDING).
2. Data model
One collection: quality_inspections. Extends BaseSchema, uses mongoose-delete (soft delete with deletedAt), carries companyId / branchId tenant scoping. It is a single flat document — no child lines.
2.1 quality_inspections (quality/quality-inspection.scheme.ts)
| field | type | required | description |
|---|---|---|---|
inspectionNumber |
string |
yes | human key; resolver auto-fills QI-<nanoId(8) upper> if omitted |
productionOrderId |
ObjectId |
no (sch.) / yes (input) | the production order inspected (FK → production_orders); $lookup-joined as productionOrder |
operationId |
ObjectId |
no | the routing operation (FK → production_operations); joined as operation |
itemId |
ObjectId |
no (sch.) / yes (input) | the item inspected (FK → items); joined as item |
inspectionType |
InspectionType |
yes | IN_PROCESS / FINAL / INCOMING |
result |
QualityResult |
no (default PENDING) |
PASS / FAIL / CONDITIONAL_PASS / PENDING |
inspectorName |
string |
yes | free-text inspector (not a user FK) |
inspectionDate |
number (unix ts) |
yes | BaseSchema.toUnixTimestamp |
quantityInspected |
number |
yes (default 0) |
total units inspected |
quantityPassed |
number |
no (default 0) |
units that passed |
quantityFailed |
number |
no (default 0) |
units that failed |
defectDescription |
string |
no | what was wrong |
correctiveAction |
string |
no | what to do about it |
notes |
string |
no | free text |
status |
InspectionStatus |
no (default OPEN) |
OPEN / CLOSED |
disposition |
NonConformanceDisposition |
no (default PENDING) |
how failed units are handled |
dispositionNotes |
string |
no | disposition free text |
dispositionDate |
number (unix ts) |
no | when dispositioned |
dispositionBy |
string |
no | free-text who dispositioned (not a user FK) |
quarantineWarehouseId |
ObjectId |
no | where failed/quarantined stock is held (FK, not enforced/used — no stock movement) |
reworkQuantity |
number |
no (default 0) |
units routed to rework |
scrapQuantity |
number |
no (default 0) |
units scrapped |
companyId, branchId |
ObjectId |
no | tenant/branch scope |
2.2 Enums (verbatim — quality/quality-inspection.scheme.ts)
export enum QualityResult {
PASS = "PASS",
FAIL = "FAIL",
CONDITIONAL_PASS = "CONDITIONAL_PASS", // accepted with caveats
PENDING = "PENDING", // default — not yet judged
}
export enum InspectionType {
IN_PROCESS = "IN_PROCESS", // mid-route check
FINAL = "FINAL", // finished-good check (UI default)
INCOMING = "INCOMING", // received-material check
}
export enum InspectionStatus {
OPEN = "OPEN", // default — unresolved
CLOSED = "CLOSED", // resolved
}
export enum NonConformanceDisposition {
REWORK = "REWORK", // fix and re-inspect
SCRAP = "SCRAP", // discard
ACCEPT_AS_IS = "ACCEPT_AS_IS", // use despite defect
RETURN_TO_VENDOR = "RETURN_TO_VENDOR", // send back (incoming)
PENDING = "PENDING", // default — not yet decided
}Relationships & joins. Every read aggregation (find / findById / findOne / page) $lookups three collections — production_orders → productionOrder, items → item, production_operations → operation (all preserveNullAndEmptyArrays). The GraphQL QualityInspection type exposes the resolved productionOrder: ProductionOrder and item: Item (the operation join is computed but not surfaced in the DTO). No embedded child documents; one inspection = one row.
3. API surface
Single resolver (quality/quality-inspection.resolver.ts), @ApInitGqlAuthorize(), extends ApBaseResolver. Page query uses @ApGqlAuthorize({ includeBranchQuery: false }) (company-wide, not branch-filtered). All mutations carry @AuditMeta({ module: "quality", collection: "quality_inspections", … }).
| Operation | Type | Input | Returns | Permission / audit |
|---|---|---|---|---|
qualityInspectionPage(page) |
Query | QualityInspectionPageInput {skip, take, keyword, sortBy, sortOrder, inspectionType, result, productionOrderId} |
QualityInspectionPageResult {totalRecords, data} |
JWT + access group |
findQualityInspection(_id) |
Query | ID |
QualityInspection (nullable) |
JWT |
createQualityInspection(qualityInspection) |
Mutation | CreateQualityInspectionInput |
QualityInspection |
JWT + audit(CREATE snapshot) |
updateQualityInspection(_id, qualityInspection) |
Mutation | UpdateQualityInspectionInput |
QualityInspection |
JWT + audit(UPDATE snapshot) |
deleteQualityInspection(_id) |
Mutation | String |
Boolean |
JWT + audit(DELETE) |
deleteQualityInspections(_ids) |
Mutation | [String] |
Boolean |
JWT + audit(DELETE), loops delete |
createQualityInspection defaults inspectionNumber to QI-${helper.nanoId(8).toUpperCase()} and stamps createdBy.
Input split (quality-inspection.dto.ts). Create accepts only the "header" fields (number, prodOrder, operation, item, type, inspector, date, qty inspected/passed/failed, defect, corrective, notes). The result/disposition/status fields are update-only — they are added in UpdateQualityInspectionInput (which extends PartialType(CommonQualityInspectionInput)):
input CreateQualityInspectionInput {
inspectionNumber: String
productionOrderId: String! # required on create
operationId: String
itemId: String! # required on create
inspectionType: InspectionType!
inspectorName: String!
inspectionDate: Float!
quantityInspected: Float!
quantityPassed: Float
quantityFailed: Float
defectDescription: String
correctiveAction: String
notes: String
}
input UpdateQualityInspectionInput { # all create fields optional, plus:
result: QualityResult
status: InspectionStatus
disposition: NonConformanceDisposition
dispositionNotes: String
dispositionDate: Float
dispositionBy: String
quarantineWarehouseId: String
reworkQuantity: Float
scrapQuantity: Float
}Note
productionOrderId/itemIdare required in the GraphQL input but optional in the Mongo schema — so the API enforces them on create even though the collection would allow nulls.
No REST endpoints.
4. Business rules & calculations
4.1 Validation
- Server-side: only what the GraphQL input types enforce —
productionOrderId,itemId,inspectionType,inspectorName,inspectionDate,quantityInspectedrequired on create. No class-validator decorators, no service-level invariants. The service is a bareAbstractBaseService(CRUD only;setSession/seedare no-ops). - Client-side (Formik/Yup,
components/create.tsx):productionOrder,item,inspectionType,inspectorNamerequired;inspectionDaterequired (number);quantityInspectedrequiredmin(1). - No quantity reconciliation. Nothing checks
quantityPassed + quantityFailed == quantityInspected, norreworkQuantity + scrapQuantity == quantityFailed. All quantities are independent free entries.
4.2 Pass/fail — there is no algorithm
result is a manually-set enum, not derived from quantityPassed / quantityFailed. It defaults to PENDING on create (the create form does not expose result), and is set later via update (the edit form exposes the result dropdown). So "pass/fail" is the inspector's explicit choice, recorded alongside the pass/fail counts. There is no acceptance-limit / AQL / sampling logic.
4.3 Scrap & rework — quantities only, no movement
For failed units, the inspector records a disposition plus quantities:
disposition = REWORK | SCRAP | ACCEPT_AS_IS | RETURN_TO_VENDOR | PENDING
reworkQuantity, scrapQuantity = how many units go each way (free entry)
quarantineWarehouseId = where they sit (recorded, never acted on)
These are logbook fields. Setting disposition: SCRAP, scrapQuantity: 5:
- does not create a
StockOUT (scrap) row, - does not decrement the production order's completed quantity or bump its scrap,
- does not post a scrap-cost journal entry.
The scrapQuantity does surface in the reports module — productionYieldReport sums quantityScrapped from production orders (a different field on a different collection), and qualitySummaryReport aggregates inspected/passed/failed from inspections (see accounting-reports §reports). But the quality module itself performs no scrap accounting.
4.4 Status / state machine
create update(result/qty) update(disposition) update(status)
(none) ──────────────────────────▶ OPEN ──────────────────────────────────────────────────────────────▶ CLOSED
result: PENDING result := PASS/FAIL/… disposition := … status := CLOSED
disposition: PENDING (manual) reworkQty/scrapQty (manual close)
status: OPEN
- Two independent enums:
result(the verdict) andstatus(open/closed lifecycle). Neither is enforced or auto-transitioned — any value can be written viaupdateQualityInspectionat any time. There are no guards (e.g. "can't close while result PENDING").
4.5 Side effects & transactionality
- None beyond the row write + audit. No GL, no stock, no production-order update, no notifications.
@AuditMetasnapshots fire on create/update/delete. - No Mongo transaction is used (single-document writes).
5. Permissions
- Resolver:
@ApInitGqlAuthorize()(JWT). Page query:@ApGqlAuthorize({ includeBranchQuery: false })— access-group gated, not branch-scoped (inspections visible company-wide). - Audit module tag
"quality", collectionquality_inspections, snapshots on CREATE / UPDATE / DELETE. - No CASL ability strings or custom role gates beyond the standard access-group check. Module wires
AuthModule+UserModule(forwardRef). See permissions and audit trail. - UI gates the Edit button on
inspection.canUpdate(resolved permission flag fromApBaseResolver).
6. Flows
6.1 Create an inspection (final-check happy path)
- Admin → Manufacturing › Quality Control → "New Inspection" →
CreateQualityInspectionmodal. - Select production order (
ApProductionOrderSelectInput) + item (ApItemSelection) + inspection type (defaults FINAL), enter inspector name, date, qty inspected (≥1), optional qty passed/failed, defect/corrective/notes. createQualityInspectionmutation →QualityInspectionService.create→quality_inspectionsrow withresult: PENDING,status: OPEN,disposition: PENDING, autoinspectionNumberif blank,createdBystamped. Audit CREATE snapshot.- List refreshes (optimistic
setInspections([data, ...])).
6.2 Judge & disposition (fail path)
- Admin opens an inspection → Edit →
CreateQualityInspectionin update mode. - Edit mode additionally exposes the
resultdropdown; the submit payload (wheninspection._idexists) includesresult,status,disposition,dispositionNotes. - Inspector sets
result: FAIL,quantityFailed,disposition: SCRAP|REWORK|…,scrapQuantity/reworkQuantity(note: rework/scrap qty inputs are present on the schema/DTO but the create/edit form does not render dedicated inputs for them — they would be set via API or a future form field; the detail view displays them under "Disposition"). updateQualityInspection→ row updated, audit UPDATE snapshot. No downstream effect — the failed/scrapped units are recorded but not removed from inventory or charged anywhere.- Optionally set
status: CLOSEDto mark resolved.
6.3 Unhappy paths
- Missing required field on create → blocked client-side by Yup, and server-side by the non-null GraphQL input fields.
- Delete →
deleteQualityInspectionsoft-deletes (mongoose-delete); the row drops out of all aggregations (and out ofqualitySummaryReport, which filtersdeleted ≠ true).
7. Admin UI
Source: zerp-admin/src/modules/manufacturing/quality — zync-nextjs context pattern (useQualityState() → useQualityQuery() → Apollo). Route /manufacturing/quality; detail rendered by QualityDetailPage (detail.tsx), also embedded in the table's view-detail drawer.
page.tsx(QualityPage):ApPageHeader "Quality Control"+ "New Inspection" →ApModal(width 70%) withCreateQualityInspection;ApSearchInput(keyword);QualityTable. Refetches onfilterchange.components/table.tsx(QualityTable): AntApTable— Inspection #, Type (Tag), Result (colorTag: PASS green / FAIL red / CONDITIONAL_PASS orange / PENDING default), Inspector, Qty Inspected / Passed / Failed, Status (TagOPEN blue / CLOSED default), Actions (edit / delete / view-detail drawer).hideActionsprop for embedding.components/create.tsx(CreateQualityInspection): Formik + Yup (see §4.1). Create shows header fields; update mode adds theresultselect and submits result/status/disposition. Inspection number viaApIdInput(localStoragemfg_inspection_number). Type/result options derive from the enums (replace('_',' ')).detail.tsx(QualityDetailPage): info grid (number, type, resultTag, statusTag, inspector, date, qty inspected/passed/failed, production order) + defect/corrective/notes; a conditional Disposition section (shown whendispositionset) with dispositionTag, disposition by/date, rework qty, scrap qty, disposition notes. Edit button gated oncanUpdate.context.tsx(useQualityState): exposesfetchInspections,saveInspection(create/update split on_id),deleteInspection, plus filter/modal state. Optimistic local list updates on create/update/delete.gql/query.tswraps page/find/create/update/delete.
The UI exposes the full CRUD + disposition lifecycle through the edit form, but no rework/scrap quantity inputs are rendered in the form (those fields are display-only in
detail.tsx). To capture them through the UI you'd extendCreateQualityInspectionwithApTextInputs forreworkQuantity/scrapQuantity/dispositionselect.
8. Dependencies & integrations
- Production module (
../production):productionOrderId/operationIdreferences; the GraphQL type embeds the resolvedProductionOrder. One-way reference only — inspections never write back to production orders. - Inventory item (
../inventory/item):itemIdreference; resolvedItemembedded in the GraphQL type.quarantineWarehouseIdreferences a warehouse but is never acted on. - Reports module (
./accounting-reports.md): themanufacturing-reportmodule imports theQualityInspectionschema and runsqualitySummaryReport(pass/fail aggregation). This is the only cross-module consumer of inspection data. - Auth / User / Audit: standard
@ApInitGqlAuthorize+@AuditMeta. No cron, no events, no external services.
9. Gotchas & project-specific rules
- Pure logbook — zero side effects. Recording scrap/rework/fail does not move stock, touch the production order, or post to the GL. This is the single most important fact: quality here is documentation, not control.
resultis manual, not derived. It defaultsPENDINGand is set by the inspector; it is not computed from passed/failed counts.- No quantity reconciliation.
passed + failedneed not equalinspected;rework + scrapneed not equalfailed. All free entries. - Inspector & dispositionBy are free-text strings, not user FKs — no link to the authenticated user beyond
createdBy. - Create vs update field split: result/disposition/status are update-only in the API. You cannot set a PASS/FAIL on create — create always lands
PENDING. - GraphQL requires
productionOrderId+itemIdon create even though the Mongo schema marks them optional. - Rework/scrap quantities have no form input — display-only in the admin; set via API or a future UI extension.
- Page query is company-wide (
includeBranchQuery: false) — not branch-filtered despitebranchIdon the row. - Scrap in reports comes from production orders, not inspections.
productionYieldReport.totalScrapsumsproduction_orders.quantityScrapped;qualitySummaryReportuses inspection pass/fail. The two scrap figures are independent (see accounting-reports).
Cross-links
- Production orders this references:
../manufacturing/production module (production/production.scheme.ts), planning that creates them:./mrp.md - Pass/fail rates aggregation + production yield/scrap:
./accounting-reports.md - Item catalog:
../inventory/item.md - Inventory stock (where scrap/rework would move if integrated):
../inventory/stock.md - Permissions / audit:
../../platform/permissions-access.md,../../platform/audit-trail.md