-- 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/palleskur med plads, nåelig -- reserve = liter | nil, reservedFor = {navne} | nil } ... } } -- gårdens eget behov (sælges ikke) -- 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 -- Paller: en autoload-bil opgiver ingen liter-kapacitet, så "fuldt læs" = mindst så mange paller klar. ADOutboundPlanner.PALLET_FULL_LOAD_COUNT = 4 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, pallets = 0, 0, 0 for _, source in ipairs(product.sources or {}) do total = total + (source.available or 0) pallets = pallets + (source.palletCount 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, pallets end -- Fodring: flowet har "Lager til" (et bestemt anlæg der bruger varen, fx gylle til en biogas-fermentor). -- Så køres der så snart kilden har så meget — at vente på fuldt læs/80 % sulter anlægget. ADOutboundPlanner.FEED_MIN_LITERS = 10000 function ADOutboundPlanner.evaluate(product, input) local available, storagePercent, palletCount = sumSources(product) local percent = product.quote ~= nil and product.quote.percent or nil local reserve = math.max(0, product.reserve or 0) local fullLoad if input.capacity ~= nil and input.capacity > 0 then fullLoad = available >= input.capacity else fullLoad = product.isPallet == true and palletCount >= ADOutboundPlanner.PALLET_FULL_LOAD_COUNT end return { available = available, reserve = reserve, sellable = math.max(0, available - reserve), palletCount = palletCount, fullLoad = fullLoad, storagePercent = storagePercent, storageHigh = storagePercent >= (input.storeAbovePercent or 80), percent = percent, -- sælg fra 0 % = "Altid": uanset pris (også uden prisdata) priceOk = input.sellAtPercent == 0 or (percent ~= nil and percent >= (input.sellAtPercent or 90)), -- "sælg altid over X %": kilden er så fuld, at der sælges uanset pris overflow = (input.sellAbovePercent or 0) > 0 and storagePercent >= input.sellAbovePercent, } end local function formatLiters(liters) if liters >= 1000000 then return string.format("%.1fM", liters / 1000000) elseif liters >= 1000 then return string.format("%.1fk", liters / 1000) end return string.format("%d", liters) end -- Salg må ikke røre reserven: enten er intet reserveret, eller et helt læs kan tages uden om den -- (AutoDrive læsser altid vognen fuld, så en delvis mængde kan ikke styres). local function canSellWithoutReserve(e, input) if e.reserve <= 0 then return e.sellable >= ADOutboundPlanner.MIN_AVAILABLE_LITERS end local capacity = input.capacity or 0 return e.sellable >= ADOutboundPlanner.MIN_AVAILABLE_LITERS and (capacity <= 0 or e.sellable >= capacity) end local function reasonFor(product, e, input) if e.available < ADOutboundPlanner.MIN_AVAILABLE_LITERS then return product.name .. ": intet på lager" end if wantsSell(product.policy) and e.priceOk and product.quote ~= nil and not canSellWithoutReserve(e, input) then local names = table.concat(product.reservedFor or {}, ", ") return string.format("%s: %s reserveret til %s", product.name, formatLiters(e.reserve), names ~= "" and names or "gårdens eget forbrug") 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 if product.storeReason ~= nil then return product.name .. ": " .. product.storeReason end return product.name .. ": intet lager med plads (silo eller egen fabrik der bruger varen)" 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 or e.overflow) and product.quote ~= nil and canSellWithoutReserve(e, input) 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 or (input.feedMode and e.available >= ADOutboundPlanner.FEED_MIN_LITERS)) 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 -- Kilder hvis læssestationer trækker fra samme lagre (fx mælketanke der er udvidelser inden for hinandens -- storageRadius) er ét stop: læsser man ved én, tømmes de alle. Den første i rækkefølgen beholdes, de -- øvrige lægges ind under den. storagesOf(source) -> liste af lager-objekter (nil = ukendt, beholdes). -- Returnerer kept (rækkefølge bevaret) og merged {[beholdt wayPointId] = {sammenlagte wayPointIds}}. function ADOutboundPlanner.dedupeSharedSources(sources, storagesOf) local kept, merged, groups = {}, {}, {} for _, source in ipairs(sources or {}) do local storages = storagesOf(source) local owner = nil if storages ~= nil then for _, group in ipairs(groups) do for _, storage in ipairs(storages) do if group.storages[storage] then owner = group break end end if owner ~= nil then break end end end if owner ~= nil then for _, storage in ipairs(storages) do owner.storages[storage] = true end merged[owner.source.wayPointId] = merged[owner.source.wayPointId] or {} table.insert(merged[owner.source.wayPointId], source.wayPointId) else table.insert(kept, source) if storages ~= nil then local set = {} for _, storage in ipairs(storages) do set[storage] = true end table.insert(groups, {source = source, storages = set}) end end end return kept, merged 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