Spare Parts
The maintenance module doesn’t introduce its own stock ledger. Every spare-part issue, return, and write-off is a regular stock movement tagged with a maintenance reference type, so all your existing lot/serial tracking and location accounting continues to apply unchanged. What the module adds on top is the catalog work to make the right parts available at the right time.
Asset-to-part associations
You can declare which parts which asset (or asset class) typically consumes. The association says:
- The product (and optionally variant) — the spare-part SKU.
- The typical quantity and unit per intervention.
- The usage pattern —
EVERY_PM(consumed every preventive service),EVERY_N_PM(every Nth, e.g., gaskets every 4th oil change),ON_FAILURE(only when this part fails),ON_INSPECTION(gathered observationally — air filter checked every time, replaced when dirty). - An optional preferred supplier, used to default the supplier on auto-emitted purchase requisitions.
Associations attached to an asset class apply to every asset in that class — useful for parts that are class-uniform (every CNC mill takes the same way-cover); associations attached to a specific asset override (this one press has the unique gasket because it’s a custom retrofit).
Maintenance kits
A kit is a transactional convenience: a named bundle of spare parts that a technician issues to a WO in one click. The forklift 250h kit might bundle engine oil + oil filter + grease + tire gauge. The kit detail page exposes a Components tab where each component has:
- Product (and optionally variant), quantity, unit.
- A sequence number for display order.
- An
is_optionalflag — the technician decides per-issue whether to consume.
Issuing a kit expands the components, picks lots FEFO/FIFO from the source warehouse, and emits one stock movement per kit line. The kit itself is a definition, not stock — there’s no “kit on hand.”
Preview expansion (dry run)
The kit detail page has a Preview expansion button next to Edit. It opens a side drawer that calls the same expansion engine the real kit issue uses — but writes nothing. For every active component you see:
- The component’s product, required quantity, and unit.
- The estimated unit cost (taken from
products.standard_cost). - The line estimate = required quantity × unit cost.
- The available quantity at the work order’s source warehouse (only when a work order is selected; otherwise availability is hidden).
- A Shortage badge when required quantity > available. Mandatory shortages would block a full kit issue; optional shortages flag visually but never block.
A footer line sums every component into the aggregate estimate — what actual_parts_cost will roll up to on the work order once the real kit issue posts (before lot-cost adjustments). The drawer also lets you pick a work order from your active list so the preview is scoped to its warehouse; when no work order is chosen, the unit-cost estimate still renders but availability is suppressed.
Use it before pressing “Issue kit” to spot shortages, decide whether to short-issue or wait for back-order receipts, and confirm the cost the kit will post is the one you expected.
Each planned WO part remembers whether it came from a kit. The issue_method on the part line is either:
- Manual — the default; the technician issues each unit individually via the per-part issue button.
- Kit issue — set when the line came from a kit expansion; the entire kit issues in one click via the work order’s “Issue kit” action.
Mixing isn’t allowed on a single line — once a line was created from a kit, all subsequent issues on it run through the kit-issue path. Picking the same part ad-hoc on the same WO creates a separate Manual line.
Critical-spare policies
Some spares matter so much that running out costs more than carrying stock — the unique gasket for that one critical press, the proprietary bearing the OEM takes 6 weeks to ship. A critical-spare policy declares per-product / per-warehouse:
- A minimum quantity — when on-hand minus reserved drops below, action is needed.
- A maximum quantity — when topping up, order back to here.
- An optional preferred supplier and lead time.
A daily background job walks every active policy. When on-hand minus reserved drops below the minimum (parts sitting in a repair pool don’t count toward on-hand), it drafts a purchase requisition for (max − on_hand) units against the preferred supplier. The PR is created with priority HIGH, status DRAFT, requested-delivery = today + the policy’s lead time, and metadata flagging it as MAINTENANCE_CRITICAL_SPARE so procurement knows the trigger source. The job is idempotent — if a DRAFT requisition already exists for the same product, variant, and warehouse, it doesn’t create a duplicate.
Reservation policy
When an MWO is released:
- Non-optional parts are reserved against the source warehouse stock (parts sitting in a repair pool are excluded — they aren’t available yet).
- Reserved quantity counts against the on-hand for other reservations (so two parallel WOs can’t both reserve the last unit).
- Reservation is keyed by (product, variant, warehouse, lot if lot-tracked). Partial reservations are allowed — if only 3 of 5 needed are available, the WO releases with 3 reserved and the planner sees the shortfall.
Reservations release on:
- Part issuance (reservation decrements, on-hand decrements).
- Part-issue cancellation (reservation increments back).
- WO cancellation (all remaining reservations release).
- WO completion if anything still reserved (it didn’t get used, so it’s freed).
Repair pool
Some parts are repairable rather than disposable: an electronic board, a precision gauge, a motor. The flow:
- The technician returns the failed part with
repair_required = true. The movement isMAINTENANCE_RETURNto a sub-location flaggedmetadata.is_repair_pool = true. - Both reservation (at WO release) and the FEFO issue picker ignore repair-pool stock — these aren’t available for new WOs yet.
- After repair (either in-house, with a maintenance WO of its own, or sent out as an external service), the repaired part posts back to general stock with
is_repaired = true. The new unit cost is the original lot cost plus a per-unit share of the WO’s other cost (actual_other_cost) — that is, the total repair cost divided across the returned units, not the full amount added to each unit.