///| Engineering bounds. They are large enough for a real planning run and still
///| keep every intermediate value inside Int64.
///|
let max_items : Int = 4096
///|
let max_horizon : Int = 1024
///|
let max_edges : Int = 100000
///|
let max_movements : Int = 100000
///|
let max_lead_time : Int = 4096
///|
/// 10^12 units, the largest quantity accepted or produced (in milli-units).
let qty_limit_milli : Int64 = 1000000000000000L
///|
let int64_max : Int64 = 9223372036854775807L
///|
fn qty_ok(milli : Int64) -> Bool {
milli >= 0L && milli <= qty_limit_milli
}
///|
fn positive_qty(milli : Int64) -> Bool {
milli > 0L && milli <= qty_limit_milli
}
///|
fn lot_valid(lot : LotRule) -> Bool {
match lot {
LotForLot => true
FixedQuantity(size) => positive_qty(size.milli)
Multiple(m) => positive_qty(m.milli)
MinimumMultiple(minimum, m) =>
positive_qty(minimum.milli) && positive_qty(m.milli)
PeriodOrder(periods) => periods >= 1 && periods <= max_horizon
MinMax(minimum, maximum) =>
minimum.milli >= 0L &&
positive_qty(maximum.milli) &&
minimum.milli <= maximum.milli
ReorderPoint(level, quantity) =>
level.milli >= 0L && qty_ok(level.milli) && positive_qty(quantity.milli)
}
}
///|
fn safety_valid(safety : SafetyStock) -> Bool {
match safety {
Fixed(q) => qty_ok(q.milli)
DaysOfDemand(periods) => periods >= 0 && periods <= max_horizon
PercentOfDemand(percent) => percent >= 0 && percent <= 100
}
}
///|
fn safety_is_zero(safety : SafetyStock) -> Bool {
match safety {
Fixed(q) => q.milli == 0L
DaysOfDemand(periods) => periods == 0
PercentOfDemand(percent) => percent == 0
}
}
///|
/// Per-bucket safety floor. Fixed is constant, PercentOfDemand follows the
/// bucket gross, DaysOfDemand uses the horizon average gross demand.
fn safety_target(
item : Item,
t : Int,
gross : Array[Int64],
horizon : Int,
) -> Result[Int64, Unit] {
match item.safety {
Fixed(q) => Ok(q.milli)
PercentOfDemand(percent) => mul_div_ceil(gross[t], percent.to_int64(), 100L)
DaysOfDemand(periods) => {
if periods == 0 {
return Ok(0L)
}
let mut total = 0L
for g in gross {
if total > int64_max - g {
return Err(())
}
total = total + g
}
let horizon64 = horizon.to_int64()
let average = total / horizon64 +
(if total % horizon64 == 0L { 0L } else { 1L })
let target = match mul_div_ceil(average, periods.to_int64(), 1L) {
Ok(value) => value
Err(_) => return Err(())
}
if qty_ok(target) {
Ok(target)
} else {
Err(())
}
}
}
}
///|
fn ceil_to_multiple(q : Int64, multiple : Int64) -> Result[Int64, Unit] {
if q == 0L {
return Ok(0L)
}
let count = q / multiple + (if q % multiple == 0L { 0L } else { 1L })
if count > int64_max / multiple {
return Err(())
}
Ok(count * multiple)
}
///|
fn apply_lot(q : Int64, lot : LotRule) -> Result[Int64, Unit] {
match lot {
LotForLot => Ok(q)
FixedQuantity(size) => ceil_to_multiple(q, size.milli)
Multiple(m) => ceil_to_multiple(q, m.milli)
MinimumMultiple(minimum, m) => {
let target = if q > minimum.milli { q } else { minimum.milli }
ceil_to_multiple(target, m.milli)
}
PeriodOrder(_) => Ok(q)
ReorderPoint(_, quantity) =>
if q < quantity.milli {
Ok(quantity.milli)
} else {
Ok(q)
}
MinMax(_, maximum) =>
if q < maximum.milli {
Ok(maximum.milli)
} else {
Ok(q)
}
}
}
///|
/// Multiply two bounded non-negative numbers and divide by `divisor`, rounding
/// up. `Err` means the intermediate product would overflow Int64.
fn mul_div_ceil(a : Int64, b : Int64, divisor : Int64) -> Result[Int64, Unit] {
if a == 0L || b == 0L {
return Ok(0L)
}
if a > int64_max / b {
return Err(())
}
let product = a * b
let quotient = product / divisor
if product % divisor == 0L {
Ok(quotient)
} else {
Ok(quotient + 1L)
}
}
///|
/// Child demand for one parent order: scrap padding, unit usage and optional
/// unit conversion, each step rounded up to 0.001.
fn component_demand(
start : Int64,
units_per : Int64,
scrap_per_mille : Int,
conversion : Int64,
) -> Result[Int64, Unit] {
let padded = match
mul_div_ceil(start, 1000L + scrap_per_mille.to_int64(), 1000L) {
Ok(value) => value
Err(_) => return Err(())
}
let nominal = match mul_div_ceil(padded, units_per, 1000L) {
Ok(value) => value
Err(_) => return Err(())
}
if conversion == 0L {
Ok(nominal)
} else {
mul_div_ceil(nominal, conversion, 1000L)
}
}
///|
/// Quantity needed to cover `periods` buckets without dropping below the safety
/// floor, starting from the balance after scheduled receipts.
fn window_requirement(
available : Int64,
start_bucket : Int,
periods : Int,
horizon : Int,
gross : Array[Int64],
scheduled : Array[Int64],
safety : Int64,
) -> Result[Int64, Unit] {
let mut required = 0L
let mut projected = available
let end = if start_bucket + periods < horizon {
start_bucket + periods
} else {
horizon
}
for k in start_bucket.. int64_max - shortfall {
return Err(())
}
required = required + shortfall
projected = safety
}
}
Ok(required)
}
///|
/// Lot rounding for one net requirement. `PeriodOrder` and `MinMax` need a
/// calendar, so here they return the requirement unchanged (or the `MinMax`
/// maximum when it is larger).
pub fn order_quantity(net : Qty, lot : LotRule) -> Result[Qty, PlanningError] {
if !qty_ok(net.milli) || !lot_valid(lot) {
return Err(InvalidItem("lot or net quantity"))
}
if net.milli == 0L {
return Ok(Qty::zero())
}
match apply_lot(net.milli, lot) {
Ok(value) =>
if qty_ok(value) {
Ok(Qty::milli(value))
} else {
Err(QuantityLimit("lot rounding"))
}
Err(_) => Err(QuantityLimit("lot rounding"))
}
}
///|
fn validate_movements(
movements : Array[Movement],
index : Map[String, Int],
horizon : Int,
) -> Result[Unit, PlanningError] {
let ids : Map[String, Bool] = Map([])
for m in movements {
if m.id == "" ||
m.bucket < 0 ||
m.bucket >= horizon ||
m.quantity.milli <= 0L ||
!qty_ok(m.quantity.milli) {
return Err(InvalidMovement(m.id))
}
if !index.contains(m.item) {
return Err(UnknownItem(m.item))
}
if ids.contains(m.id) {
return Err(DuplicateMovement(m.id))
}
ids[m.id] = true
}
Ok(())
}
///|
/// Validate the whole graph, net parents before children, apply scrap, yield,
/// unit conversion and the lot policy, then peg every gross requirement.
///
/// `ReleasePolicy::Strict` rejects the calculation when an order would have to
/// be released before bucket zero; `AllowLate` clamps it to bucket zero, flags
/// the order and records an `Incident` instead. No input array is mutated and no
/// partial plan is returned on error.
pub fn plan(request : Request) -> Result[Plan, PlanningError] {
let h = request.horizon
if h < 1 || h > max_horizon {
return Err(InvalidHorizon)
}
let n = request.items.length()
if n == 0 ||
n > max_items ||
request.bom.length() > max_edges ||
request.demands.length() > max_movements ||
request.receipts.length() > max_movements {
return Err(SizeLimit)
}
let items = request.items.copy()
items.sort_by(fn(a, b) { a.sku.lexical_compare(b.sku) })
let index : Map[String, Int] = Map([])
for i, item in items {
if item.sku == "" ||
!qty_ok(item.on_hand.milli) ||
!qty_ok(item.max_stock.milli) ||
!safety_valid(item.safety) ||
(
item.phantom &&
(
item.on_hand.milli != 0L ||
item.max_stock.milli != 0L ||
!safety_is_zero(item.safety)
)
) ||
item.lead_time < 0 ||
item.lead_time > max_lead_time ||
item.yield_per_mille < 1 ||
item.yield_per_mille > 1000 ||
!lot_valid(item.lot) {
return Err(InvalidItem(item.sku))
}
if index.contains(item.sku) {
return Err(DuplicateItem(item.sku))
}
index[item.sku] = i
}
let conversions : Map[String, Int64] = Map([])
for c in request.conversions {
if c.from == "" ||
c.to == "" ||
c.from == c.to ||
!positive_qty(c.factor.milli) {
return Err(InvalidConversion(c.from, c.to))
}
let key = c.from + "->" + c.to
if conversions.contains(key) {
return Err(InvalidConversion(c.from, c.to))
}
conversions[key] = c.factor.milli
}
match validate_movements(request.demands, index, h) {
Err(e) => return Err(e)
Ok(_) => ()
}
match validate_movements(request.receipts, index, h) {
Err(e) => return Err(e)
Ok(_) => ()
}
let bom = request.bom.copy()
bom.sort_by(fn(a, b) {
if a.parent == b.parent {
a.child.lexical_compare(b.child)
} else {
a.parent.lexical_compare(b.parent)
}
})
let indegree = Array::make(n, 0)
let children : Array[Array[(Int, Int64, Int, Int64)]] = Array::makei(n, fn(
_,
) {
[]
})
for i, edge in bom {
if !index.contains(edge.parent) {
return Err(UnknownItem(edge.parent))
}
if !index.contains(edge.child) {
return Err(UnknownItem(edge.child))
}
if edge.parent == edge.child ||
!positive_qty(edge.units_per.milli) ||
edge.scrap_per_mille < 0 ||
edge.scrap_per_mille > 1000 {
return Err(InvalidComponent(edge.parent, edge.child))
}
if i > 0 &&
bom[i - 1].parent == edge.parent &&
bom[i - 1].child == edge.child {
return Err(DuplicateComponent(edge.parent, edge.child))
}
let p = index[edge.parent]
let c = index[edge.child]
let mut conversion = 0L
if edge.usage_unit != "" && edge.usage_unit != items[c].unit {
let key = edge.usage_unit + "->" + items[c].unit
match conversions.get(key) {
Some(factor) => conversion = factor
None => return Err(InvalidConversion(edge.usage_unit, items[c].unit))
}
}
children[p].push(
(c, edge.units_per.milli, edge.scrap_per_mille, conversion),
)
indegree[c] += 1
}
let visited = Array::make(n, false)
let order : Array[Int] = []
for _ in 0.. qty_limit_milli {
return Err(QuantityLimit(d.item))
}
gross[i][d.bucket] = total
pegs.push({
item: d.item,
bucket: d.bucket,
quantity: d.quantity,
source: Demand(d.id),
})
}
for r in request.receipts {
let i = index[r.item]
let total = scheduled[i][r.bucket] + r.quantity.milli
if total > qty_limit_milli {
return Err(QuantityLimit(r.item))
}
scheduled[i][r.bucket] = total
}
let plans : Array[ItemPlan] = []
let orders : Array[Order] = []
let incidents : Array[Incident] = []
let exceptions : Array[StockException] = []
for i in order {
let item = items[i]
if item.phantom {
let pass_through : Array[Bucket] = []
for t in 0.. 0L {
return Err(InvalidItem(item.sku))
}
if gross[i][t] > 0L {
for child in children[i] {
let (child_index, units, scrap, conversion) = child
let demand = match
component_demand(gross[i][t], units, scrap, conversion) {
Ok(value) => value
Err(_) => return Err(QuantityLimit(items[child_index].sku))
}
let total = gross[child_index][t] + demand
if total > qty_limit_milli {
return Err(QuantityLimit(items[child_index].sku))
}
gross[child_index][t] = total
pegs.push({
item: items[child_index].sku,
bucket: t,
quantity: Qty::milli(demand),
source: ParentOrder(item.sku, t),
})
}
}
pass_through.push({
index: t,
gross: Qty::milli(gross[i][t]),
scheduled: Qty::milli(scheduled[i][t]),
net: Qty::zero(),
planned_receipt: Qty::zero(),
received: Qty::zero(),
planned_release: Qty::zero(),
projected: Qty::zero(),
})
}
plans.push({ item: item.sku, buckets: pass_through, })
continue
}
let releases = Array::make(h, 0L)
let buckets : Array[Bucket] = []
let mut available = item.on_hand.milli
for t in 0.. qty_limit_milli {
return Err(QuantityLimit(item.sku))
}
let safety = match safety_target(item, t, gross[i], h) {
Ok(value) => value
Err(_) => return Err(QuantityLimit(item.sku))
}
let needed = gross[i][t] + safety
if needed > qty_limit_milli {
return Err(QuantityLimit(item.sku))
}
let net = if needed > available { needed - available } else { 0L }
let mut required_good = 0L
match item.lot {
MinMax(minimum, maximum) => {
let after_demand = available - gross[i][t]
if after_demand < minimum.milli {
let target = maximum.milli - after_demand
if target < 0L || target > qty_limit_milli {
return Err(QuantityLimit(item.sku))
}
required_good = target
}
}
ReorderPoint(level, quantity) => {
let after_demand = available - gross[i][t]
if after_demand < level.milli {
required_good = if net > quantity.milli {
net
} else {
quantity.milli
}
if required_good > qty_limit_milli {
return Err(QuantityLimit(item.sku))
}
}
}
PeriodOrder(periods) =>
if net > 0L {
match
window_requirement(
available,
t,
periods,
h,
gross[i],
scheduled[i],
safety,
) {
Ok(value) => required_good = value
Err(_) => return Err(QuantityLimit(item.sku))
}
}
_ => required_good = net
}
let mut start = 0L
let mut received = 0L
if required_good > 0L {
start = match
mul_div_ceil(required_good, 1000L, item.yield_per_mille.to_int64()) {
Ok(value) => value
Err(_) => return Err(QuantityLimit(item.sku))
}
match item.lot {
PeriodOrder(_) => ()
MinMax(_, _) => ()
ReorderPoint(_, _) => ()
_ =>
start = match apply_lot(start, item.lot) {
Ok(value) => value
Err(_) => return Err(QuantityLimit(item.sku))
}
}
if !qty_ok(start) {
return Err(QuantityLimit(item.sku))
}
received = start * item.yield_per_mille.to_int64() / 1000L
if received > qty_limit_milli {
return Err(QuantityLimit(item.sku))
}
let required_release = t - item.lead_time
let release = if required_release < 0 {
match request.policy {
Strict => return Err(BeforeHorizon(item.sku, t, required_release))
AllowLate => 0
}
} else {
required_release
}
if required_release < 0 {
incidents.push({
item: item.sku,
receipt_bucket: t,
required_release,
actual_release: release,
})
}
releases[release] = releases[release] + start
if releases[release] > qty_limit_milli {
return Err(QuantityLimit(item.sku))
}
orders.push({
item: item.sku,
receipt_bucket: t,
release_bucket: release,
quantity: Qty::milli(start),
late: required_release < 0,
})
for child in children[i] {
let (child_index, units, scrap, conversion) = child
let demand = match component_demand(start, units, scrap, conversion) {
Ok(value) => value
Err(_) => return Err(QuantityLimit(items[child_index].sku))
}
let total = gross[child_index][release] + demand
if total > qty_limit_milli {
return Err(QuantityLimit(items[child_index].sku))
}
gross[child_index][release] = total
pegs.push({
item: items[child_index].sku,
bucket: release,
quantity: Qty::milli(demand),
source: ParentOrder(item.sku, t),
})
}
}
available = available + received - gross[i][t]
if available > qty_limit_milli {
return Err(QuantityLimit(item.sku))
}
if item.max_stock.milli > 0L && available > item.max_stock.milli {
exceptions.push({
item: item.sku,
bucket: t,
projected: Qty::milli(available),
maximum: item.max_stock,
})
}
buckets.push({
index: t,
gross: Qty::milli(gross[i][t]),
scheduled: Qty::milli(scheduled[i][t]),
net: Qty::milli(net),
planned_receipt: Qty::milli(start),
received: Qty::milli(received),
planned_release: Qty::zero(),
projected: Qty::milli(available),
})
}
let final_buckets = buckets.map(fn(b) {
{ ..b, planned_release: Qty::milli(releases[b.index]), }
})
plans.push({ item: item.sku, buckets: final_buckets, })
}
plans.sort_by(fn(a, b) { a.item.lexical_compare(b.item) })
orders.sort_by(fn(a, b) {
if a.item == b.item {
a.receipt_bucket.compare(b.receipt_bucket)
} else {
a.item.lexical_compare(b.item)
}
})
pegs.sort_by(fn(a, b) {
if a.item != b.item {
a.item.lexical_compare(b.item)
} else if a.bucket != b.bucket {
a.bucket.compare(b.bucket)
} else {
match (a.source, b.source) {
(Demand(x), Demand(y)) => x.lexical_compare(y)
(Demand(_), ParentOrder(_, _)) => -1
(ParentOrder(_, _), Demand(_)) => 1
(ParentOrder(x, tx), ParentOrder(y, ty)) =>
if x == y {
tx.compare(ty)
} else {
x.lexical_compare(y)
}
}
}
})
incidents.sort_by(fn(a, b) {
if a.item == b.item {
a.receipt_bucket.compare(b.receipt_bucket)
} else {
a.item.lexical_compare(b.item)
}
})
exceptions.sort_by(fn(a, b) {
if a.item == b.item {
a.bucket.compare(b.bucket)
} else {
a.item.lexical_compare(b.item)
}
})
Ok({ horizon: h, items: plans, orders, pegs, incidents, exceptions, })
}