🔩 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== defaultdoes not work here (CompletedOnis aDateTime?) and the arithmetic goes through?? 0. The failure surfaces not as "wrong field" but asCS1503 cannot convert from 'decimal?' to 'decimal'insideMath.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, otherwiseWorkCenter.HourlyRate, otherwise 0;ProblemAsync(orderId)— the refusal text ornull.
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