ZuloOne
English
Sign in
Menu

🔩 The ProductionOrder routing

Updated

← Developer wiki For the user: what the interface shows.

IRoutingService.StampOperationsAsync(Guid) clears ProductionOrder.Operations and copies the product's first live Routing (as it does with the first BOM). The lookup goes through IDictionaryManager.GetRecordsAsync<T>(Expression) (EntityMarshaler): the typed IDictionaryManager<T>.GetRecordsAsync() JSON round-trip drops the Guid columns, and Product in memory then does not match. RunMinutes = RunMinutesPerUnit × the order's base quantity. An empty routing is not an error. The contract returns the number of rows stamped.

ExpandBom always stamps afresh. AutoExpand only when there are no operations yet.

The rows live in TP_ProductionOrderOperations. The card reads them through the table-part bindings (REST GetAllRowsAsync). The typed GetDocumentAsync<ProductionOrder> DOES populate the Operations collection — the labour calculation below rests on exactly that (proven by ProductionLaborCostTest). The earlier note that said the second table part is not hydrated is obsolete; ProductionOrderZr still mixes rows in from IDataService as a harmless fallback.

Shop-floor confirmation and labour#

Confirmation is three optional row fields: ActualMinutes, CompletedOn and Employee (a Ref to Employee, hence the Production→HR dependency). The mark for "confirmed" is CompletedOn, not ActualMinutes: an operation that takes zero minutes is valid, and by the zero alone it is indistinguishable from an unconfirmed one.

Trap. Optional scalars on a TABLE PART are generated nullable (decimal?, int?), unlike dictionary fields, which are generated non-nullable. So == default does not work here (CompletedOn is a DateTime?) and the arithmetic goes through ?? 0. The failure surfaces not as "wrong field" but as CS1503 cannot convert from 'decimal?' to 'decimal' inside Math.Round.

ILaborCostService (Production) is one API for both questions:

  • OrderLaborCostAsync(orderId) — Σ ActualMinutes / 60 × rate, rounded to the minor unit per operation;
  • RateAsync(employee, workCenter) — the rate: Employee → Position.HourlyRate, otherwise WorkCenter.HourlyRate, otherwise 0;
  • ProblemAsync(orderId) — the refusal text or null.

The single predicate is deliberate: negative minutes make the output cheaper, so they must be checked both on posting (ProductionOrderEventHandler.OnBeforePostAsync) and before valuation. The message names the operation NUMBER — "one of the rows is wrong" does not let you find it.

Zero when no rate is filled in is not a placeholder but a working default: existing orders have no confirmed operations at all, so their cost does not change. That is why there is no switch in ProductionSettings.

Where labour reaches the numbers#

Door What it does
ProductionOrderEventHandler.OnAfterPostAsync totalCost += OrderLaborCostAsync → the product's ItemCostFifo layer and InventoryValue
ProductionOrderEventHandler.PostLaborToLedgerAsync on Finished: Dr inventory / Cr LaborAbsorbedAccountCode, the order's own GL movement

The second row is mandatory, and here is why. The material leg — Released Dr WIP / Cr inventory — computes cost from the negative ItemCostFifo amounts, that is, from materials, and leaves with the flip to Finished. The output layer costs materials plus labour. Without a separate posting the costing register would grow by the labour while the ledger would not, and the difference would quietly survive until reconciliation. LaborAbsorbed is a contra account to payroll expense: the accrual is made by PayrollAccrualGLTx, this posting merely moves part of it into stock.

An empty account means no posting and the output still posts: the same best-effort discipline as every other leg of the profile.

Posting still does not read the operations from transaction scripts — labour arrives from the event, where asynchronous reads are available.

Coverage — ProductionLaborCostTest (5): the standard-time button and its idempotence, the work center rate (100 of material + 180 of labour = 280), the position rate (550), an unconfirmed operation (100), and the refusal on negative minutes.

The load on a center#

WorkCenter.CapacityMinutesPerDay plus IWorkCenterLoadService (QueuedMinutesAsync, PlannedMinutes, CapacityAsync, OverloadWarningAsync, ScheduleAsync).

The queue is orders in Released, operations with CompletedOn == null, and PLANNED minutes (SetupMinutes + RunMinutes). Hence the single link to the confirmation contour: a confirmed operation drops out of the queue, so the shop floor removes the load from the center automatically.

QueuedMinutesAsync takes an exceptOrder — that is how "what is queued at the center WITHOUT this order" is computed, so OverloadWarningAsync does not add the order to itself. The warning is computed in ReleaseProduction AFTER the move to Released: otherwise one would have to decide whether the order counts as queued yet.

An overload does not block the release. Capacity is a plan (overtime, a second shift), so the service returns TEXT and the command prints it; EventResult.Cancel would be wrong here semantically. A capacity of 0 means "no ceiling declared", not "zero capacity".

The queue day is ScheduledOn if the year is ≥ 1902, otherwise DocumentDate (orders that never filled a schedule). ScheduleAsync walks by Sequence: the next number starts on the same day while remaining + minutes ≤ capacity. Capacity 0 and an operation longer than the day return the start day without splitting. A marked operation (CompletedOn) occupies no day and is not moved itself. Parallel machines and shifts are not modelled.

Coverage — WorkCenterLoadTest (5).

Output cell#

ProductionOutputTx writes the finished item's Stock to OutputLocation if it is filled, otherwise to Location. ProductionConsumeTx and ProductionWipTx do not read the output cell: raw materials and WIP stay on the order cell. Otherwise reverting Released would remove WIP from a different analytic than the one where release put it.

An empty Guid on update means "the field was not in the payload". OnBeforeCreate copies the cell from module settings, so a new card opens with it already set. OnBeforeSave returns the saved value and only then, if it is still empty, copies ProductionSettings.DefaultOutputLocation. The cell code is a fallback key when the reference is empty and the ID string is written. A partial save cannot clear the output cell.

Coverage — ProductionOutputCellTest. The module setting survives the test rollback: the defaulting case restores the previous reference in finally.

Same topic for other readers: Analyst · User

Where next#

Was this page helpful?