feat: adOutboundPlanner — turvurdering, prisregel, politikker, opsamlingsrunde (ren)
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
-- AD Outbound Planner
|
||||
-- Ren turvurdering (ingen spil-API) for udkørsel: skal traktoren køre nu, med hvilke
|
||||
-- varer, fra hvilke kilder, til silo eller salgssted?
|
||||
--
|
||||
-- input = {
|
||||
-- capacity = liter (vognen), hasAutoload = bool (Universal Autoload på vognen),
|
||||
-- sellAtPercent, storeAbovePercent, collectRound,
|
||||
-- products = { { fillType, name, policy = "store"|"sell"|"sellElseStore", isPallet,
|
||||
-- sources = { {wayPointId, available, level, capacity} ... }, -- kilder med varen
|
||||
-- quote = {percent, price, wayPointId, stationName} | nil, -- bedste NÅELIGE salgssted
|
||||
-- store = {wayPointId, name, free} | nil } ... } } -- silo med plads, nåelig
|
||||
-- plan(input) -> trip | nil, reasons
|
||||
-- trip = {kind = "sell"|"store", fillTypes, names, sourceIds, targetWayPointId, targetName, percent}
|
||||
-- reasons = én linje pr. vare der ikke gav en tur ("SUGAR 78 % af top (kræver 90), lager 41 %")
|
||||
|
||||
ADOutboundPlanner = {}
|
||||
|
||||
-- Mindre end dette på lager regnes som intet.
|
||||
ADOutboundPlanner.MIN_AVAILABLE_LITERS = 1
|
||||
-- Opsamlingsrunde (bulk): næste kilde besøges kun hvis mindst så stor en andel af vognen er fri.
|
||||
ADOutboundPlanner.ROUND_MIN_FREE_FRACTION = 0.1
|
||||
|
||||
local function wantsSell(policy)
|
||||
return policy == "sell" or policy == "sellElseStore"
|
||||
end
|
||||
|
||||
local function wantsStore(policy)
|
||||
return policy == "store" or policy == "sellElseStore"
|
||||
end
|
||||
|
||||
-- Samlet tilgængeligt over kilderne og den højeste fyldningsgrad (%) blandt dem.
|
||||
local function sumSources(product)
|
||||
local total, percent = 0, 0
|
||||
for _, source in ipairs(product.sources or {}) do
|
||||
total = total + (source.available or 0)
|
||||
if source.capacity ~= nil and source.capacity > 0 then
|
||||
percent = math.max(percent, 100 * (source.level or 0) / source.capacity)
|
||||
end
|
||||
end
|
||||
return total, percent
|
||||
end
|
||||
|
||||
function ADOutboundPlanner.evaluate(product, input)
|
||||
local available, storagePercent = sumSources(product)
|
||||
local percent = product.quote ~= nil and product.quote.percent or nil
|
||||
return {
|
||||
available = available,
|
||||
fullLoad = input.capacity ~= nil and input.capacity > 0 and available >= input.capacity,
|
||||
storagePercent = storagePercent,
|
||||
storageHigh = storagePercent >= (input.storeAbovePercent or 80),
|
||||
percent = percent,
|
||||
priceOk = percent ~= nil and percent >= (input.sellAtPercent or 90),
|
||||
}
|
||||
end
|
||||
|
||||
local function reasonFor(product, e, input)
|
||||
if e.available < ADOutboundPlanner.MIN_AVAILABLE_LITERS then
|
||||
return product.name .. ": intet på lager"
|
||||
end
|
||||
local canStore = wantsStore(product.policy) and product.store ~= nil
|
||||
if wantsSell(product.policy) and product.quote == nil and not canStore then
|
||||
return product.name .. ": intet nåeligt salgssted"
|
||||
end
|
||||
if wantsStore(product.policy) and product.store == nil and (e.fullLoad or e.storageHigh) and not e.priceOk then
|
||||
return product.name .. ": ingen silo med plads"
|
||||
end
|
||||
local parts = {}
|
||||
if wantsSell(product.policy) and e.percent ~= nil then
|
||||
table.insert(parts, string.format("%d %% af top (kræver %d)", e.percent, input.sellAtPercent or 90))
|
||||
end
|
||||
table.insert(parts, string.format("lager %d %%", math.floor(e.storagePercent + 0.5)))
|
||||
return product.name .. " " .. table.concat(parts, ", ")
|
||||
end
|
||||
|
||||
-- Kandidater: {product, e, kind, targetWayPointId, targetName, percent}; salg og lager hver for sig.
|
||||
local function collectCandidates(input)
|
||||
local sell, store, reasons = {}, {}, {}
|
||||
for _, product in ipairs(input.products or {}) do
|
||||
local e = ADOutboundPlanner.evaluate(product, input)
|
||||
local added = false
|
||||
if e.available >= ADOutboundPlanner.MIN_AVAILABLE_LITERS then
|
||||
if wantsSell(product.policy) and e.priceOk and product.quote ~= nil then
|
||||
table.insert(sell, {product = product, e = e, kind = "sell", targetWayPointId = product.quote.wayPointId, targetName = product.quote.stationName, percent = e.percent})
|
||||
added = true
|
||||
elseif wantsStore(product.policy) and (e.fullLoad or e.storageHigh) and product.store ~= nil then
|
||||
table.insert(store, {product = product, e = e, kind = "store", targetWayPointId = product.store.wayPointId, targetName = product.store.name})
|
||||
added = true
|
||||
end
|
||||
end
|
||||
if not added then
|
||||
table.insert(reasons, reasonFor(product, e, input))
|
||||
end
|
||||
end
|
||||
table.sort(sell, function(a, b)
|
||||
if a.e.percent ~= b.e.percent then return a.e.percent > b.e.percent end
|
||||
if a.e.available ~= b.e.available then return a.e.available > b.e.available end
|
||||
return a.product.name < b.product.name
|
||||
end)
|
||||
table.sort(store, function(a, b)
|
||||
if a.e.storagePercent ~= b.e.storagePercent then return a.e.storagePercent > b.e.storagePercent end
|
||||
if a.e.available ~= b.e.available then return a.e.available > b.e.available end
|
||||
return a.product.name < b.product.name
|
||||
end)
|
||||
return sell, store, reasons
|
||||
end
|
||||
|
||||
-- Kilder med varen, mest først (rester ryddes med i samme tur ved runde).
|
||||
local function sortedSources(product)
|
||||
local sources = {}
|
||||
for _, source in ipairs(product.sources or {}) do
|
||||
if (source.available or 0) >= ADOutboundPlanner.MIN_AVAILABLE_LITERS then
|
||||
table.insert(sources, source)
|
||||
end
|
||||
end
|
||||
table.sort(sources, function(a, b)
|
||||
if a.available ~= b.available then return a.available > b.available end
|
||||
return a.wayPointId < b.wayPointId
|
||||
end)
|
||||
return sources
|
||||
end
|
||||
|
||||
-- Runde med paller (UAL): alle kandidater af samme slags med samme mål på én tur.
|
||||
-- Bulk: én vare, men fra alle dens kilder efter tur. Uden runde: én vare, én kilde.
|
||||
local function buildTrip(primary, candidates, input)
|
||||
local trip = {
|
||||
kind = primary.kind,
|
||||
fillTypes = {primary.product.fillType},
|
||||
names = {primary.product.name},
|
||||
sourceIds = {},
|
||||
targetWayPointId = primary.targetWayPointId,
|
||||
targetName = primary.targetName,
|
||||
percent = primary.percent,
|
||||
}
|
||||
local members = {primary}
|
||||
if input.collectRound and input.hasAutoload and primary.product.isPallet then
|
||||
for _, candidate in ipairs(candidates) do
|
||||
if candidate ~= primary and candidate.kind == primary.kind and candidate.targetWayPointId == primary.targetWayPointId and candidate.product.isPallet then
|
||||
table.insert(members, candidate)
|
||||
table.insert(trip.fillTypes, candidate.product.fillType)
|
||||
table.insert(trip.names, candidate.product.name)
|
||||
end
|
||||
end
|
||||
end
|
||||
local seen = {}
|
||||
for _, member in ipairs(members) do
|
||||
local sources = sortedSources(member.product)
|
||||
local limit = input.collectRound and #sources or math.min(1, #sources)
|
||||
for index = 1, limit do
|
||||
local id = sources[index].wayPointId
|
||||
if not seen[id] then
|
||||
seen[id] = true
|
||||
table.insert(trip.sourceIds, id)
|
||||
end
|
||||
end
|
||||
end
|
||||
return trip
|
||||
end
|
||||
|
||||
function ADOutboundPlanner.plan(input)
|
||||
local sell, store, reasons = collectCandidates(input)
|
||||
if sell[1] ~= nil then
|
||||
return buildTrip(sell[1], sell, input), reasons
|
||||
end
|
||||
if store[1] ~= nil then
|
||||
return buildTrip(store[1], store, input), reasons
|
||||
end
|
||||
return nil, reasons
|
||||
end
|
||||
Reference in New Issue
Block a user