Quality — manufacturing quality inspection (pass/fail, non-conformance disposition: scrap/rework)

The whole quality subsystem reduces to one idea: a QualityInspection is a single record of "we inspected N units of an item (usually tied to a production order / operation), P passed, F failed", with a result (PASS / FAIL / CONDITIONAL_PASS / PENDING) and — for the failures — a disposition (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: SCRAP and scrapQuantity: 10 does not write a scrap Stock OUT 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 reports qualitySummaryReport (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 Stock rows 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 result from quantities — result is set manually (defaults PENDING).

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_ordersproductionOrder, itemsitem, production_operationsoperation (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 / itemId are 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, quantityInspected required on create. No class-validator decorators, no service-level invariants. The service is a bare AbstractBaseService (CRUD only; setSession / seed are no-ops).
  • Client-side (Formik/Yup, components/create.tsx): productionOrder, item, inspectionType, inspectorName required; inspectionDate required (number); quantityInspected required min(1).
  • No quantity reconciliation. Nothing checks quantityPassed + quantityFailed == quantityInspected, nor reworkQuantity + 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 Stock OUT (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) and status (open/closed lifecycle). Neither is enforced or auto-transitioned — any value can be written via updateQualityInspection at 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. @AuditMeta snapshots 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", collection quality_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 from ApBaseResolver).

6. Flows

6.1 Create an inspection (final-check happy path)

  1. Admin → Manufacturing › Quality Control → "New Inspection" → CreateQualityInspection modal.
  2. Select production order (ApProductionOrderSelectInput) + item (ApItemSelection) + inspection type (defaults FINAL), enter inspector name, date, qty inspected (≥1), optional qty passed/failed, defect/corrective/notes.
  3. createQualityInspection mutation → QualityInspectionService.createquality_inspections row with result: PENDING, status: OPEN, disposition: PENDING, auto inspectionNumber if blank, createdBy stamped. Audit CREATE snapshot.
  4. List refreshes (optimistic setInspections([data, ...])).

6.2 Judge & disposition (fail path)

  1. Admin opens an inspection → Edit → CreateQualityInspection in update mode.
  2. Edit mode additionally exposes the result dropdown; the submit payload (when inspection._id exists) includes result, status, disposition, dispositionNotes.
  3. 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").
  4. updateQualityInspection → row updated, audit UPDATE snapshot. No downstream effect — the failed/scrapped units are recorded but not removed from inventory or charged anywhere.
  5. Optionally set status: CLOSED to 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 → deleteQualityInspection soft-deletes (mongoose-delete); the row drops out of all aggregations (and out of qualitySummaryReport, which filters deleted ≠ 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%) with CreateQualityInspection; ApSearchInput (keyword); QualityTable. Refetches on filter change.
  • components/table.tsx (QualityTable): Ant ApTable — Inspection #, Type (Tag), Result (color Tag: PASS green / FAIL red / CONDITIONAL_PASS orange / PENDING default), Inspector, Qty Inspected / Passed / Failed, Status (Tag OPEN blue / CLOSED default), Actions (edit / delete / view-detail drawer). hideActions prop for embedding.
  • components/create.tsx (CreateQualityInspection): Formik + Yup (see §4.1). Create shows header fields; update mode adds the result select and submits result/status/disposition. Inspection number via ApIdInput (localStorage mfg_inspection_number). Type/result options derive from the enums (replace('_',' ')).
  • detail.tsx (QualityDetailPage): info grid (number, type, result Tag, status Tag, inspector, date, qty inspected/passed/failed, production order) + defect/corrective/notes; a conditional Disposition section (shown when disposition set) with disposition Tag, disposition by/date, rework qty, scrap qty, disposition notes. Edit button gated on canUpdate.
  • context.tsx (useQualityState): exposes fetchInspections, saveInspection (create/update split on _id), deleteInspection, plus filter/modal state. Optimistic local list updates on create/update/delete. gql/query.ts wraps 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 extend CreateQualityInspection with ApTextInputs for reworkQuantity / scrapQuantity / disposition select.


8. Dependencies & integrations

  • Production module (../production): productionOrderId / operationId references; the GraphQL type embeds the resolved ProductionOrder. One-way reference only — inspections never write back to production orders.
  • Inventory item (../inventory/item): itemId reference; resolved Item embedded in the GraphQL type. quarantineWarehouseId references a warehouse but is never acted on.
  • Reports module (./accounting-reports.md): the manufacturing-report module imports the QualityInspection schema and runs qualitySummaryReport (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.
  • result is manual, not derived. It defaults PENDING and is set by the inspector; it is not computed from passed/failed counts.
  • No quantity reconciliation. passed + failed need not equal inspected; rework + scrap need not equal failed. 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 + itemId on 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 despite branchId on the row.
  • Scrap in reports comes from production orders, not inspections. productionYieldReport.totalScrap sums production_orders.quantityScrapped; qualitySummaryReport uses inspection pass/fail. The two scrap figures are independent (see accounting-reports).