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 fixedassetId. This is the thing that costs money per hour and has finite capacity. - Routing (
routings) — a header that links anitemIdto an ordered set of routing operations (routing_operations). Each operation is one step (sequenceNo,operationName) assigned to oneworkCenterId, withsetupTimeHours,runTimePerUnitHours,overlapPercent, ascrapFactor, free-textinstructions, and fileattachments.
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) andWorkCenterStatus(ACTIVE/INACTIVE) are descriptive flags only — nothing in the BE blocks using aDRAFTrouting or anINACTIVEwork 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.itemIdreferencesitems. 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$lookuponroutingId. The routing service treats them as owned children (delete-and-recreate on update). - Operation → WorkCenter:
workCenterIdreferenceswork_centers, resolved via$lookupWorkCenter. - WorkCenter → Asset: optional
assetId→assets. - Scoping: all three carry
companyId(+branchIdon work center) fromBaseSchema. Soft-delete excludes rows from queries automatically. attachmentsare 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:
WorkCenterResolveris decorated@ApInitGqlAuthorize()at the class level (init-mode authorize), andworkCenterPageadds@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
@Fieldnullability (noclass-validatordecorators 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,statusrequired. - Routing:
code,name,statusrequired. (Note:itemIdis not required in the admin Yup, even though the BE schema marksitemIdrequired — see gotchas.)
- Work center:
- Uniqueness: only
work_centers.codehas a DBuniqueindex. 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
operationsis provided, the servicedeleteMany({ routingId })first, then re-applies each line — updating those that still carry an_idand creating the rest. Because the delete runs before the per-lineupdate/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)
costRatePerHouris 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 itscostRatePerHour × hours. The split into a labor GL leg vs an overhead GL leg (if any) happens at the production-costing layer, not here.efficiencyPercentandcapacityHoursPerDayare not auto-applied to these formulas in the BE; a rebuild that wants efficiency-adjusted time (operationTime / efficiency%) must add it explicitly.scrapFactoron the operation andoverlapPercentare 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/updatewrap header + all operation writes in onewithRetryTransaction(single Mongo session). Work center CRUD is single-document (no transaction needed);deleteManyWorkCentersloops single deletes (not a single transaction).
5. Permissions
- Work Center: class-level
@ApInitGqlAuthorize()(init authorize);workCenterPageadds@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 howApGqlAuthorizenormally gates; here it is explicitly relaxed). Mutations are audited undermodule: "routing". - No per-operation CASL action declarations beyond the class guards.
6. Flows
6.1 Create a work center
- Admin opens Manufacturing → Work Center (
/manufacturing/work-center) → "Create". CreateWorkCenterform (Formik + Yup) →saveWorkCenter("", payload)→createWorkCentermutation.- Resolver stamps
createdBy = user._id→WorkCenterService.create→WorkCenterRepository.create. - Row written to
work_centers(uniquecodeenforced; 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)
- Admin opens Manufacturing → Routing (
/manufacturing/routing) → "Create". CreateRoutingform: header fields + an Operations sub-table (add/remove rows). On submit the admin renumberssequenceNo = idx + 1and maps each row to{ operationName, workCenterId, setupTimeHours, runTimePerUnitHours, overlapPercent, description, instructions, attachments }.saveRouting("", payload)→createRouting(routing: { ...header, operations: [...] }).RoutingService.create(retry txn): writes theRouting, thenPromise.allcreates eachRoutingOperationwithroutingId = routing._id. Commits atomically. Audit CREATE.
6.3 Update a routing (replace operations)
- Admin edits a routing → submits the full operation list.
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)→ servicedelete→mongoose-deletesoft-delete (deletedAtset). Rows drop out of all queries. Operations are not cascade-soft-deleted bydeleteRouting(only the header) — they simply become orphaned and excluded from the routing's$lookuponly if the routing is gone; a rebuild may want to cascade.
6.5 Unhappy paths
- Duplicate work-center
code→ MongoE11000duplicate key (unique index). - Missing required field (
code/name/type/statusfor WC;code/name/itemId/statusfor routing) → GraphQL non-null validation error before the service runs (admin Yup also blocks for the fields it validates — noteitemIdis 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+, defaultsworkingDays = [MON..FRI]. (Note:holidaysandassetIdexist in the model butholidaysis not surfaced in this form;assetIdis 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 viaApIdInputwithstorageKey "mfg_routing_code", Name, Status, Item viaApItemSelection, Description) + an Operations editable sub-table (OperationsTable): per-row Operation Name, Work Center (ApWorkCenterSelectInput), Setup Time (hrs), Run Time/Unit (hrs), Instructions, Attachments (AntUpload, multi-file), and a delete-row. "Add Operation" appends a blank row. On submit,sequenceNois renumberedidx+1and the last-used code is cached tolocalStorage. The detail modal wrapsCreateRoutingin aWorkCenterContextProviderso 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 byrouting.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:
assetIdreferences the fixed-asset module; the routing operation table reusesApWorkCenterSelectInput. - Routing → Item:
routing.itemIdreferences inventory/item; joined via$lookupItem. Routing create form usesApItemSelection. - Consumed by Production:
./production.md— aProductionOrdercarries aroutingId, and production operations carry aworkCenterId. Production reads these standard times +costRatePerHourwhen 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:
RoutingModuleimportsRoutingOperationModule,AuthModule,UserModule(allforwardRef).WorkCenterModuleis imported by the manufacturing module and exportsWorkCenterService. - No cron, queue, or external service. Operation
attachmentsare file references (fileUrl) managed by the admin upload component — see files/assets/upload.
9. Gotchas & project-specific rules
- Routing resolvers are
authNotRequired: true. BothRoutingResolverandRoutingOperationResolverare 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. itemIdrequired in BE schema but not in admin Yup. TheRoutingschema marksitemIdrequired, yet the adminCreateRoutingYup omits it (onlycode/name/status). A routing saved without an item from the admin will be rejected by the BE non-null onitemId— keep them in sync.- Work-center
codeis globally unique (schemaunique: 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.
updatedoesdeleteMany({ 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,scrapFactorare 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
costRatePerHourper 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. deleteRoutingdoes not cascade-delete operations. Only the routing header is soft-deleted; childrouting_operationsare not soft-deleted by the routing delete. They simply stop being looked up.RoutingOperationServicehas no business logic — it's a bareAbstractBaseService; all routing orchestration lives inRoutingService.Routing.statusisString!over GraphQL (not theRoutingStatusenum), unlike work center's proper enums. Validate values client-side.