scheduling

package
v1.1.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 23, 2026 License: Apache-2.0 Imports: 9 Imported by: 0

Documentation

Overview

Package scheduling is the production schedule solver.

It is a pure port of the knit-scheduling script (dashboard/apps/api/src/scripts/knit-scheduling-merz.ts): no database access, no clock, no randomness. Everything it needs is loaded up front into SolverInput, and Solve returns the plan. That boundary is what makes the parity gate against the script possible — the same input must produce the same plan.

The pipeline, in order:

measure    per-item run rate, cost, lot size and machine affinity from history
changeover calibrate the yarn-driven setup time model
demand     pool finished-goods demand back onto the constraint item
policy     EOQ, safety stock, reorder point, ABC class
levelling  the capacity-levelled (s,S) sweep across the horizon
explode    push the constraint plan downstream through the process DAG

Determinism

The script leans on JavaScript's insertion-ordered maps and stable sort. Go's map iteration is randomized, so anything that iterates a map here MUST sort first or the plan will differ between identical runs. TestSolve_Deterministic guards this.

Index

Constants

View Source
const (
	// ReasonLeadTimeInfeasible means the plant cannot produce inside the window customers were promised, so the stock has to exist before the order does.
	ReasonLeadTimeInfeasible = "lead_time_infeasible"
	// ReasonNoRecentDemand means nothing has sold for long enough that holding a buffer is holding dead stock.
	ReasonNoRecentDemand = "no_recent_demand"
	// ReasonSingleCustomer means effectively one customer buys this, and that customer is served to order.
	ReasonSingleCustomer = "single_customer"
	// ReasonLumpyDemand means demand arrives rarely and in wildly different sizes, which is the shape a safety stock sizes worst.
	ReasonLumpyDemand = "lumpy_demand"
	// ReasonSlowMovingHighValue means each unit is expensive and few sell, so the buffer ties up more money than the service it buys.
	ReasonSlowMovingHighValue = "slow_moving_high_value"
	// ReasonSteadyDemand means demand is regular enough to forecast, which is what a buffer is for.
	ReasonSteadyDemand = "steady_demand"
)

Recommendation reasons. Each names the rule that decided, so a verdict is never a bare answer.

View Source
const (
	DemandBasisTrailing12  = string(constants.ScheduleDemandBasisTrailing12)
	DemandBasisSeasonalEMA = string(constants.ScheduleDemandBasisSeasonalEMA)
)

Demand basis codes, aliased from the shared enum so the engine cannot drift from the API contract.

Demand override types, aliased from the shared enum so the engine cannot drift from the API contract.

View Source
const (
	OverrideScopeItem        = string(constants.DemandOverrideScopeItem)
	OverrideScopeProductLine = string(constants.DemandOverrideScopeProductLine)
	OverrideScopeAccount     = string(constants.DemandOverrideScopeAccount)
)

Override scopes, aliased from the shared enum so the engine cannot drift from the API contract.

Fulfillment policies, aliased from the shared enum so the engine cannot drift from the API contract.

Policy sources, aliased from the shared enum.

View Source
const (
	LeadTimeSourceCustomer       = string(constants.LeadTimeSourceCustomer)
	LeadTimeSourceParentCustomer = string(constants.LeadTimeSourceParentCustomer)
	LeadTimeSourceAccountGroup   = string(constants.LeadTimeSourceAccountGroup)
	LeadTimeSourceAccount        = string(constants.LeadTimeSourceAccount)
	LeadTimeSourceManual         = string(constants.LeadTimeSourceManual)
	LeadTimeSourceOrderLeadTime  = string(constants.LeadTimeSourceOrderLeadTime)
	LeadTimeSourceOrderShipBy    = string(constants.LeadTimeSourceOrderShipBy)
)

Lead-time sources, aliased from the shared enum so the engine cannot drift from the API contract.

View Source
const (
	CommitmentStepBasis           = string(constants.CommitmentStepBasis)
	CommitmentStepReceiveCalendar = string(constants.CommitmentStepReceiveCalendar)
	CommitmentStepCarrierTransit  = string(constants.CommitmentStepCarrierTransit)
	CommitmentStepShipCalendar    = string(constants.CommitmentStepShipCalendar)
	CommitmentStepPickupCutoff    = string(constants.CommitmentStepPickupCutoff)
)

Commitment step codes, naming which rule moved a date. Aliased for the same reason as the sources.

View Source
const (
	LotSourceItemOverride          = string(constants.ItemLotSourceItemOverride)
	LotSourceProductLine           = string(constants.ItemLotSourceProductLine)
	LotSourceDownstreamProductLine = string(constants.ItemLotSourceDownstreamProductLine)
	LotSourceAccountDefault        = string(constants.ItemLotSourceAccountDefault)
)

Lot sources, aliased from the shared enum so the engine cannot drift from the API contract.

View Source
const (
	AtRiskReasonPastDue = "past_due"
	AtRiskReasonUndated = "undated"
	AtRiskReasonShort   = "short"
)

At-risk reasons.

View Source
const FinishingSupplyLagWeeks = 1

FinishingSupplyLagWeeks is how long stage-one output waits before stage two can work it.

One week, and the reason is arithmetic rather than physical: a campaign planned in week w is produced across that week, so treating it as available to finishing in the same week would let the plan finish greige that is still on the needles. A configurable lag was considered and dropped — it would be a second knob over a lag the week granularity already fixes.

View Source
const LotRoundingTolerance = 1e-6

LotRoundingTolerance is the remainder below which a final short lot is folded into the previous one instead of becoming a batch of its own.

Planned quantities are decimals, so a campaign that is conceptually six whole doffs can arrive as 359.9999999. Without this a release would emit a seventh batch of a millionth of a unit.

View Source
const MaxExplosionDepth = 10

MaxExplosionDepth bounds the walk downstream of the constraint. Production graphs contain rework loops, so an unbounded walk would not terminate.

View Source
const MaxLotsPerCampaign = 500

MaxLotsPerCampaign bounds how many batches one campaign can be split into.

A misconfigured lot size — one unit per doff, say — would otherwise turn a single week into tens of thousands of batch rows and a production run nobody can work. The release refuses rather than writing them, so the bad setting surfaces as an error instead of as an unusable run.

View Source
const SolverVersion = "v1"

SolverVersion is stamped onto every generated schedule so a plan can be traced back to the algorithm that produced it. Bump it whenever the output changes for the same input.

View Source
const UnassignedLabel = "Unassigned"

UnassignedLabel is what a breakdown files outcomes under when they carry no value for that dimension. They are kept rather than dropped: orders with no sales rep are exactly the ones nobody is watching.

Variables

This section is empty.

Functions

func AnalyzeDeliveryPerformance

func AnalyzeDeliveryPerformance(outcomes []DeliveryOutcome, bucketOf func(time.Time) time.Time, asOf time.Time) (buckets []DeliveryPerformance, overall DeliveryPerformance)

AnalyzeDeliveryPerformance turns a window of commitments into delivery performance, bucketed and overall.

Only orders carrying a commitment participate. An order with no ship-by date cannot be late, and counting it as on time would inflate the rate with orders nobody promised anything about — the count of those is reported separately by the caller so the exclusion is visible.

An order due in the window that has not shipped counts against on-time rather than being held back until it does. A promise not yet met is not a promise kept, and excluding open orders would let a plant with a growing late backlog report perfect delivery.

func AverageInputsAdded

func AverageInputsAdded(batches []BatchMeasurement, stepInputs map[string]map[string]bool) float64

AverageInputsAdded measures how many new inputs a typical product transition introduces, which is what calibrates the changeover slope.

Campaigns are derived per machine by run-length-encoding the scan-ordered item sequence: consecutive batches of the same item are one campaign, so one changeover. stepInputs maps a production step to the set of input items it consumes.

func CeilWeeks

func CeilWeeks(weeks float64) int

CeilWeeks rounds a fractional week count up to whole weeks, which is how a schedule counts: a plan is written in weeks, and half a week of finishing still occupies one.

func CountLots

func CountLots(quantity, lotUnits float64) int

CountLots reports how many batches SplitIntoLots would produce, without building them. Used to validate a release before any row is written.

func EarliestPromiseWeek

func EarliestPromiseWeek(itemID string, quantity float64, campaigns []Campaign, firm FirmSchedule, onHandByItem map[string]float64, horizonWeeks int) (int, bool)

EarliestPromiseWeek is the first horizon week by which the plan could supply a quantity of an item that is not already committed to something else.

Capable-to-promise: what could still be promised, as opposed to what has been. Existing commitments are consumed first, because a date offered out of stock somebody else is already owed is not a date at all.

Returns false when the horizon cannot supply it, which is the honest answer — a plan that runs thirteen weeks cannot speak for the fourteenth.

func EstimateArrival

func EstimateArrival(shipBy time.Time, transit *Transit, cals Calendars) (time.Time, bool)

func MeanFloat

func MeanFloat(values []float64) float64

MeanFloat is a small helper used by the measurement passes.

func MixedStreamShare

func MixedStreamShare(customers []CustomerDemand, itemPolicy string) float64

MixedStreamShare is how much of an item's demand comes from customers whose own policy disagrees with how the item is planned.

Reported rather than acted on. Policy is resolved per SKU, so a SKU sold to both a stocking distributor and a contract customer has one policy either way; this is the number that says the choice is uncomfortable, and it is what would justify splitting demand streams later.

func ParseDayMask

func ParseDayMask(mask string) ([dayMaskLength]bool, error)

ParseDayMask reads a seven-character days-of-week mask, Monday first, where '1' is an open day.

func ResolveDemand

func ResolveDemand(in DemandInput) ([]ItemDemand, []AppliedOverride)

ResolveDemand computes the demand picture for every item, applying overrides.

Returns items sorted by ID and the list of overrides that actually changed a number.

func ResolveLeadTime

func ResolveLeadTime(in LeadTimeInput) (days int, source string, ok bool)

ResolveLeadTime picks the number of days and names the rule that produced it.

The chain, most specific first: the customer, then its parent account, then the customer's account group, then the account default. Returns false only when nothing in the chain holds a usable value, which is a misconfiguration rather than a normal state — the account default is not nullable.

The parent outranks the group because it is the narrower statement: a group is a segment somebody sorted customers into, while a parent is the head office that negotiated the terms every one of its locations buys on. Putting the group first would mean a lead time set on a head office silently failed to reach exactly the grouped locations it was set for.

A negative value is skipped rather than honoured. It can only arrive from a hand-written database row (the write path rejects it), and a commitment that falls before the order was placed is worse than falling through to the next rule.

func SecondsPerUnitFromLaborTime

func SecondsPerUnitFromLaborTime(value float64, unitAbbreviation string) float64

SecondsPerUnitFromLaborTime converts a production step's labor time to seconds.

The unit abbreviation decides the scale. An unrecognized unit is treated as seconds, matching the script — a wrong guess here silently rescales every run hour in the plan, so the recognized set is kept explicit.

func SetupCost

func SetupCost(avgMinutes, laborRate, overheadRate float64) float64

SetupCost is what one changeover costs, and therefore the "S" in the EOQ formula.

It uses a dedicated technician rate rather than the per-machine-hour production labor allocation: a tech works the single machine through the changeover, so the thin allocated rate understates it and EOQ would come out too small — meaning too many short campaigns and more changeovers than the floor can absorb.

func SplitIntoLots

func SplitIntoLots(quantity, lotUnits float64) []float64

SplitIntoLots breaks a planned quantity into the batch sizes the floor actually runs.

Full lots come first and the remainder, if any, trails as one short lot. That ordering matters on the floor: the short doff is the one that gets cut when a week runs late, so it belongs at the end of the run rather than buried in the middle.

A non-positive lot size means the item is not lotted, so the campaign is one batch.

func StdDevFloat

func StdDevFloat(values []float64) float64

StdDevFloat returns the sample standard deviation, or zero for fewer than two observations.

func SubtractBusinessDays

func SubtractBusinessDays(from time.Time, n int) time.Time

SubtractBusinessDays walks back n weekdays from a date, skipping weekends and nothing else.

Retained as the un-configured case of Calendar.SubtractDays: it is what every account got before calendars existed, and keeping it named separately keeps that baseline behaviour pinned by its own tests.

Types

type AllocationResult

type AllocationResult struct {
	Allocations []OrderAllocation
	Uncovered   []UncoveredRequirement
}

AllocationResult is the whole answer: what each campaign is for, and what is not covered.

func AllocateCampaignsToOrders

func AllocateCampaignsToOrders(campaigns []Campaign, firm FirmSchedule, onHandByItem map[string]float64) AllocationResult

AllocateCampaignsToOrders decides which campaign is building which order.

Earliest promise first, and supply consumed in the order it becomes available: stock on hand, then each campaign as it lands. A requirement can only be served by supply that exists by the week it is due — a campaign in week 6 cannot fill an order the constraint owed in week 2, and pretending otherwise is exactly how a plan reports itself achievable while the floor misses dates.

What is left after the walk is what the plan does not build in time, with the amount it does build recorded alongside. A partly-covered order is not a total miss and should not read as one.

Deterministic: both sides are sorted before the walk, so the same plan and order book always produce the same links.

type AppliedOverride

type AppliedOverride struct {
	OverrideID string    `json:"override_id"`
	ItemID     string    `json:"item_id"`
	MonthStart time.Time `json:"month_start"`
	Before     float64   `json:"before"`
	After      float64   `json:"after"`
	TypeCode   string    `json:"adjustment"`
	ReasonCode string    `json:"reason"`
}

AppliedOverride records an override that actually changed a number, so the plan can explain why it differs from history.

type AtRiskOrder

type AtRiskOrder struct {
	SalesOrderID     string  `json:"sales_order_id"`
	SalesOrderNumber string  `json:"sales_order_number"`
	ItemID           string  `json:"item_id"`
	SKU              string  `json:"sku"`
	Units            float64 `json:"units"`
	DueWeek          int     `json:"due_week"`
	// Reason is why the promise is at risk: `past_due` (the constraint stage needed to start before the horizon), `undated` (no commitment was ever recorded), or `short` (the plan projects less stock than the order needs in its week).
	Reason string `json:"reason"`
}

AtRiskOrder is a commitment the plan does not meet, with the reason it does not.

type BacklogBucket

type BacklogBucket struct {
	// Label names the band; MinDaysLate and MaxDaysLate bound it, with MaxDaysLate zero meaning unbounded.
	Label       string
	MinDaysLate int
	MaxDaysLate int
	OrderCount  int
	Units       float64
}

BacklogBucket is one age band of orders already past their promise and still unshipped.

func AnalyzeBacklogAging

func AnalyzeBacklogAging(outcomes []DeliveryOutcome, asOf time.Time) []BacklogBucket

AnalyzeBacklogAging groups orders that are past their promise and still unshipped into age bands.

Shipped orders are excluded however late they were: this is a queue of work still owed, not a record of past misses. How late a shipped order was belongs to the on-time rate.

type BatchMeasurement

type BatchMeasurement struct {
	BatchID          string
	ItemID           string
	SKU              string
	ScannedAt        time.Time
	Quantity         float64
	ProductionStepID string
	MachineID        string
	MachineName      string
	UnitCost         float64

	// LaborTimeValue is the production step's labor time in LaborTimeUnit per unit.
	LaborTimeValue float64
	LaborTimeUnit  string
	LaborRate      float64
	OverheadRate   float64

	// RunCreatedAt is when the production run was opened; paired with ScannedAt it gives the observed lead time for this batch.
	RunCreatedAt *time.Time
}

BatchMeasurement is one historical batch, as loaded from the database.

type Calendar

type Calendar struct {
	// OpenDays is indexed by ISO weekday, Monday at 0 through Sunday at 6.
	OpenDays [dayMaskLength]bool
	// Closures are dated shutdowns — holidays, shutdown weeks — keyed on the UTC-truncated date.
	Closures map[time.Time]struct{}
}

Calendar is the set of days an operation runs: which weekdays it is open, less any dated closures.

One type covers all three parties to a shipment because they differ only in their days, never in how the days are counted — the plant tenders freight Monday to Thursday, the carrier moves it Monday to Friday, the customer's dock accepts it on its own days, and each of them shuts for its own holidays.

The zero value is closed every day and is not usable. Build one with NewCalendar or DefaultCalendar.

func DefaultCalendar

func DefaultCalendar() Calendar

DefaultCalendar is Monday to Friday with nothing closed.

This is what the system did before calendars existed, and it is deliberately the fallback for an account that has configured nothing: adding the feature must not move a single date until somebody says which days they actually work.

func NewCalendar

func NewCalendar(mask string, closures []time.Time) (Calendar, error)

NewCalendar builds a calendar from a stored day mask and closure dates.

A mask with no open day is rejected rather than stored: it describes an operation that never runs, and every date resolved against it would walk the full snap-back limit before failing. Catching it here means the failure names the calendar instead of surfacing later as an order that cannot be committed.

func (Calendar) AddDays

func (c Calendar) AddDays(from time.Time, n int) (time.Time, bool)

func (Calendar) IsOpen

func (c Calendar) IsOpen(d time.Time) bool

IsOpen reports whether the operation runs on a date.

func (Calendar) SnapBack

func (c Calendar) SnapBack(d time.Time) (snapped time.Time, moved int, ok bool)

SnapBack moves a date to the nearest open day at or before it, and reports how many days that took.

Always backward, never forward. A date the operation cannot act on has to resolve to one it can, and resolving forward would push a shipment past the day it was promised for — the whole failure this exists to prevent. Snapping back can only make an order leave early, which is safe.

ok is false when no open day exists within the snap-back limit, which means the calendar is closed indefinitely. Callers treat that as an unresolvable commitment rather than substituting a date.

func (Calendar) SnapForward

func (c Calendar) SnapForward(d time.Time) (snapped time.Time, moved int, ok bool)

func (Calendar) SubtractDays

func (c Calendar) SubtractDays(from time.Time, n int) (time.Time, bool)

SubtractDays walks back n open days from a date.

Carriers quote transit in the days they actually move freight: a service quoted at three days and handed over on a Thursday delivers the following Tuesday, not Sunday. Counting calendar days instead would put every ship-by date up to two days late in exactly the cases that matter, since a week has more weekday-crossing lanes than not — and the same holds for a holiday the carrier's network is down.

n <= 0 returns the starting date, so a lane with no transit means ship-by is the delivery date. The starting date is not itself snapped: callers that need it on an open day snap it first, and conflating the two would hide which rule moved the date.

type Calendars

type Calendars struct {
	// Receive is the days the customer's dock accepts freight.
	Receive Calendar
	// Carrier is the days the carrier moves freight. Transit is counted in these.
	Carrier Calendar
	// Ship is the days the plant tenders freight.
	Ship Calendar
	// ShipCutoff is the local time freight has to be tendered by, as "15:00". Empty when the plant has not set one, in which case a ship-by date carries no time of day.
	ShipCutoff string
	// ShipLocation is the zone ShipCutoff is read in. Nil falls back to UTC.
	ShipLocation *time.Location
}

Calendars are the day-sets a commitment has to respect, plus the zones its two time-of-day boundaries are read in.

Three separate calendars because the three parties genuinely differ: a plant may tender freight Monday to Thursday, its carrier moves Monday to Friday, and a customer's dock has its own days and its own holidays. Collapsing them into one weekday rule is what produced ship-by dates nobody could ship on.

func DefaultCalendars

func DefaultCalendars() Calendars

DefaultCalendars is Monday to Friday for all three parties with no closures and no cutoff, which is how every date was computed before calendars existed.

type Campaign

type Campaign struct {
	ItemID    string
	SKU       string
	MachineID string
	WeekIndex int
	Units     float64
	Lots      int
	// LotUnits is the granularity the campaign was sized at, carried onto the plan so releasing the week to the floor splits into exactly the lots that were planned.
	LotUnits float64
	// LotUnitID is what the lot is counted in — pairs for sock greige, eaches for armsleeve greige. Without it a 60 on the plan cannot be reconciled with a 60 on the floor.
	LotUnitID string
	RunHours  float64
}

Campaign is one planned production block: make this item, on this machine, in this week.

type Changeover

type Changeover struct {
	// contains filtered or unexported fields
}

Changeover models setup time as a function of how many inputs the next product introduces. Threading one extra yarn is quick; threading eight is not, and a flat average hides exactly the sequencing decision the planner is trying to make.

minutes(added) = clamp(min + slope * added, min, max)

The slope is calibrated from history rather than configured, so the modelled average lands on the number the floor actually reports.

func CalibrateChangeover

func CalibrateChangeover(minMinutes, avgMinutes, maxMinutes, avgInputsAdded float64) Changeover

CalibrateChangeover solves for the slope that makes the model reproduce avgMinutes over the observed transitions.

avgInputsAdded is the mean number of new inputs across historical product transitions. With no history (or no variation) the slope is zero and every changeover costs the minimum — deliberately optimistic, but the alternative is inventing a slope from nothing.

Script: CO_SLOPE = (CO_AVG_MIN - CO_MIN_MIN) / avgYarnsAdded.

func (Changeover) Minutes

func (c Changeover) Minutes(inputsAdded int) float64

Minutes returns the modelled changeover time for a transition that introduces inputsAdded new inputs.

func (Changeover) Slope

func (c Changeover) Slope() float64

Slope exposes the calibrated minutes-per-added-input for diagnostics.

type ClassificationInput

type ClassificationInput struct {
	ItemID string
	SKU    string

	// Monthly is the item's demand history, one entry per month with demand. Months with none may be absent; MonthsObserved is the window they were drawn from.
	Monthly        []float64
	MonthsObserved int
	// MonthsSinceLastSale is how long since anything sold. Negative means nothing ever has.
	MonthsSinceLastSale int

	AnnualDemand float64
	UnitCost     float64

	// TotalProductionLeadTimeWeeks is constraint plus finishing: how long from deciding to build to having sellable stock.
	TotalProductionLeadTimeWeeks float64

	Customers []CustomerDemand

	// CurrentPolicy is what the item is planned as today, so the recommendation can say whether anything would change.
	CurrentPolicy string
}

ClassificationInput is everything the classifier needs about one item.

type Closure

type Closure struct {
	Date time.Time
	Name string
}

Closure is one dated shutdown on a calendar.

func USFederalHolidays

func USFederalHolidays(year int) []Closure

USFederalHolidays returns the eleven federal holidays for a year, on the dates they are observed.

Computed rather than listed because most of them float: Martin Luther King Jr. Day is the third Monday in January, Thanksgiving the fourth Thursday in November, and a hardcoded table would be wrong from the year after it was written. The four fixed-date holidays are shifted to the adjacent weekday when they fall at a weekend, which is what the observance rule does and what carriers and banks actually follow — Independence Day on a Saturday closes the Friday before.

This is a seed, not a rule. It is written into an account's calendars as ordinary editable closures, because a plant that runs through Columbus Day or shuts the whole week of Christmas needs to say so, and nothing about the federal list is binding on a private factory.

type Commitment

type Commitment struct {
	ShipByDate   time.Time
	LeadTimeDays int
	Source       string
	// TransitDays is the carrier transit subtracted to get ShipByDate, nil when transit was unknown or did not apply.
	TransitDays *int
	// TransitSource names where TransitDays came from, empty when TransitDays is nil.
	TransitSource string
	// ShipByCutoffAt is ShipByDate at the plant's pickup cutoff, as an instant. The date alone says which day freight has to leave; this says by when, which is the deadline a shop floor actually works to. Nil when the ship calendar carries no cutoff.
	ShipByCutoffAt *time.Time
	// CalendarAdjustmentDays is how many days the receiving and shipping calendars pulled ShipByDate back beyond what transit alone accounted for. Zero when every date already landed on an open day, and the one number that answers "why the 24th and not the 27th".
	CalendarAdjustmentDays int
	// Steps is the ordered derivation, one entry per rule that touched the date. Built on the way through so a preview and the stamped commitment are produced by the same walk and cannot disagree about how a date was reached.
	Steps []CommitmentStep
}

Commitment is what an order promises: the date it is due to ship, how many days that was, and which rule decided.

All of it is stamped onto the order at issue rather than derived on read. A lead time is a rule that can be renegotiated; a commitment is a fact about a moment. Deriving the date later would let a customer moving from 30 days to 21 retroactively make last month's orders late, which is the opposite of what a contract is for. Transit and the calendar adjustment are stamped for the same reason: a carrier estimate refreshed next month, or a holiday added to a calendar in March, must not silently move a date the customer already has.

func ResolveCommitment

func ResolveCommitment(issuedAt time.Time, basis CommitmentBasis, in LeadTimeInput, transit *Transit, cals Calendars) (Commitment, bool)

ResolveCommitment turns an order's issue date into its ship-by commitment.

Four bases, most specific first. A pinned ship date is taken as given. A promised delivery date has the customer's receiving days, the carrier's transit and the plant's shipping days worked back through it, because the difference between when a customer wants it and when it has to leave is the whole point of tracking transit. A per-order lead time replaces the standing chain. Failing all three, the chain itself decides.

Only the promised-delivery branch subtracts transit. Every other basis already names a *ship* date or a *ship* lead time — the days a customer waits before the order leaves — so deducting the journey again would charge for it twice.

Every branch ends on a day the plant actually ships. A ship-by date on a closed Friday or inside a shutdown week is not a deadline anybody can meet, and leaving it there is what made orders late on the day they were created.

Dates are calendar days truncated to the day, because ship_by_date is a DATE column and a commitment is a day rather than an instant. The one exception is ShipByCutoffAt, which exists precisely to name a time.

Returns false when no rule produced a date, or when a calendar is closed indefinitely. Leaving the commitment unstamped is the honest outcome: an order with no ship-by date reads as uncommitted, where a fabricated one would read as a promise nobody made.

type CommitmentBasis

type CommitmentBasis struct {
	// PromisedAt is a promised *delivery* date. The order has to leave early enough for the carrier to cover the lane, so this is the only basis transit is subtracted from.
	PromisedAt *time.Time
	// LeadTimeOverrideDays is this order's own lead time in days, replacing whatever the customer chain would have resolved to.
	LeadTimeOverrideDays *int
	// ShipByOverrideDate pins the ship date itself, bypassing transit and the receiving calendar.
	ShipByOverrideDate *time.Time
}

CommitmentBasis is the explicit input somebody pinned on one order, displacing the standing lead-time chain.

At most one field may be set. The three are alternative answers to the same question and combining them has no meaning — a delivery date and a ship date cannot both be the thing being promised — so the service layer rejects more than one rather than inventing a precedence nobody could predict. The ordering ResolveCommitment applies is a defence against a bad row, not a documented rule.

func (CommitmentBasis) Count

func (b CommitmentBasis) Count() int

Count is how many bases were pinned. Anything above one is a conflict the caller must reject.

func (CommitmentBasis) IsEmpty

func (b CommitmentBasis) IsEmpty() bool

IsEmpty reports whether nothing was pinned, so the standing chain applies.

type CommitmentStep

type CommitmentStep struct {
	// Code names the rule that applied.
	Code string
	// Date is where the running date stood after this rule.
	Date time.Time
	// DaysMoved is how far this rule pulled the date back. Zero means the rule applied and changed nothing, which is worth reporting: it says the promised date was already a day the customer receives.
	DaysMoved int
	// Detail carries the rule's own parameter — the transit days counted, the cutoff time applied — for a caller rendering an explanation.
	Detail string
}

CommitmentStep is one rule's contribution to a ship-by date.

type CustomerDemand

type CustomerDemand struct {
	CustomerAccountID string
	CustomerName      string
	Units             float64
	// LeadTimeDays is what this customer is committed to, resolved through the same chain an order is stamped from.
	LeadTimeDays int
	// FulfillmentPolicy is how this customer buys, when they say. Empty means they express no preference.
	FulfillmentPolicy string
}

CustomerDemand is one customer's share of an item's demand, with the lead time that customer is promised.

type DeliveryBreakdown

type DeliveryBreakdown struct {
	Key   string
	Label string
	DeliveryPerformance
}

DeliveryBreakdown is delivery performance for one slice of the order book — a customer, a customer group, a product line, a sales rep, or the rule the commitment came from.

func AnalyzeDeliveryBreakdown

func AnalyzeDeliveryBreakdown(
	outcomes []DeliveryOutcome,
	asOf time.Time,
	keyOf func(DeliveryOutcome) []DeliveryBreakdownKey,
) []DeliveryBreakdown

AnalyzeDeliveryBreakdown slices the same outcomes by whatever dimension keyOf names.

keyOf returns every (key, label) the outcome belongs to, so a dimension an order sits in more than once — product lines — fans out rather than having to pick one arbitrarily. Returning no keys drops the outcome from that breakdown entirely.

Rows come back ordered by late count descending, then by committed count: the point of a breakdown is to put the worst offender first, and sorting alphabetically would bury it.

type DeliveryBreakdownKey

type DeliveryBreakdownKey struct {
	Key   string
	Label string
}

DeliveryBreakdownKey is one slice an outcome belongs to.

func ByCommitmentSource

func ByCommitmentSource(o DeliveryOutcome) []DeliveryBreakdownKey

ByCommitmentSource keys outcomes on which rule produced their ship-by date.

The most useful breakdown nobody asks for: it says how much of the on-time score rests on an account-wide default nobody deliberately set, versus on dates actually agreed with a customer.

func ByCustomer

func ByCustomer(o DeliveryOutcome) []DeliveryBreakdownKey

ByCustomer keys outcomes on the buying account.

func ByCustomerGroup

func ByCustomerGroup(o DeliveryOutcome) []DeliveryBreakdownKey

ByCustomerGroup keys outcomes on the customer's group.

func ByProductLine

func ByProductLine(o DeliveryOutcome) []DeliveryBreakdownKey

ByProductLine fans an order out across every line it contains.

type DeliveryOutcome

type DeliveryOutcome struct {
	SalesOrderID     string
	SalesOrderNumber string
	BuyerAccountID   string
	CustomerName     string

	// CustomerGroupID and SalesRepID are empty when the order carries no such association; a breakdown files those under "Unassigned" rather than dropping them.
	CustomerGroupID   string
	CustomerGroupName string
	SalesRepID        string

	// ProductLines is every distinct product line on the order. An order spanning two lines is counted under both, because a late order is late for every line on it.
	ProductLines []ProductLineRef

	ShipByDate time.Time
	IssuedAt   *time.Time
	// FirstShipAt is nil for an order that has not shipped at all.
	FirstShipAt *time.Time

	QuantityOrdered float64
	QuantityPacked  float64

	// CommittedLeadTimeDays is what was promised; zero when the order predates commitment tracking.
	CommittedLeadTimeDays int
	// CommitmentSource names which rule produced the ship-by date — an explicitly promised date, the customer's lead time, their group's, or the account default. Empty for an order stamped before the source was recorded.
	CommitmentSource string
}

DeliveryOutcome is one order's commitment and what actually happened to it.

func (DeliveryOutcome) ActualLeadTimeDays

func (o DeliveryOutcome) ActualLeadTimeDays() *int

ActualLeadTimeDays is how long the order actually took, or nil when it has not shipped or was never issued.

func (DeliveryOutcome) DaysLate

func (o DeliveryOutcome) DaysLate(asOf time.Time) int

DaysLate is how far past the promise the first shipment went, or how far past today an unshipped order already is. Zero or negative means not late.

func (DeliveryOutcome) IsInFull

func (o DeliveryOutcome) IsInFull() bool

IsInFull reports whether the whole ordered quantity has been packed.

A tolerance rather than an exact comparison: quantities are decimals and a rounding difference of a millionth of a unit is not a short shipment.

func (DeliveryOutcome) IsOnTime

func (o DeliveryOutcome) IsOnTime() bool

IsOnTime reports whether the first shipment left on or before the promised date.

Measured on the first shipment rather than the last: the promise is that the order starts moving by that date, and judging on the last shipment would fail an order the customer received on time in two boxes.

func (DeliveryOutcome) IsShipped

func (o DeliveryOutcome) IsShipped() bool

IsShipped reports whether anything has left for this order.

type DeliveryPerformance

type DeliveryPerformance struct {
	// PeriodStart is the first day of the bucket; zero for the overall summary.
	PeriodStart time.Time

	CommittedOrderCount int
	ShippedOrderCount   int
	OnTimeOrderCount    int
	OnTimeInFullCount   int
	LateOrderCount      int
	// NotYetShippedCount is orders due in this period that have not shipped at all. They count against on-time, because a promise not yet met is not a promise kept.
	NotYetShippedCount int

	// Ratios are nil rather than zero when nothing was due, so a quiet week does not render as total failure.
	OnTimePct       *float64
	OnTimeInFullPct *float64

	// AverageDaysLate is measured over late orders only; nil when none were late. Averaging over every order would dilute a real problem into a number that looks fine.
	AverageDaysLate *float64
	// AverageLeadTimeDays is how long shipped orders actually took, and AverageCommittedLeadTimeDays what they were promised. The gap between them is what a merchant renegotiates on.
	AverageLeadTimeDays          *float64
	AverageCommittedLeadTimeDays *float64
}

DeliveryPerformance is the delivery picture for one period, or for the whole window.

type DemandInput

type DemandInput struct {
	AsOf         time.Time
	BasisCode    string
	ForecastZ    float64
	WeeksPerYear int
	// ForecastMonths is how far forward to project when using the seasonal-EMA basis.
	ForecastMonths int

	// MonthlyByItem is monthly demand per constraint item, already pooled from the finished goods that item becomes. Series need not be dense.
	MonthlyByItem map[string][]MonthlyDemand
	// DownstreamByItem is the per-finished-good series behind each constraint item, needed to compute the downstream sigma sum separately from the pooled one.
	DownstreamByItem map[string][]FinishedGood

	Overrides []DemandOverride
	// ItemsByProductLine resolves a product-line-scoped override onto items.
	ItemsByProductLine map[string][]string
}

DemandInput is everything the demand pass needs, already loaded.

type DemandOverride

type DemandOverride struct {
	ID          string
	ScopeCode   string
	ScopeRefID  string
	PeriodStart time.Time
	PeriodEnd   time.Time
	TypeCode    string
	Value       float64
	ReasonCode  string
	CreatedAt   time.Time
}

DemandOverride is management's adjustment to the forecast baseline. This is the only mechanism for departing from history — there is no growth multiplier.

type DerivedLine

type DerivedLine struct {
	SourceLineID   string
	ProductionStep string
	DepartmentID   string
	ItemID         string
	SKU            string
	// WeekIndex is the constraint week plus the accumulated lead-time offset.
	WeekIndex int
	Quantity  float64
	// Depth is how many steps downstream of the constraint this work sits, which is what a readiness chip keys off. Zero is the constraint step itself.
	Depth int
}

DerivedLine is work a constraint campaign implies for a department — the constraint step's own work at depth 0, and everything downstream of it below that.

func Explode

func Explode(in ExplosionInput) []DerivedLine

Explode walks the production-step graph from each constraint campaign and returns the work each department has to do as a result.

Two properties matter more than the traversal itself:

Determinism. Go randomizes map iteration, so every collection is sorted before it is walked. Without that, two runs over the same plan produce the same rows in a different order, and a diff between versions becomes unreadable.

Termination. Production graphs contain rework loops — a quality step feeding back into the step before it — so the walk is bounded by depth and refuses to revisit a step it has already reached at the same depth. An unbounded walk on a real graph does not return.

type Diagnostics

type Diagnostics struct {
	LevellingDiagnostics
	AppliedOverrides []AppliedOverride `json:"applied_overrides"`
	// ChangeoverSlopeMinutes is the calibrated minutes per additional input.
	ChangeoverSlopeMinutes float64 `json:"changeover_slope_minutes"`
	AverageInputsAdded     float64 `json:"average_inputs_added"`
	// ItemsWithoutRunRate never got a measured seconds-per-unit and cannot be scheduled; they are reported rather than silently dropped.
	ItemsWithoutRunRate []string `json:"items_without_run_rate"`
	ExcludedItemCount   int      `json:"excluded_item_count"`

	// FirmDemandUnits is the whole open order book expressed at the constraint. Zero means the plan is forecast-only.
	FirmDemandUnits float64 `json:"firm_demand_units"`
	// UndatedFirmOrderCount is how many open orders carry no ship-by commitment and were dated at the front of the horizon. A non-zero count means orders predating commitment tracking still need backfilling.
	UndatedFirmOrderCount int `json:"undated_firm_order_count"`
	// MakeToOrderItemCount is how many planned items are built only against the order book.
	MakeToOrderItemCount int `json:"make_to_order_item_count"`
	// AtRiskOrders are commitments this plan does not cover in time. This is the most actionable thing the solver produces: everything else describes what the plan does, and this says which promises it breaks.
	AtRiskOrders []AtRiskOrder `json:"at_risk_orders"`

	// ConstraintMachineCount is how many machines the constraint department contributed, and MeasuredBatchCount how much production history was found on them. A plan with machines but no history is empty for a reason a planner can act on — nothing has been scanned in the demand window — and saying so beats an empty grid.
	ConstraintMachineCount int `json:"constraint_machine_count"`
	MeasuredBatchCount     int `json:"measured_batch_count"`
	// MachinesWithoutStep have no production step, so their campaigns derive no downstream department work.
	MachinesWithoutStep int `json:"machines_without_step"`

	// Finishing is stage two's own account of itself: what it could not make, and why.
	Finishing FinishingDiagnostics `json:"finishing"`
	// FinishingMachineCount is how many machines the second stage was sized from. Zero means its capacity was estimated from the shift pattern alone.
	FinishingMachineCount int `json:"finishing_machine_count"`
	// FinishingCapacityIsEstimated says the plant has no machines outside the constraint department, so stage two was sized as a single notional resource rather than counted. A levelled plan against a guessed capacity is worth flagging as such.
	FinishingCapacityIsEstimated bool `json:"finishing_capacity_is_estimated"`
}

Diagnostics is the honest account of what the solver could not do and why the plan differs from raw history. A plan that cannot explain itself will not be trusted.

type ExplosionCampaign

type ExplosionCampaign struct {
	LineID    string
	ItemID    string
	SKU       string
	MachineID string
	WeekIndex int
	Quantity  float64
	// StepID is where the campaign runs — the constraint step the walk starts from.
	StepID string
}

ExplosionCampaign is one constraint campaign to explode downstream.

type ExplosionInput

type ExplosionInput struct {
	// Campaigns are the constraint-level lines the plan already committed to.
	Campaigns []ExplosionCampaign
	Edges     []StepEdge
	Steps     map[string]StepInfo
	MaxDepth  int
}

ExplosionInput is everything needed to derive department work from a constraint plan.

type FinishedGood

type FinishedGood struct {
	ItemID        string
	SKU           string
	ProductLineID string
	Monthly       []MonthlyDemand
	OnHand        float64
}

FinishedGood is one finished SKU a constraint item becomes, carried with its identity so the pooled buffer can be decomposed back into per-SKU targets.

type FinishedGoodDemand

type FinishedGoodDemand struct {
	ItemID        string
	SKU           string
	ProductLineID string
	AnnualDemand  float64
	SigmaWeekly   float64
	OnHand        float64
}

FinishedGoodDemand is one finished SKU's measured demand, carried out of the pooling step so its own target can be computed.

type FinishedPolicy

type FinishedPolicy struct {
	ItemID        string
	SKU           string
	GreigeItemID  string
	GreigeSKU     string
	ProductLineID string

	AnnualDemand float64
	WeeklyDemand float64
	SigmaWeekly  float64

	// Sized against the finishing lead time, not the knit lead time: this stock is replenished by finishing, not by the constraint.
	SafetyStock  float64
	ReorderPoint float64
	OnHand       float64
	WeeksOfCover float64
}

FinishedPolicy is one finished SKU's own inventory target.

The greige buffer is pooled across the family because one greige feeds many finished SKUs and their variability partly cancels. That pooling is what makes the buffer cheap, and it is also what makes these rows necessary: once pooled, the echelon total can no longer answer "is this SKU short".

func ComputeFinishedPolicies

func ComputeFinishedPolicies(greige ItemPolicy, finished []FinishedGoodDemand, s Settings) []FinishedPolicy

ComputeFinishedPolicies decomposes one greige family's pooled buffer into a target per finished SKU.

The greige buffer is sized against the knit lead time and pooled across the family; these are sized against the finishing lead time and stand alone, because that is the stage that actually replenishes them. The two are complementary, not alternatives: the greige buffer decouples knitting from finishing, and these cover the finishing lead time in front of the customer.

type FinishingDiagnostics

type FinishingDiagnostics struct {
	WeeklyCapacityHours float64   `json:"weekly_capacity_hours"`
	PlannedHoursByWeek  []float64 `json:"planned_hours_by_week"`
	// UtilisationByWeek is planned hours over capacity. Nil entries are impossible here — capacity is constant across the horizon — but a zero-capacity input yields zeroes rather than infinities.
	UtilisationByWeek []float64 `json:"utilisation_by_week"`
	// GreigeStarvedSKUs wanted building and had no greige to build from.
	GreigeStarvedSKUs []string `json:"greige_starved_skus"`
	// CapacityStarvedSKUs had greige and no hours.
	CapacityStarvedSKUs []string `json:"capacity_starved_skus"`
	// ItemsWithoutRunRate never got a measured finishing rate and cannot be levelled.
	ItemsWithoutRunRate []string `json:"items_without_run_rate"`
	// UnusedGreigeUnits is stage-one output the horizon never converts. A large number means the two stages are planned against different demand, which is worth surfacing rather than leaving as an unexplained pile of greige.
	UnusedGreigeUnits float64 `json:"unused_greige_units"`
	TotalPlannedUnits float64 `json:"total_planned_units"`
	LineCount         int     `json:"line_count"`
}

FinishingDiagnostics is the honest account of what stage two could not do.

The two starvation lists are the point of the whole model. A SKU held back for want of greige is a stage-one problem — knit more, or knit it sooner — and a SKU held back for want of hours is a stage-two problem — another shift, or a different mix. Collapsing them into one "short" list would throw away the only thing a two-stage plan knows that a one-stage plan does not.

type FinishingInput

type FinishingInput struct {
	Items  []FinishingItem
	Supply []FinishingSupply
	// GreigeOnHand is what is already knitted and waiting when the horizon opens. Without it the first weeks of every plan would read as starved while the greige store sat full.
	GreigeOnHand map[string]float64
	// WeeklyCapacityHours is the whole second stage's capacity in one week.
	WeeklyCapacityHours float64
	Settings            Settings
}

FinishingInput is everything the second-stage sweep needs, already loaded.

type FinishingItem

type FinishingItem struct {
	ItemID string
	SKU    string
	// GreigeItemID names the stage-one item this is made from. It is what ties the two schedules together: a finished SKU cannot be built in a week the greige it comes from has not arrived in.
	GreigeItemID  string
	GreigeSKU     string
	ProductLineID string

	// WeeklyDemand and OnHand are this SKU's own, not the family's. The pooled figures behind the knit plan cannot answer "is this colourway short", which is the whole question stage two exists to answer.
	WeeklyDemand float64
	OnHand       float64
	ReorderPoint float64
	SafetyStock  float64

	// SecondsPerUnit is the measured finishing run rate. Without one the SKU cannot be levelled, because there is no way to know what an hour of the department buys.
	SecondsPerUnit float64
	LotUnits       float64
	LotUnitID      string
	UnitID         string

	// GreigePerUnit is how much stage-one output one finished unit consumes. One unless a yield ratio says otherwise; a finishing loss makes it greater than one.
	GreigePerUnit float64

	// ProductionStepID and DepartmentID are where the SKU is made. The sweep does not decide with them — the second stage is one pool — but the plan line carries them so a department rollup needs no join.
	ProductionStepID string
	DepartmentID     string

	// FirmByWeek is the order book at this SKU, by the week it is owed.
	FirmByWeek    []float64
	IsMakeToOrder bool
}

FinishingItem is one finished SKU the second stage can make.

type FinishingLine

type FinishingLine struct {
	ItemID       string
	SKU          string
	GreigeItemID string
	GreigeSKU    string
	WeekIndex    int

	Quantity  float64
	Lots      int
	LotUnits  float64
	LotUnitID string
	RunHours  float64
	// GreigeConsumed is what this line takes out of the stage-one buffer, which is what makes the two schedules reconcilable.
	GreigeConsumed float64
	// FirmUnits is how much of the week's draw was an order rather than a forecast, so a planner can see which lines are promises.
	FirmUnits float64

	// ProductionStepID and DepartmentID name where the work runs.
	ProductionStepID string
	DepartmentID     string

	ProjectedOnHandBefore float64
	ProjectedOnHandAfter  float64
}

FinishingLine is one finished SKU's build in one week.

type FinishingResult

type FinishingResult struct {
	Lines []FinishingLine
	// ProjectedOnHand[itemID][weekIndex] is the finished SKU's position at the end of that week.
	ProjectedOnHand map[string][]float64
	Diagnostics     FinishingDiagnostics
}

FinishingResult is the second-stage plan.

func LevelFinishing

func LevelFinishing(in FinishingInput) FinishingResult

LevelFinishing runs the capacity-levelled sweep over the second stage.

Each week: greige arrives, every SKU whose projected position has fallen below its trigger becomes a candidate, and candidates are served most-depleted-first out of two shared pools — the greige their family knitted, and the department's hours. What does not fit waits for the next week, which is what makes this a levelling rather than an allocation.

Determinism: SKUs are sorted before any iteration and every tie breaks on SKU, so the same plan solves to the same mix twice. Go randomizes map iteration, and a mix that wobbled between runs would make a version diff unreadable.

type FinishingStep

type FinishingStep struct {
	ProductionStepID string
	DepartmentID     string
}

FinishingStep is where a finished good is made, as the second stage's history records it.

type FinishingSupply

type FinishingSupply struct {
	GreigeItemID string
	WeekIndex    int
	Quantity     float64
}

FinishingSupply is stage-one output becoming available to stage two.

WeekIndex is the week the greige can be worked, not the week it was knitted — the caller applies the lag between the two, because how long greige sits before finishing can start is a property of the plant rather than of the plan.

type FirmRequirement

type FirmRequirement struct {
	// ItemID is the constraint item, not the finished good: the requirement has already been pooled onto whatever the plan actually builds.
	ItemID string
	// FinishedItemID is the finished good the order was placed for, kept so an at-risk order can name the SKU rather than the greige behind it.
	FinishedItemID string

	SalesOrderID     string
	SalesOrderNumber string
	SalesOrderLineID string

	// Units is the outstanding quantity in the constraint item's own unit.
	Units float64

	// ShipByWeek is the horizon week the finished good must exist in. Negative means the commitment is already behind the plan.
	ShipByWeek int
	// DueWeek is when the constraint stage must finish for the finishing lead time to still make ShipByWeek. Clamped at zero.
	DueWeek int

	// IsPastDue is set when the constraint stage would have had to start before the plan does. The clamp to week 0 hides that, so it is recorded rather than inferred from DueWeek.
	IsPastDue bool
	// IsUndated marks an order with no ship-by commitment, dated at the start of the horizon because it is issued and unshipped. Reported separately so a planner can tell a real promise from a guess.
	IsUndated bool
}

FirmRequirement is one open order line's outstanding quantity, expressed against the constraint item that has to produce it.

This is demand the plan owes, as opposed to the demand it forecasts. The distinction matters because a forecast is an average the buffer absorbs, while an order is a date somebody was promised.

type FirmSchedule

type FirmSchedule struct {
	// ByItemWeek[itemID][weekIndex] is the firm quantity due from the constraint stage in that week.
	ByItemWeek map[string][]float64
	// Requirements is the flat list, sorted, for diagnostics and at-risk reporting.
	Requirements []FirmRequirement
	// TotalUnits is the whole order book expressed at the constraint.
	TotalUnits float64
	// UndatedCount is how many requirements had no ship-by commitment.
	UndatedCount int
	// PastDueCount is how many needed the constraint stage to start before the horizon.
	PastDueCount int
}

FirmSchedule is the order book resolved into per-item, per-week quantities the sweep can draw down.

func BuildFirmSchedule

func BuildFirmSchedule(lines []OpenOrderLine, horizonStart time.Time, s Settings) FirmSchedule

BuildFirmSchedule dates the open order book against the horizon and pools it onto the constraint items.

Dating walks backwards from the promise: the finished good has to exist by its ship-by week, so the constraint stage has to finish a finishing lead time earlier. A requirement whose constraint week lands before the horizon is not dropped — it is clamped to week 0 and flagged, because an order that needed to start last month is the single most useful thing the plan can tell a planner, and silently moving it forward would make the plan look achievable when it is not.

Deterministic: requirements are sorted before they are returned, so two solves over the same order book produce the same diagnostics.

func (FirmSchedule) RequirementForWeek

func (f FirmSchedule) RequirementForWeek(itemID string, week int) float64

RequirementForWeek is the firm quantity due for one item in one week, safe against items with no order book at all.

type FulfillmentResolution

type FulfillmentResolution struct {
	Policy string
	Source string
}

FulfillmentResolution is a SKU's policy and the rule that produced it.

func ResolveFulfillmentPolicy

func ResolveFulfillmentPolicy(itemID string, in PolicyResolutionInput) FulfillmentResolution

ResolveFulfillmentPolicy decides how one item is produced.

The chain, most specific first: an explicit item override, the item's own product line, the lines of what it becomes, then the account default.

The downstream step exists for the same reason it exists for lot sizes: greige is not sold, carries no product line of its own, and has to inherit from the finished goods it becomes. It differs in one way, and the difference is the whole design. A greige item is built to order only when **every** finished good it feeds is built to order — one stocked sibling means the greige is still forecast-driven, because that sibling's buffer has to come from somewhere. This is what makes a two-value enum sufficient: the pooled greige buffer shrinks in proportion to how much of its family went make-to-order, with no third policy to name.

type ItemDemand

type ItemDemand struct {
	ItemID string

	// AnnualDemand is the forward-looking annual run rate the policy uses.
	AnnualDemand float64
	// TrailingAnnual is the last twelve complete months, for comparison.
	TrailingAnnual float64
	// SigmaWeeklyPooled is the pooled weekly standard deviation at the constraint. Pooled as sqrt(sum of squares) because one constraint item feeds many finished SKUs whose variability partially cancels.
	SigmaWeeklyPooled float64
	// FinishedGoods is the per-SKU detail behind the pooled figures above.
	FinishedGoods []FinishedGoodDemand
	// SigmaDownstreamSum is the plain sum of downstream sigmas, used for the finished goods buffer where the risk does not pool.
	SigmaDownstreamSum float64
	DownstreamCount    int
}

ItemDemand is the resolved demand picture for one constraint item.

type ItemMeasurement

type ItemMeasurement struct {
	ItemID string
	SKU    string

	// SecondsPerUnit is the run rate: how long one unit occupies the constraint.
	SecondsPerUnit float64
	UnitCost       float64
	// OverheadRate is the production step's machine burden. Production labor is deliberately NOT included: a changeover is worked by a technician on the single machine, so setup cost uses the dedicated changeover labor rate from settings instead of the thin per-machine-hour production allocation. Including both would double-count labor and inflate EOQ.
	OverheadRate float64

	// LotCount is how many batches were produced; the script treats one batch as one lot regardless of its quantity, and so does this.
	LotCount int
	Quantity float64

	// EligibleMachineID is the set of machines that have actually run this item. Empty means unconstrained.
	EligibleMachineID map[string]bool

	// MeasuredLeadTimeWeeks is the mean observed run-open to scan time. Zero when no usable samples existed, in which case the settings default applies.
	MeasuredLeadTimeWeeks float64
	LeadTimeSampleCount   int

	ProductionStepID string
}

ItemMeasurement is what history tells us about one constraint item.

func MeasureItems

func MeasureItems(batches []BatchMeasurement) []ItemMeasurement

MeasureItems aggregates batch history into per-item measurements.

Determinism: the result is sorted by SKU. Callers iterate it directly, so returning a map would reintroduce the nondeterminism the port exists to remove.

type ItemPolicy

type ItemPolicy struct {
	ItemID string
	SKU    string
	// UnitID is what every quantity in this policy is counted in. A reorder point of 2,508 is uninterpretable without it, and the policy exists whether or not the item won a slot this horizon — so it carries its own unit rather than borrowing one from a campaign that may not exist.
	UnitID string

	AnnualDemand   float64
	WeeklyDemand   float64
	SecondsPerUnit float64
	UnitCost       float64

	SetupCost   float64
	HoldingCost float64
	EOQUnits    float64

	// Lead times, in weeks.
	ConstraintLeadTimeWeeks float64
	FinishLeadTimeWeeks     float64

	// Two-echelon safety stock. The buffer is pooled at the constraint item because one greige feeds many finished SKUs, so their variability partially cancels (sigma_pooled = sqrt(sum of squares), not the sum). Finished goods keep a smaller per-SKU buffer for service.
	SigmaWeeklyPooled     float64
	SigmaDownstreamSum    float64
	SafetyStockPrimary    float64
	SafetyStockDownstream float64

	ReorderPoint float64
	OrderUpTo    float64

	// FulfillmentPolicy is how this item is produced, and PolicySource which rule decided. A make-to-order item holds no buffer and is built only against the order book.
	FulfillmentPolicy string
	PolicySource      string
	// FirmDemandUnits is what the order book already owes over the horizon; ForecastDemandUnits is what the forecast projects for the same window. Split so a planner can see which drove a campaign.
	FirmDemandUnits     float64
	ForecastDemandUnits float64

	OnHandEchelon float64
	// OnHandGreige is the constraint stage on its own. The echelon figure is what the build decision is made against; this is what is actually in the greige store, which a pooled total cannot be decomposed back into.
	OnHandGreige float64
	// Greige-stage holding: the buffer plus half a campaign on average, and a whole campaign at the peak. Finished safety stock is held as finished goods and counted on the finished policies, so the two stages sum to network storage without double-counting.
	AverageGreigeInventory float64
	MaxGreigeInventory     float64
	WeeksOfCover           float64
	ABCClass               string
}

ItemPolicy is the computed inventory policy for one constraint item.

func ClassifyABC

func ClassifyABC(policies []ItemPolicy) []ItemPolicy

ClassifyABC assigns A/B/C by cumulative share of annual run hours: A up to 80%, B to 95%, C the tail. Mutates in place and returns the slice sorted by run hours descending, which is also the order the caller wants for display.

Ties are broken by SKU so the classification is stable — without it, two items with identical run hours could swap classes between runs.

Known edge case, preserved from the script for parity: the share is cumulative INCLUSIVE of the current item, so a portfolio dominated by one item classifies that item as C rather than A (its own share already exceeds 95%). With a realistic spread of SKUs this never triggers. Do not "fix" it without re-running the parity gate — the classification feeds display and reporting, not the plan itself.

func ComputePolicy

func ComputePolicy(in PolicyInput, s Settings) ItemPolicy

ComputePolicy derives the inventory policy for one item.

EOQ = sqrt(2 * D * S / H)                              classic economic order quantity
SS_primary    = z * sigma_pooled * sqrt(constraint LT)
SS_downstream = z * sum(sigma_fg) * sqrt(finish LT)
ROP = weekly * (constraint LT + finish LT) + SS_primary + SS_downstream

The reorder point covers demand over the whole pipeline — constraint plus finishing — because that is how long it takes for a decision made today to become sellable stock.

func (ItemPolicy) AnnualRunHours

func (p ItemPolicy) AnnualRunHours() float64

AnnualRunHours is the machine time this item's yearly demand consumes. ABC is classified on this rather than on revenue: the constraint is machine time, so the A items are the ones that eat the schedule.

func (ItemPolicy) IsMakeToOrder

func (p ItemPolicy) IsMakeToOrder() bool

IsMakeToOrder reports whether this item is built only against the order book.

type LatenessBucket

type LatenessBucket struct {
	Label       string
	MinDaysLate int
	MaxDaysLate int
	OrderCount  int
	// ShippedCount is how many of these have since shipped. The remainder are still owed, and are the same orders the backlog aging counts.
	ShippedCount int
	// Units is what was still unpacked across the band's orders.
	Units float64
}

LatenessBucket is how many of the window's misses fell in one lateness band.

Reported alongside the average because an average is the one number that cannot distinguish "everything slips a day" from "most orders are fine and four are two months late", and those are opposite problems with opposite fixes.

func AnalyzeLatenessDistribution

func AnalyzeLatenessDistribution(outcomes []DeliveryOutcome, asOf time.Time) []LatenessBucket

AnalyzeLatenessDistribution bands every order that missed its date by how far it missed by.

Unlike backlog aging this counts shipped orders too: the question is how badly the period's promises were missed, not what is still owed. `ShippedCount` separates the two inside each band.

type LeadTimeInput

type LeadTimeInput struct {
	// CustomerLeadTimeDays is the customer's own commitment, from account_relation.
	CustomerLeadTimeDays *int
	// ParentCustomerLeadTimeDays is the parent account's commitment, inherited by every child that has not set its own. One level: the parent's own parent is not consulted, matching how a child inherits its parent's prices.
	ParentCustomerLeadTimeDays *int
	// AccountGroupLeadTimeDays is inherited by every customer in the group that has not set its own.
	AccountGroupLeadTimeDays *int
	// AccountLeadTimeDays is the account-wide default, the last fallback.
	AccountLeadTimeDays *int
}

LeadTimeInput is the standing chain, gathered once. Nil means "not set at this level, keep looking"; a configured zero is a real answer and means same-day.

type LevellingDiagnostics

type LevellingDiagnostics struct {
	// EOQCappedSKUs had their economic lot size reduced to fit one machine-week, meaning shorter and more frequent campaigns than the policy would prefer.
	EOQCappedSKUs []string `json:"eoq_capped_skus"`
	// UnschedulableSKUs cannot fit even a single minimum lot into a machine-week. They are never scheduled, so expect stockouts.
	UnschedulableSKUs []string `json:"unschedulable_skus"`
	// CapacityStarvedSKUs are below their reorder point but never won a slot in the horizon. This is the honest signal that the plant is short of capacity.
	CapacityStarvedSKUs []string `json:"capacity_starved_skus"`
}

LevellingDiagnostics records what the solver could not do. These are the numbers a planner needs in order to trust or challenge the plan, so they are part of the output rather than log lines.

type LevellingItem

type LevellingItem struct {
	Policy            ItemPolicy
	EligibleMachineID map[string]bool
	// LotUnits is the rounding granularity for this item (a doff, a pallet).
	LotUnits float64
	// LotUnitID is what that granularity is counted in.
	LotUnitID string
	// FirmByWeek is the dated order book for this item, indexed by week. Nil when nothing is on order, which is the case the plan behaved as before this existed.
	FirmByWeek []float64
}

itemEligibility restricts an item to the machines that have historically run it. Empty means any machine.

type LevellingResult

type LevellingResult struct {
	Campaigns   []Campaign
	Diagnostics LevellingDiagnostics
	// ProjectedOnHand[itemID][weekIndex] is the position at the END of that week, after that week's campaigns land and that week's demand is drawn down.
	ProjectedOnHand map[string][]float64
}

LevellingResult is the plan plus the per-item projected stock position.

func Level

func Level(items []LevellingItem, machines []Machine, s Settings, pinned []PinnedCampaign) LevellingResult

Level runs the capacity-levelled (s,S) sweep across the horizon.

Each week, every item whose projected position has fallen below its trigger is a candidate. Candidates are served most-depleted-first and placed on the least-loaded eligible machine that still has room. Anything that does not fit waits for the next week.

Pinned campaigns are applied to each week before its candidates are chosen: their inflow and capacity use are facts of the plan, not proposals, so everything the sweep derives already accounts for them. Pins are not re-emitted as campaigns — they exist as lines already.

Determinism: items are sorted by SKU and machines by name before any iteration, and the due-set sort breaks ties by SKU. Iterating the maps directly would produce a different plan on every run.

type LotDefault

type LotDefault struct {
	Quantity float64
	UnitID   string
	// Source explains which rule produced this lot, so a plan can say why it is knitting in sixties. Values: item_override, product_line, downstream_product_line, account_default.
	Source string
	// ProductLineID is the line the convention came from, empty for the account default.
	ProductLineID string
}

LotDefault is the lot an item is made in: how many, counted in what.

The unit is not decoration. A doff of sock greige is 60 pairs and a doff of armsleeve greige is 60 eaches; the number is the same and the quantities are not, so a lot size without a unit cannot be reconciled with anything downstream of it.

func ResolveLotDefault

func ResolveLotDefault(itemID string, in LotResolutionInput) (LotDefault, bool)

ResolveLotDefault decides what lot one item is made in.

The chain, most specific first:

  1. A per-item override, which changes the size but keeps the item's own unit.
  2. The item's own product line, for anything that is itself sellable.
  3. The product lines of what the item becomes. Greige has no product line of its own — it is not sold — so a doff of sock greige takes its lot from the sock line. Where a greige feeds several lines, the one carrying the most demand wins.
  4. The account default, counted in the item's own unit.

Returns false when nothing in the chain yields a usable lot, which means the item is planned unlotted rather than in a lot of some guessed size.

type LotResolutionInput

type LotResolutionInput struct {
	// ItemOverrides are per-item lot sizes set by hand. They carry no unit — an override changes how big a lot is, not what it is counted in.
	ItemOverrides map[string]float64
	// ProductLineByItem maps an item to the line it sells under. Intermediate items — greige — are absent, which is what makes downstream inheritance necessary.
	ProductLineByItem map[string]string
	// LotByProductLine is the configured convention per line.
	LotByProductLine map[string]ProductLineLot
	// DownstreamByItem is what each planned item becomes, used to inherit a lot for greige that has no product line of its own.
	DownstreamByItem map[string][]FinishedGood
	// AccountLotUnits is the account-wide fallback size.
	AccountLotUnits float64
	// UnitByItem is each item's own counting unit, used for the account fallback where there is no product line to take a unit from.
	UnitByItem map[string]string
}

LotResolutionInput is everything the chain needs, gathered once.

type Machine

type Machine struct {
	ID   string
	Name string
}

Machine is one unit of constraint capacity.

type MonthlyDemand

type MonthlyDemand struct {
	MonthStart time.Time
	Quantity   float64
}

MonthlyDemand is one month of demand for one item.

type OpenOrderLine

type OpenOrderLine struct {
	SalesOrderID     string
	SalesOrderNumber string
	// SalesOrderLineID is the line the quantity is owed against, which is what a shipment is measured against later.
	SalesOrderLineID string
	// FinishedItemID is the item the order was placed for.
	FinishedItemID string
	// ConstraintItemID is the item in the plan that produces it. Empty means nothing planned produces this order, and the line is dropped.
	ConstraintItemID string
	// Units is already scaled into the constraint item's unit by the caller, the same way pooled historical demand is.
	Units float64
	// ShipByDate is nil for an order issued before commitments were tracked.
	ShipByDate *time.Time
}

OpenOrderLine is one outstanding order line as loaded, before it is dated or pooled.

type OrderAllocation

type OrderAllocation struct {
	ItemID string
	// CampaignWeek and CampaignMachineID identify the campaign; the caller maps them back onto the line it wrote.
	CampaignWeek      int
	CampaignMachineID string

	SalesOrderID     string
	SalesOrderNumber string
	SalesOrderLineID string
	Units            float64
}

OrderAllocation is one campaign's contribution to one order.

type PinnedCampaign

type PinnedCampaign struct {
	ItemID    string
	MachineID string
	WeekIndex int
	Units     float64
}

PinnedCampaign is a hand-edited campaign the sweep must plan around rather than re-derive. Its units raise the item's projected position in its week and its run time consumes that machine's capacity, so the rest of the plan responds to the hand edit: build something sooner and the solver builds less of it later; trim a campaign and the solver replenishes earlier.

type PolicyInput

type PolicyInput struct {
	ItemID string
	SKU    string

	AnnualDemand   float64
	SecondsPerUnit float64
	UnitCost       float64

	// OverheadRate is the step's machine burden. Setup cost adds the dedicated changeover labor rate from settings on top; production labor is excluded.
	OverheadRate float64

	// MeasuredLeadTimeWeeks is the observed constraint-step lead time; zero means unmeasured, in which case the settings default applies.
	MeasuredLeadTimeWeeks float64

	SigmaWeeklyPooled  float64
	SigmaDownstreamSum float64
	OnHandEchelon      float64
	OnHandGreige       float64

	// FulfillmentPolicy decides whether this item carries a statistical buffer at all. Empty means make-to-stock, so a caller that has not adopted policies gets the behaviour it had before they existed.
	FulfillmentPolicy string
	PolicySource      string
	// FirmDemandUnits and ForecastDemandUnits are carried through onto the policy for reporting; neither changes the arithmetic.
	FirmDemandUnits     float64
	ForecastDemandUnits float64
}

PolicyInput is one item's measured facts, before policy is applied.

type PolicyResolutionInput

type PolicyResolutionInput struct {
	// ItemOverrides are explicit per-item policies.
	ItemOverrides map[string]string
	// ProductLineByItem maps an item to the line it sells under. Intermediate items are absent.
	ProductLineByItem map[string]string
	// PolicyByProductLine is the default configured on each line.
	PolicyByProductLine map[string]string
	// AccountDefault is the last resort; empty means make-to-stock.
	AccountDefault string
	// DownstreamByItem is what each planned item becomes, so an intermediate can inherit the policy of the finished goods it turns into.
	DownstreamByItem map[string][]FinishedGood
}

PolicyResolutionInput is everything the chain needs, gathered once.

Customer and account-group policies are deliberately absent. Policy is resolved per SKU, and a SKU sold to both a stocking distributor and a contract customer cannot take its policy from whichever customer is being looked at — those settings drive the recommendation instead, which is a different question asked at a different time.

type ProductLineLot

type ProductLineLot struct {
	ProductLineID string
	Quantity      float64
	UnitID        string
}

ProductLineLot is one line's configured lot convention.

type ProductLineRef

type ProductLineRef struct {
	ID   string
	Name string
}

ProductLineRef is one product line an order touches.

type Recommendation

type Recommendation struct {
	ItemID string
	SKU    string

	CurrentPolicy     string
	RecommendedPolicy string
	// Reason is the rule that decided. Exactly one, because the rules are ordered and the first match wins — listing every rule that happened to agree would obscure which one actually drove it.
	Reason string

	// The measurements behind the verdict, reported so a planner can disagree with the rule rather than only with the answer.
	AverageDemandInterval  float64
	CoefficientOfVariation float64
	TopCustomerSharePct    float64
	TopCustomerName        string
	// DemandWeightedLeadTimeDays is what customers are promised on average, weighted by how much they buy.
	DemandWeightedLeadTimeDays float64
	AnnualCOGS                 float64
	MonthsSinceLastSale        int
}

Recommendation is what the engine thinks an item should be, and why.

func RecommendPolicy

RecommendPolicy decides whether an item should be built to stock or to order, and names the rule that decided.

The rules are ordered and the first match wins:

  1. lead_time_infeasible — customers are promised less time than the plant needs. Checked first because it is a *necessary* condition rather than a preference: if you cannot produce inside the window you promised, stocking it is not a choice you get to make.
  2. no_recent_demand — nothing has sold in the dormant window. A buffer here is dead stock.
  3. single_customer — effectively one customer buys it and that customer is served to order.
  4. lumpy_demand — demand is both intermittent and highly variable, which is the shape a statistical safety stock sizes worst. Uses the Syntetos-Boylan cut points.
  5. slow_moving_high_value — expensive units, few sold. The buffer costs more than the service it buys.
  6. otherwise steady_demand — regular enough to forecast, which is what stocking is for.

A recommendation is advice, never applied on its own. Reclassifying a SKU silently would change what the plant builds without anyone deciding to.

func (Recommendation) Changes

func (r Recommendation) Changes() bool

Changes reports whether adopting this recommendation would actually change how the item is planned.

type RecommendationThresholds

type RecommendationThresholds struct {
	// DormantMonths is how long without a sale before an item counts as dead.
	DormantMonths int
	// ConcentrationPct is the share of demand one customer must hold to count as the only customer.
	ConcentrationPct float64
	// ADIThreshold and CV2Threshold are the Syntetos-Boylan cut points separating smooth demand from lumpy.
	ADIThreshold float64
	CV2Threshold float64
	// SlowMoverCOGS is the annual cost of goods below which an item is a slow mover.
	SlowMoverCOGS float64
	// HighValueUnitCost is the unit cost above which holding stock is expensive.
	HighValueUnitCost float64
}

RecommendationThresholds are the merchant-editable cut points the classifier draws against.

func DefaultRecommendationThresholds

func DefaultRecommendationThresholds() RecommendationThresholds

DefaultRecommendationThresholds mirrors the schema defaults so a caller that has never configured them still classifies.

type Settings

type Settings struct {
	// Horizon
	HorizonWeeks int `json:"horizon_weeks"` // HORIZON_WEEKS = 13
	FrozenWeeks  int `json:"frozen_weeks"`
	// WeekStartDay is the weekday a horizon week begins on, 0 = Sunday through 6 = Saturday.
	WeekStartDay int `json:"week_start_day"`

	// Capacity
	ShiftsPerDay        int     `json:"shifts_per_day"`        // SHIFTS = 2
	HoursPerShift       float64 `json:"hours_per_shift"`       // HOURS_PER_SHIFT = 7
	WorkDaysPerWeek     int     `json:"work_days_per_week"`    // WORK_DAYS_WK = 5
	WeeksPerYear        int     `json:"weeks_per_year"`        // WEEKS_YR = 52
	CapacityHeadroomPct float64 `json:"capacity_headroom_pct"` // the 0.9 headroom reserved for changeover
	DefaultLotUnits     float64 `json:"default_lot_units"`     // AVG_DOFF = 60

	// Changeover
	ChangeoverAvgMinutes float64 `json:"changeover_avg_minutes"` // CO_AVG_MIN = 30
	ChangeoverMinMinutes float64 `json:"changeover_min_minutes"` // CO_MIN_MIN = 15
	ChangeoverMaxMinutes float64 `json:"changeover_max_minutes"` // CO_MAX_MIN = 90
	ChangeoverLaborRate  float64 `json:"changeover_labor_rate"`  // CHANGEOVER_LABOR_RATE = 20

	// Inventory policy
	HoldingRatePct                 float64 `json:"holding_rate_pct"`                   // HOLDING_RATE_PCT = 0.25
	ServiceLevelZ                  float64 `json:"service_level_z"`                    // SERVICE_Z = 1.645
	FinishLeadTimeWeeks            float64 `json:"finish_lead_time_weeks"`             // FINISH_LT_WEEKS = 6
	DefaultConstraintLeadTimeWeeks float64 `json:"default_constraint_lead_time_weeks"` // KNIT_LT_WEEKS_DEFAULT = 1.3
	MaxWeeksSupply                 float64 `json:"max_weeks_supply"`                   // MAX_WEEKS_SUPPLY = 12
	MaxFlowDepth                   int     `json:"max_flow_depth"`                     // MAX_FLOW_DEPTH = 10

	// Fulfillment commitments
	//
	// DefaultCustomerLeadTimeDays is the last fallback in the ship-by chain, behind the customer and its account group. It lives with the planning assumptions because it is what a make-to-order promise is measured against: the date it produces has to be the same date the plan is solved to.
	DefaultCustomerLeadTimeDays int `json:"default_customer_lead_time_days"`
}

Settings are the planning assumptions, already resolved to effective values by the caller. Every field was a hardcoded constant in the script; the mapping is noted so a parity failure can be traced back to a specific line.

func DefaultSettings

func DefaultSettings() Settings

DefaultSettings mirrors the script's constants, with one deliberate exception: there is no growth multiplier. The script's GROWTH_MULT defaulted to 2, silently doubling all demand; that intent is now expressed as a demand override, which carries a reason and an author.

func (Settings) MachineWeeklyCapacityHours

func (s Settings) MachineWeeklyCapacityHours() float64

MachineWeeklyCapacityHours is the hours one machine can be planned for in a week.

The headroom factor is not slack: changeovers are not scheduled as explicit blocks, so the reserve is what stops a plan that is 100% run time and therefore impossible. Script: SHIFTS * HOURS_PER_SHIFT * WORK_DAYS_WK * 0.9.

func (Settings) MaxFlowDepthOrDefault

func (s Settings) MaxFlowDepthOrDefault() int

MaxFlowDepthOrDefault bounds the genealogy walk so a rework cycle cannot loop forever.

type SolverInput

type SolverInput struct {
	AccountID    string
	PlanningAsOf time.Time
	Settings     Settings

	Machines []Machine
	Batches  []BatchMeasurement

	// StepInputs maps a production step to the input items it consumes, which drives the changeover model.
	StepInputs map[string]map[string]bool

	// MonthlyByItem is demand pooled onto each constraint item from the finished goods it becomes; DownstreamByItem keeps the per-finished-good detail so the two safety-stock echelons can be computed separately and so each finished SKU's own target can be reported rather than only its contribution to the pool.
	MonthlyByItem    map[string][]MonthlyDemand
	DownstreamByItem map[string][]FinishedGood

	// OnHandByItem is echelon stock — the constraint item plus everything downstream of it — which is what the build decision is made against. GreigeOnHandByItem is the constraint stage on its own, which the echelon total cannot be decomposed back into once summed.
	OnHandByItem       map[string]float64
	GreigeOnHandByItem map[string]float64

	// MachinesWithoutStep is how many machines in the constraint department have no production step, and therefore derive no downstream work.
	MachinesWithoutStep int

	Overrides          []DemandOverride
	ItemsByProductLine map[string][]string

	// Fulfillment policy resolution. All empty means every item is make-to-stock, which is how the plan behaved before policies existed.
	ItemPolicyOverrides      map[string]string
	ProductLineByItem        map[string]string
	PolicyByProductLine      map[string]string
	DefaultFulfillmentPolicy string

	// OpenOrders is the order book the plan owes: outstanding quantities already pooled onto the constraint items that produce them. Empty means the plan is driven purely by forecast, which is how it behaved before the order book was read.
	OpenOrders []OpenOrderLine
	// HorizonStart is the first day of week 0, used to date the order book against the horizon.
	HorizonStart time.Time

	// ItemLotUnits overrides the default lot size for specific items.
	ItemLotUnits map[string]float64
	// LotDefaultByItem is the resolved lot each item is made in, size and unit together. Populated by the input load, which runs the whole precedence chain once.
	LotDefaultByItem map[string]LotDefault
	// ExcludedItemIDs are items the merchant has taken out of planning.
	ExcludedItemIDs map[string]bool

	// PinnedCampaigns are hand-edited campaigns already on the plan; the sweep plans around them rather than re-deriving them.
	PinnedCampaigns []PinnedCampaign

	DemandBasisCode string
	ForecastZ       float64
	ForecastMonths  int

	// Stage two: the rest of the factory. Empty means the plan stops at the constraint, which is how it behaved before the finishing stage existed.
	//
	// FinishingMachines is every machine outside the constraint department; their count is what the second stage's weekly capacity is derived from, so hiring a shift onto a new machine changes the plan the way it changes the plant.
	FinishingMachines []Machine
	// FinishingBatches is production history for the finished goods, measured anywhere outside the constraint department. It is where the second stage's run rates come from.
	FinishingBatches []BatchMeasurement
	// FinishingRateScaleByItem converts a finished good's measured seconds-per-unit into the unit the plan is denominated in — the greige's. A sock scanned in eaches and knitted in pairs finishes at twice its per-each rate per planned unit.
	FinishingRateScaleByItem map[string]float64
	// FinishingStepByItem is where each finished good is made, denormalized onto its plan line so a department rollup needs no join.
	FinishingStepByItem map[string]FinishingStep
	// FinishingLotByItem is the lot each finished good is made in. Absent means the SKU is planned unlotted.
	FinishingLotByItem map[string]LotDefault
}

SolverInput is everything the solver needs. It is loaded up front by the repository so this package stays pure: same input, same plan, no database, no clock.

type SolverOutput

type SolverOutput struct {
	SolverVersion string
	PlanningAsOf  time.Time

	Policies []ItemPolicy
	// FinishedPolicies is the per-finished-SKU decomposition of the pooled greige buffers in Policies. The two stages together are the whole inventory picture and do not overlap: greige holds its own buffer, finished goods hold theirs.
	FinishedPolicies []FinishedPolicy
	Campaigns        []Campaign
	// ProjectedOnHand[itemID][weekIndex] is the position at the end of that week.
	ProjectedOnHand map[string][]float64

	// Allocations say which campaign is building which order. Written alongside the plan so "what is this campaign for" and "is my order covered" are two readings of one answer.
	Allocations []OrderAllocation

	// FinishingLines are stage two: how many of which finished good to make from the knitted parts, week by week. Empty when the plant has no second stage configured.
	FinishingLines []FinishingLine
	// FinishingProjectedOnHand[itemID][weekIndex] is a finished SKU's own position at the end of that week, which the pooled greige projection cannot answer.
	FinishingProjectedOnHand map[string][]float64

	Diagnostics Diagnostics
}

SolverOutput is the plan plus everything needed to explain it.

func Solve

func Solve(in SolverInput) SolverOutput

Solve produces the production plan.

Deterministic by construction: every collection is sorted before iteration, so the same input yields byte-identical output. See TestSolve_Deterministic.

type StepEdge

type StepEdge struct {
	UpstreamStepID   string
	DownstreamStepID string
}

StepEdge is one directed link in the production-step graph, oriented upstream → downstream. The storage table spells this (A=downstream, B=upstream); the confusion stops at the query, and everything in here is already the right way round.

type StepInfo

type StepInfo struct {
	StepID       string
	DepartmentID string
	Name         string
	// LeadTimeOffsetWeeks is how far after the constraint campaign this step runs. It is a whole number of weeks because a schedule is planned in weeks; sub-week precision here would imply a resolution the plan does not have.
	LeadTimeOffsetWeeks int
	// YieldRatio is units of this step's output per unit of its input. 1 means no gain or loss; 0.95 means a 5% loss at this step.
	YieldRatio float64
}

StepInfo is what the explosion needs to know about one production step.

type Transit

type Transit struct {
	Days   int
	Source string
}

Transit is how long the carrier takes to cover an order's lane, in the days it moves freight, and where that number came from.

type UncoveredRequirement

type UncoveredRequirement struct {
	ItemID           string
	SalesOrderID     string
	SalesOrderNumber string
	DueWeek          int
	ShortUnits       float64
	// CoveredUnits is how much of it the plan does build, so a partly-covered order reads as partly covered rather than as a total miss.
	CoveredUnits float64
}

UncoveredRequirement is an order the plan does not build in time, and by how much.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL