Routing & Work Center — the operation sequence and the resources that run it

Two cooperating master-data modules. A Work Center is a costed resource (a machine, a labor pool, or a subcontractor) with a capacity calendar and a costRatePerHour. A Routing is an ordered list of operations that turn an item's components into the finished item; each operation names which work center runs it and how long (setup + per-unit run time). Together they define the process (routing) and the resources/rates (work centers) that the BOM and Production modules consume to plan time and cost. Both are pure reference data — neither writes stock or GL. They are the inputs that production reads when it captures labor and overhead.

Source: BE manufacturing/routing (routing.schema.ts, routing.dto.ts, routing.service.ts, routing.repository.ts, routing.resolver.ts, routing.module.ts) + manufacturing/routing/operation (routing-operation.*) · manufacturing/work-center (work-center.scheme.ts, work-center.dto.ts, work-center.service.ts, work-center.repository.ts, work-center.resolver.ts, work-center.filter.ts) · Admin src/modules/manufacturing/routing, src/modules/manufacturing/work-center · pages src/pages/manufacturing/routing/{index,[id]}.tsx, src/pages/manufacturing/work-center/{index,[id]}.tsx

Related: ./_overview.md · ./bom.md (what gets consumed) · ./production.md (where time + cost are actually captured) · ../../platform/permissions-access.md


1. Purpose & scope

These two modules own the process definition for manufacturing:

  • Work Center (work_centers) — a named, costed resource. Carries a type (MACHINE | LABOR | SUBCONTRACTOR), a cost rate (costRatePerHour), a daily capacity, an efficiency factor, and a shift/working-days/holidays calendar. Optionally links to a fixed assetId. This is the thing that costs money per hour and has finite capacity.
  • Routing (routings) — a header that links an itemId to an ordered set of routing operations (routing_operations). Each operation is one step (sequenceNo, operationName) assigned to one workCenterId, with setupTimeHours, runTimePerUnitHours, overlapPercent, a scrapFactor, free-text instructions, and file attachments.

What they are responsible for:

  • Maintaining the master list of work centers (CRUD, capacity/cost/calendar config).
  • Maintaining routings and their operation lines (CRUD; the routing service owns the child operations and re-writes them on every update).
  • Supplying the resource + rate + standard-time data that production reads.

What they explicitly do NOT do:

  • No stock movement, no GL postings, no journal entries. Editing a work center's cost rate or a routing's run time has zero accounting effect on its own. Cost is only realized when a production order captures operation hours against these rates.
  • No status lifecycle/state machine that gates anything. RoutingStatus (DRAFT/ACTIVE/OBSOLETE) and WorkCenterStatus (ACTIVE/INACTIVE) are descriptive flags only — nothing in the BE blocks using a DRAFT routing or an INACTIVE work center (they're filter/display values).
  • No capacity scheduling / load-leveling. The calendar fields (workingDays, shiftsPerDay, hoursPerShift, holidays, capacityHoursPerDay) are stored but not consumed by any finite-capacity scheduler in this codebase — they are informational config that MRP/reporting may read. There is no service that books capacity windows.
  • No automatic time→cost rollup on the routing itself. A routing does not compute or store a total time/cost. That math happens in production (and in any standard-cost rollup the BOM/costing path does).

2. Data model

2.1 WorkCenter (collection work_centers)

One row per resource. @ApSchema({ collection: "work_centers", timestamps: true }), soft-deletes via mongoose-delete (deletedAt). Extends BaseSchema (supplies key, ref, documentDate, createdBy/At, updatedBy/At, soft-delete fields, and the canDelete/canUpdate/canView/canPost flags). Note: companyId and branchId are re-declared on WorkCenter (both via BaseSchema.toObjectId setter) on top of what BaseSchema provides.

field type required default description
code string yes Resource code. unique: true at the schema level (global unique index — see gotchas).
name string yes Display name.
type WorkCenterType no (enum) MACHINE MACHINE / LABOR / SUBCONTRACTOR. Stored as String.
capacityHoursPerDay number no 8 Nominal productive hours/day. Informational (no scheduler consumes it).
efficiencyPercent number no 100 Efficiency factor (%). Informational; not applied to any auto time calc in BE.
costRatePerHour number no 0 The labor/overhead rate. Production multiplies captured operation hours by this to value labor (§4.3).
description string no Free text.
status WorkCenterStatus no (enum) ACTIVE ACTIVE / INACTIVE. Descriptive only.
assetId ObjectId no Optional ref to a fixed asset (assets); the physical machine. Setter coerces string→ObjectId.
workingDays string[] no [MON..FRI] Day names the center operates. Calendar config; not consumed by a scheduler.
shiftsPerDay number no 1 Shifts/day. Calendar config.
hoursPerShift number no 8 Hours/shift. Calendar config.
holidays Date[] no [] Non-working dates. Calendar config.
calendarNotes string no Free text.
companyId ObjectId no Tenant scope (re-declared on schema).
branchId ObjectId no Branch scope (re-declared on schema).
// work-center.scheme.ts
export enum WorkCenterType { MACHINE = "MACHINE", LABOR = "LABOR", SUBCONTRACTOR = "SUBCONTRACTOR" }
export enum WorkCenterStatus { ACTIVE = "ACTIVE", INACTIVE = "INACTIVE" }

@ApSchema({ collection: "work_centers", timestamps: true })
export class WorkCenter extends BaseSchema {
  @Prop({ required: true, unique: true }) code: string;
  @Prop({ required: true }) name: string;
  @Prop({ type: String, enum: WorkCenterType, default: WorkCenterType.MACHINE }) type: WorkCenterType;
  @Prop({ default: 8 })   capacityHoursPerDay: number;
  @Prop({ default: 100 }) efficiencyPercent: number;
  @Prop({ default: 0 })   costRatePerHour: number;     // ← the hourly rate production charges
  @Prop() description: string;
  @Prop({ type: String, enum: WorkCenterStatus, default: WorkCenterStatus.ACTIVE }) status: WorkCenterStatus;
  @Prop({ set: BaseSchema.toObjectId }) assetId: Types.ObjectId;
  // calendar / shift config (stored, not auto-scheduled)
  @Prop({ type: [String], default: ["MONDAY","TUESDAY","WEDNESDAY","THURSDAY","FRIDAY"] }) workingDays: string[];
  @Prop({ default: 1 }) shiftsPerDay: number;
  @Prop({ default: 8 }) hoursPerShift: number;
  @Prop({ type: [Date], default: [] }) holidays: Date[];
  @Prop() calendarNotes: string;
  @Prop({ set: BaseSchema.toObjectId }) companyId: Types.ObjectId;
  @Prop({ set: BaseSchema.toObjectId }) branchId: Types.ObjectId;
}
WorkCenterSchema.plugin(SoftDelete, { deletedAt: true });

2.2 Routing (collection routings)

One row per process definition. @ApSchema({ collection: "routings" }), soft-deletes (deletedAt). operations is a virtual array populated by a $lookup ($lookupOperations) — not an embedded column.

field type required default description
code string yes Routing code (no unique index here — unlike work center).
name string yes Display name.
itemId ObjectId yes The finished item this routing produces. Setter coerces string→ObjectId. Joined to items via $lookupItem.
status RoutingStatus no (enum) DRAFT ACTIVE / DRAFT / OBSOLETE. Descriptive only.
description string no Free text.
operations (virtual) RoutingOperation[] Child lines via $lookup on routing_operations.routingId. Not stored on the routing.
item (virtual) Item Joined finished item via $lookupItem. Not stored.
// routing.schema.ts
export enum RoutingStatus { ACTIVE = "ACTIVE", DRAFT = "DRAFT", OBSOLETE = "OBSOLETE" }

@ApSchema({ collection: "routings" })
export class Routing extends BaseSchema {
  @Prop({ required: true }) code: string;
  @Prop({ required: true }) name: string;
  @Prop({ required: true, set: (v) => BaseSchema.toObjectId(v) }) itemId: Types.ObjectId;
  @Prop({ enum: RoutingStatus, default: RoutingStatus.DRAFT }) status: RoutingStatus;
  @Prop({}) description: string;
  operations: any[];   // virtual via $lookupOperations
}
RoutingSchema.plugin(SoftDelete, { deletedAt: true });

// $lookupOperations → routing_operations.routingId === routing._id  (as "operations")
// $lookupItem       → items._id === routing.itemId                  (as "item", unwound)
// $combinedLookup   = [...$lookupOperations, ...$lookupItem]   ← used by page() and findOne()

2.3 RoutingOperation (collection routing_operations)

One row per step in a routing. @ApSchema({ collection: "routing_operations" }), soft-deletes (deletedAt). The routing service owns the lifecycle of these (it creates them with the parent and re-writes them on update — §4.2).

field type required default description
routingId ObjectId yes Back-ref to parent Routing. Setter coerces.
sequenceNo number yes Step order (1, 2, 3…). The admin renumbers these idx+1 on save (§7).
operationName string yes Step label (e.g. "Cutting", "Assembly").
workCenterId ObjectId yes The work center that runs this step. Setter coerces. Joined to work_centers via $lookupWorkCenter.
setupTimeHours number no 0 Fixed setup time for the step (per run, not per unit).
runTimePerUnitHours number no 0 Per-unit run time. Total step run time = runTimePerUnitHours × qty (the standard time formula, §4.3).
overlapPercent number no 0 Operation-overlap % (lets the next op start before this one finishes — informational; no scheduler applies it).
description string no Free text.
scrapFactor number no 0 Expected scrap fraction at this step. Stored; not auto-applied by routing (production scrap is recorded separately — see production scrap).
instructions string no Work instructions shown to the operator.
attachments object[] no [] Embedded array { fileName, fileUrl, fileType, uploadedAt } — SOP/drawing files.
workCenter (virtual) WorkCenter Joined via $lookupWorkCenter. Not stored.
// routing-operation.schema.ts
@ApSchema({ collection: "routing_operations" })
export class RoutingOperation extends BaseSchema {
  @Prop({ required: true, set: (v) => BaseSchema.toObjectId(v) }) routingId: Types.ObjectId;
  @Prop({ required: true }) sequenceNo: number;
  @Prop({ required: true }) operationName: string;
  @Prop({ required: true, set: (v) => BaseSchema.toObjectId(v) }) workCenterId: Types.ObjectId;
  @Prop({ default: 0 }) setupTimeHours: number;
  @Prop({ default: 0 }) runTimePerUnitHours: number;
  @Prop({ default: 0 }) overlapPercent: number;
  @Prop({}) description: string;
  @Prop({ default: 0 }) scrapFactor: number;
  @Prop() instructions: string;
  @Prop({ type: [{ fileName: String, fileUrl: String, fileType: String, uploadedAt: { type: Date, default: Date.now } }], default: [] })
  attachments: Array<{ fileName: string; fileUrl: string; fileType: string; uploadedAt: Date }>;
  workCenter: any;   // virtual via $lookupWorkCenter
}
RoutingOperationSchema.plugin(SoftDelete, { deletedAt: true });

2.4 Relationships & scoping

Item (1) ──< Routing (1) ──< RoutingOperation (N) >── WorkCenter (1)
                                                              └── Asset (0..1, assetId)
  • Routing → Item: routing.itemId references items. One routing names one finished item (no uniqueness enforced — an item could have multiple routings).
  • Routing → Operations: referenced (not embedded). Lines live in routing_operations, fetched by $lookup on routingId. The routing service treats them as owned children (delete-and-recreate on update).
  • Operation → WorkCenter: workCenterId references work_centers, resolved via $lookupWorkCenter.
  • WorkCenter → Asset: optional assetIdassets.
  • Scoping: all three carry companyId (+ branchId on work center) from BaseSchema. Soft-delete excludes rows from queries automatically.
  • attachments are embedded sub-documents on the operation (the only embedded relationship here).

3. API surface

All operations are GraphQL (code-first). There are no REST controllers in either module.

3.1 Work Center

Auth note: WorkCenterResolver is decorated @ApInitGqlAuthorize() at the class level (init-mode authorize), and workCenterPage adds @ApGqlAuthorize({ includeBranchQuery: false }) — i.e. the page query is not branch-filtered (work centers are company-wide). Mutations carry @AuditMeta(...).

Operation Type Input Returns Auth
workCenterPage Query WorkCenterPageInput (skip, take, keyword, sortBy, sortOrder, status, type) WorkCenterPageResult ({ totalRecords, data: [WorkCenter] }) @ApGqlAuthorize({ includeBranchQuery: false })
findWorkCenter Query _id: ID! WorkCenter (nullable) init-authorize
createWorkCenter Mutation workCenter: CreateWorkCenterInput! WorkCenter @AuditMeta CREATE; stamps createdBy = user._id
updateWorkCenter Mutation _id: String!, workCenter: UpdateWorkCenterInput! WorkCenter @AuditMeta UPDATE; stamps updatedBy = user._id
deleteWorkCenter Mutation _id: String! Boolean @AuditMeta DELETE (soft delete)
deleteManyWorkCenters Mutation _ids: [String!]! Boolean @AuditMeta DELETE; loops delete(id)

CreateWorkCenterInput (CommonWorkCenterInput) requires code, name, type, status; everything else is nullable (BE defaults apply). UpdateWorkCenterInput = PartialType(CommonWorkCenterInput). WorkCenterType/WorkCenterStatus are registerEnumType-d for GraphQL.

3.2 Routing + Routing Operation

Both resolvers are @ApGqlAuthorize({ authNotRequired: true }) at the class level — auth is not required to call routing queries/mutations (see gotchas §9). Mutations carry @AuditMeta({ module: "routing", ... }).

Operation Type Input Returns Auth
routingPage Query RoutingPageInput (_id, keyword, code, itemId, status, fromDate, toDate, skip, take) RoutingPageResult ({ totalRecords, data: [Routing] }) — with operations + item joined authNotRequired
findOneRouting Query routing: RoutingQueryInput Routing (with operations + item) authNotRequired
createRouting Mutation routing: CreateRoutingInput! Routing @AuditMeta CREATE
updateRouting Mutation _id: String!, routing: UpdateRoutingInput! Routing @AuditMeta UPDATE
deleteRouting Mutation _id: String! Boolean @AuditMeta DELETE (soft delete)
routingOperationPage Query RoutingOperationPageInput (_id, keyword, routingId, skip, take) RoutingOperationPageResult authNotRequired
findOneRoutingOperation Query routingOperation: RoutingOperationQueryInput RoutingOperation (with work center) authNotRequired
createRoutingOperation Mutation routingOperation: CreateRoutingOperationInput! RoutingOperation @AuditMeta CREATE
updateRoutingOperation Mutation _id: String!, routingOperation: UpdateRoutingOperationInput! RoutingOperation @AuditMeta UPDATE
deleteRoutingOperation Mutation _id: String! Boolean @AuditMeta DELETE

CreateRoutingInput (RoutingCommonInput) requires code, name, itemId, status, plus an optional operations: [RoutingOperationInput] array — so a routing and its full operation list are created in one mutation (the service fans them out). RoutingOperationInput mirrors RoutingOperationCommonInput with routingId made nullable (it's set server-side from the created routing).

GraphQL type shapes (schema.gql): Routing.status is exposed as String! (not the enum), while WorkCenter.type/.status are the proper WorkCenterType!/WorkCenterStatus enums.


4. Business rules & calculations

4.1 Validation

  • Class-validator: the DTOs use only @Field nullability (no class-validator decorators on these inputs). Required-ness is enforced by GraphQL non-null (code, name, type/status, itemId, sequenceNo, operationName, workCenterId).
  • Admin Yup (the practical validation surface):
    • Work center: code, name, type, status required.
    • Routing: code, name, status required. (Note: itemId is not required in the admin Yup, even though the BE schema marks itemId required — see gotchas.)
  • Uniqueness: only work_centers.code has a DB unique index. Routing/operation codes are not unique-constrained.

4.2 Routing owns its operations (delete-and-recreate on update)

RoutingService is the only place operation lifecycle is orchestrated, and it runs in a retry transaction:

// routing.service.ts
public create(data) {
  return this.withRetryTransaction("create_routing", async () => {
    const created = await this.routingRepo.create(data);
    await Promise.all((data.operations || []).map((op) =>
      this.routingOperationSvc.create({ ...op, routingId: created._id })));   // fan out children
    return created;
  });
}

public async update(id, data) {
  return this.withRetryTransaction("update_routing", async () => {
    const updated = await this.routingRepo.update(id, data);
    if (data.operations?.length) {
      await this.routingOperationSvc.deleteMany({ routingId: id });           // wipe existing
      await Promise.all(data.operations.map((op) =>
        op._id ? this.routingOperationSvc.update(op._id.toString(), op)       // (kept rows by _id)
               : this.routingOperationSvc.create({ ...op, routingId: updated._id }))); // new rows
    }
    return updated;
  });
}

Update semantics: when operations is provided, the service deleteMany({ routingId }) first, then re-applies each line — updating those that still carry an _id and creating the rest. Because the delete runs before the per-line update/create, the practical effect is a full replace of the operation set on each routing update (operations omitted from the payload are removed). Both create and update commit atomically (withRetryTransaction).

RoutingOperationService itself is a thin AbstractBaseService (no custom logic) — it exists so the operations can also be CRUD'd standalone via routingOperation* mutations.

4.3 Time & cost formulas (defined here, applied in production)

The routing/work-center modules store the inputs; they do not compute totals. The standard formulas a rebuild must apply (and which production uses when capturing operation hours) are:

Standard time for one operation, producing `qty` units:
  operationTime = setupTimeHours + (runTimePerUnitHours × qty)        // hours
  (overlapPercent can shorten the *schedule* span between ops; it does NOT change total hours)

Routing standard time (whole process):
  totalTime = Σ over operations [ setupTimeHours + runTimePerUnitHours × qty ]

Operation cost (labor + machine/overhead, charged at the work-center rate):
  operationCost = operationTime × workCenter.costRatePerHour

Routing standard cost (labor/overhead component of the item):
  totalProcessCost = Σ over operations (operationTime × workCenter.costRatePerHour)
  • costRatePerHour is the single blended rate per work center — there is no separate labor-vs-overhead split column. Whether a center represents labor (type=LABOR) or machine/overhead (type=MACHINE) it contributes its costRatePerHour × hours. The split into a labor GL leg vs an overhead GL leg (if any) happens at the production-costing layer, not here.
  • efficiencyPercent and capacityHoursPerDay are not auto-applied to these formulas in the BE; a rebuild that wants efficiency-adjusted time (operationTime / efficiency%) must add it explicitly.
  • scrapFactor on the operation and overlapPercent are stored but not auto-applied by any routing/cost calc in this codebase.

4.4 Status & state machine

There is no enforced state machine. RoutingStatus (DRAFT → ACTIVE → OBSOLETE) and WorkCenterStatus (ACTIVE/INACTIVE) are free-set enum values with no guarded transitions and no gating of downstream use. They drive the status filter and the admin color tags only.

4.5 Side effects & transactionality

  • Side effects: none beyond the audit trail. No stock rows, no GL legs, no notifications. The only writes are the document(s) themselves; routing create/update also writes/rewrites the child operations.
  • Transactionality: RoutingService.create/update wrap header + all operation writes in one withRetryTransaction (single Mongo session). Work center CRUD is single-document (no transaction needed); deleteManyWorkCenters loops single deletes (not a single transaction).

5. Permissions

  • Work Center: class-level @ApInitGqlAuthorize() (init authorize); workCenterPage adds @ApGqlAuthorize({ includeBranchQuery: false }) so the list is company-wide (not branch-scoped). Mutations are audited (module: "work-center", collection: "work_centers").
  • Routing & Routing Operation: class-level @ApGqlAuthorize({ authNotRequired: true }) — these resolvers do not require authentication in the current code (see permissions for how ApGqlAuthorize normally gates; here it is explicitly relaxed). Mutations are audited under module: "routing".
  • No per-operation CASL action declarations beyond the class guards.

6. Flows

6.1 Create a work center

  1. Admin opens Manufacturing → Work Center (/manufacturing/work-center) → "Create".
  2. CreateWorkCenter form (Formik + Yup) → saveWorkCenter("", payload)createWorkCenter mutation.
  3. Resolver stamps createdBy = user._idWorkCenterService.createWorkCenterRepository.create.
  4. Row written to work_centers (unique code enforced; duplicate code → Mongo duplicate-key error). Audit CREATE snapshot recorded. Context prepends the new row to the in-memory list.

6.2 Create a routing with operations (one mutation)

  1. Admin opens Manufacturing → Routing (/manufacturing/routing) → "Create".
  2. CreateRouting form: header fields + an Operations sub-table (add/remove rows). On submit the admin renumbers sequenceNo = idx + 1 and maps each row to { operationName, workCenterId, setupTimeHours, runTimePerUnitHours, overlapPercent, description, instructions, attachments }.
  3. saveRouting("", payload)createRouting(routing: { ...header, operations: [...] }).
  4. RoutingService.create (retry txn): writes the Routing, then Promise.all creates each RoutingOperation with routingId = routing._id. Commits atomically. Audit CREATE.

6.3 Update a routing (replace operations)

  1. Admin edits a routing → submits the full operation list.
  2. updateRouting(_id, { ...header, operations })RoutingService.update (retry txn): updates header, deleteMany({ routingId }), then re-applies lines (update by _id, create the rest). Net effect: the operation set is replaced by the submitted list. Commits atomically. Audit UPDATE.

6.4 Delete (soft)

  • deleteRouting(_id) / deleteWorkCenter(_id) → service deletemongoose-delete soft-delete (deletedAt set). Rows drop out of all queries. Operations are not cascade-soft-deleted by deleteRouting (only the header) — they simply become orphaned and excluded from the routing's $lookup only if the routing is gone; a rebuild may want to cascade.

6.5 Unhappy paths

  • Duplicate work-center code → Mongo E11000 duplicate key (unique index).
  • Missing required field (code/name/type/status for WC; code/name/itemId/status for routing) → GraphQL non-null validation error before the service runs (admin Yup also blocks for the fields it validates — note itemId is not in the admin Yup).

7. Admin UI

Pages/routes:

  • Work Center: src/pages/manufacturing/work-center/index.tsx (list) + [id].tsx (detail).
  • Routing: src/pages/manufacturing/routing/index.tsx (list) + [id].tsx (detail).

Work Center module (src/modules/manufacturing/work-center):

  • page.tsx — header with keyword search + Type/Status filters + "Create"; WorkCenterTable.
  • components/table.tsx (WorkCenterTable): columns Code / Name / Type (tag: blue MACHINE, green LABOR, orange SUBCONTRACTOR) / Status (green ACTIVE, red INACTIVE) / Capacity (hrs/day) / Efficiency % (val%) / Cost Rate/Hr (val.toFixed(2)) / Actions (edit, delete, view-detail).
  • components/create.tsx (CreateWorkCenter): Formik form — Code, Name, Type, Status, Capacity/Day, Efficiency %, Cost Rate/Hr, Shifts/Day, Hours/Shift, Description, Calendar Notes. Submit coerces numeric fields with +, defaults workingDays = [MON..FRI]. (Note: holidays and assetId exist in the model but holidays is not surfaced in this form; assetId is passed through only if present.)
  • components/select.tsx (ApWorkCenterSelectInput) — reused by the routing operations table to pick the work center per step.
  • context.tsx (useWorkCenterState): workCenters, totalRecords, filter, modal, fetchWorkCenters(filter), saveWorkCenter(_id, payload) (create/update switch on _id), deleteWorkCenter(_id). Optimistic in-memory list updates + toast on each mutation.

Routing module (src/modules/manufacturing/routing):

  • page.tsx — list with keyword/status filters; RoutingTable.
  • components/table.tsx (RoutingTable): columns Code / Name / Status (tag: green ACTIVE, orange DRAFT, red OBSOLETE) / Operations (count) / Description (truncated) / Actions (edit, delete, view-detail → /manufacturing/routing/[id]).
  • components/create.tsx (CreateRouting): header (Code via ApIdInput with storageKey "mfg_routing_code", Name, Status, Item via ApItemSelection, Description) + an Operations editable sub-table (OperationsTable): per-row Operation Name, Work Center (ApWorkCenterSelectInput), Setup Time (hrs), Run Time/Unit (hrs), Instructions, Attachments (Ant Upload, multi-file), and a delete-row. "Add Operation" appends a blank row. On submit, sequenceNo is renumbered idx+1 and the last-used code is cached to localStorage. The detail modal wraps CreateRouting in a WorkCenterContextProvider so the work-center select can load options.
  • detail.tsx (RoutingDetailPage): routing info card + read-only operations table (#, Operation Name, Work Center, Setup Time, Run Time/Unit, Overlap %), with an Edit button gated by routing.canUpdate.
  • context.tsx (useRoutingState): routings, totalRecords, filter, modal, fetchRoutings(filter), saveRouting(_id, payload), deleteRouting(_id), findOneRouting(_id).

Notable UX: routing operations are edited inline as a grid (not a separate page); attachments upload inline per operation; ApIdInput remembers the last routing code. The admin RoutingStatus and WorkCenter* enums match the BE.


8. Dependencies & integrations

  • Work center → Asset/Item: assetId references the fixed-asset module; the routing operation table reuses ApWorkCenterSelectInput.
  • Routing → Item: routing.itemId references inventory/item; joined via $lookupItem. Routing create form uses ApItemSelection.
  • Consumed by Production: ./production.md — a ProductionOrder carries a routingId, and production operations carry a workCenterId. Production reads these standard times + costRatePerHour when it captures actual operation hours and values labor/overhead. This is the only place these rates turn into cost.
  • Consumed by MRP/BOM/reports: BOM (process cost rollup into standard item cost) and the manufacturing reports/dashboard read routing/work-center data; MRP may read capacity config. None of these write back.
  • Module wiring: RoutingModule imports RoutingOperationModule, AuthModule, UserModule (all forwardRef). WorkCenterModule is imported by the manufacturing module and exports WorkCenterService.
  • No cron, queue, or external service. Operation attachments are file references (fileUrl) managed by the admin upload component — see files/assets/upload.

9. Gotchas & project-specific rules

  • Routing resolvers are authNotRequired: true. Both RoutingResolver and RoutingOperationResolver are class-decorated @ApGqlAuthorize({ authNotRequired: true }) — routing queries/mutations do not require a logged-in user. Work-center mutations do (init authorize). If a rebuild needs routing locked down, change the class decorator.
  • itemId required in BE schema but not in admin Yup. The Routing schema marks itemId required, yet the admin CreateRouting Yup omits it (only code/name/status). A routing saved without an item from the admin will be rejected by the BE non-null on itemId — keep them in sync.
  • Work-center code is globally unique (schema unique: true) — there is no per-company scoping on the unique index, so two tenants cannot share a work-center code. Note this for multi-tenant deployments.
  • Routing update replaces the operation set. update does deleteMany({ routingId }) before re-applying, so any operation not in the submitted payload is removed. Always send the full operations list on update.
  • Calendar/efficiency fields are stored but not scheduled. capacityHoursPerDay, efficiencyPercent, shiftsPerDay, hoursPerShift, workingDays, holidays, overlapPercent, scrapFactor are config only — no finite-capacity scheduler, efficiency-adjusted time, or auto-scrap inflation consumes them in this codebase. They are inputs for a future scheduler / for reporting.
  • One blended costRatePerHour per work center. There is no labor-vs-overhead rate split at this layer; the labor/overhead GL distinction (if any) is decided in production costing.
  • deleteRouting does not cascade-delete operations. Only the routing header is soft-deleted; child routing_operations are not soft-deleted by the routing delete. They simply stop being looked up.
  • RoutingOperationService has no business logic — it's a bare AbstractBaseService; all routing orchestration lives in RoutingService.
  • Routing.status is String! over GraphQL (not the RoutingStatus enum), unlike work center's proper enums. Validate values client-side.