Commit 58128f3f authored by Tong Li's avatar Tong Li

MP

parent b3685a77
=== MacroPlanner 优化器运行日志 ===
=== 运行时间: 2026-08-11 16:47:50 ===
=== 开始构建 MacroPlanner 优化模型 ===
[警告] ========== 数据检查: 4 个警告 ==========
[警告] ⚠️ 工序 OP_R1 leadTimeDays=1 天, 但第一个周期前无对应生产周期, 第1周期该产品无到货
[警告] ⚠️ 工序 OP_R1_Alt leadTimeDays=2 天, 但第一个周期前无对应生产周期, 第1周期该产品无到货
[警告] ⚠️ 工序 OP_R2 leadTimeDays=1 天, 但第一个周期前无对应生产周期, 第1周期该产品无到货
[警告] ⚠️ 工序 OP_R2_Alt leadTimeDays=3 天, 但第一个周期前无对应生产周期, 第1周期该产品无到货
[信息] ✅ 数据检查通过: 无错误, 4 个警告
[OK] 数据检查通过
[OK] 决策变量创建完成
[信息] ========== 开始构建约束 ==========
[信息] [1. 物料平衡] 确保每个 PISPIP 流入 = 流出 | +15约束
[信息] [2. BOM依赖需求] OperationDemandQty = PTQty × BOM因子 | +27约束
[信息] [3. 需求满足量汇总] DemandFulfillment = SalesDemandQty + OperationDemandQty | +15约束
[信息] [4. 需求缺口联动] SalesDemandQty + DemandSlack >= DemandQuantity | +6约束
[警告] [4.5 工序产量一致] Routing 内相邻工序 PTQty 相等, 防止 WIP 堆积 → 未创建任何约束或变量! (可能数据为空)
[信息] [库存规格] 构建完成: 0 个使用安全库存天数, 45 个使用绝对数量
[信息] [5. 库存规格] InvQty + Slack ≥ Min/Max/Target (支持天数模式) | +45约束
[信息] [6. 产能约束] Σ(PTQty×coeff) ∈ [MinCapacity, MaxCapacity] | +21约束
[信息] [7. 供应规格] ΣPTQty + Slack ≥ Target/Min/Max | +14约束
[信息] [8. 批次大小] PTQty×QTPFactor + Slack ∈ [LotSize] | +24约束
[信息] [9. KPI汇总] TotalKPI = Σ 松弛变量 (12个KPI) | +10约束 | +12变量
[信息] ========== 约束构建完成: 总计 177 约束, 242 变量 ==========
[OK] 约束与KPI汇总创建完成
[OK] 目标函数创建完成
[OK] LP模型文件已导出: F:\product\MES\hyh.apsj\mp\lp\model.lp
模型统计: 变量=242, 约束=177
=== 开始分层求解 ===
--- 第 1/4 层: 需求满足 (松弛=0%) ---
SCIP Status : OPTIMAL
Solving Time (sec) : 0.01
Primal Bound : +0.000000e+00
需求缺口: 0.00 (系数=100.0, 惩罚=0.00)
已添加边界约束: 上层目标 ≤ 0.00
--- 第 2/4 层: 产能约束 (松弛=0%) ---
SCIP Status : OPTIMAL
Solving Time (sec) : 0.01
Primal Bound : +7.680000e+02
产能超载: 38.40 (系数=20.0, 惩罚=768.00)
已添加边界约束: 上层目标 ≤ 768.00
--- 第 3/4 层: 业务KPI (松弛=5%) ---
SCIP Status : OPTIMAL
Solving Time (sec) : 0.01
Primal Bound : +8.430000e+03
批次偏差: 570.00 (系数=10.0, 惩罚=5700.00)
目标库存偏差: 255.00 (系数=8.0, 惩罚=2040.00)
供应目标偏差: 120.00 (系数=8.0, 惩罚=960.00)
销售优先级: 270.00 (系数=-1.0, 惩罚=-270.00)
已添加边界约束: 上层目标 ≤ 8851.50
--- 第 4/4 层: 软约束 (松弛=10%) ---
SCIP Status : OPTIMAL
Solving Time (sec) : 0.01
Primal Bound : +3.550000e+02
超库存: 0.00 (系数=5.0, 惩罚=0.00)
欠库存: 71.00 (系数=5.0, 惩罚=355.00)
最小供应不足: 0.00 (系数=5.0, 惩罚=0.00)
最大供应超出: 0.00 (系数=5.0, 惩罚=0.00)
📋 BOM关系验证:
P1 → 消耗: [S1@WH_Semi×2, R1@WH_RM×3], 被消耗于: []
P2 → 消耗: [S1@WH_Semi×1], 被消耗于: []
S1 → 消耗: [R2@WH_RM×2], 被消耗于: [P1@WH_FG×2, P2@WH_FG×1]
R1 → 消耗: [], 被消耗于: [P1@WH_FG×3]
R2 → 消耗: [], 被消耗于: [S1@WH_Semi×2]
========== 网状BOM + MRP 排产求解 ==========
成品(根节点) 2 种:P1 P2
生产品 5 种:P1 P2 S1 R1 R2
周期 3 天
═══════════════════ 第 1 天 ═══════════════════
▶ 产线视图(按产线分组)
【L1】产能10h
Make-P1(P1): 40件 (耗时4.0h)
利用率: 4/10 h (40%)
Make-P2(P2): 20件 (耗时2.5h)
利用率: 3/10 h (25%)
【L2】产能10h
Make-S1(S1): 50件 (耗时1.7h)
利用率: 2/10 h (17%)
【L3】产能10h
(休息)
【SuA】产能24h
Procure-R1(R1): 165件 (耗时19.8h) , 批次200
利用率: 20/24 h (83%)
【SuB】产能24h
Procure-R1-Alt(R1): 100件 (耗时24.0h) , 批次150
利用率: 24/24 h (100%)
【Su1】产能24h
Procure-R2(R2): 346件 (耗时55.4h) , 批次200
利用率: 55/24 h (231%)
【Su2】产能100h
Procure-R2-Alt(R2): 150件 (耗时45.0h) , 批次150
利用率: 45/100 h (45%)
▶ 产品视图(汇总各产线产量)
P1@WH_FG: 期初库存20件 → 期末库存10件 [目标20/最小5/最大150] | 到货共40件 [L1(到货40件)] 生产中40件 | 销售50/50件
P2@WH_FG: 期初库存10件 → 期末库存0件 [目标15/最小3/最大120] | 到货共20件 [L1(到货20件)] 生产中20件 | 销售30/30件
S1@WH_Semi: 期初库存50件 → 期末库存0件 [目标30/最小10/最大200] | 到货共50件 [L2(到货50件)] 生产中50件 | 销售0/0件
R1@WH_RM: 期初库存200件 → 期末库存80件 [目标80/最小50/最大500] | 无到货 | 销售0/0件
R2@WH_RM: 期初库存100件 → 期末库存0件 [目标40/最小20/最大400] | 无到货 | 销售0/0件
▶ 物料需求展开(按BOM逐级展开)
Make-P1(P1)生产40件 →
─── 物料需求汇总 ───
S1(生产): 期初库存50件, 今日生产50件, 今日需求80件, 期末库存0件
└ S1需求80件(由P1×2系数)
─── 物料需求汇总 ───
R1(采购): 期初库存200件, 今日生产265件, 今日需求120件, 期末库存80件
└ R1需求120件(由P1×3系数)
Make-P2(P2)生产20件 →
─── 物料需求汇总 ───
S1(生产): 期初库存50件, 今日生产50件, 今日需求20件, 期末库存0件
└ S1需求20件(由P2×1系数)
Make-S1(S1)生产50件 →
─── 物料需求汇总 ───
R2(采购): 期初库存100件, 今日生产496件, 今日需求100件, 期末库存0件
└ R2需求100件(由S1×2系数)
Procure-R1(R1)生产165件 →
Procure-R1-Alt(R1)生产100件 →
Procure-R2(R2)生产346件 →
Procure-R2-Alt(R2)生产150件 →
▶ 期末库存 P1@WH_FG=10 P2@WH_FG=0 S1@WH_Semi=0 R1@WH_RM=80 R2@WH_RM=0
═══════════════════ 第 2 天 ═══════════════════
▶ 产线视图(按产线分组)
【L1】产能10h
Make-P2(P2): 24件 (耗时3.0h)
利用率: 3/10 h (30%)
【L2】产能10h
Make-S1(S1): 163件 (耗时5.4h)
利用率: 5/10 h (54%)
【L3】产能10h
Make-P1(P1): 55件 (耗时6.9h)
利用率: 7/10 h (69%)
Make-P2(P2): 19件 (耗时3.1h)
利用率: 3/10 h (31%)
【SuA】产能24h
Procure-R1(R1): 5件 (耗时0.6h) , 批次200
利用率: 1/24 h (3%)
【SuB】产能24h
Procure-R1-Alt(R1): 100件 (耗时24.0h) , 批次150
利用率: 24/24 h (100%)
【Su1】产能24h
Procure-R2(R2): 194件 (耗时31.0h) , 批次200
利用率: 31/24 h (129%)
【Su2】产能100h
Procure-R2-Alt(R2): 150件 (耗时45.0h) , 批次150
利用率: 45/100 h (45%)
▶ 产品视图(汇总各产线产量)
P1@WH_FG: 上期库存10件 → 期末库存5件 [目标20/最小5/最大150] | 到货共55件 [L3(到货55件)] 生产中55件 | 销售60/60件
P2@WH_FG: 上期库存0件 → 期末库存3件 [目标15/最小3/最大120] | 到货共43件 [L1(到货24件), L3(到货19件)] 生产中43件 | 销售40/40件
S1@WH_Semi: 上期库存0件 → 期末库存10件 [目标30/最小10/最大200] | 到货共163件 [L2(到货163件)] 生产中163件 | 销售0/0件
R1@WH_RM: 上期库存80件 → 期末库存80件 [目标80/最小50/最大500] | 到货共165件 [SuA(到货165件)] 生产中105件 | 销售0/0件
R2@WH_RM: 上期库存0件 → 期末库存20件 [目标40/最小20/最大400] | 到货共346件 [Su1(到货346件)] 生产中344件 | 销售0/0件
▶ 物料需求展开(按BOM逐级展开)
Make-P1(P1)生产55件 →
─── 物料需求汇总 ───
S1(生产): 期初库存0件, 今日生产163件, 今日需求110件, 期末库存10件
└ S1需求110件(由P1×2系数)
─── 物料需求汇总 ───
R1(采购): 期初库存80件, 今日生产105件, 今日需求165件, 期末库存80件
└ R1需求165件(由P1×3系数)
Make-P2(P2)生产43件 →
─── 物料需求汇总 ───
S1(生产): 期初库存0件, 今日生产163件, 今日需求43件, 期末库存10件
└ S1需求43件(由P2×1系数)
Make-S1(S1)生产163件 →
─── 物料需求汇总 ───
R2(采购): 期初库存0件, 今日生产344件, 今日需求326件, 期末库存20件
└ R2需求326件(由S1×2系数)
Procure-R1(R1)生产5件 →
Procure-R1-Alt(R1)生产100件 →
Procure-R2(R2)生产194件 →
Procure-R2-Alt(R2)生产150件 →
▶ 期末库存 P1@WH_FG=5 P2@WH_FG=3 S1@WH_Semi=10 R1@WH_RM=80 R2@WH_RM=20
═══════════════════ 第 3 天 ═══════════════════
▶ 产线视图(按产线分组)
【L1】产能10h
Make-P1(P1): 35件 (耗时3.5h)
利用率: 4/10 h (35%)
Make-P2(P2): 47件 (耗时5.9h)
利用率: 6/10 h (59%)
【L2】产能10h
Make-S1(S1): 107件 (耗时3.6h)
利用率: 4/10 h (36%)
【L3】产能10h
(休息)
【SuA】产能24h
Procure-R1(R1): 200件 (耗时24.0h) , 批次200
利用率: 24/24 h (100%)
【SuB】产能24h
Procure-R1-Alt(R1): 100件 (耗时24.0h) , 批次150
利用率: 24/24 h (100%)
【Su1】产能24h
Procure-R2(R2): 150件 (耗时24.0h) , 批次200
利用率: 24/24 h (100%)
【Su2】产能100h
Procure-R2-Alt(R2): 150件 (耗时45.0h) , 批次150
利用率: 45/100 h (45%)
▶ 产品视图(汇总各产线产量)
P1@WH_FG: 上期库存5件 → 期末库存0件 [目标20/最小5/最大150] | 到货共35件 [L1(到货35件)] 生产中35件 | 销售40/40件
P2@WH_FG: 上期库存3件 → 期末库存0件 [目标15/最小3/最大120] | 到货共47件 [L1(到货47件)] 生产中47件 | 销售50/50件
S1@WH_Semi: 上期库存10件 → 期末库存0件 [目标30/最小10/最大200] | 到货共107件 [L2(到货107件)] 生产中107件 | 销售0/0件
R1@WH_RM: 上期库存80件 → 期末库存80件 [目标80/最小50/最大500] | 到货共105件 [SuA(到货5件), SuB(到货100件)] 生产中300件 | 销售0/0件
R2@WH_RM: 上期库存20件 → 期末库存0件 [目标40/最小20/最大400] | 到货共194件 [Su1(到货194件)] 生产中300件 | 销售0/0件
▶ 物料需求展开(按BOM逐级展开)
Make-P1(P1)生产35件 →
─── 物料需求汇总 ───
S1(生产): 期初库存10件, 今日生产107件, 今日需求70件, 期末库存0件
└ S1需求70件(由P1×2系数)
─── 物料需求汇总 ───
R1(采购): 期初库存80件, 今日生产300件, 今日需求105件, 期末库存80件
└ R1需求105件(由P1×3系数)
Make-P2(P2)生产47件 →
─── 物料需求汇总 ───
S1(生产): 期初库存10件, 今日生产107件, 今日需求47件, 期末库存0件
└ S1需求47件(由P2×1系数)
Make-S1(S1)生产107件 →
─── 物料需求汇总 ───
R2(采购): 期初库存20件, 今日生产300件, 今日需求214件, 期末库存0件
└ R2需求214件(由S1×2系数)
Procure-R1(R1)生产200件 →
Procure-R1-Alt(R1)生产100件 →
Procure-R2(R2)生产150件 →
Procure-R2-Alt(R2)生产150件 →
▶ 期末库存 P1@WH_FG=0 P2@WH_FG=0 S1@WH_Semi=0 R1@WH_RM=80 R2@WH_RM=0
═══════════════════ KPI 汇总 ═══════════════════
目标函数值 (最小总惩罚): 355.00
需求缺口惩罚: 0.00 (权重100 × 0.00)
批次偏差惩罚: 5820.00 (权重10 × 582.00)
超库存惩罚: 0.00 (权重5 × 0.00)
欠库存惩罚: 355.00 (权重5 × 71.00)
目标库存偏差: 2136.00 (权重8 × 267.00)
产能超载惩罚: 768.00 (权重20 × 38.40)
供应目标偏差: 1165.50 (权重8 × 145.69)
最小供应不足: 0.00 (权重5 × 0.00)
最大供应超出: 0.00 (权重5 × 0.00)
销售优先级(负): 270.00 (权重1 × 270.00)
═══════════════════ 求解统计 ═══════════════════
变量数:242
约束数:180
耗时:0.131 秒
═══════════════════ 分层优化汇总 ═══════════════════
层级 名称 松弛比例 最优值 包含KPI
1 需求满足 0% 0.00 需求缺口
2 产能约束 0% 768.00 产能超载
3 业务KPI 5% 8430.00 批次偏差, 目标库存偏差, 供应目标偏差, 销售优先级
4 软约束 10% 355.00 超库存, 欠库存, 最小供应不足, 最大供应超出
--- 最终 KPI 值 (加权总惩罚) ---
加权总惩罚: 9974.50
需求缺口: 0.00 (权重100 × 0.00)
产能超载: 768.00 (权重20 × 38.40)
批次偏差: 5820.00 (权重10 × 582.00)
目标库存偏差: 2136.00 (权重8 × 267.00)
供应目标偏差: 1165.50 (权重8 × 145.69)
超库存: 0.00 (权重5 × 0.00)
欠库存: 355.00 (权重5 × 71.00)
最小供应不足: 0.00 (权重5 × 0.00)
最大供应超出: 0.00 (权重5 × 0.00)
销售优先级(负): 270.00 (权重1 × 270.00)
16:47:54.837 [main] INFO com.aps.service.plan.SceneService - 保存成功,场景ID: 1, 文件: F:\product\MES\hyh.apsj\mp\result\optimization_result_1.json
[OK] 优化结果JSON已导出: true
=== 运行结束 ===
This source diff could not be displayed because it is too large. You can view the blob instead.
{"timestamp":"2026-08-11 16:47:54","solver":"SCIP (via Google OR-Tools)","version":"1.0.0","periodTasks":[{"operationId":"OP_P1","operationName":"Make-P1","unitId":"L1","periodIndex":0,"periodStartDate":"2026-01-05","productionQty":40.0,"capacityUsed":4.0,"capacityCoeff":0.1,"lotSize":null,"lotSizeOver":0.0,"lotSizeUnder":0.0,"outputs":[{"productId":"P1","spId":"WH_FG","factor":1.0}]},{"operationId":"OP_P1","operationName":"Make-P1","unitId":"L1","periodIndex":2,"periodStartDate":"2026-01-07","productionQty":35.0,"capacityUsed":3.5,"capacityCoeff":0.1,"lotSize":null,"lotSizeOver":0.0,"lotSizeUnder":0.0,"outputs":[{"productId":"P1","spId":"WH_FG","factor":1.0}]},{"operationId":"OP_P1","operationName":"Make-P1","unitId":"L3","periodIndex":1,"periodStartDate":"2026-01-06","productionQty":55.0,"capacityUsed":6.875,"capacityCoeff":0.125,"lotSize":null,"lotSizeOver":0.0,"lotSizeUnder":0.0,"outputs":[{"productId":"P1","spId":"WH_FG","factor":1.0}]},{"operationId":"OP_P2","operationName":"Make-P2","unitId":"L1","periodIndex":0,"periodStartDate":"2026-01-05","productionQty":20.0,"capacityUsed":2.5,"capacityCoeff":0.125,"lotSize":null,"lotSizeOver":0.0,"lotSizeUnder":0.0,"outputs":[{"productId":"P2","spId":"WH_FG","factor":1.0}]},{"operationId":"OP_P2","operationName":"Make-P2","unitId":"L1","periodIndex":1,"periodStartDate":"2026-01-06","productionQty":24.287425149700603,"capacityUsed":3.0359281437125754,"capacityCoeff":0.125,"lotSize":null,"lotSizeOver":0.0,"lotSizeUnder":0.0,"outputs":[{"productId":"P2","spId":"WH_FG","factor":1.0}]},{"operationId":"OP_P2","operationName":"Make-P2","unitId":"L1","periodIndex":2,"periodStartDate":"2026-01-07","productionQty":47.0,"capacityUsed":5.875,"capacityCoeff":0.125,"lotSize":null,"lotSizeOver":0.0,"lotSizeUnder":0.0,"outputs":[{"productId":"P2","spId":"WH_FG","factor":1.0}]},{"operationId":"OP_P2","operationName":"Make-P2","unitId":"L3","periodIndex":1,"periodStartDate":"2026-01-06","productionQty":18.712574850299397,"capacityUsed":3.1249999999999996,"capacityCoeff":0.167,"lotSize":null,"lotSizeOver":0.0,"lotSizeUnder":0.0,"outputs":[{"productId":"P2","spId":"WH_FG","factor":1.0}]},{"operationId":"OP_S1","operationName":"Make-S1","unitId":"L2","periodIndex":0,"periodStartDate":"2026-01-05","productionQty":50.0,"capacityUsed":1.6650000000000003,"capacityCoeff":0.0333,"lotSize":null,"lotSizeOver":0.0,"lotSizeUnder":0.0,"outputs":[{"productId":"S1","spId":"WH_Semi","factor":1.0}]},{"operationId":"OP_S1","operationName":"Make-S1","unitId":"L2","periodIndex":1,"periodStartDate":"2026-01-06","productionQty":163.0,"capacityUsed":5.4279,"capacityCoeff":0.0333,"lotSize":null,"lotSizeOver":0.0,"lotSizeUnder":0.0,"outputs":[{"productId":"S1","spId":"WH_Semi","factor":1.0}]},{"operationId":"OP_S1","operationName":"Make-S1","unitId":"L2","periodIndex":2,"periodStartDate":"2026-01-07","productionQty":107.0,"capacityUsed":3.5631000000000004,"capacityCoeff":0.0333,"lotSize":null,"lotSizeOver":0.0,"lotSizeUnder":0.0,"outputs":[{"productId":"S1","spId":"WH_Semi","factor":1.0}]},{"operationId":"OP_R1","operationName":"Procure-R1","unitId":"SuA","periodIndex":0,"periodStartDate":"2026-01-05","productionQty":165.0,"capacityUsed":19.8,"capacityCoeff":0.12,"lotSize":200.0,"lotSizeOver":35.0,"lotSizeUnder":0.0,"outputs":[{"productId":"R1","spId":"WH_RM","factor":1.0}]},{"operationId":"OP_R1","operationName":"Procure-R1","unitId":"SuA","periodIndex":1,"periodStartDate":"2026-01-06","productionQty":5.0,"capacityUsed":0.6,"capacityCoeff":0.12,"lotSize":200.0,"lotSizeOver":195.0,"lotSizeUnder":0.0,"outputs":[{"productId":"R1","spId":"WH_RM","factor":1.0}]},{"operationId":"OP_R1","operationName":"Procure-R1","unitId":"SuA","periodIndex":2,"periodStartDate":"2026-01-07","productionQty":200.0,"capacityUsed":24.0,"capacityCoeff":0.12,"lotSize":200.0,"lotSizeOver":0.0,"lotSizeUnder":0.0,"outputs":[{"productId":"R1","spId":"WH_RM","factor":1.0}]},{"operationId":"OP_R1_Alt","operationName":"Procure-R1-Alt","unitId":"SuB","periodIndex":0,"periodStartDate":"2026-01-05","productionQty":100.0,"capacityUsed":24.0,"capacityCoeff":0.24,"lotSize":150.0,"lotSizeOver":50.0,"lotSizeUnder":0.0,"outputs":[{"productId":"R1","spId":"WH_RM","factor":1.0}]},{"operationId":"OP_R1_Alt","operationName":"Procure-R1-Alt","unitId":"SuB","periodIndex":1,"periodStartDate":"2026-01-06","productionQty":100.0,"capacityUsed":24.0,"capacityCoeff":0.24,"lotSize":150.0,"lotSizeOver":50.0,"lotSizeUnder":0.0,"outputs":[{"productId":"R1","spId":"WH_RM","factor":1.0}]},{"operationId":"OP_R1_Alt","operationName":"Procure-R1-Alt","unitId":"SuB","periodIndex":2,"periodStartDate":"2026-01-07","productionQty":100.0,"capacityUsed":24.0,"capacityCoeff":0.24,"lotSize":150.0,"lotSizeOver":50.0,"lotSizeUnder":0.0,"outputs":[{"productId":"R1","spId":"WH_RM","factor":1.0}]},{"operationId":"OP_R2","operationName":"Procure-R2","unitId":"Su1","periodIndex":0,"periodStartDate":"2026-01-05","productionQty":346.0,"capacityUsed":55.36,"capacityCoeff":0.16,"lotSize":200.0,"lotSizeOver":0.0,"lotSizeUnder":146.0,"outputs":[{"productId":"R2","spId":"WH_RM","factor":1.0}]},{"operationId":"OP_R2","operationName":"Procure-R2","unitId":"Su1","periodIndex":1,"periodStartDate":"2026-01-06","productionQty":194.00000000000003,"capacityUsed":31.040000000000006,"capacityCoeff":0.16,"lotSize":200.0,"lotSizeOver":5.999999999999972,"lotSizeUnder":0.0,"outputs":[{"productId":"R2","spId":"WH_RM","factor":1.0}]},{"operationId":"OP_R2","operationName":"Procure-R2","unitId":"Su1","periodIndex":2,"periodStartDate":"2026-01-07","productionQty":150.0,"capacityUsed":24.0,"capacityCoeff":0.16,"lotSize":200.0,"lotSizeOver":50.0,"lotSizeUnder":0.0,"outputs":[{"productId":"R2","spId":"WH_RM","factor":1.0}]},{"operationId":"OP_R2_Alt","operationName":"Procure-R2-Alt","unitId":"Su2","periodIndex":0,"periodStartDate":"2026-01-05","productionQty":150.0,"capacityUsed":45.0,"capacityCoeff":0.3,"lotSize":150.0,"lotSizeOver":0.0,"lotSizeUnder":0.0,"outputs":[{"productId":"R2","spId":"WH_RM","factor":1.0}]},{"operationId":"OP_R2_Alt","operationName":"Procure-R2-Alt","unitId":"Su2","periodIndex":1,"periodStartDate":"2026-01-06","productionQty":150.0,"capacityUsed":45.0,"capacityCoeff":0.3,"lotSize":150.0,"lotSizeOver":0.0,"lotSizeUnder":0.0,"outputs":[{"productId":"R2","spId":"WH_RM","factor":1.0}]},{"operationId":"OP_R2_Alt","operationName":"Procure-R2-Alt","unitId":"Su2","periodIndex":2,"periodStartDate":"2026-01-07","productionQty":150.0,"capacityUsed":45.0,"capacityCoeff":0.3,"lotSize":150.0,"lotSizeOver":0.0,"lotSizeUnder":0.0,"outputs":[{"productId":"R2","spId":"WH_RM","factor":1.0}]}],"salesDemands":[{"salesDemandId":"P1_WH_FG_0","productId":"P1","spId":"WH_FG","periodIndex":0,"periodStartDate":"2026-01-05","demandQty":50.0,"fulfilledQty":50.0,"unmetQty":0.0,"demandSlack":0.0,"fulfillmentRate":1.0,"priority":1.0},{"salesDemandId":"P1_WH_FG_1","productId":"P1","spId":"WH_FG","periodIndex":1,"periodStartDate":"2026-01-06","demandQty":60.0,"fulfilledQty":60.0,"unmetQty":0.0,"demandSlack":0.0,"fulfillmentRate":1.0,"priority":1.0},{"salesDemandId":"P1_WH_FG_2","productId":"P1","spId":"WH_FG","periodIndex":2,"periodStartDate":"2026-01-07","demandQty":40.0,"fulfilledQty":40.0,"unmetQty":0.0,"demandSlack":0.0,"fulfillmentRate":1.0,"priority":1.0},{"salesDemandId":"P2_WH_FG_0","productId":"P2","spId":"WH_FG","periodIndex":0,"periodStartDate":"2026-01-05","demandQty":30.0,"fulfilledQty":30.0,"unmetQty":0.0,"demandSlack":0.0,"fulfillmentRate":1.0,"priority":1.0},{"salesDemandId":"P2_WH_FG_1","productId":"P2","spId":"WH_FG","periodIndex":1,"periodStartDate":"2026-01-06","demandQty":40.0,"fulfilledQty":40.0,"unmetQty":0.0,"demandSlack":0.0,"fulfillmentRate":1.0,"priority":1.0},{"salesDemandId":"P2_WH_FG_2","productId":"P2","spId":"WH_FG","periodIndex":2,"periodStartDate":"2026-01-07","demandQty":50.0,"fulfilledQty":50.0,"unmetQty":0.0,"demandSlack":0.0,"fulfillmentRate":1.0,"priority":1.0}],"pispips":[{"productId":"P1","spId":"WH_FG","periodIndex":0,"periodStartDate":"2026-01-05","openingInventory":20.0,"endingInventory":10.0,"targetInventoryLevel":20.0,"targetInventoryDays":null,"belowTarget":10.0,"minInventoryLevel":5.0,"minInventoryDays":null,"belowMin":0.0,"maxInventoryLevel":150.0,"maxInventoryDays":null,"aboveMax":0.0,"productionArrived":40.0,"inTransitArrival":0.0,"totalInflow":40.0,"salesDemandQty":50.0,"salesFulfilledQty":50.0,"dependentDemandQty":0.0,"totalOutflow":50.0,"demandFulfillment":50.0,"demandSlack":0.0,"productionInProgress":40.0,"productionDetails":[{"operationId":"OP_P1","operationName":"Make-P1","unitId":"L1","quantity":40.0,"leadTimeDays":0,"bomConsumptions":[{"inputProductId":"S1","inputSpId":"WH_Semi","factor":2.0,"consumedQty":80.0},{"inputProductId":"R1","inputSpId":"WH_RM","factor":3.0,"consumedQty":120.0}]}]},{"productId":"P1","spId":"WH_FG","periodIndex":1,"periodStartDate":"2026-01-06","openingInventory":10.0,"endingInventory":5.0,"targetInventoryLevel":20.0,"targetInventoryDays":null,"belowTarget":15.0,"minInventoryLevel":5.0,"minInventoryDays":null,"belowMin":0.0,"maxInventoryLevel":150.0,"maxInventoryDays":null,"aboveMax":0.0,"productionArrived":55.0,"inTransitArrival":0.0,"totalInflow":55.0,"salesDemandQty":60.0,"salesFulfilledQty":60.0,"dependentDemandQty":0.0,"totalOutflow":60.0,"demandFulfillment":60.0,"demandSlack":0.0,"productionInProgress":55.0,"productionDetails":[{"operationId":"OP_P1","operationName":"Make-P1","unitId":"L3","quantity":55.0,"leadTimeDays":0,"bomConsumptions":[{"inputProductId":"S1","inputSpId":"WH_Semi","factor":2.0,"consumedQty":110.0},{"inputProductId":"R1","inputSpId":"WH_RM","factor":3.0,"consumedQty":165.0}]}]},{"productId":"P1","spId":"WH_FG","periodIndex":2,"periodStartDate":"2026-01-07","openingInventory":5.0,"endingInventory":0.0,"targetInventoryLevel":20.0,"targetInventoryDays":null,"belowTarget":20.0,"minInventoryLevel":5.0,"minInventoryDays":null,"belowMin":5.0,"maxInventoryLevel":150.0,"maxInventoryDays":null,"aboveMax":0.0,"productionArrived":35.0,"inTransitArrival":0.0,"totalInflow":35.0,"salesDemandQty":40.0,"salesFulfilledQty":40.0,"dependentDemandQty":0.0,"totalOutflow":40.0,"demandFulfillment":40.0,"demandSlack":0.0,"productionInProgress":35.0,"productionDetails":[{"operationId":"OP_P1","operationName":"Make-P1","unitId":"L1","quantity":35.0,"leadTimeDays":0,"bomConsumptions":[{"inputProductId":"S1","inputSpId":"WH_Semi","factor":2.0,"consumedQty":70.0},{"inputProductId":"R1","inputSpId":"WH_RM","factor":3.0,"consumedQty":105.0}]}]},{"productId":"P2","spId":"WH_FG","periodIndex":0,"periodStartDate":"2026-01-05","openingInventory":10.0,"endingInventory":0.0,"targetInventoryLevel":15.0,"targetInventoryDays":null,"belowTarget":15.0,"minInventoryLevel":3.0,"minInventoryDays":null,"belowMin":3.0,"maxInventoryLevel":120.0,"maxInventoryDays":null,"aboveMax":0.0,"productionArrived":20.0,"inTransitArrival":0.0,"totalInflow":20.0,"salesDemandQty":30.0,"salesFulfilledQty":30.0,"dependentDemandQty":0.0,"totalOutflow":30.0,"demandFulfillment":30.0,"demandSlack":0.0,"productionInProgress":20.0,"productionDetails":[{"operationId":"OP_P2","operationName":"Make-P2","unitId":"L1","quantity":20.0,"leadTimeDays":0,"bomConsumptions":[{"inputProductId":"S1","inputSpId":"WH_Semi","factor":1.0,"consumedQty":20.0}]}]},{"productId":"P2","spId":"WH_FG","periodIndex":1,"periodStartDate":"2026-01-06","openingInventory":0.0,"endingInventory":3.0,"targetInventoryLevel":15.0,"targetInventoryDays":null,"belowTarget":12.0,"minInventoryLevel":3.0,"minInventoryDays":null,"belowMin":0.0,"maxInventoryLevel":120.0,"maxInventoryDays":null,"aboveMax":0.0,"productionArrived":43.0,"inTransitArrival":0.0,"totalInflow":43.0,"salesDemandQty":40.0,"salesFulfilledQty":40.0,"dependentDemandQty":0.0,"totalOutflow":40.0,"demandFulfillment":40.0,"demandSlack":0.0,"productionInProgress":43.0,"productionDetails":[{"operationId":"OP_P2","operationName":"Make-P2","unitId":"L1","quantity":24.287425149700603,"leadTimeDays":0,"bomConsumptions":[{"inputProductId":"S1","inputSpId":"WH_Semi","factor":1.0,"consumedQty":43.0}]},{"operationId":"OP_P2","operationName":"Make-P2","unitId":"L3","quantity":18.712574850299397,"leadTimeDays":0,"bomConsumptions":[{"inputProductId":"S1","inputSpId":"WH_Semi","factor":1.0,"consumedQty":43.0}]}]},{"productId":"P2","spId":"WH_FG","periodIndex":2,"periodStartDate":"2026-01-07","openingInventory":3.0,"endingInventory":0.0,"targetInventoryLevel":15.0,"targetInventoryDays":null,"belowTarget":15.0,"minInventoryLevel":3.0,"minInventoryDays":null,"belowMin":3.0,"maxInventoryLevel":120.0,"maxInventoryDays":null,"aboveMax":0.0,"productionArrived":47.0,"inTransitArrival":0.0,"totalInflow":47.0,"salesDemandQty":50.0,"salesFulfilledQty":50.0,"dependentDemandQty":0.0,"totalOutflow":50.0,"demandFulfillment":50.0,"demandSlack":0.0,"productionInProgress":47.0,"productionDetails":[{"operationId":"OP_P2","operationName":"Make-P2","unitId":"L1","quantity":47.0,"leadTimeDays":0,"bomConsumptions":[{"inputProductId":"S1","inputSpId":"WH_Semi","factor":1.0,"consumedQty":47.0}]}]},{"productId":"S1","spId":"WH_Semi","periodIndex":0,"periodStartDate":"2026-01-05","openingInventory":50.0,"endingInventory":0.0,"targetInventoryLevel":30.0,"targetInventoryDays":null,"belowTarget":30.0,"minInventoryLevel":10.0,"minInventoryDays":null,"belowMin":10.0,"maxInventoryLevel":200.0,"maxInventoryDays":null,"aboveMax":0.0,"productionArrived":50.0,"inTransitArrival":0.0,"totalInflow":50.0,"salesDemandQty":0.0,"salesFulfilledQty":0.0,"dependentDemandQty":100.0,"totalOutflow":100.0,"demandFulfillment":100.0,"demandSlack":0.0,"productionInProgress":50.0,"productionDetails":[{"operationId":"OP_S1","operationName":"Make-S1","unitId":"L2","quantity":50.0,"leadTimeDays":0,"bomConsumptions":[{"inputProductId":"R2","inputSpId":"WH_RM","factor":2.0,"consumedQty":100.0}]}]},{"productId":"S1","spId":"WH_Semi","periodIndex":1,"periodStartDate":"2026-01-06","openingInventory":0.0,"endingInventory":10.0,"targetInventoryLevel":30.0,"targetInventoryDays":null,"belowTarget":20.0,"minInventoryLevel":10.0,"minInventoryDays":null,"belowMin":0.0,"maxInventoryLevel":200.0,"maxInventoryDays":null,"aboveMax":0.0,"productionArrived":163.0,"inTransitArrival":0.0,"totalInflow":163.0,"salesDemandQty":0.0,"salesFulfilledQty":0.0,"dependentDemandQty":153.0,"totalOutflow":153.0,"demandFulfillment":153.0,"demandSlack":0.0,"productionInProgress":163.0,"productionDetails":[{"operationId":"OP_S1","operationName":"Make-S1","unitId":"L2","quantity":163.0,"leadTimeDays":0,"bomConsumptions":[{"inputProductId":"R2","inputSpId":"WH_RM","factor":2.0,"consumedQty":326.0}]}]},{"productId":"S1","spId":"WH_Semi","periodIndex":2,"periodStartDate":"2026-01-07","openingInventory":10.0,"endingInventory":0.0,"targetInventoryLevel":30.0,"targetInventoryDays":null,"belowTarget":30.0,"minInventoryLevel":10.0,"minInventoryDays":null,"belowMin":10.0,"maxInventoryLevel":200.0,"maxInventoryDays":null,"aboveMax":0.0,"productionArrived":107.0,"inTransitArrival":0.0,"totalInflow":107.0,"salesDemandQty":0.0,"salesFulfilledQty":0.0,"dependentDemandQty":117.0,"totalOutflow":117.0,"demandFulfillment":117.0,"demandSlack":0.0,"productionInProgress":107.0,"productionDetails":[{"operationId":"OP_S1","operationName":"Make-S1","unitId":"L2","quantity":107.0,"leadTimeDays":0,"bomConsumptions":[{"inputProductId":"R2","inputSpId":"WH_RM","factor":2.0,"consumedQty":214.0}]}]},{"productId":"R1","spId":"WH_RM","periodIndex":0,"periodStartDate":"2026-01-05","openingInventory":200.0,"endingInventory":80.0,"targetInventoryLevel":80.0,"targetInventoryDays":null,"belowTarget":0.0,"minInventoryLevel":50.0,"minInventoryDays":null,"belowMin":0.0,"maxInventoryLevel":500.0,"maxInventoryDays":null,"aboveMax":0.0,"productionArrived":0.0,"inTransitArrival":0.0,"totalInflow":0.0,"salesDemandQty":0.0,"salesFulfilledQty":0.0,"dependentDemandQty":120.0,"totalOutflow":120.0,"demandFulfillment":120.0,"demandSlack":0.0,"productionInProgress":265.0,"productionDetails":[{"operationId":"OP_R1","operationName":"Procure-R1","unitId":"SuA","quantity":165.0,"leadTimeDays":1,"bomConsumptions":[]},{"operationId":"OP_R1_Alt","operationName":"Procure-R1-Alt","unitId":"SuB","quantity":100.0,"leadTimeDays":2,"bomConsumptions":[]}]},{"productId":"R1","spId":"WH_RM","periodIndex":1,"periodStartDate":"2026-01-06","openingInventory":80.0,"endingInventory":80.0,"targetInventoryLevel":80.0,"targetInventoryDays":null,"belowTarget":0.0,"minInventoryLevel":50.0,"minInventoryDays":null,"belowMin":0.0,"maxInventoryLevel":500.0,"maxInventoryDays":null,"aboveMax":0.0,"productionArrived":165.0,"inTransitArrival":0.0,"totalInflow":165.0,"salesDemandQty":0.0,"salesFulfilledQty":0.0,"dependentDemandQty":165.0,"totalOutflow":165.0,"demandFulfillment":165.0,"demandSlack":0.0,"productionInProgress":105.0,"productionDetails":[{"operationId":"OP_R1","operationName":"Procure-R1","unitId":"SuA","quantity":5.0,"leadTimeDays":1,"bomConsumptions":[]},{"operationId":"OP_R1_Alt","operationName":"Procure-R1-Alt","unitId":"SuB","quantity":100.0,"leadTimeDays":2,"bomConsumptions":[]}]},{"productId":"R1","spId":"WH_RM","periodIndex":2,"periodStartDate":"2026-01-07","openingInventory":80.0,"endingInventory":80.0,"targetInventoryLevel":80.0,"targetInventoryDays":null,"belowTarget":0.0,"minInventoryLevel":50.0,"minInventoryDays":null,"belowMin":0.0,"maxInventoryLevel":500.0,"maxInventoryDays":null,"aboveMax":0.0,"productionArrived":105.0,"inTransitArrival":0.0,"totalInflow":105.0,"salesDemandQty":0.0,"salesFulfilledQty":0.0,"dependentDemandQty":105.0,"totalOutflow":105.0,"demandFulfillment":105.0,"demandSlack":0.0,"productionInProgress":300.0,"productionDetails":[{"operationId":"OP_R1","operationName":"Procure-R1","unitId":"SuA","quantity":200.0,"leadTimeDays":1,"bomConsumptions":[]},{"operationId":"OP_R1_Alt","operationName":"Procure-R1-Alt","unitId":"SuB","quantity":100.0,"leadTimeDays":2,"bomConsumptions":[]}]},{"productId":"R2","spId":"WH_RM","periodIndex":0,"periodStartDate":"2026-01-05","openingInventory":100.0,"endingInventory":0.0,"targetInventoryLevel":40.0,"targetInventoryDays":null,"belowTarget":40.0,"minInventoryLevel":20.0,"minInventoryDays":null,"belowMin":20.0,"maxInventoryLevel":400.0,"maxInventoryDays":null,"aboveMax":0.0,"productionArrived":0.0,"inTransitArrival":0.0,"totalInflow":0.0,"salesDemandQty":0.0,"salesFulfilledQty":0.0,"dependentDemandQty":100.0,"totalOutflow":100.0,"demandFulfillment":100.0,"demandSlack":0.0,"productionInProgress":496.0,"productionDetails":[{"operationId":"OP_R2","operationName":"Procure-R2","unitId":"Su1","quantity":346.0,"leadTimeDays":1,"bomConsumptions":[]},{"operationId":"OP_R2_Alt","operationName":"Procure-R2-Alt","unitId":"Su2","quantity":150.0,"leadTimeDays":3,"bomConsumptions":[]}]},{"productId":"R2","spId":"WH_RM","periodIndex":1,"periodStartDate":"2026-01-06","openingInventory":0.0,"endingInventory":20.0,"targetInventoryLevel":40.0,"targetInventoryDays":null,"belowTarget":20.0,"minInventoryLevel":20.0,"minInventoryDays":null,"belowMin":0.0,"maxInventoryLevel":400.0,"maxInventoryDays":null,"aboveMax":0.0,"productionArrived":346.0,"inTransitArrival":0.0,"totalInflow":346.0,"salesDemandQty":0.0,"salesFulfilledQty":0.0,"dependentDemandQty":326.0,"totalOutflow":326.0,"demandFulfillment":326.0,"demandSlack":0.0,"productionInProgress":344.0,"productionDetails":[{"operationId":"OP_R2","operationName":"Procure-R2","unitId":"Su1","quantity":194.00000000000003,"leadTimeDays":1,"bomConsumptions":[]},{"operationId":"OP_R2_Alt","operationName":"Procure-R2-Alt","unitId":"Su2","quantity":150.0,"leadTimeDays":3,"bomConsumptions":[]}]},{"productId":"R2","spId":"WH_RM","periodIndex":2,"periodStartDate":"2026-01-07","openingInventory":20.0,"endingInventory":2.8421709430404007E-14,"targetInventoryLevel":40.0,"targetInventoryDays":null,"belowTarget":39.99999999999997,"minInventoryLevel":20.0,"minInventoryDays":null,"belowMin":19.99999999999997,"maxInventoryLevel":400.0,"maxInventoryDays":null,"aboveMax":0.0,"productionArrived":194.00000000000003,"inTransitArrival":0.0,"totalInflow":194.00000000000003,"salesDemandQty":0.0,"salesFulfilledQty":0.0,"dependentDemandQty":214.0,"totalOutflow":214.0,"demandFulfillment":214.0,"demandSlack":0.0,"productionInProgress":300.0,"productionDetails":[{"operationId":"OP_R2","operationName":"Procure-R2","unitId":"Su1","quantity":150.0,"leadTimeDays":1,"bomConsumptions":[]},{"operationId":"OP_R2_Alt","operationName":"Procure-R2-Alt","unitId":"Su2","quantity":150.0,"leadTimeDays":3,"bomConsumptions":[]}]}],"productNetwork":{"finishedGoods":[{"productId":"P1","spId":"WH_FG","level":0,"supplySources":[{"type":"OPERATION","operationId":"OP_P1","operationName":"Make-P1","unitId":"L1","totalProduction":130.0,"capacityUsed":14.375,"lotSize":null}],"totalSalesDemand":150.0,"totalSalesFulfilled":150.0,"totalDependentDemand":0.0,"consumers":[],"summary":{"totalProduction":130.0,"totalArrived":130.0,"totalInTransit":0.0,"totalSalesDemand":150.0,"totalSalesFulfilled":150.0,"totalDependentDemand":0.0,"totalDemandFulfillment":150.0,"totalDemandSlack":0.0,"initialInventory":20.0,"finalInventory":0.0,"averageInventory":5.0,"totalBelowTarget":45.0,"totalBelowMin":5.0,"totalAboveMax":0.0},"children":[{"productId":"S1","spId":"WH_Semi","totalFactor":2.0,"totalConsumedQty":260.0},{"productId":"R1","spId":"WH_RM","totalFactor":3.0,"totalConsumedQty":390.0}]},{"productId":"P2","spId":"WH_FG","level":0,"supplySources":[{"type":"OPERATION","operationId":"OP_P2","operationName":"Make-P2","unitId":"L1","totalProduction":110.0,"capacityUsed":14.535928143712574,"lotSize":null}],"totalSalesDemand":120.0,"totalSalesFulfilled":120.0,"totalDependentDemand":0.0,"consumers":[],"summary":{"totalProduction":110.0,"totalArrived":110.0,"totalInTransit":0.0,"totalSalesDemand":120.0,"totalSalesFulfilled":120.0,"totalDependentDemand":0.0,"totalDemandFulfillment":120.0,"totalDemandSlack":0.0,"initialInventory":10.0,"finalInventory":0.0,"averageInventory":1.0,"totalBelowTarget":42.0,"totalBelowMin":6.0,"totalAboveMax":0.0},"children":[{"productId":"S1","spId":"WH_Semi","totalFactor":1.0,"totalConsumedQty":110.0}]}],"allNodes":{"P1@WH_FG":{"productId":"P1","spId":"WH_FG","level":0,"supplySources":[{"type":"OPERATION","operationId":"OP_P1","operationName":"Make-P1","unitId":"L1","totalProduction":130.0,"capacityUsed":14.375,"lotSize":null}],"totalSalesDemand":150.0,"totalSalesFulfilled":150.0,"totalDependentDemand":0.0,"consumers":[],"summary":{"totalProduction":130.0,"totalArrived":130.0,"totalInTransit":0.0,"totalSalesDemand":150.0,"totalSalesFulfilled":150.0,"totalDependentDemand":0.0,"totalDemandFulfillment":150.0,"totalDemandSlack":0.0,"initialInventory":20.0,"finalInventory":0.0,"averageInventory":5.0,"totalBelowTarget":45.0,"totalBelowMin":5.0,"totalAboveMax":0.0},"children":[{"productId":"S1","spId":"WH_Semi","totalFactor":2.0,"totalConsumedQty":260.0},{"productId":"R1","spId":"WH_RM","totalFactor":3.0,"totalConsumedQty":390.0}]},"S1@WH_Semi":{"productId":"S1","spId":"WH_Semi","level":1,"supplySources":[{"type":"OPERATION","operationId":"OP_S1","operationName":"Make-S1","unitId":"L2","totalProduction":320.0,"capacityUsed":10.656,"lotSize":null}],"totalSalesDemand":0.0,"totalSalesFulfilled":0.0,"totalDependentDemand":370.0,"consumers":[{"consumerProductId":"P1","consumerSpId":"WH_FG","operationId":"OP_P1","operationName":"Make-P1","factor":2.0,"totalConsumed":260.0},{"consumerProductId":"P2","consumerSpId":"WH_FG","operationId":"OP_P2","operationName":"Make-P2","factor":1.0,"totalConsumed":110.0}],"summary":{"totalProduction":320.0,"totalArrived":320.0,"totalInTransit":0.0,"totalSalesDemand":0.0,"totalSalesFulfilled":0.0,"totalDependentDemand":370.0,"totalDemandFulfillment":370.0,"totalDemandSlack":0.0,"initialInventory":50.0,"finalInventory":0.0,"averageInventory":3.3333333333333335,"totalBelowTarget":80.0,"totalBelowMin":20.0,"totalAboveMax":0.0},"children":[{"productId":"R2","spId":"WH_RM","totalFactor":2.0,"totalConsumedQty":640.0}]},"R2@WH_RM":{"productId":"R2","spId":"WH_RM","level":2,"supplySources":[{"type":"OPERATION","operationId":"OP_R2","operationName":"Procure-R2","unitId":"Su1","totalProduction":690.0,"capacityUsed":110.4,"lotSize":200.0},{"type":"OPERATION","operationId":"OP_R2_Alt","operationName":"Procure-R2-Alt","unitId":"Su2","totalProduction":450.0,"capacityUsed":135.0,"lotSize":150.0}],"totalSalesDemand":0.0,"totalSalesFulfilled":0.0,"totalDependentDemand":640.0,"consumers":[{"consumerProductId":"S1","consumerSpId":"WH_Semi","operationId":"OP_S1","operationName":"Make-S1","factor":2.0,"totalConsumed":640.0}],"summary":{"totalProduction":1140.0,"totalArrived":540.0,"totalInTransit":0.0,"totalSalesDemand":0.0,"totalSalesFulfilled":0.0,"totalDependentDemand":640.0,"totalDemandFulfillment":640.0,"totalDemandSlack":0.0,"initialInventory":100.0,"finalInventory":2.8421709430404007E-14,"averageInventory":6.666666666666676,"totalBelowTarget":99.99999999999997,"totalBelowMin":39.99999999999997,"totalAboveMax":0.0},"children":[]},"R1@WH_RM":{"productId":"R1","spId":"WH_RM","level":1,"supplySources":[{"type":"OPERATION","operationId":"OP_R1","operationName":"Procure-R1","unitId":"SuA","totalProduction":370.0,"capacityUsed":44.400000000000006,"lotSize":200.0},{"type":"OPERATION","operationId":"OP_R1_Alt","operationName":"Procure-R1-Alt","unitId":"SuB","totalProduction":300.0,"capacityUsed":72.0,"lotSize":150.0}],"totalSalesDemand":0.0,"totalSalesFulfilled":0.0,"totalDependentDemand":390.0,"consumers":[{"consumerProductId":"P1","consumerSpId":"WH_FG","operationId":"OP_P1","operationName":"Make-P1","factor":3.0,"totalConsumed":390.0}],"summary":{"totalProduction":670.0,"totalArrived":270.0,"totalInTransit":0.0,"totalSalesDemand":0.0,"totalSalesFulfilled":0.0,"totalDependentDemand":390.0,"totalDemandFulfillment":390.0,"totalDemandSlack":0.0,"initialInventory":200.0,"finalInventory":80.0,"averageInventory":80.0,"totalBelowTarget":0.0,"totalBelowMin":0.0,"totalAboveMax":0.0},"children":[]},"P2@WH_FG":{"productId":"P2","spId":"WH_FG","level":0,"supplySources":[{"type":"OPERATION","operationId":"OP_P2","operationName":"Make-P2","unitId":"L1","totalProduction":110.0,"capacityUsed":14.535928143712574,"lotSize":null}],"totalSalesDemand":120.0,"totalSalesFulfilled":120.0,"totalDependentDemand":0.0,"consumers":[],"summary":{"totalProduction":110.0,"totalArrived":110.0,"totalInTransit":0.0,"totalSalesDemand":120.0,"totalSalesFulfilled":120.0,"totalDependentDemand":0.0,"totalDemandFulfillment":120.0,"totalDemandSlack":0.0,"initialInventory":10.0,"finalInventory":0.0,"averageInventory":1.0,"totalBelowTarget":42.0,"totalBelowMin":6.0,"totalAboveMax":0.0},"children":[{"productId":"S1","spId":"WH_Semi","totalFactor":1.0,"totalConsumedQty":110.0}]}}},"unitCapacities":[{"unitId":"L1","operationIds":null,"totalMaxCapacity":30.0,"totalCapacityUsed":18.910928143712574,"totalUtilizationRate":0.0,"totalProduction":0.0,"totalOverloaded":0.0,"totalNotMet":0.0,"overallUtilization":0.6303642714570858,"periodDetails":[{"periodIndex":0,"periodStartDate":"2026-01-05","maxCapacity":10.0,"capacityUsed":6.5,"utilization":0.65,"overloaded":0.0,"notMet":-0.0,"production":0.0,"tasks":[{"operationId":"OP_P1","operationName":"Make-P1","productionQty":40.0,"capacityUsed":4.0,"capacityCoeff":0.1,"lotSize":null,"lotSizeOver":0.0,"lotSizeUnder":0.0,"outputs":["P1@WH_FG"]},{"operationId":"OP_P2","operationName":"Make-P2","productionQty":20.0,"capacityUsed":2.5,"capacityCoeff":0.125,"lotSize":null,"lotSizeOver":0.0,"lotSizeUnder":0.0,"outputs":["P2@WH_FG"]}]},{"periodIndex":1,"periodStartDate":"2026-01-06","maxCapacity":10.0,"capacityUsed":3.0359281437125754,"utilization":0.3035928143712575,"overloaded":0.0,"notMet":-0.0,"production":0.0,"tasks":[{"operationId":"OP_P2","operationName":"Make-P2","productionQty":24.287425149700603,"capacityUsed":3.0359281437125754,"capacityCoeff":0.125,"lotSize":null,"lotSizeOver":0.0,"lotSizeUnder":0.0,"outputs":["P2@WH_FG"]}]},{"periodIndex":2,"periodStartDate":"2026-01-07","maxCapacity":10.0,"capacityUsed":9.375,"utilization":0.9375,"overloaded":0.0,"notMet":-0.0,"production":0.0,"tasks":[{"operationId":"OP_P1","operationName":"Make-P1","productionQty":35.0,"capacityUsed":3.5,"capacityCoeff":0.1,"lotSize":null,"lotSizeOver":0.0,"lotSizeUnder":0.0,"outputs":["P1@WH_FG"]},{"operationId":"OP_P2","operationName":"Make-P2","productionQty":47.0,"capacityUsed":5.875,"capacityCoeff":0.125,"lotSize":null,"lotSizeOver":0.0,"lotSizeUnder":0.0,"outputs":["P2@WH_FG"]}]}]},{"unitId":"L2","operationIds":null,"totalMaxCapacity":30.0,"totalCapacityUsed":10.656,"totalUtilizationRate":0.0,"totalProduction":0.0,"totalOverloaded":0.0,"totalNotMet":0.0,"overallUtilization":0.3552,"periodDetails":[{"periodIndex":0,"periodStartDate":"2026-01-05","maxCapacity":10.0,"capacityUsed":1.6650000000000003,"utilization":0.16650000000000004,"overloaded":0.0,"notMet":-0.0,"production":0.0,"tasks":[{"operationId":"OP_S1","operationName":"Make-S1","productionQty":50.0,"capacityUsed":1.6650000000000003,"capacityCoeff":0.0333,"lotSize":null,"lotSizeOver":0.0,"lotSizeUnder":0.0,"outputs":["S1@WH_Semi"]}]},{"periodIndex":1,"periodStartDate":"2026-01-06","maxCapacity":10.0,"capacityUsed":5.4279,"utilization":0.54279,"overloaded":0.0,"notMet":-0.0,"production":0.0,"tasks":[{"operationId":"OP_S1","operationName":"Make-S1","productionQty":163.0,"capacityUsed":5.4279,"capacityCoeff":0.0333,"lotSize":null,"lotSizeOver":0.0,"lotSizeUnder":0.0,"outputs":["S1@WH_Semi"]}]},{"periodIndex":2,"periodStartDate":"2026-01-07","maxCapacity":10.0,"capacityUsed":3.5631000000000004,"utilization":0.35631,"overloaded":0.0,"notMet":-0.0,"production":0.0,"tasks":[{"operationId":"OP_S1","operationName":"Make-S1","productionQty":107.0,"capacityUsed":3.5631000000000004,"capacityCoeff":0.0333,"lotSize":null,"lotSizeOver":0.0,"lotSizeUnder":0.0,"outputs":["S1@WH_Semi"]}]}]},{"unitId":"L3","operationIds":null,"totalMaxCapacity":30.0,"totalCapacityUsed":10.0,"totalUtilizationRate":0.0,"totalProduction":0.0,"totalOverloaded":0.0,"totalNotMet":0.0,"overallUtilization":0.3333333333333333,"periodDetails":[{"periodIndex":0,"periodStartDate":"2026-01-05","maxCapacity":10.0,"capacityUsed":0.0,"utilization":0.0,"overloaded":0.0,"notMet":-0.0,"production":0.0,"tasks":[]},{"periodIndex":1,"periodStartDate":"2026-01-06","maxCapacity":10.0,"capacityUsed":10.0,"utilization":1.0,"overloaded":0.0,"notMet":-0.0,"production":0.0,"tasks":[{"operationId":"OP_P1","operationName":"Make-P1","productionQty":55.0,"capacityUsed":6.875,"capacityCoeff":0.125,"lotSize":null,"lotSizeOver":0.0,"lotSizeUnder":0.0,"outputs":["P1@WH_FG"]},{"operationId":"OP_P2","operationName":"Make-P2","productionQty":18.712574850299397,"capacityUsed":3.1249999999999996,"capacityCoeff":0.167,"lotSize":null,"lotSizeOver":0.0,"lotSizeUnder":0.0,"outputs":["P2@WH_FG"]}]},{"periodIndex":2,"periodStartDate":"2026-01-07","maxCapacity":10.0,"capacityUsed":0.0,"utilization":0.0,"overloaded":0.0,"notMet":-0.0,"production":0.0,"tasks":[]}]},{"unitId":"SuA","operationIds":null,"totalMaxCapacity":72.0,"totalCapacityUsed":44.400000000000006,"totalUtilizationRate":0.0,"totalProduction":0.0,"totalOverloaded":0.0,"totalNotMet":0.0,"overallUtilization":0.6166666666666667,"periodDetails":[{"periodIndex":0,"periodStartDate":"2026-01-05","maxCapacity":24.0,"capacityUsed":19.8,"utilization":0.8250000000000001,"overloaded":0.0,"notMet":-0.0,"production":0.0,"tasks":[{"operationId":"OP_R1","operationName":"Procure-R1","productionQty":165.0,"capacityUsed":19.8,"capacityCoeff":0.12,"lotSize":200.0,"lotSizeOver":35.0,"lotSizeUnder":0.0,"outputs":["R1@WH_RM"]}]},{"periodIndex":1,"periodStartDate":"2026-01-06","maxCapacity":24.0,"capacityUsed":0.6,"utilization":0.024999999999999998,"overloaded":0.0,"notMet":-0.0,"production":0.0,"tasks":[{"operationId":"OP_R1","operationName":"Procure-R1","productionQty":5.0,"capacityUsed":0.6,"capacityCoeff":0.12,"lotSize":200.0,"lotSizeOver":195.0,"lotSizeUnder":0.0,"outputs":["R1@WH_RM"]}]},{"periodIndex":2,"periodStartDate":"2026-01-07","maxCapacity":24.0,"capacityUsed":24.0,"utilization":1.0,"overloaded":0.0,"notMet":-0.0,"production":0.0,"tasks":[{"operationId":"OP_R1","operationName":"Procure-R1","productionQty":200.0,"capacityUsed":24.0,"capacityCoeff":0.12,"lotSize":200.0,"lotSizeOver":0.0,"lotSizeUnder":0.0,"outputs":["R1@WH_RM"]}]}]},{"unitId":"SuB","operationIds":null,"totalMaxCapacity":72.0,"totalCapacityUsed":72.0,"totalUtilizationRate":0.0,"totalProduction":0.0,"totalOverloaded":0.0,"totalNotMet":0.0,"overallUtilization":1.0,"periodDetails":[{"periodIndex":0,"periodStartDate":"2026-01-05","maxCapacity":24.0,"capacityUsed":24.0,"utilization":1.0,"overloaded":0.0,"notMet":-0.0,"production":0.0,"tasks":[{"operationId":"OP_R1_Alt","operationName":"Procure-R1-Alt","productionQty":100.0,"capacityUsed":24.0,"capacityCoeff":0.24,"lotSize":150.0,"lotSizeOver":50.0,"lotSizeUnder":0.0,"outputs":["R1@WH_RM"]}]},{"periodIndex":1,"periodStartDate":"2026-01-06","maxCapacity":24.0,"capacityUsed":24.0,"utilization":1.0,"overloaded":0.0,"notMet":-0.0,"production":0.0,"tasks":[{"operationId":"OP_R1_Alt","operationName":"Procure-R1-Alt","productionQty":100.0,"capacityUsed":24.0,"capacityCoeff":0.24,"lotSize":150.0,"lotSizeOver":50.0,"lotSizeUnder":0.0,"outputs":["R1@WH_RM"]}]},{"periodIndex":2,"periodStartDate":"2026-01-07","maxCapacity":24.0,"capacityUsed":24.0,"utilization":1.0,"overloaded":0.0,"notMet":-0.0,"production":0.0,"tasks":[{"operationId":"OP_R1_Alt","operationName":"Procure-R1-Alt","productionQty":100.0,"capacityUsed":24.0,"capacityCoeff":0.24,"lotSize":150.0,"lotSizeOver":50.0,"lotSizeUnder":0.0,"outputs":["R1@WH_RM"]}]}]},{"unitId":"Su1","operationIds":null,"totalMaxCapacity":72.0,"totalCapacityUsed":110.4,"totalUtilizationRate":0.0,"totalProduction":0.0,"totalOverloaded":38.400000000000006,"totalNotMet":0.0,"overallUtilization":1.5333333333333334,"periodDetails":[{"periodIndex":0,"periodStartDate":"2026-01-05","maxCapacity":24.0,"capacityUsed":55.36,"utilization":2.3066666666666666,"overloaded":31.36,"notMet":-0.0,"production":0.0,"tasks":[{"operationId":"OP_R2","operationName":"Procure-R2","productionQty":346.0,"capacityUsed":55.36,"capacityCoeff":0.16,"lotSize":200.0,"lotSizeOver":0.0,"lotSizeUnder":146.0,"outputs":["R2@WH_RM"]}]},{"periodIndex":1,"periodStartDate":"2026-01-06","maxCapacity":24.0,"capacityUsed":31.040000000000006,"utilization":1.2933333333333337,"overloaded":7.040000000000006,"notMet":-0.0,"production":0.0,"tasks":[{"operationId":"OP_R2","operationName":"Procure-R2","productionQty":194.00000000000003,"capacityUsed":31.040000000000006,"capacityCoeff":0.16,"lotSize":200.0,"lotSizeOver":5.999999999999972,"lotSizeUnder":0.0,"outputs":["R2@WH_RM"]}]},{"periodIndex":2,"periodStartDate":"2026-01-07","maxCapacity":24.0,"capacityUsed":24.0,"utilization":1.0,"overloaded":0.0,"notMet":-0.0,"production":0.0,"tasks":[{"operationId":"OP_R2","operationName":"Procure-R2","productionQty":150.0,"capacityUsed":24.0,"capacityCoeff":0.16,"lotSize":200.0,"lotSizeOver":50.0,"lotSizeUnder":0.0,"outputs":["R2@WH_RM"]}]}]},{"unitId":"Su2","operationIds":null,"totalMaxCapacity":300.0,"totalCapacityUsed":135.0,"totalUtilizationRate":0.0,"totalProduction":0.0,"totalOverloaded":0.0,"totalNotMet":0.0,"overallUtilization":0.45,"periodDetails":[{"periodIndex":0,"periodStartDate":"2026-01-05","maxCapacity":100.0,"capacityUsed":45.0,"utilization":0.45,"overloaded":0.0,"notMet":-0.0,"production":0.0,"tasks":[{"operationId":"OP_R2_Alt","operationName":"Procure-R2-Alt","productionQty":150.0,"capacityUsed":45.0,"capacityCoeff":0.3,"lotSize":150.0,"lotSizeOver":0.0,"lotSizeUnder":0.0,"outputs":["R2@WH_RM"]}]},{"periodIndex":1,"periodStartDate":"2026-01-06","maxCapacity":100.0,"capacityUsed":45.0,"utilization":0.45,"overloaded":0.0,"notMet":-0.0,"production":0.0,"tasks":[{"operationId":"OP_R2_Alt","operationName":"Procure-R2-Alt","productionQty":150.0,"capacityUsed":45.0,"capacityCoeff":0.3,"lotSize":150.0,"lotSizeOver":0.0,"lotSizeUnder":0.0,"outputs":["R2@WH_RM"]}]},{"periodIndex":2,"periodStartDate":"2026-01-07","maxCapacity":100.0,"capacityUsed":45.0,"utilization":0.45,"overloaded":0.0,"notMet":-0.0,"production":0.0,"tasks":[{"operationId":"OP_R2_Alt","operationName":"Procure-R2-Alt","productionQty":150.0,"capacityUsed":45.0,"capacityCoeff":0.3,"lotSize":150.0,"lotSizeOver":0.0,"lotSizeUnder":0.0,"outputs":["R2@WH_RM"]}]}]}],"demandSummary":[{"productId":"P1","periodEntries":[{"periodIndex":0,"periodStartDate":"2026-01-05","demandQty":50.0,"fulfilledQty":50.0,"unmetQty":0.0,"fulfillmentRate":1.0},{"periodIndex":1,"periodStartDate":"2026-01-06","demandQty":60.0,"fulfilledQty":60.0,"unmetQty":0.0,"fulfillmentRate":1.0},{"periodIndex":2,"periodStartDate":"2026-01-07","demandQty":40.0,"fulfilledQty":40.0,"unmetQty":0.0,"fulfillmentRate":1.0}],"totalDemand":150.0,"totalFulfilled":150.0,"totalUnmet":0.0,"fulfillmentRate":1.0},{"productId":"P2","periodEntries":[{"periodIndex":0,"periodStartDate":"2026-01-05","demandQty":30.0,"fulfilledQty":30.0,"unmetQty":0.0,"fulfillmentRate":1.0},{"periodIndex":1,"periodStartDate":"2026-01-06","demandQty":40.0,"fulfilledQty":40.0,"unmetQty":0.0,"fulfillmentRate":1.0},{"periodIndex":2,"periodStartDate":"2026-01-07","demandQty":50.0,"fulfilledQty":50.0,"unmetQty":0.0,"fulfillmentRate":1.0}],"totalDemand":120.0,"totalFulfilled":120.0,"totalUnmet":0.0,"fulfillmentRate":1.0}],"kpis":{"objectiveValue":354.9999999999999,"entries":[{"name":"需求缺口(Fulfillment)","rawValue":0.0,"weight":100.0,"penalty":0.0,"isBenefit":false},{"name":"批次偏差(LotSize)","rawValue":582.0,"weight":10.0,"penalty":5820.0,"isBenefit":false},{"name":"超库存(MaxInventory)","rawValue":0.0,"weight":5.0,"penalty":0.0,"isBenefit":false},{"name":"欠库存(MinInventory)","rawValue":70.99999999999997,"weight":5.0,"penalty":354.9999999999999,"isBenefit":false},{"name":"目标库存偏差(TargetInv)","rawValue":267.0,"weight":8.0,"penalty":2136.0,"isBenefit":false},{"name":"产能超载(UnitCapacity)","rawValue":38.400000000000006,"weight":20.0,"penalty":768.0000000000001,"isBenefit":false},{"name":"供应目标偏差(SupplyTarget)","rawValue":145.68750000000003,"weight":8.0,"penalty":1165.5000000000002,"isBenefit":false},{"name":"最小供应不足(MinSupply)","rawValue":0.0,"weight":5.0,"penalty":0.0,"isBenefit":false},{"name":"最大供应超出(MaxSupply)","rawValue":0.0,"weight":5.0,"penalty":0.0,"isBenefit":false},{"name":"销售优先级(SalesPriority)","rawValue":270.0,"weight":1.0,"penalty":-270.0,"isBenefit":true},{"name":"推迟惩罚(Postponement)","rawValue":-0.0,"weight":20.0,"penalty":-0.0,"isBenefit":false},{"name":"过程最大量(ProcessMax)","rawValue":-0.0,"weight":5.0,"penalty":-0.0,"isBenefit":false}]},"statistics":{"status":"OPTIMAL","numVariables":242,"numConstraints":180,"elapsedSeconds":0.763,"objectiveValue":354.9999999999999,"bestBound":null,"gap":null,"iterations":53}}
\ No newline at end of file
......@@ -32,7 +32,7 @@ import java.util.stream.Collectors;
* <tr><td>POST</td><td>/macroResult/run?sceneId=xxx</td><td>触发排产优化: 转换→验证→建模→分层求解→保存JSON</td></tr>
* <tr><td>GET </td><td>/macroResult/salesDemands?sceneId=xxx</td><td>订单级需求满足明细</td></tr>
* <tr><td>GET </td><td>/macroResult/supplyChain?sceneId=xxx</td><td>产品级库存流转与销售满足汇总</td></tr>
* <tr><td>GET </td><td>/macroResult/productSummary?sceneId=xxx</td><td>产品-库位跨周期库存汇总</td></tr>
* <tr><td>GET </td><td>/macroResult/productSummary?sceneId=xxx</td><td>产品-库位库存周期明细(不聚合)</td></tr>
* <tr><td>GET </td><td>/macroResult/unitCapacity?sceneId=xxx</td><td>设备产能使用情况(含利用率)</td></tr>
* <tr><td>GET </td><td>/macroResult/productNetwork?sceneId=xxx</td><td>BOM生产网络</td></tr>
* <tr><td>GET </td><td>/macroResult/summary?sceneId=xxx</td><td>KPI与求解统计</td></tr>
......@@ -42,7 +42,7 @@ import java.util.stream.Collectors;
* <ol>
* <li>POST /run → 触发求解, 返回 status:"SUCCESS"</li>
* <li>GET /salesDemands → 查看订单需求满足</li>
* <li>GET /supplyChain → 查看库存与销售满足</li>
* <li>GET /productSummary → 查看库存周期明细</li>
* <li>GET /unitCapacity → 查看设备产能使用</li>
* <li>GET /productNetwork → 查看BOM网络</li>
* <li>GET /summary → 查看KPI与求解统计</li>
......@@ -163,30 +163,30 @@ public class MacroPlannerResultController {
}
/**
* 获取产品级库存汇总: 按 (productId, spId) 聚合跨周期库存流转。
* 获取产品级库存明细: 按 (productId, spId) 分组, 保留每周期原始库存数据, 不跨周期聚合。
*
* <h3>返回字段说明</h3>
* <table>
* <tr><td>totalCount</td><td>产品-库位总数</td></tr>
* <tr><td>totalCount</td><td>产品-库位组合总数</td></tr>
* <tr><td>totalPispipRecords</td><td>库存点库存原始记录数</td></tr>
* <tr><td>summaries</td><td>按产品+库位聚合的库存汇总</td></tr>
* <tr><td>summaries</td><td>按产品+库位分组的库存数据</td></tr>
* <tr><td>summaries[].key</td><td>分组键: productId@spId</td></tr>
* <tr><td>summaries[].productId / spId</td><td>产品ID / 库存点ID</td></tr>
* <tr><td>summaries[].periodCount</td><td>周期数</td></tr>
* <tr><td>summaries[].initialInventory</td><td>首周期期初库存</td></tr>
* <tr><td>summaries[].finalInventory</td><td>末周期期末库存</td></tr>
* <tr><td>summaries[].totalInflow</td><td>总流入(生产到货+在途到货)</td></tr>
* <tr><td>summaries[].totalOutflow</td><td>总流出(销售消耗+BOM依赖消耗)</td></tr>
* <tr><td>summaries[].totalProduction</td><td>生产到货合计</td></tr>
* <tr><td>summaries[].totalInTransit</td><td>在途到货合计</td></tr>
* <tr><td>summaries[].belowTarget</td><td>低于目标库存累计值</td></tr>
* <tr><td>summaries[].belowMin / aboveMax</td><td>违反最小/最大库存惩罚</td></tr>
* <tr><td>summaries[].periodCount</td><td>该产品-库位的周期数</td></tr>
* <tr><td>summaries[].records</td><td>PispipResult[] 每周期原始明细</td></tr>
* <tr><td>records[].periodIndex</td><td>周期索引</td></tr>
* <tr><td>records[].openingInventory / endingInventory</td><td>期初/期末库存</td></tr>
* <tr><td>records[].totalInflow / totalOutflow</td><td>流入/流出</td></tr>
* <tr><td>records[].productionArrived</td><td>生产到货量</td></tr>
* <tr><td>records[].inTransitArrival</td><td>在途到货量</td></tr>
* <tr><td>records[].belowTarget / belowMin / aboveMax</td><td>库存偏差</td></tr>
* </table>
*/
@GetMapping("/productSummary")
@Operation(summary = "产品库存汇总",
description = "按产品+库位聚合跨周期库存汇总: 期初/期末库存、总流入/流出、生产到货、库存偏差。"
+ "数据来源: PispipResult(库存点库存)。"
+ "注: 如需订单级需求满足明细, 请使用 /salesDemands 接口。")
@Operation(summary = "产品库存明细",
description = "按产品+库位分组, 保留每周期原始PispipResult明细, 不跨周期聚合。"
+ "与 supplyChain 中的 productSummaries (跨周期求和) 不同, "
+ "此接口返回每周期独立数据, 适合前端展开查看各周期详情。")
public R<Map<String, Object>> getProductSummary(
@RequestParam("sceneId") @Parameter(description = "场景ID", required = true) String sceneId) {
OptimizationResult result = loadResult(sceneId);
......@@ -195,12 +195,31 @@ public class MacroPlannerResultController {
}
List<PispipResult> pispips = result.getPispips();
List<Map<String, Object>> summaries = buildProductSummaries(pispips);
// 按 productId@spId 分组,保留每周期明细,不跨周期聚合
Map<String, List<PispipResult>> grouped = pispips != null
? pispips.stream().collect(Collectors.groupingBy(
p -> p.getProductId() + "@" + p.getSpId(),
LinkedHashMap::new,
Collectors.toList()))
: new LinkedHashMap<>();
List<Map<String, Object>> summaries = new ArrayList<>();
for (Map.Entry<String, List<PispipResult>> entry : grouped.entrySet()) {
List<PispipResult> records = entry.getValue();
Map<String, Object> s = new LinkedHashMap<>();
s.put("key", entry.getKey());
s.put("productId", records.get(0).getProductId());
s.put("spId", records.get(0).getSpId());
s.put("periodCount", records.size());
s.put("records", records); // 每周期明细, 不聚合
summaries.add(s);
}
Map<String, Object> data = new LinkedHashMap<>();
data.put("totalCount", summaries.size());
data.put("PispipRecords", pispips);
//data.put("summaries", summaries);
data.put("totalPispipRecords", pispips != null ? pispips.size() : 0);
data.put("summaries", summaries);
return R.ok(data);
}
......
package com.aps.macroplanner;
import com.aps.macroplanner.MacroPlannerOptimizer;
import com.google.ortools.Loader;
import com.aps.macroplanner.data.ComprehensiveTestDataBuilder;
/**
* 综合测试运行器 — 使用覆盖所有约束场景的测试数据运行优化器。
*
* <p>与 {@link MacroPlannerOptimizer} 使用相同的模块架构,
* 仅替换数据源为 {@link ComprehensiveTestDataBuilder}。</p>
*
* <h2>测试场景</h2>
* 3级BOM + 2设备 + 5周期 + 需求波动 + 紧约束:
* <ul>
* <li>物料平衡 — 3级链式传递, 5周期</li>
* <li>BOM依赖 — OP1双输入, PC共享消耗</li>
* <li>产能 — Unit1共享(OP1+OP3), Unit2最小产能30</li>
* <li>库存 — PC最大库存80紧约束</li>
* <li>供应 — 目标偏高</li>
* <li>批次 — OP1 lotSize=100, OP2 lotSize=50</li>
* </ul>
*/
public class ComprehensiveTestRunner {
public static void main(String[] args) {
Loader.loadNativeLibraries();
System.out.println("╔══════════════════════════════════════════════════════╗");
System.out.println("║ MacroPlanner 综合测试 — 覆盖所有约束场景 ║");
System.out.println("║ 3级BOM + 2设备 + 5周期 + 需求波动 + 紧约束 ║");
System.out.println("╚══════════════════════════════════════════════════════╝\n");
MacroPlannerOptimizer optimizer = new MacroPlannerOptimizer(
new ComprehensiveTestDataBuilder());
optimizer.buildModel();
optimizer.solve();
}
}
\ No newline at end of file
package com.aps.macroplanner.constraint;
import com.google.ortools.linearsolver.MPConstraint;
import com.google.ortools.linearsolver.MPVariable;
import com.aps.macroplanner.data.*;
import com.aps.macroplanner.model.MacroPlannerModel;
import java.util.Map;
/**
* 物料平衡约束构建器 (Balance)
*
* <p>确保每个 PISPIP (Product In StockingPoint In Period) 的物料流守恒:
* 流入 = 流出</p>
*
* <h3>数学公式</h3>
* <pre>
* Σ(PTQty×relDur) + DemandSlack + InvQty(t-1) + 在途到货
* - ΣSalesDemandQty - DependentDemand - InvQty(t) = 初始库存(t=0)
* </pre>
*/
public class BalanceConstraint {
/**
* 构建物料平衡约束。
*/
public static void build(MacroPlannerModel model, TestDataBuilder data) {
Map<String, MPVariable> ptQtyVars = model.getPtQtyVars();
Map<String, MPVariable> invQtyVars = model.getInvQtyVars();
Map<String, MPVariable> salesDemandQtyVars = model.getSalesDemandQtyVars();
Map<String, MPVariable> demandSlackVars = model.getDemandSlackVars();
Map<String, MPVariable> dependentDemandVars = model.getDependentDemandVars();
for (Product prod : data.getProducts()) {
for (StockingPoint sp : data.getStockingPointsForProduct(prod.getId())) {
for (Period p : data.getPeriods()) {
String invKey = prod.getId() + "_" + sp.getId() + "_" + p.getIndex();
MPConstraint balance = model.getSolver().makeConstraint(0.0, 0.0,
"Balance_" + prod.getId() + "_" + sp.getId() + "_P" + p.getIndex());
// === 流入 ===
// PTQty 产出 (考虑提前期: productionDate 生产, productionDate+leadTimeDays 到货)
// 关键: 只统计产出到当前库存点(sp)的操作, 避免多工序路由中产出到错误的库存点
for (Operation op : data.getOperations()) {
if (!op.producesProductAtSp(prod.getId(), sp.getId())) continue;
int leadTimeDays = op.getLeadTimeDays();
Period productionPeriod = data.getPeriodOffsetByDays(p, leadTimeDays);
if (productionPeriod == null) continue;
for (UnitOperation uo : op.getUnitOperations()) {
MPVariable ptVar = ptQtyVars.get(op.ptQtyKey(uo, productionPeriod.getIndex()));
if (ptVar != null) balance.setCoefficient(ptVar, op.getRelativeDuration());
}
}
// DemandSlack
MPVariable slackVar = demandSlackVars.get(invKey);
if (slackVar != null) balance.setCoefficient(slackVar, 1.0);
// 上一周期库存 (t>0)
if (p.getIndex() > 0) {
String prevKey = prod.getId() + "_" + sp.getId() + "_" + (p.getIndex() - 1);
MPVariable prevVar = invQtyVars.get(prevKey);
if (prevVar != null) balance.setCoefficient(prevVar, 1.0);
}
// === 流出 ===
// SalesDemandQty
for (SalesDemand sd : data.getSalesDemandsFor(prod, sp, p)) {
MPVariable sdVar = salesDemandQtyVars.get(sd.getKey());
if (sdVar != null) balance.setCoefficient(sdVar, -1.0);
}
// DependentDemandInPISPIP
MPVariable ddVar = dependentDemandVars.get(invKey);
if (ddVar != null) balance.setCoefficient(ddVar, -1.0);
// InvQty
MPVariable invVar = invQtyVars.get(invKey);
if (invVar != null) balance.setCoefficient(invVar, -1.0);
// === RHS ===
double rhs = 0.0;
// 初始库存 (t=0)
if (p.getIndex() == 0) {
rhs -= data.getInitialInventory(prod.getId(), sp.getId());
}
// 在途供应 (供应商已发货, 固定到货量, 按日期匹配周期)
for (InTransitSupply its : data.getInTransitSupplies()) {
if (its.getProduct().getId().equals(prod.getId())
&& its.getStockingPoint().getId().equals(sp.getId())
&& p.equals(data.getPeriodByDate(its.getArrivalDate()))) {
rhs -= its.getQuantity();
}
}
balance.setBounds(rhs, rhs);
}
}
}
}
}
\ No newline at end of file
package com.aps.macroplanner.constraint;
import com.google.ortools.linearsolver.MPConstraint;
import com.google.ortools.linearsolver.MPVariable;
import com.aps.macroplanner.data.*;
import com.aps.macroplanner.model.MacroPlannerModel;
import java.util.Map;
/**
* BOM 依赖需求约束构建器 (BomConstraint)
*
* <p>建模多级供应链中的物料消耗关系:
* 生产成品需要消耗原材料/半成品 (BOM 展开)。</p>
*
* <h3>约束 1: OperationDemandQty = PTQty × BOM 因子</h3>
* <h3>约束 2: DependentDemandInPISPIP = Σ OperationDemandQty</h3>
*/
public class BomConstraint {
/**
* 构建 BOM 依赖需求约束。
*/
public static void build(MacroPlannerModel model, TestDataBuilder data) {
Map<String, MPVariable> ptQtyVars = model.getPtQtyVars();
Map<String, MPVariable> operationDemandQtyVars = model.getOperationDemandQtyVars();
Map<String, MPVariable> dependentDemandVars = model.getDependentDemandVars();
// --- 约束 1: OperationDemandQty = input.Factor × ΣPTQty(跨所有Unit) ---
for (OperationInput input : data.getOperationInputs()) {
Operation op = input.getOperation();
for (Period p : data.getPeriods()) {
String odKey = input.getKey() + "_" + p.getIndex();
MPVariable odVar = operationDemandQtyVars.get(odKey);
if (odVar == null) continue;
MPConstraint odd = model.getSolver().makeConstraint(0.0, 0.0,
"OpDemandDef_" + odKey);
odd.setCoefficient(odVar, 1.0);
for (UnitOperation uo : op.getUnitOperations()) {
MPVariable ptVar = ptQtyVars.get(op.ptQtyKey(uo, p.getIndex()));
if (ptVar != null) odd.setCoefficient(ptVar, -input.getFactor());
}
}
}
// --- 约束 2: DependentDemandInPISPIP = Σ OperationDemandQty ---
for (Product prod : data.getProducts()) {
for (StockingPoint sp : data.getStockingPointsForProduct(prod.getId())) {
for (Period p : data.getPeriods()) {
String ddKey = prod.getId() + "_" + sp.getId() + "_" + p.getIndex();
MPVariable ddVar = dependentDemandVars.get(ddKey);
if (ddVar == null) continue;
MPConstraint ddCon = model.getSolver().makeConstraint(0.0, 0.0,
"DepDemandDef_" + ddKey);
ddCon.setCoefficient(ddVar, -1.0);
for (OperationInput input : data.getOperationInputs()) {
if (!input.getInputProduct().getId().equals(prod.getId())) continue;
if (!input.getInputSp().getId().equals(sp.getId())) continue;
MPVariable odVar = operationDemandQtyVars.get(
input.getKey() + "_" + p.getIndex());
if (odVar != null) ddCon.setCoefficient(odVar, 1.0);
}
}
}
}
}
}
\ No newline at end of file
package com.aps.macroplanner.constraint;
import com.google.ortools.linearsolver.MPConstraint;
import com.google.ortools.linearsolver.MPSolver;
import com.google.ortools.linearsolver.MPVariable;
import com.aps.macroplanner.data.*;
import com.aps.macroplanner.model.MacroPlannerModel;
import java.util.Map;
/**
* 产能约束构建器 (CapacityConstraint)
*
* <p>确保每个设备在每个周期的产能使用量在 [MinCapacity, MaxCapacity] 范围内。</p>
*
* <h3>数学公式</h3>
* <pre>
* 最大产能: Σ(PTQty × coeff) - CapacityOverloaded ≤ MaxCapacity
* 最小产能: Σ(PTQty × coeff) + CapacityNotMet ≥ MinCapacity
* </pre>
*/
public class CapacityConstraint {
/**
* 构建产能上下限约束。
*/
public static void build(MacroPlannerModel model, TestDataBuilder data) {
Map<String, MPVariable> ptQtyVars = model.getPtQtyVars();
Map<String, MPVariable> overloadVars = model.getCapacityOverloadedVars();
Map<String, MPVariable> notMetVars = model.getCapacityNotMetVars();
for (UnitPeriod up : data.getUnitPeriods()) {
String capKey = up.getKey();
// --- 最大产能 ---
MPConstraint maxCap = model.getSolver().makeConstraint(
-MPSolver.infinity(), up.getMaxCapacity(), "MaxCap_" + capKey);
for (Operation op : data.getOperations()) {
for (UnitOperation uo : op.getUnitOperations()) {
if (!uo.getUnitId().equals(up.getUnitId())) continue;
MPVariable ptVar = ptQtyVars.get(op.ptQtyKey(uo, up.getPeriod().getIndex()));
if (ptVar != null) maxCap.setCoefficient(ptVar, uo.getCapacityCoeff());
}
}
MPVariable ov = overloadVars.get(capKey);
if (ov != null) maxCap.setCoefficient(ov, -1.0);
// --- 最小产能 ---
if (up.hasMinCapacity()) {
MPConstraint minCap = model.getSolver().makeConstraint(
up.getMinCapacity(), MPSolver.infinity(), "MinCap_" + capKey);
for (Operation op : data.getOperations()) {
for (UnitOperation uo : op.getUnitOperations()) {
if (!uo.getUnitId().equals(up.getUnitId())) continue;
MPVariable ptVar = ptQtyVars.get(op.ptQtyKey(uo, up.getPeriod().getIndex()));
if (ptVar != null) minCap.setCoefficient(ptVar, uo.getCapacityCoeff());
}
}
MPVariable nm = notMetVars.get(capKey);
if (nm != null) minCap.setCoefficient(nm, 1.0);
}
}
}
}
\ No newline at end of file
package com.aps.macroplanner.constraint;
import com.aps.macroplanner.data.TestDataBuilder;
import com.aps.macroplanner.model.MacroPlannerModel;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* 约束工厂 — 统一调度所有约束的构建。
*
* <p>设计原则: 与 {@link com.aps.macroplanner.model.VariableFactory} 对应,
* 提供单一入口 {@link #buildAll(MacroPlannerModel, TestDataBuilder)},
* 按逻辑顺序逐个委托给各约束构建器。</p>
*
* <h3>构建顺序 (必须严格遵循)</h3>
* <pre>
* 1. 物料平衡约束 — 核心约束, 确保流入=流出
* 2. BOM 依赖需求 — 依赖 PTQty 变量, 定义物料消耗关系
* 3. 需求满足量汇总 — 依赖 SalesDemandQty + OperationDemandQty
* 4. 需求缺口联动 — 强制 SalesDemandQty + DemandSlack >= DemandQuantity
* 5. 库存规格约束 — 依赖 InvQty + DemandFulfillment (in-days 模式)
* 6. 产能约束 — 依赖 PTQty 变量, 限制设备使用
* 7. 供应规格约束 — 依赖 PTQty 变量, 管理总供应量
* 8. 批次大小约束 — 依赖 PTQty 变量, 控制批量生产
* 9. KPI 汇总变量 — 依赖所有松弛变量, 汇总到目标函数
* </pre>
*
* <p>注意: 各约束构建器之间通过 {@link MacroPlannerModel} 共享变量,
* 因此构建顺序相对独立, 但建议按上述顺序以确保可读性。</p>
*/
public class ConstraintFactory {
private static final Logger LOG = Logger.getLogger(ConstraintFactory.class.getName());
/**
* 构建所有约束和 KPI 汇总变量。
*
* <p>每个步骤前后记录约束数量的变化, 便于验证每个构建器是否生效。</p>
*
* @param model 模型容器 (提供求解器和所有变量)
* @param data 测试数据 (提供索引维度和规格参数)
*/
public static void buildAll(MacroPlannerModel model, TestDataBuilder data) {
LOG.info("========== 开始构建约束 ==========");
int beforeConstraints = model.getSolver().numConstraints();
int beforeVariables = model.getSolver().numVariables();
// 1. 物料平衡约束 (核心约束, 必须最先创建)
BalanceConstraint.build(model, data);
logStep("1. 物料平衡", "确保每个 PISPIP 流入 = 流出",
model, beforeConstraints, beforeVariables);
beforeConstraints = model.getSolver().numConstraints();
beforeVariables = model.getSolver().numVariables();
// 2. BOM 依赖需求约束 (多级物料消耗)
BomConstraint.build(model, data);
logStep("2. BOM依赖需求", "OperationDemandQty = PTQty × BOM因子",
model, beforeConstraints, beforeVariables);
beforeConstraints = model.getSolver().numConstraints();
beforeVariables = model.getSolver().numVariables();
// 3. 需求满足量汇总 (安全库存天数计算的基础)
DemandFulfillmentConstraint.build(model, data);
logStep("3. 需求满足量汇总", "DemandFulfillment = SalesDemandQty + OperationDemandQty",
model, beforeConstraints, beforeVariables);
beforeConstraints = model.getSolver().numConstraints();
beforeVariables = model.getSolver().numVariables();
// 4. 需求缺口联动 (强制 SalesDemandQty + DemandSlack >= DemandQuantity)
DemandSlackLinkageConstraint.build(model, data);
logStep("4. 需求缺口联动", "SalesDemandQty + DemandSlack >= DemandQuantity",
model, beforeConstraints, beforeVariables);
beforeConstraints = model.getSolver().numConstraints();
beforeVariables = model.getSolver().numVariables();
// 4.5 工序产量一致性 (强制 Routing 内相邻工序 PTQty 相等, 防止 WIP 堆积)
RoutingConstraint.build(model, data);
logStep("4.5 工序产量一致", "Routing 内相邻工序 PTQty 相等, 防止 WIP 堆积",
model, beforeConstraints, beforeVariables);
beforeConstraints = model.getSolver().numConstraints();
beforeVariables = model.getSolver().numVariables();
// 5. 库存规格约束 (最小/最大/目标库存)
InventorySpecConstraint.build(model, data);
logStep("5. 库存规格", "InvQty + Slack ≥ Min/Max/Target (支持天数模式)",
model, beforeConstraints, beforeVariables);
beforeConstraints = model.getSolver().numConstraints();
beforeVariables = model.getSolver().numVariables();
// 6. 产能约束 (设备产能上下限)
CapacityConstraint.build(model, data);
logStep("6. 产能约束", "Σ(PTQty×coeff) ∈ [MinCapacity, MaxCapacity]",
model, beforeConstraints, beforeVariables);
beforeConstraints = model.getSolver().numConstraints();
beforeVariables = model.getSolver().numVariables();
// 7. 供应规格约束 (跨周期总量供应)
SupplySpecConstraint.build(model, data);
logStep("7. 供应规格", "ΣPTQty + Slack ≥ Target/Min/Max",
model, beforeConstraints, beforeVariables);
beforeConstraints = model.getSolver().numConstraints();
beforeVariables = model.getSolver().numVariables();
// 8. 批次大小约束 (批量生产)
LotSizeConstraint.build(model, data);
logStep("8. 批次大小", "PTQty×QTPFactor + Slack ∈ [LotSize]",
model, beforeConstraints, beforeVariables);
beforeConstraints = model.getSolver().numConstraints();
beforeVariables = model.getSolver().numVariables();
// 9. KPI 汇总变量及定义约束 (必须在目标函数之前)
KpiAggregator.build(model, data);
logStep("9. KPI汇总", "TotalKPI = Σ 松弛变量 (12个KPI)",
model, beforeConstraints, beforeVariables);
logSummary(model);
}
/**
* 记录单个约束构建步骤的结果。
* 输出新增约束数和新增变量数, 如果为 0 则输出 WARNING。
*/
private static void logStep(String stepName, String description,
MacroPlannerModel model,
int beforeConstraints, int beforeVariables) {
int addedConstraints = model.getSolver().numConstraints() - beforeConstraints;
int addedVariables = model.getSolver().numVariables() - beforeVariables;
if (addedConstraints == 0 && addedVariables == 0) {
LOG.warning(String.format("[%s] %s → 未创建任何约束或变量! (可能数据为空)",
stepName, description));
} else {
StringBuilder sb = new StringBuilder();
sb.append(String.format("[%s] %s", stepName, description));
if (addedConstraints > 0) {
sb.append(String.format(" | +%d约束", addedConstraints));
}
if (addedVariables > 0) {
sb.append(String.format(" | +%d变量", addedVariables));
}
LOG.info(sb.toString());
}
}
/**
* 输出构建完成汇总。
*/
private static void logSummary(MacroPlannerModel model) {
int totalConstraints = model.getSolver().numConstraints();
int totalVariables = model.getSolver().numVariables();
LOG.info(String.format("========== 约束构建完成: 总计 %d 约束, %d 变量 ==========",
totalConstraints, totalVariables));
}
}
\ No newline at end of file
package com.aps.macroplanner.constraint;
import com.google.ortools.linearsolver.MPConstraint;
import com.google.ortools.linearsolver.MPVariable;
import com.aps.macroplanner.data.*;
import com.aps.macroplanner.model.MacroPlannerModel;
import java.util.Map;
/**
* 需求满足量汇总约束 (DemandFulfillmentInPISPIP)
*
* <p>定义每个 PISPIP 的总需求满足量 = 销售需求 + BOM 依赖需求。
* 这个变量是安全库存天数计算的基础。</p>
*
* <h3>数学公式</h3>
* <pre>
* DemandFulfillmentInPISPIP = Σ SalesDemandQty + Σ OperationDemandQty
*
* 即: 每个 PISPIP 的总需求满足量 = 所有销售需求满足量 + 所有 BOM 消耗量
* </pre>
*
* <h3>用途</h3>
* 安全库存天数约束: InvQty ≥ Σ(ratio × DemandFulfillment)
* 即: 期末库存必须覆盖未来N天的需求满足量
*/
public class DemandFulfillmentConstraint {
/**
* 构建 DemandFulfillmentInPISPIP 定义约束。
*/
public static void build(MacroPlannerModel model, TestDataBuilder data) {
Map<String, MPVariable> dfVars = model.getDemandFulfillmentVars();
Map<String, MPVariable> sdVars = model.getSalesDemandQtyVars();
Map<String, MPVariable> opDemandVars = model.getOperationDemandQtyVars();
for (Product prod : data.getProducts()) {
for (StockingPoint sp : data.getStockingPointsForProduct(prod.getId())) {
for (Period p : data.getPeriods()) {
String key = prod.getId() + "_" + sp.getId() + "_" + p.getIndex();
// DemandFulfillment = SalesDemandQty + OperationDemandQty
MPConstraint c = model.getSolver().makeConstraint(
0.0, 0.0, "DFulfill_" + key);
// + DemandFulfillment 变量
MPVariable dfVar = dfVars.get(key);
if (dfVar != null) c.setCoefficient(dfVar, -1.0);
// + SalesDemandQty
for (SalesDemand sd : data.getSalesDemands()) {
if (sd.getProduct().getId().equals(prod.getId())
&& sd.getStockingPoint().getId().equals(sp.getId())
&& sd.getPeriod().getIndex() == p.getIndex()) {
MPVariable sdVar = sdVars.get(sd.getKey());
if (sdVar != null) c.setCoefficient(sdVar, 1.0);
}
}
// + OperationDemandQty (BOM 消耗)
for (OperationInput input : data.getOperationInputs()) {
if (input.getInputProduct().getId().equals(prod.getId())
&& input.getInputSp().getId().equals(sp.getId())) {
String opKey = input.getKey() + "_" + p.getIndex();
MPVariable odVar = opDemandVars.get(opKey);
if (odVar != null) c.setCoefficient(odVar, 1.0);
}
}
}
}
}
}
}
\ No newline at end of file
package com.aps.macroplanner.constraint;
import com.google.ortools.linearsolver.MPConstraint;
import com.google.ortools.linearsolver.MPSolver;
import com.google.ortools.linearsolver.MPVariable;
import com.aps.macroplanner.data.*;
import com.aps.macroplanner.model.MacroPlannerModel;
import com.aps.macroplanner.util.SafetyStockCalculator;
import com.aps.macroplanner.util.SafetyStockTerm;
import java.util.List;
import java.util.Map;
import java.util.logging.Logger;
/**
* 库存规格约束构建器 (InventorySpecConstraint)
*
* <p>为每个产品/库存点/周期定义三个库存水平约束,
* 支持两种模式: 绝对数量模式 和 安全库存天数模式。</p>
*
* <h3>绝对数量模式</h3>
* <pre>
* 最小库存: InvQty + MinInvQtyUnder ≥ MinLevel (固定数量)
* 最大库存: InvQty - MaxInvQtyOver ≤ MaxLevel
* 目标库存: InvQty + InvQtyUnderTarget = TargetLevel
* </pre>
*
* <h3>安全库存天数模式</h3>
* <pre>
* 委托给 {@link SafetyStockCalculator} 进行折算。
* 最小库存: InvQty + MinInvQtyUnder ≥ Σ(ratio_i × DemandFulfillment_i)
* 最大库存: InvQty - MaxInvQtyOver ≤ Σ(ratio_i × DemandFulfillment_i)
* 目标库存: InvQty + InvQtyUnderTarget = Σ(ratio_i × DemandFulfillment_i)
* </pre>
*/
public class InventorySpecConstraint {
private static final Logger LOG = Logger.getLogger(InventorySpecConstraint.class.getName());
/**
* 构建库存规格约束 (自动判断绝对数量模式 vs 天数模式)。
*
* <p>在安全库存天数模式下, 会输出详细日志, 便于排查:
* <ul>
* <li>每个 PISPIP 的安全库存天数折算过程</li>
* <li>约束添加的 DemandFulfillment 项及其系数</li>
* <li>绝对数量模式下跳过的详细信息</li>
* </ul></p>
*/
public static void build(MacroPlannerModel model, TestDataBuilder data) {
Map<String, MPVariable> invVars = model.getInvQtyVars();
Map<String, MPVariable> minUnderVars = model.getMinInvQtyUnderVars();
Map<String, MPVariable> maxOverVars = model.getMaxInvQtyOverVars();
Map<String, MPVariable> targetUnderVars = model.getInvQtyUnderTargetVars();
Map<String, MPVariable> dfVars = model.getDemandFulfillmentVars();
List<Period> periods = data.getPeriods();
int inDaysCount = 0;
int absoluteCount = 0;
for (InventorySpec spec : data.getInventorySpecs()) {
String invKey = spec.getKey();
MPVariable invVar = invVars.get(invKey);
if (invVar == null) continue;
int periodIdx = spec.getPeriod().getIndex();
boolean usedInDays = false;
// === 最小库存 ===
if (spec.hasMinLevel()) {
if (spec.hasMinLevelInDays()) {
usedInDays = true;
// 安全库存天数模式: RHS=0, 由 DemandFulfillment 项提供 RHS
MPConstraint c = model.getSolver().makeConstraint(
0.0, MPSolver.infinity(), "MinInv_" + invKey);
c.setCoefficient(invVar, 1.0);
MPVariable u = minUnderVars.get(invKey);
if (u != null) c.setCoefficient(u, 1.0);
LOG.info(String.format("[MinInv_天] %s: 最小库存=%d天, 开始折算...",
invKey, (int) spec.getMinLevelInDays()));
addDemandFulfillmentTerms(c, invKey, dfVars, periods, periodIdx,
spec.getMinLevelInDays(), "MinInv");
} else {
// 绝对数量模式
double rhs = spec.getMinLevel();
MPConstraint c = model.getSolver().makeConstraint(
rhs, MPSolver.infinity(), "MinInv_" + invKey);
c.setCoefficient(invVar, 1.0);
MPVariable u = minUnderVars.get(invKey);
if (u != null) c.setCoefficient(u, 1.0);
absoluteCount++;
}
}
// === 最大库存 ===
if (spec.hasMaxLevel()) {
if (spec.hasMaxLevelInDays()) {
usedInDays = true;
MPConstraint c = model.getSolver().makeConstraint(
-MPSolver.infinity(), 0.0, "MaxInv_" + invKey);
c.setCoefficient(invVar, 1.0);
MPVariable o = maxOverVars.get(invKey);
if (o != null) c.setCoefficient(o, -1.0);
LOG.info(String.format("[MaxInv_天] %s: 最大库存=%d天",
invKey, (int) spec.getMaxLevelInDays()));
addDemandFulfillmentTerms(c, invKey, dfVars, periods, periodIdx,
spec.getMaxLevelInDays(), "MaxInv");
} else {
double rhs = spec.getMaxLevel();
MPConstraint c = model.getSolver().makeConstraint(
-MPSolver.infinity(), rhs, "MaxInv_" + invKey);
c.setCoefficient(invVar, 1.0);
MPVariable o = maxOverVars.get(invKey);
if (o != null) c.setCoefficient(o, -1.0);
absoluteCount++;
}
}
// === 目标库存 ===
if (spec.hasTarget()) {
if (spec.hasTargetInDays()) {
usedInDays = true;
MPConstraint c = model.getSolver().makeConstraint(
0.0, 0.0, "TargetInv_" + invKey);
c.setCoefficient(invVar, 1.0);
MPVariable u = targetUnderVars.get(invKey);
if (u != null) c.setCoefficient(u, 1.0);
LOG.info(String.format("[TargetInv_天] %s: 目标库存=%d天",
invKey, (int) spec.getTargetInDays()));
addDemandFulfillmentTerms(c, invKey, dfVars, periods, periodIdx,
spec.getTargetInDays(), "TargetInv");
} else {
double rhs = spec.getTargetLevel();
MPConstraint c = model.getSolver().makeConstraint(
rhs, rhs, "TargetInv_" + invKey);
c.setCoefficient(invVar, 1.0);
MPVariable u = targetUnderVars.get(invKey);
if (u != null) c.setCoefficient(u, 1.0);
absoluteCount++;
}
}
if (usedInDays) inDaysCount++;
}
LOG.info(String.format("[库存规格] 构建完成: %d 个使用安全库存天数, %d 个使用绝对数量",
inDaysCount, absoluteCount));
}
/**
* 向约束添加 DemandFulfillment 项 (委托给 {@link SafetyStockCalculator})。
*
* <p>添加负系数项, 效果是:
* InvQty + Slack ≥ Σ(ratio × DemandFulfillment)</p>
*
* @param constraint 库存约束
* @param invKey 库存 key (productId_spId_periodIndex)
* @param dfVars DemandFulfillment 变量映射
* @param periods 周期列表
* @param currentPeriodIdx 当前周期索引
* @param safetyStockDays 安全库存天数
* @param constraintType 约束类型 (MinInv / MaxInv / TargetInv), 仅用于日志
*/
private static void addDemandFulfillmentTerms(
MPConstraint constraint,
String invKey,
Map<String, MPVariable> dfVars,
List<Period> periods,
int currentPeriodIdx,
double safetyStockDays,
String constraintType) {
// 委托给工具类计算折算项
List<SafetyStockTerm> terms = SafetyStockCalculator.calculate(
currentPeriodIdx, safetyStockDays, periods, invKey);
if (terms.isEmpty()) {
LOG.warning(String.format("[%s] %s: 安全库存天数=%.2f天, 但没有找到未来周期!",
constraintType, invKey, safetyStockDays));
return;
}
// 将折算项添加到约束
int termsAdded = 0;
for (SafetyStockTerm term : terms) {
MPVariable dfVar = dfVars.get(term.getDemandFulfillmentKey());
if (dfVar != null) {
// 负系数: -ratio × DemandFulfillment
// 约束: InvQty + Slack ≥ 0
// 移项: InvQty + Slack ≥ Σ(ratio × DemandFulfillment)
constraint.setCoefficient(dfVar, -term.getRatio());
termsAdded++;
} else {
LOG.warning(String.format("[%s] %s: DemandFulfillment 变量 '%s' 不存在! "
+ "周期 %s 的需求预测未计入安全库存约束",
constraintType, invKey, term.getDemandFulfillmentKey(),
term.getPeriodName()));
}
}
LOG.fine(String.format("[%s] %s: 已添加 %d/%d 个 DemandFulfillment 项到约束",
constraintType, invKey, termsAdded, terms.size()));
}
}
\ No newline at end of file
package com.aps.macroplanner.constraint;
import com.google.ortools.linearsolver.MPConstraint;
import com.google.ortools.linearsolver.MPSolver;
import com.google.ortools.linearsolver.MPVariable;
import com.aps.macroplanner.data.*;
import com.aps.macroplanner.model.MacroPlannerModel;
import java.util.Map;
/**
* KPI 汇总变量及定义约束构建器
*
* <p>创建全局 KPI 汇总变量, 每个 KPI = 对应松弛/惩罚变量的总和。
* 这些汇总变量被目标函数直接引用。</p>
*
* <h3>KPI 汇总一览</h3>
* <pre>
* TotalFulfillment = Σ DemandSlack (权重 100)
* TotalLotSize = Σ PTLotSizeOver + Under (权重 10)
* TotalMaxInventoryLevel = Σ MaxInvQtyOver (权重 5)
* TotalMinInventoryLevel = Σ MinInvQtyUnder (权重 5)
* TotalTargetInvLevel = Σ InvQtyUnderTarget (权重 8)
* TotalUnitCapacity = Σ CapacityOverloaded (权重 3)
* TotalSupplyTarget = Σ SupplyTargetQtyUnder (权重 8)
* TotalMinSupply = Σ MinSupplyQtyUnder (权重 5)
* TotalMaxSupply = Σ MaxSupplyQtyOver (权重 5)
* TotalSalesDemandPriority= Σ SalesDemandQty × Priority (权重 1)
* TotalPostponementPenalty= 0 (简化版) (权重 20)
* TotalProcessMaxQuantity = 0 (简化版) (权重 5)
* </pre>
*/
public class KpiAggregator {
/**
* 创建 KPI 汇总变量及其定义约束, 并注册到模型中。
*/
public static void build(MacroPlannerModel model, TestDataBuilder data) {
double inf = MPSolver.infinity();
// TotalFulfillment = Σ DemandSlack
model.setTotalFulfillment(createSumKpi(model, "TotalFulfillment",
model.getDemandSlackVars()));
// TotalLotSize = Σ PTLotSizeOver + Σ PTLotSizeUnder
MPVariable totalLotSize = model.getSolver().makeNumVar(0.0, inf, "TotalLotSize");
MPConstraint lotDef = model.getSolver().makeConstraint(0.0, 0.0, "Def_TotalLotSize");
lotDef.setCoefficient(totalLotSize, -1.0);
for (MPVariable v : model.getPtLotSizeOverVars().values()) lotDef.setCoefficient(v, 1.0);
for (MPVariable v : model.getPtLotSizeUnderVars().values()) lotDef.setCoefficient(v, 1.0);
model.setTotalLotSize(totalLotSize);
// TotalMaxInventoryLevel = Σ MaxInvQtyOver
model.setTotalMaxInventoryLevel(createSumKpi(model, "TotalMaxInvLevel",
model.getMaxInvQtyOverVars()));
// TotalMinInventoryLevel = Σ MinInvQtyUnder
model.setTotalMinInventoryLevel(createSumKpi(model, "TotalMinInvLevel",
model.getMinInvQtyUnderVars()));
// TotalTargetInvLevel = Σ InvQtyUnderTarget
model.setTotalTargetInvLevel(createSumKpi(model, "TotalTargetInvLevel",
model.getInvQtyUnderTargetVars()));
// TotalUnitCapacity = Σ CapacityOverloaded
model.setTotalUnitCapacity(createSumKpi(model, "TotalUnitCapacity",
model.getCapacityOverloadedVars()));
// TotalSupplyTarget = Σ SupplyTargetQtyUnder
model.setTotalSupplyTarget(createSumKpi(model, "TotalSupplyTarget",
model.getSupplyTargetQtyUnderVars()));
// TotalMinSupply = Σ MinSupplyQtyUnder
model.setTotalMinSupply(createSumKpi(model, "TotalMinSupply",
model.getMinSupplyQtyUnderVars()));
// TotalMaxSupply = Σ MaxSupplyQtyOver
model.setTotalMaxSupply(createSumKpi(model, "TotalMaxSupply",
model.getMaxSupplyQtyOverVars()));
// TotalSalesDemandPriority = Σ SalesDemandQty × Priority
MPVariable totalSDPri = model.getSolver().makeNumVar(0.0, inf, "TotalSalesDemandPriority");
MPConstraint sdDef = model.getSolver().makeConstraint(0.0, 0.0, "Def_TotalSDPriority");
sdDef.setCoefficient(totalSDPri, -1.0);
for (SalesDemand sd : data.getSalesDemands()) {
MPVariable sdVar = model.getSalesDemandQtyVars().get(sd.getKey());
if (sdVar != null) sdDef.setCoefficient(sdVar, sd.getPriority());
}
model.setTotalSalesDemandPriority(totalSDPri);
// 简化版 KPI (固定为 0)
model.setTotalPostponementPenalty(
model.getSolver().makeNumVar(0.0, 0.0, "TotalPostponementPenalty"));
model.setTotalProcessMaxQuantity(
model.getSolver().makeNumVar(0.0, 0.0, "TotalProcessMaxQty"));
}
/** 辅助方法: 创建 SumVar = Σ(values) 的汇总变量 */
private static MPVariable createSumKpi(MacroPlannerModel model, String name,
Map<String, MPVariable> sourceVars) {
MPVariable sumVar = model.getSolver().makeNumVar(0.0, MPSolver.infinity(), name);
MPConstraint def = model.getSolver().makeConstraint(0.0, 0.0, "Def_" + name);
def.setCoefficient(sumVar, -1.0);
for (MPVariable v : sourceVars.values()) {
if (v != null) def.setCoefficient(v, 1.0);
}
return sumVar;
}
}
\ No newline at end of file
package com.aps.macroplanner.constraint;
import com.google.ortools.linearsolver.MPConstraint;
import com.google.ortools.linearsolver.MPSolver;
import com.google.ortools.linearsolver.MPVariable;
import com.aps.macroplanner.data.*;
import com.aps.macroplanner.model.MacroPlannerModel;
import java.util.Map;
/**
* 批次大小约束构建器 (LotSizeConstraint)
*
* <p>确保每个操作的生产量符合批次大小要求。</p>
*
* <h3>数学公式 (简化版, PTNrOfLots=1)</h3>
* <pre>
* 批次下限: PTQty × QTPFactor + PTLotSizeOver ≥ LotSize
* 批次上限: PTQty × QTPFactor - PTLotSizeUnder ≤ LotSize
* </pre>
*/
public class LotSizeConstraint {
/**
* 构建批次大小约束。
*/
public static void build(MacroPlannerModel model, TestDataBuilder data) {
Map<String, MPVariable> ptQtyVars = model.getPtQtyVars();
Map<String, MPVariable> overVars = model.getPtLotSizeOverVars();
Map<String, MPVariable> underVars = model.getPtLotSizeUnderVars();
for (Operation op : data.getOperations()) {
for (UnitOperation uo : op.getUnitOperations()) {
if (!uo.hasLotSize()) continue;
for (Period p : data.getPeriods()) {
String key = op.ptQtyKey(uo, p.getIndex());
MPVariable ptVar = ptQtyVars.get(key);
if (ptVar == null) continue;
double lotSize = uo.getLotSize();
// 批次下限
MPVariable ov = overVars.get(key);
if (ov != null) {
MPConstraint c = model.getSolver().makeConstraint(
lotSize, MPSolver.infinity(), "LotOver_" + key);
c.setCoefficient(ptVar, uo.getQtpfactor());
c.setCoefficient(ov, 1.0);
}
// 批次上限
MPVariable uv = underVars.get(key);
if (uv != null) {
MPConstraint c = model.getSolver().makeConstraint(
-MPSolver.infinity(), lotSize, "LotUnder_" + key);
c.setCoefficient(ptVar, uo.getQtpfactor());
c.setCoefficient(uv, -1.0);
}
}
}
}
}
}
\ No newline at end of file
package com.aps.macroplanner.constraint;
import com.google.ortools.linearsolver.MPConstraint;
import com.google.ortools.linearsolver.MPSolver;
import com.google.ortools.linearsolver.MPVariable;
import com.aps.macroplanner.data.*;
import com.aps.macroplanner.model.MacroPlannerModel;
import java.util.Map;
/**
* 供应规格约束构建器 (SupplySpecConstraint)
*
* <p>跨周期汇总某个供应规格的总供应量, 与目标/最小/最大值比较。
* 不同于库存规格 (按周期), 供应规格是跨所有周期的总量约束。</p>
*
* <h3>数学公式</h3>
* <pre>
* 目标供应: ΣPTQty + SupplyTargetQtyUnder ≥ TargetQty
* 最小供应: ΣPTQty + MinSupplyQtyUnder ≥ MinQty
* 最大供应: ΣPTQty - MaxSupplyQtyOver ≤ MaxQty
* </pre>
*/
public class SupplySpecConstraint {
/**
* 构建供应规格约束。
*/
public static void build(MacroPlannerModel model, TestDataBuilder data) {
Map<String, MPVariable> ptQtyVars = model.getPtQtyVars();
Map<String, MPVariable> targetUnderVars = model.getSupplyTargetQtyUnderVars();
Map<String, MPVariable> minUnderVars = model.getMinSupplyQtyUnderVars();
Map<String, MPVariable> maxOverVars = model.getMaxSupplyQtyOverVars();
for (SupplySpec spec : data.getSupplySpecs()) {
// 目标供应
MPConstraint target = model.getSolver().makeConstraint(
spec.getTargetQuantity(), MPSolver.infinity(),
"TargetSupply_" + spec.getName());
addPtQtyTerms(ptQtyVars, spec, data, target);
MPVariable tu = targetUnderVars.get(spec.getName());
if (tu != null) target.setCoefficient(tu, 1.0);
// 最小供应
MPConstraint min = model.getSolver().makeConstraint(
spec.getMinQuantity(), MPSolver.infinity(),
"MinSupply_" + spec.getName());
addPtQtyTerms(ptQtyVars, spec, data, min);
MPVariable mu = minUnderVars.get(spec.getName());
if (mu != null) min.setCoefficient(mu, 1.0);
// 最大供应
if (spec.hasMaxQuantity()) {
MPConstraint max = model.getSolver().makeConstraint(
-MPSolver.infinity(), spec.getMaxQuantity(),
"MaxSupply_" + spec.getName());
addPtQtyTerms(ptQtyVars, spec, data, max);
MPVariable mo = maxOverVars.get(spec.getName());
if (mo != null) max.setCoefficient(mo, -1.0);
}
}
}
/** 将供应规格关联的所有操作(所有Unit)、所有周期的 PTQty 变量加入约束 */
private static void addPtQtyTerms(Map<String, MPVariable> ptQtyVars, SupplySpec spec,
TestDataBuilder data, MPConstraint constraint) {
for (Operation op : spec.getOperations()) {
for (UnitOperation uo : op.getUnitOperations()) {
for (Period p : data.getPeriods()) {
MPVariable ptVar = ptQtyVars.get(op.ptQtyKey(uo, p.getIndex()));
if (ptVar != null) constraint.setCoefficient(ptVar, 1.0);
}
}
}
}
}
\ No newline at end of file
package com.aps.macroplanner.data;
import java.util.*;
/**
* 综合测试数据构建器 — 覆盖所有约束场景的 3 级 BOM 供应链优化实例
*
* <h2>场景概览</h2>
* <pre>
* 物料流 (3级BOM):
* OP3 → ProductC@SP3 ──┬──→ (被 OP1 消耗, 因子 0.5)
* │
* OP2 → ProductB@SP2 ───┤──→ (被 OP1 消耗, 因子 2.0)
* │
* OP1 → ProductA@SP1 ───┘──→ (外部销售需求, P3 高峰 120)
*
* 设备:
* Unit1 (max=200) ← OP1(coeff=1.0) + OP3(coeff=0.8) 争抢
* Unit2 (max=120, min=30) ← OP2 专用
* </pre>
*
* <h2>各约束覆盖情况</h2>
* <pre>
* 约束类型 | 触发场景 | 预期触发的松弛变量
* ─────────────────┼───────────────────────────────────────────┼────────────────────
* 物料平衡 | 3级BOM链式传递, 5周期, 初始库存40/30/60 | DemandSlack
* BOM依赖需求 | OP1双输入(PB×2.0, PC×0.5), PC共享消耗 | OperationDemandQty
* 产能(最大) | Unit1 200被OP1+OP3争抢, P3需求高峰 | CapacityOverloaded
* 产能(最小) | Unit2 最小产能30 强制OP2生产 | CapacityNotMet
* 库存(最小) | 安全库存10, 可能被突破 | MinInvQtyUnder
* 库存(最大) | PC 最大库存80 紧约束 | MaxInvQtyOver
* 库存(目标) | 目标库存80/40/50, 无法完全达成 | InvQtyUnderTarget
* 供应(目标) | 目标偏高(380/200/250) | SupplyTargetQtyUnder
* 供应(最小) | 最小供应可满足 | (可能不触发)
* 供应(最大) | 最大供应宽松 | (可能不触发)
* 批次大小 | OP1 lotSize=100, OP2 lotSize=50 | PTLotSizeOver/Under
* ─────────────────┴───────────────────────────────────────────┴────────────────────
* </pre>
*/
public class ComprehensiveTestDataBuilder extends TestDataBuilder {
// 产品引用 (供 build() 内部使用)
private Product prodA, prodB, prodC;
private StockingPoint sp1, sp2, sp3;
private Operation op1, op2, op3;
/** 构造器 — 调用父类 protected 构造器跳过自动初始化, 然后自行构建 */
public ComprehensiveTestDataBuilder() {
super(true);
build();
}
@Override
protected void build() {
createPeriods();
createProducts();
createStockingPoints();
createOperations();
createBomInputs();
createUnitPeriods();
createInitialInventory();
createSalesDemands();
createInventorySpecs();
createSupplySpecs();
createKpiWeights();
}
// ==================== 1. 周期 (5个周期) ====================
private void createPeriods() {
for (int i = 0; i < 5; i++) {
periods.add(new Period(i, "P" + (i + 1)));
}
}
// ==================== 2. 产品 ====================
private void createProducts() {
prodA = new Product("PA", "ProductA-成品");
prodB = new Product("PB", "ProductB-半成品");
prodC = new Product("PC", "ProductC-原材料");
products.addAll(Arrays.asList(prodA, prodB, prodC));
}
// ==================== 3. 库存点 ====================
private void createStockingPoints() {
sp1 = new StockingPoint("SP1", "FG-成品仓");
sp2 = new StockingPoint("SP2", "SF-半成品仓");
sp3 = new StockingPoint("SP3", "RM-原料仓");
stockingPoints.addAll(Arrays.asList(sp1, sp2, sp3));
// 产品→库存点映射
productSpMappings.add(new ProductSpMapping(prodA, sp1));
productSpMappings.add(new ProductSpMapping(prodB, sp2));
productSpMappings.add(new ProductSpMapping(prodC, sp3));
}
// ==================== 4. 操作 ====================
private void createOperations() {
// OP1: 装配 ProductA, 使用 Unit1, 产能系数 1.0, 批次 100
op1 = new Operation("OP1", "装配-ProductA", "Unit1",
new OperationOutput(prodA, sp1), 1.0, 1.0, true, 100.0, 1.0);
// OP2: 加工 ProductB, 使用 Unit2, 产能系数 1.0, 批次 50
op2 = new Operation("OP2", "加工-ProductB", "Unit2",
new OperationOutput(prodB, sp2), 1.0, 1.0, true, 50.0, 1.0);
// OP3: 备料 ProductC, 使用 Unit1, 产能系数 0.8, 无批次
// (与 OP1 共享 Unit1, 争抢产能)
op3 = new Operation("OP3", "备料-ProductC", "Unit1",
new OperationOutput(prodC, sp3), 0.8, 1.0, false, 0.0, 1.0);
operations.addAll(Arrays.asList(op1, op2, op3));
}
// ==================== 5. BOM / 操作输入物料 ====================
private void createBomInputs() {
// OP1 (装配成品A) 每生产 1 个 ProductA, 消耗:
// - 2.0 个 ProductB@SP2 (半成品)
// - 0.5 个 ProductC@SP3 (原材料)
operationInputs.add(new OperationInput(op1, prodB, sp2, 2.0));
operationInputs.add(new OperationInput(op1, prodC, sp3, 0.5));
// OP2 (加工半成品B) 每生产 1 个 ProductB, 消耗:
// - 1.5 个 ProductC@SP3 (原材料)
// 注意: PC 同时被 OP1(0.5×) 和 OP2(1.5×) 消耗, 测试共享输入
operationInputs.add(new OperationInput(op2, prodC, sp3, 1.5));
}
// ==================== 6. 单元周期 ====================
private void createUnitPeriods() {
// Unit1: 最大产能 200/周期, 无最小产能 (OP1 和 OP3 共享)
// Unit2: 最大产能 120/周期, 最小产能 30 (OP2 专用, 强制最低生产)
for (Period p : periods) {
unitPeriods.add(new UnitPeriod("Unit1", p, 0.0, 200.0, false));
unitPeriods.add(new UnitPeriod("Unit2", p, 30.0, 120.0, true));
}
}
// ==================== 7. 初始库存 ====================
private void createInitialInventory() {
initialInventories.add(new InitialInventory(prodA, sp1, 40.0));
initialInventories.add(new InitialInventory(prodB, sp2, 30.0));
initialInventories.add(new InitialInventory(prodC, sp3, 60.0));
}
// ==================== 8. 销售需求 ====================
private void createSalesDemands() {
// ProductA 成品 @ SP1: 需求波动 (P3 高峰 120!)
double[] paDemands = {60, 80, 120, 80, 60};
for (int i = 0; i < 5; i++) {
salesDemands.add(new SalesDemand(prodA, sp1, periods.get(i),
paDemands[i], 1.0));
}
// ProductB 半成品 @ SP2: 少量外部销售 (高优先级)
for (Period p : periods) {
salesDemands.add(new SalesDemand(prodB, sp2, p, 10.0, 2.0));
}
// ProductC 原材料 @ SP3: 无外部销售, 仅被 BOM 消耗
}
// ==================== 9. 库存规格 ====================
private void createInventorySpecs() {
// ProductA @ SP1: 目标库存 = 3天安全库存, 最小库存 = 2天安全库存, 最大库存 = 200 (绝对数量)
// 含义: 期末库存应覆盖未来3天的需求, 且至少覆盖2天, 最多不超过200个
for (Period p : periods) {
inventorySpecs.add(new InventorySpec(prodA, sp1, p,
0.0, 0.0, 200.0, true, true, true, // 绝对数量: 全部启用 (但值用天数覆盖)
3.0, 2.0, 0.0, true, true, false)); // 天数: target=3天, min=2天
}
// ProductB @ SP2: 目标 40, 最小 10, 最大 120 (全部绝对数量 — 传统模式)
for (Period p : periods) {
inventorySpecs.add(new InventorySpec(prodB, sp2, p,
40.0, 10.0, 120.0, true, true, true));
}
// ProductC @ SP3: 目标 50, 最小 10, 最大 80 (全部绝对数量 — 紧约束)
for (Period p : periods) {
inventorySpecs.add(new InventorySpec(prodC, sp3, p,
50.0, 10.0, 80.0, true, true, true));
}
}
// ==================== 10. 供应规格 ====================
private void createSupplySpecs() {
// Supply-A: OP1 总产量目标 380, 最小 280, 最大 500
// 5周期 PA 需求合计 400, 目标 380 偏高, 预期触发 SupplyTargetQtyUnder
supplySpecs.add(new SupplySpec("Supply-A", 380.0, 280.0, 500.0,
true, Collections.singletonList(op1)));
// Supply-B: OP2 总产量目标 200, 最小 150, 最大 300
supplySpecs.add(new SupplySpec("Supply-B", 200.0, 150.0, 300.0,
true, Collections.singletonList(op2)));
// Supply-C: OP3 总产量目标 250, 最小 180, 最大 350
supplySpecs.add(new SupplySpec("Supply-C", 250.0, 180.0, 350.0,
true, Collections.singletonList(op3)));
}
// ==================== 11. KPI 权重 ====================
private void createKpiWeights() {
kpiWeights = new KPIWeights(
100.0, // fulfillmentWeight — 最高优先级
10.0, // lotSizeWeight
5.0, // maxInventoryLevelWeight
5.0, // minInventoryLevelWeight
8.0, // targetInventoryLevelWeight
3.0, // unitCapacityWeight
8.0, // supplyTargetWeight
5.0, // minSupplyWeight
5.0, // maxSupplyWeight
1.0, // salesDemandPriorityWeight
20.0, // postponementPenaltyWeight
5.0 // processMaxQuantityWeight
);
}
}
\ No newline at end of file
package com.aps.macroplanner.data;
/**
* 库存规格 (InventorySpec) — 对应 Quintiq 中的 InventorySpecification
*
* <p>定义某个产品/库存点/周期的库存水平约束。
* 支持两种模式:</p>
* <ul>
* <li><b>绝对数量模式</b>: targetLevel/minLevel/maxLevel 直接指定库存数量</li>
* <li><b>安全库存天数模式</b>: targetInDays/minLevelInDays/maxLevelInDays 指定天数,
* 约束的 RHS 由未来周期的 DemandFulfillment 动态计算</li>
* </ul>
*/
public class InventorySpec {
private final Product product;
private final StockingPoint stockingPoint;
private final Period period;
// === 绝对数量模式 ===
private final double targetLevel;
private final double minLevel;
private final double maxLevel;
private final boolean hasTarget;
private final boolean hasMinLevel;
private final boolean hasMaxLevel;
// === 安全库存天数模式 ===
/** 目标库存天数 (如 5 天), 0 表示不使用天数模式 */
private final double targetInDays;
/** 最小库存天数 (如 3 天) */
private final double minLevelInDays;
/** 最大库存天数 (如 10 天) */
private final double maxLevelInDays;
private final boolean hasTargetInDays;
private final boolean hasMinLevelInDays;
private final boolean hasMaxLevelInDays;
public InventorySpec(Product product, StockingPoint stockingPoint, Period period,
double targetLevel, double minLevel, double maxLevel,
boolean hasTarget, boolean hasMinLevel, boolean hasMaxLevel) {
this(product, stockingPoint, period,
targetLevel, minLevel, maxLevel, hasTarget, hasMinLevel, hasMaxLevel,
0.0, 0.0, 0.0, false, false, false);
}
public InventorySpec(Product product, StockingPoint stockingPoint, Period period,
double targetLevel, double minLevel, double maxLevel,
boolean hasTarget, boolean hasMinLevel, boolean hasMaxLevel,
double targetInDays, double minLevelInDays, double maxLevelInDays,
boolean hasTargetInDays, boolean hasMinLevelInDays, boolean hasMaxLevelInDays) {
this.product = product;
this.stockingPoint = stockingPoint;
this.period = period;
this.targetLevel = targetLevel;
this.minLevel = minLevel;
this.maxLevel = maxLevel;
this.hasTarget = hasTarget;
this.hasMinLevel = hasMinLevel;
this.hasMaxLevel = hasMaxLevel;
this.targetInDays = targetInDays;
this.minLevelInDays = minLevelInDays;
this.maxLevelInDays = maxLevelInDays;
this.hasTargetInDays = hasTargetInDays;
this.hasMinLevelInDays = hasMinLevelInDays;
this.hasMaxLevelInDays = hasMaxLevelInDays;
}
public Product getProduct() { return product; }
public StockingPoint getStockingPoint() { return stockingPoint; }
public Period getPeriod() { return period; }
public double getTargetLevel() { return targetLevel; }
public double getMinLevel() { return minLevel; }
public double getMaxLevel() { return maxLevel; }
public boolean hasTarget() { return hasTarget; }
public boolean hasMinLevel() { return hasMinLevel; }
public boolean hasMaxLevel() { return hasMaxLevel; }
public double getTargetInDays() { return targetInDays; }
public double getMinLevelInDays() { return minLevelInDays; }
public double getMaxLevelInDays() { return maxLevelInDays; }
public boolean hasTargetInDays() { return hasTargetInDays; }
public boolean hasMinLevelInDays() { return hasMinLevelInDays; }
public boolean hasMaxLevelInDays() { return hasMaxLevelInDays; }
public String getKey() {
return product.getId() + "_" + stockingPoint.getId() + "_" + period.getIndex();
}
@Override
public String toString() {
return "InvSpec[" + product + ", " + stockingPoint + ", " + period + "]";
}
}
\ No newline at end of file
package com.aps.macroplanner.data;
/**
* KPI 权重配置 — 对应 Quintiq 中的 KPIWeightMacroPlan
* 定义每个 KPI 的优化层级和权重
*/
public class KPIWeights {
// 非财务 KPI 权重
private final double fulfillmentWeight;
private final double lotSizeWeight;
private final double maxInventoryLevelWeight;
private final double minInventoryLevelWeight;
private final double targetInventoryLevelWeight;
private final double unitCapacityWeight;
private final double supplyTargetWeight;
private final double minSupplyWeight;
private final double maxSupplyWeight;
private final double salesDemandPriorityWeight;
private final double postponementPenaltyWeight;
private final double processMaxQuantityWeight;
public KPIWeights(double fulfillmentWeight, double lotSizeWeight,
double maxInventoryLevelWeight, double minInventoryLevelWeight,
double targetInventoryLevelWeight, double unitCapacityWeight,
double supplyTargetWeight, double minSupplyWeight,
double maxSupplyWeight, double salesDemandPriorityWeight,
double postponementPenaltyWeight, double processMaxQuantityWeight) {
this.fulfillmentWeight = fulfillmentWeight;
this.lotSizeWeight = lotSizeWeight;
this.maxInventoryLevelWeight = maxInventoryLevelWeight;
this.minInventoryLevelWeight = minInventoryLevelWeight;
this.targetInventoryLevelWeight = targetInventoryLevelWeight;
this.unitCapacityWeight = unitCapacityWeight;
this.supplyTargetWeight = supplyTargetWeight;
this.minSupplyWeight = minSupplyWeight;
this.maxSupplyWeight = maxSupplyWeight;
this.salesDemandPriorityWeight = salesDemandPriorityWeight;
this.postponementPenaltyWeight = postponementPenaltyWeight;
this.processMaxQuantityWeight = processMaxQuantityWeight;
}
public double getFulfillmentWeight() { return fulfillmentWeight; }
public double getLotSizeWeight() { return lotSizeWeight; }
public double getMaxInventoryLevelWeight() { return maxInventoryLevelWeight; }
public double getMinInventoryLevelWeight() { return minInventoryLevelWeight; }
public double getTargetInventoryLevelWeight() { return targetInventoryLevelWeight; }
public double getUnitCapacityWeight() { return unitCapacityWeight; }
public double getSupplyTargetWeight() { return supplyTargetWeight; }
public double getMinSupplyWeight() { return minSupplyWeight; }
public double getMaxSupplyWeight() { return maxSupplyWeight; }
public double getSalesDemandPriorityWeight() { return salesDemandPriorityWeight; }
public double getPostponementPenaltyWeight() { return postponementPenaltyWeight; }
public double getProcessMaxQuantityWeight() { return processMaxQuantityWeight; }
}
\ No newline at end of file
......@@ -165,18 +165,24 @@ public class MacroPlannerDataConverter {
.eq(ProdLaunchOrder::getSceneId, sceneId));
log.info("加载订单: {} 条", ctx.prodLaunchOrders.size());
// 2. 收集 routingIds
List<Integer> routingIds = ctx.prodLaunchOrders.stream()
.map(ProdLaunchOrder::getRoutingId)
// 2. 收集 materialIds
Set<String> materialIds = ctx.prodLaunchOrders.stream()
.map(ProdLaunchOrder::getMaterialId)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
.collect(Collectors.toSet());
List<Integer> routingIds=null;
// 3. 工艺路线头表
if (!routingIds.isEmpty()) {
if (!materialIds.isEmpty()) {
ctx.routingHeaders = routingHeaderMapper.selectList(
new LambdaQueryWrapper<RoutingHeader>()
.in(RoutingHeader::getId, routingIds));
.in(RoutingHeader::getMaterialId, materialIds));
routingIds = ctx.routingHeaders.stream()
.map(RoutingHeader::getId)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
}
log.info("加载工艺路线: {} 条", ctx.routingHeaders.size());
......@@ -202,16 +208,12 @@ public class MacroPlannerDataConverter {
log.info("加载工艺物料消耗: {} 条", ctx.routingsupportings.size());
// 6. 收集所有 materialId (订单 + 工艺物料消耗 + 工艺路线头表)
Set<String> materialIds = new HashSet<>();
ctx.prodLaunchOrders.forEach(o -> {
if (o.getMaterialId() != null) materialIds.add(o.getMaterialId());
});
ctx.routingsupportings.forEach(rs -> {
if (rs.getMaterialId() != null) materialIds.add(rs.getMaterialId());
});
ctx.routingHeaders.forEach(rh -> {
if (rh.getMaterialId() != null) materialIds.add(rh.getMaterialId());
});
// 7. 物料主数据
if (!materialIds.isEmpty()) {
......
package com.aps.macroplanner.data;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
/**
* 操作/工艺 (Operation) — 对应 Quintiq 中的 Operation。
* 表示一个生产操作,可通过多个 {@link UnitOperation} 在不同单元上执行,
* 每个单元可有不同的产能消耗系数和批次参数。
*
* <h3>多产出支持 (联产品 / 副产品)</h3>
* 一个 Operation 可以有多个 {@link OperationOutput},对应 Quintiq 中
* Operation 可以产出到多个 PISP (Product in Stocking Point)。
* 例如: 炼油工序同时产出汽油和柴油。
*
* <h3>向后兼容</h3>
* {@link #getOutput()} / {@link #getOutputProductId()} / {@link #getOutputSpId()}
* 返回第一个产出,保持与旧代码的兼容。
* 新代码应使用 {@link #getOutputs()} 并遍历所有产出。
*/
public class Operation {
private final String id;
private final String name;
private final List<UnitOperation> unitOperations; // 可在多个单元执行, 每个单元参数不同
private final List<OperationOutput> outputs; // 产出列表 (产品 + 库存点), 对应 Quintiq 中 OperationOutput→PISP
private final double relativeDuration; // 产出系数 (每 PTQty 单位产出多少件)
private final int leadTimeDays; // 提前期(天数): 生产/采购当天, 入库在 productionDate + leadTimeDays 天
// ==================== 新构造器 (推荐使用) ====================
/** 完整构造器 (有产出 + leadTime) */
public Operation(String id, String name, List<UnitOperation> unitOperations,
OperationOutput output, double relativeDuration, int leadTimeDays) {
this.id = id;
this.name = name;
this.unitOperations = new ArrayList<>(unitOperations);
this.outputs = new ArrayList<>();
if (output != null) this.outputs.add(output);
this.relativeDuration = relativeDuration;
this.leadTimeDays = leadTimeDays;
}
/** 有产出, leadTime=0 */
public Operation(String id, String name, List<UnitOperation> unitOperations,
OperationOutput output, double relativeDuration) {
this(id, name, unitOperations, output, relativeDuration, 0);
}
/** 无产出 (Routing 用), 有 leadTime */
public Operation(String id, String name, List<UnitOperation> unitOperations,
double relativeDuration, int leadTimeDays) {
this(id, name, unitOperations, null, relativeDuration, leadTimeDays);
}
/** 无产出, leadTime=0 */
public Operation(String id, String name, List<UnitOperation> unitOperations,
double relativeDuration) {
this(id, name, unitOperations, null, relativeDuration, 0);
}
// ==================== 旧构造器 (保留向后兼容, 标记为 Deprecated) ====================
/** @deprecated 请使用 {@link #Operation(String, String, List, OperationOutput, double, int)} */
@Deprecated
public Operation(String id, String name, String unitId, OperationOutput output,
double capacityCoeff, double relativeDuration,
boolean hasLotSize, double lotSize, double qtpfactor,
int leadTimeDays) {
this(id, name,
Arrays.asList(new UnitOperation(unitId, capacityCoeff, hasLotSize, lotSize, qtpfactor)),
output, relativeDuration, leadTimeDays);
}
/** @deprecated 请使用 {@link #Operation(String, String, List, OperationOutput, double)} */
@Deprecated
public Operation(String id, String name, String unitId, OperationOutput output,
double capacityCoeff, double relativeDuration,
boolean hasLotSize, double lotSize, double qtpfactor) {
this(id, name, unitId, output, capacityCoeff, relativeDuration,
hasLotSize, lotSize, qtpfactor, 0);
}
/** @deprecated 请使用 {@link #Operation(String, String, List, double, int)} */
@Deprecated
public Operation(String id, String name, String unitId,
double capacityCoeff, double relativeDuration,
boolean hasLotSize, double lotSize, double qtpfactor,
int leadTimeDays) {
this(id, name, unitId, null, capacityCoeff, relativeDuration,
hasLotSize, lotSize, qtpfactor, leadTimeDays);
}
/** @deprecated 请使用 {@link #Operation(String, String, List, double)} */
@Deprecated
public Operation(String id, String name, String unitId,
double capacityCoeff, double relativeDuration,
boolean hasLotSize, double lotSize, double qtpfactor) {
this(id, name, unitId, null, capacityCoeff, relativeDuration,
hasLotSize, lotSize, qtpfactor, 0);
}
// ==================== Getters ====================
public String getId() { return id; }
public String getName() { return name; }
/** 获取所有 UnitOperation (推荐使用)。 */
public List<UnitOperation> getUnitOperations() {
return Collections.unmodifiableList(unitOperations);
}
/**
* 生成 PTQty 变量 key: {operationId}_{unitId}_{periodIndex}。
* 统一 key 格式, 避免在多个文件中硬编码拼接。
*/
public String ptQtyKey(UnitOperation uo, int periodIndex) {
return id + "_" + uo.getUnitId() + "_" + periodIndex;
}
// === 向后兼容的旧 getter (委托给第一个 UnitOperation) ===
/** @deprecated 请使用 {@link #getUnitOperations()}.get(0).getUnitId() */
@Deprecated
public String getUnitId() { return unitOperations.isEmpty() ? null : unitOperations.get(0).getUnitId(); }
/** @deprecated 请使用 {@link #getUnitOperations()}.get(0).getCapacityCoeff() */
@Deprecated
public double getCapacityCoeff() { return unitOperations.isEmpty() ? 0 : unitOperations.get(0).getCapacityCoeff(); }
/** @deprecated 请使用 {@link #getUnitOperations()}.get(0).hasLotSize() */
@Deprecated
public boolean hasLotSize() { return !unitOperations.isEmpty() && unitOperations.get(0).hasLotSize(); }
/** @deprecated 请使用 {@link #getUnitOperations()}.get(0).getLotSize() */
@Deprecated
public double getLotSize() { return unitOperations.isEmpty() ? 0 : unitOperations.get(0).getLotSize(); }
/** @deprecated 请使用 {@link #getUnitOperations()}.get(0).getQtpfactor() */
@Deprecated
public double getQtpfactor() { return unitOperations.isEmpty() ? 0 : unitOperations.get(0).getQtpfactor(); }
/**
* 获取所有产出列表。
* 对应 Quintiq 中 Operation 的多个 OperationOutput→PISP 关联。
*/
public List<OperationOutput> getOutputs() {
return Collections.unmodifiableList(outputs);
}
/** 添加产出 (联产品/副产品)。 */
public void addOutput(OperationOutput output) {
if (output != null) this.outputs.add(output);
}
/**
* 设置唯一产出 (清除现有产出后添加)。
* 由 {@link Routing#expand} 调用,自动配置中间 WIP 库存点。
*/
public void setOutput(OperationOutput output) {
this.outputs.clear();
if (output != null) this.outputs.add(output);
}
// === 向后兼容的便捷方法 (委托给第一个产出) ===
/**
* 获取第一个产出。
* @deprecated 新代码应使用 {@link #getOutputs()} 遍历所有产出。
*/
public OperationOutput getOutput() {
return outputs.isEmpty() ? null : outputs.get(0);
}
/**
* 获取第一个产出的产品 ID。
* @deprecated 新代码应使用 {@link #getOutputs()} 遍历所有产出。
*/
public String getOutputProductId() {
return outputs.isEmpty() ? null : outputs.get(0).getProductId();
}
/**
* 获取第一个产出的库存点 ID。
* @deprecated 新代码应使用 {@link #getOutputs()} 遍历所有产出。
*/
public String getOutputSpId() {
return outputs.isEmpty() ? null : outputs.get(0).getSpId();
}
// ==================== 多产出查询方法 ====================
/**
* 检查该工序是否产出指定产品 (任一库存点)。
*/
public boolean producesProduct(String productId) {
for (OperationOutput oo : outputs) {
if (oo.getProductId().equals(productId)) return true;
}
return false;
}
/**
* 检查该工序是否产出指定产品到指定库存点。
*/
public boolean producesProductAtSp(String productId, String spId) {
for (OperationOutput oo : outputs) {
if (oo.getProductId().equals(productId) && oo.getSpId().equals(spId)) return true;
}
return false;
}
/**
* 获取所有产出到指定产品+库存点的产出对象。
*/
public List<OperationOutput> getOutputsForProductAtSp(String productId, String spId) {
List<OperationOutput> result = new ArrayList<>();
for (OperationOutput oo : outputs) {
if (oo.getProductId().equals(productId) && oo.getSpId().equals(spId)) {
result.add(oo);
}
}
return result;
}
public double getRelativeDuration() { return relativeDuration; }
public int getLeadTimeDays() { return leadTimeDays; }
@Override
public String toString() {
if (outputs.isEmpty()) return name + "(" + id + ")";
if (outputs.size() == 1) return name + "(" + id + ") → " + outputs.get(0);
return name + "(" + id + ") → " + outputs;
}
}
\ No newline at end of file
package com.aps.macroplanner.data;
/**
* 操作输入物料 (OperationInput) — 对应 Quintiq 中的 OperationInput
*
* 定义某个操作 (Operation) 生产时需要消耗的原材料/半成品,
* 即 BOM (Bill of Materials) 中的输入物料关系。
*
* 例如: 生产 1 个 ProductA 需要消耗 0.5 个 ProductB
*/
public class OperationInput {
private final Operation operation; // 所属操作
private final Product inputProduct; // 输入物料产品
private final StockingPoint inputSp; // 输入物料来源库存点
private final double factor; // 消耗因子 (每单位 PTQty 消耗的输入物料量)
public OperationInput(Operation operation, Product inputProduct,
StockingPoint inputSp, double factor) {
this.operation = operation;
this.inputProduct = inputProduct;
this.inputSp = inputSp;
this.factor = factor;
}
public Operation getOperation() { return operation; }
public Product getInputProduct() { return inputProduct; }
public StockingPoint getInputSp() { return inputSp; }
public double getFactor() { return factor; }
public String getKey() {
return operation.getId() + "_" + inputProduct.getId() + "_" + inputSp.getId();
}
@Override
public String toString() {
return "OpInput[" + operation.getName() + " <- " + inputProduct.getName()
+ "@" + inputSp.getName() + " x" + factor + "]";
}
}
\ No newline at end of file
package com.aps.macroplanner.data;
import java.time.LocalDate;
/**
* 周期 (Period) — 对应 Quintiq 中的 Period_MP
*
* <p>startDate 用于 leadTime 日期偏移计算:
* 生产在周期 t, 入库在 t+leadTimeDays 天, 通过 startDate 精确定位到目标周期。</p>
*
* <p>durationInDays 用于安全库存天数计算:
* 当库存规格以"天"为单位时, 需要知道每个周期的长度来折算比例。</p>
*/
public class Period {
private final int index;
private final String name;
/** 周期长度 (天), 默认 1.0。用于安全库存天数折算。 */
private final double durationInDays;
/** 周期起始日期, 用于 leadTime 日期偏移计算。null 表示未指定(兼容旧数据)。 */
private final LocalDate startDate;
public Period(int index, String name) {
this(index, name, 1.0, null);
}
public Period(int index, String name, double durationInDays) {
this(index, name, durationInDays, null);
}
public Period(int index, String name, LocalDate startDate) {
this(index, name, 1.0, startDate);
}
public Period(int index, String name, double durationInDays, LocalDate startDate) {
this.index = index;
this.name = name;
this.durationInDays = durationInDays;
this.startDate = startDate;
}
public int getIndex() { return index; }
public String getName() { return name; }
public double getDurationInDays() { return durationInDays; }
public LocalDate getStartDate() { return startDate; }
/**
* 判断给定日期是否落在本周期内。
* 周期区间: [startDate, nextPeriod.startDate) 或 [startDate, startDate + durationInDays)
*/
public boolean contains(LocalDate date) {
if (startDate == null || date == null) return false;
LocalDate end = startDate.plusDays((long) durationInDays);
return !date.isBefore(startDate) && date.isBefore(end);
}
@Override
public String toString() {
return name;
}
}
\ No newline at end of file
package com.aps.macroplanner.data;
/**
* 产品 (Product) — 对应 Quintiq 中的 Product_MP
*/
public class Product {
private final String id;
private final String name;
public Product(String id, String name) {
this.id = id;
this.name = name;
}
public String getId() { return id; }
public String getName() { return name; }
@Override
public String toString() {
return name + "1(" + id + ")";
}
}
\ No newline at end of file
package com.aps.macroplanner.data;
/**
* 销售需求 (SalesDemand) — 对应 Quintiq 中的 SalesDemand
* 表示某个产品在某个库存点、某个周期的销售需求
*/
public class SalesDemand {
private final Product product;
private final StockingPoint stockingPoint;
private final Period period;
private final double quantity; // 需求总量
private final double priority; // 需求优先级
public SalesDemand(Product product, StockingPoint stockingPoint, Period period,
double quantity, double priority) {
this.product = product;
this.stockingPoint = stockingPoint;
this.period = period;
this.quantity = quantity;
this.priority = priority;
}
public Product getProduct() { return product; }
public StockingPoint getStockingPoint() { return stockingPoint; }
public Period getPeriod() { return period; }
public double getQuantity() { return quantity; }
public double getPriority() { return priority; }
public String getKey() {
return product.getId() + "_" + stockingPoint.getId() + "_" + period.getIndex();
}
@Override
public String toString() {
return "SD[" + product + ", " + stockingPoint + ", " + period + "] qty=" + quantity;
}
}
\ No newline at end of file
package com.aps.macroplanner.data;
/**
* 库存点 (StockingPoint) — 对应 Quintiq 中的 StockingPoint_MP
*/
public class StockingPoint {
private final String id;
private final String name;
public StockingPoint(String id, String name) {
this.id = id;
this.name = name;
}
public String getId() { return id; }
public String getName() { return name; }
@Override
public String toString() {
return name + "(" + id + ")";
}
}
\ No newline at end of file
package com.aps.macroplanner.data;
import java.util.List;
/**
* 供应规格 (SupplySpec) — 对应 Quintiq 中的 SupplySpecification
* 定义某个供应规格的目标/最小/最大供应量
*/
public class SupplySpec {
private final String name;
private final double targetQuantity;
private final double minQuantity;
private final double maxQuantity;
private final boolean hasMaxQuantity;
private final List<Operation> operations; // 该规格关联的操作
public SupplySpec(String name, double targetQuantity, double minQuantity,
double maxQuantity, boolean hasMaxQuantity, List<Operation> operations) {
this.name = name;
this.targetQuantity = targetQuantity;
this.minQuantity = minQuantity;
this.maxQuantity = maxQuantity;
this.hasMaxQuantity = hasMaxQuantity;
this.operations = operations;
}
public String getName() { return name; }
public double getTargetQuantity() { return targetQuantity; }
public double getMinQuantity() { return minQuantity; }
public double getMaxQuantity() { return maxQuantity; }
public boolean hasMaxQuantity() { return hasMaxQuantity; }
public List<Operation> getOperations() { return operations; }
@Override
public String toString() {
return "SupplySpec[" + name + "] target=" + targetQuantity + " min=" + minQuantity + " max=" + maxQuantity;
}
}
\ No newline at end of file
package com.aps.macroplanner.data;
import java.util.*;
import java.time.LocalDate;
/**
* 测试数据构建器 — 构建一个 3 级 BOM 的供应链优化实例
*
* <h3>BOM 结构</h3>
* <pre>
* Level 0 (成品):
* P1 ──消耗──→ S1×2.0 + R1×3.0
* P2 ──消耗──→ S1×1.0
*
* Level 1 (半成品):
* S1 ──消耗──→ R2×2.0 (被 P1×2.0 + P2×1.0 消耗)
*
* Level 2 (原材料):
* R1 ──无消耗── (被 P1×3.0 消耗)
* R2 ──无消耗── (被 S1×2.0 消耗)
* </pre>
*
* <h3>物料流</h3>
* <pre>
* OP_R1 → R1@SP_R1 ──────────────────→ OP_P1 消耗 ×3.0
* OP_R2 → R2@SP_R2 ──→ OP_S1 消耗 ×2.0
* OP_S1 → S1@SP_S1 ──→ OP_P1 消耗 ×2.0 + OP_P2 消耗 ×1.0
* OP_P1 → P1@SP_P1 ──→ 外部销售
* OP_P2 → P2@SP_P2 ──→ 外部销售
* </pre>
*/
public class TestDataBuilder {
// ==================== 基础数据 ====================
/** 周期列表 (子类可访问) */
protected final List<Period> periods = new ArrayList<>();
/** 产品列表 (子类可访问) */
protected final List<Product> products = new ArrayList<>();
/** 库存点列表 (子类可访问) */
protected final List<StockingPoint> stockingPoints = new ArrayList<>();
/** 产品→库存点映射表 (一个产品可在多个库存点存储) */
protected final List<ProductSpMapping> productSpMappings = new ArrayList<>();
/** 操作列表 (子类可访问) */
protected final List<Operation> operations = new ArrayList<>();
/** 操作输入/BOM列表 (子类可访问) */
protected final List<OperationInput> operationInputs = new ArrayList<>();
/** 工艺路线列表 (子类可访问) — 用于工序间产量一致性约束 */
protected final List<Routing> routings = new ArrayList<>();
/** 单元周期列表 (子类可访问) */
protected final List<UnitPeriod> unitPeriods = new ArrayList<>();
/** 销售需求列表 (子类可访问) */
protected final List<SalesDemand> salesDemands = new ArrayList<>();
/** 库存规格列表 (子类可访问) */
protected final List<InventorySpec> inventorySpecs = new ArrayList<>();
/** 供应规格列表 (子类可访问) */
protected final List<SupplySpec> supplySpecs = new ArrayList<>();
/** 在途供应列表 (供应商已发货, 将在未来周期到货的固定量) */
protected final List<InTransitSupply> inTransitSupplies = new ArrayList<>();
/** KPI 权重 (子类可访问) */
protected KPIWeights kpiWeights;
/** 初始库存: key = productId_spId, value = 初始库存量 (子类可访问) */
/** 初始库存列表 (子类可访问) */
protected final List<InitialInventory> initialInventories = new ArrayList<>();
/** 公开构造器 — 自动调用 build() 初始化默认测试数据 */
public TestDataBuilder() {
this(false);
}
/**
* 受保护构造器 — 供子类使用。
* @param skipAutoBuild 如果为 true, 不自动调用 build(), 由子类负责初始化
*/
protected TestDataBuilder(boolean skipAutoBuild) {
if (!skipAutoBuild) {
build();
}
}
/** 初始化默认测试数据 (3级BOM, 5产品, 5操作, 4周期) */
protected void build() {
// === 周期定义 (每天一个周期, 带日期) ===
LocalDate baseDate = LocalDate.of(2026, 1, 5);
Period p1 = new Period(0, "P1", baseDate); // 2026-01-05
Period p2 = new Period(1, "P2", baseDate.plusDays(1)); // 2026-01-06
Period p3 = new Period(2, "P3", baseDate.plusDays(2)); // 2026-01-07
periods.addAll(Arrays.asList(p1, p2, p3));
// === 产品定义 (5个: 2成品 + 1半成品 + 2原材料) ===
Product prodP1 = new Product("P1", "Product-1-Finished");
Product prodP2 = new Product("P2", "Product-2-Finished");
Product prodS1 = new Product("S1", "SemiProduct-1");
Product prodR1 = new Product("R1", "RawMaterial-1");
Product prodR2 = new Product("R2", "RawMaterial-2");
products.addAll(Arrays.asList(prodP1, prodP2, prodS1, prodR1, prodR2));
// === 库存点定义 (每个产品一个库存点) ===
// === 库存点定义 (3个: 成品库, 半成品库, 原材料库) ===
StockingPoint spFG = new StockingPoint("WH_FG", "成品库");
StockingPoint spSemi = new StockingPoint("WH_Semi", "半成品库");
StockingPoint spRM = new StockingPoint("WH_RM", "原材料库");
stockingPoints.addAll(Arrays.asList(spFG, spSemi, spRM));
// === 构建产品→库存点映射表 ===
productSpMappings.add(new ProductSpMapping(prodP1, spFG));
productSpMappings.add(new ProductSpMapping(prodP2, spFG));
productSpMappings.add(new ProductSpMapping(prodS1, spSemi));
productSpMappings.add(new ProductSpMapping(prodR1, spRM));
productSpMappings.add(new ProductSpMapping(prodR2, spRM));
// === 操作定义 (7个操作, 分属 7 个生产单元) ===
// 同一操作可在多个 Unit 执行, 体现多供应商/多产线场景
// 成品 P1: 可在 L1(快线 0.1h/件) 或 L3(慢线 0.125h/件) 生产
Operation opP1 = new Operation("OP_P1", "Make-P1",
Arrays.asList(
new UnitOperation("L1", 0.1, false, 100.0, 1.0),
new UnitOperation("L3", 0.125, false, 100.0, 1.0)),
new OperationOutput(prodP1, spFG), 1.0);
// 成品 P2: 可在 L1(0.125h/件) 或 L3(0.167h/件) 生产
Operation opP2 = new Operation("OP_P2", "Make-P2",
Arrays.asList(
new UnitOperation("L1", 0.125, false, 80.0, 1.0),
new UnitOperation("L3", 0.167, false, 80.0, 1.0)),
new OperationOutput(prodP2, spFG), 1.0);
// 半成品 S1: 只在 L2 生产
Operation opS1 = new Operation("OP_S1", "Make-S1",
Arrays.asList(
new UnitOperation("L2", 0.0333, false, 120.0, 1.0)),
new OperationOutput(prodS1, spSemi), 1.0);
// 供应商 R1(主): SuA, 0.12h/件, 批次200, leadTime=1天
Operation opR1 = new Operation("OP_R1", "Procure-R1",
Arrays.asList(
new UnitOperation("SuA", 0.12, true, 200.0, 1.0)),
new OperationOutput(prodR1, spRM), 1.0, 1);
// 供应商 R1(备): SuB, 0.24h/件, 批次150, leadTime=2天
Operation opR1Alt = new Operation("OP_R1_Alt", "Procure-R1-Alt",
Arrays.asList(
new UnitOperation("SuB", 0.24, true, 150.0, 1.0)),
new OperationOutput(prodR1, spRM), 1.0, 2);
// 供应商 R2(主): Su1, 0.16h/件, 批次200, leadTime=1天
Operation opR2 = new Operation("OP_R2", "Procure-R2",
Arrays.asList(
new UnitOperation("Su1", 0.16, true, 200.0, 1.0)),
new OperationOutput(prodR2, spRM), 1.0, 1);
// 供应商 R2(备): Su2, 0.30h/件, 批次150, leadTime=3天
Operation opR2Alt = new Operation("OP_R2_Alt", "Procure-R2-Alt",
Arrays.asList(
new UnitOperation("Su2", 0.3, true, 150.0, 1.0)),
new OperationOutput(prodR2, spRM), 1.0, 3);
operations.addAll(Arrays.asList(opP1, opP2, opS1, opR1, opR1Alt, opR2, opR2Alt));
// === BOM / 操作输入物料定义 ===
// P1 消耗: S1×2.0 + R1×3.0
operationInputs.add(new OperationInput(opP1, prodS1, spSemi, 2.0));
operationInputs.add(new OperationInput(opP1, prodR1, spRM, 3.0));
// P2 消耗: S1×1.0
operationInputs.add(new OperationInput(opP2, prodS1, spSemi, 1.0));
// S1 消耗: R2×2.0
operationInputs.add(new OperationInput(opS1, prodR2, spRM, 2.0));
// === 单元周期定义 (7 个生产单元, 不同可用工时) ===
// L1: 成品线, 最大可用工时 200h/周期
for (Period p : periods) {
unitPeriods.add(new UnitPeriod("L1", p, 0.0, 10.0, false));
}
for (Period p : periods) {
unitPeriods.add(new UnitPeriod("L2", p, 0.0, 10.0, false));
}
// Unit_Semi: 半成品线, 最大产能 200/周期
for (Period p : periods) {
unitPeriods.add(new UnitPeriod("L3", p, 0.0, 10.0, false));
}
// Unit_R1: 供应商 R1, 最大产能 150/周期
for (Period p : periods) {
unitPeriods.add(new UnitPeriod("SuA", p, 0.0, 24, false));
}
// Unit_R2: 供应商 R2, 最大产能 150/周期
for (Period p : periods) {
unitPeriods.add(new UnitPeriod("SuB", p, 0.0, 24, false));
}
// Unit_R1_Alt: 备用供应商 R1, 最大产能 100/周期 (成本较高, 仅在主供应商不足时使用)
for (Period p : periods) {
unitPeriods.add(new UnitPeriod("Su1", p, 0.0, 24, false));
}
// Unit_R2_Alt: 备用供应商 R2, 最大产能 100/周期 (成本较高, 仅在主供应商不足时使用)
for (Period p : periods) {
unitPeriods.add(new UnitPeriod("Su2", p, 0.0, 100.0, false));
}
// === 初始库存 ===
initialInventories.add(new InitialInventory(prodP1, spFG, 20.0));
initialInventories.add(new InitialInventory(prodP2, spFG, 10));
initialInventories.add(new InitialInventory(prodS1, spSemi, 50));
initialInventories.add(new InitialInventory(prodR1, spRM, 200));
initialInventories.add(new InitialInventory(prodR2, spRM, 100));
// === 在途供应 (原材料供应商已发货, 将在未来日期到货) ===
// R1: 主供应商在 2026-01-07 到货 100 件
// inTransitSupplies.add(new InTransitSupply(prodR1, spR, baseDate.plusDays(2), 100.0));
// R1: 备用供应商在 2026-01-08 到货 50 件
// inTransitSupplies.add(new InTransitSupply(prodR1, spR, baseDate.plusDays(3), 50.0));
// R2: 主供应商在 2026-01-06 到货 80 件
// inTransitSupplies.add(new InTransitSupply(prodR2, spR, baseDate.plusDays(1), 80.0));
// R2: 备用供应商在 2026-01-07 到货 60 件
// inTransitSupplies.add(new InTransitSupply(prodR2, spR, baseDate.plusDays(2), 60.0));
// === 销售需求 (每个产品每周期都有外部需求) ===
// P1: 成品需求 50/周期
salesDemands.add(new SalesDemand(prodP1, spFG, p1, 50.0, 1.0));
salesDemands.add(new SalesDemand(prodP1, spFG, p2, 60.0, 1.0));
salesDemands.add(new SalesDemand(prodP1, spFG, p3, 40.0, 1.0));
salesDemands.add(new SalesDemand(prodP2, spFG, p1, 30.0, 1.0));
salesDemands.add(new SalesDemand(prodP2, spFG, p2, 40.0, 1.0));
salesDemands.add(new SalesDemand(prodP2, spFG, p3, 50.0, 1.0));
// 注意: 原材料 R1, R2 不设外部销售需求, 仅用于内部 BOM 消耗
// === 库存规格 (每个产品/库存点/周期) ===
// P1: target 100, min 20, max 200
for (Period p : periods) {
inventorySpecs.add(new InventorySpec(prodP1, spFG, p,
20, 5, 150, true, true, true));
}
// P2: target 80, min 15, max 150
for (Period p : periods) {
inventorySpecs.add(new InventorySpec(prodP2, spFG, p,
15, 3, 120.0, true, true, true));
}
// S1: target 60, min 10, max 120
for (Period p : periods) {
inventorySpecs.add(new InventorySpec(prodS1, spSemi, p,
30.0, 10.0, 200.0, true, true, true));
}
// R1: target 80.0, min 500.0, max 500.0
for (Period p : periods) {
inventorySpecs.add(new InventorySpec(prodR1, spRM, p,
80.0, 50.0, 500.0, true, true, true));
}
// R2: target 40.0, min 20.0, max 400.0
for (Period p : periods) {
inventorySpecs.add(new InventorySpec(prodR2, spRM, p,
40.0, 20.0, 400.0, true, true, true));
}
// === 供应规格 (每个产品一个供应规格, 可关联多个操作/多产线) ===
supplySpecs.add(new SupplySpec("Supply-P1", 200.0, 20.0, 400.0,
false, Arrays.asList(opP1)));
supplySpecs.add(new SupplySpec("Supply-P2", 160.0, 10.0, 300.0,
false, Arrays.asList(opP2)));
// S1: 主产线 + 备用产线都生产 S1
supplySpecs.add(new SupplySpec("Supply-S1", 240.0, 30.0, 400.0,
false, Arrays.asList(opS1)));
// R1: 主供应商 + 备用供应商
supplySpecs.add(new SupplySpec("Supply-R1", 100.0, 50.0, 200.0,
false, Arrays.asList(opR1)));
// R1: 备用供应商
supplySpecs.add(new SupplySpec("Supply-R1-Alt", 70.0, 30.0, 100.0,
false, Arrays.asList( opR1Alt)));
// R2: 主供应商 + 备用供应商
supplySpecs.add(new SupplySpec("Supply-R2", 80.0, 60.0, 150.0,
false, Arrays.asList(opR2)));
supplySpecs.add(new SupplySpec("Supply-R2-Alt", 40.0, 20.0, 80.0,
false, Arrays.asList( opR2Alt)));
// === KPI 权重 ===
kpiWeights = new KPIWeights(
100.0, // fulfillmentWeight
10.0, // lotSizeWeight
5.0, // maxInventoryLevelWeight
5.0, // minInventoryLevelWeight
8.0, // targetInventoryLevelWeight
20.0, // unitCapacityWeight
8.0, // supplyTargetWeight
5.0, // minSupplyWeight
5.0, // maxSupplyWeight
1.0, // salesDemandPriorityWeight
20.0, // postponementPenaltyWeight
5.0 // processMaxQuantityWeight
);
}
// ==================== Getters ====================
public List<Period> getPeriods() { return periods; }
public List<Product> getProducts() { return products; }
public List<StockingPoint> getStockingPoints() { return stockingPoints; }
/**
* 获取指定产品的所有库存点。
* 支持一个产品存放在多个库存点的场景。
* @return 库存点列表, 不存在则返回空列表
*/
public List<StockingPoint> getStockingPointsForProduct(String productId) {
List<StockingPoint> result = new ArrayList<>();
for (ProductSpMapping m : productSpMappings) {
if (m.getProduct().getId().equals(productId)) {
result.add(m.getStockingPoint());
}
}
return result;
}
public List<Operation> getOperations() { return operations; }
public List<OperationInput> getOperationInputs() { return operationInputs; }
public List<Routing> getRoutings() { return routings; }
public List<UnitPeriod> getUnitPeriods() { return unitPeriods; }
public List<SalesDemand> getSalesDemands() { return salesDemands; }
public List<InventorySpec> getInventorySpecs() { return inventorySpecs; }
public List<SupplySpec> getSupplySpecs() { return supplySpecs; }
public List<InTransitSupply> getInTransitSupplies() { return inTransitSupplies; }
public List<InitialInventory> getInitialInventories() { return initialInventories; }
public KPIWeights getKpiWeights() { return kpiWeights; }
/**
* 获取初始库存量
* @param productId 产品ID
* @param spId 库存点ID
* @return 初始库存量,不存在则返回 0
*/
public double getInitialInventory(String productId, String spId) {
for (InitialInventory inv : initialInventories) {
if (inv.getProduct().getId().equals(productId)
&& inv.getStockingPoint().getId().equals(spId)) {
return inv.getQuantity();
}
}
return 0.0;
}
/**
* 获取指定产品/库存点/周期的销售需求
*/
public List<SalesDemand> getSalesDemandsFor(Product product, StockingPoint sp, Period period) {
List<SalesDemand> result = new ArrayList<>();
for (SalesDemand sd : salesDemands) {
if (sd.getProduct().getId().equals(product.getId())
&& sd.getStockingPoint().getId().equals(sp.getId())
&& sd.getPeriod().getIndex() == period.getIndex()) {
result.add(sd);
}
}
return result;
}
/**
* 获取指定产品/库存点/周期的库存规格
*/
public InventorySpec getInventorySpecFor(Product product, StockingPoint sp, Period period) {
for (InventorySpec spec : inventorySpecs) {
if (spec.getProduct().getId().equals(product.getId())
&& spec.getStockingPoint().getId().equals(sp.getId())
&& spec.getPeriod().getIndex() == period.getIndex()) {
return spec;
}
}
return null;
}
/**
* 获取指定单元/周期的 UnitPeriod
*/
public UnitPeriod getUnitPeriod(String unitId, Period period) {
for (UnitPeriod up : unitPeriods) {
if (up.getUnitId().equals(unitId) && up.getPeriod().getIndex() == period.getIndex()) {
return up;
}
}
return null;
}
/**
* 获取上一个周期
*/
public Period getPreviousPeriod(Period period) {
if (period.getIndex() == 0) return null;
return periods.get(period.getIndex() - 1);
}
/**
* 根据日期查找包含该日期的周期。
* 遍历所有周期, 返回 startDate ≤ date < startDate+durationInDays 的周期。
* @return 包含该日期的周期, 若所有周期都无 startDate 或找不到则返回 null
*/
public Period getPeriodByDate(LocalDate date) {
if (date == null) return null;
for (Period p : periods) {
if (p.contains(date)) return p;
}
return null;
}
/**
* 根据参考周期和偏移天数查找目标周期。
* 用于 leadTime 计算: 参
* 考周期的 startDate - daysBack 对应哪个周期?
* @param reference 参考周期
* @param daysBack 向前偏移天数 (≥0)
* @return 包含 reference.startDate - daysBack 的周期, 无对应周期返回 null
*/
public Period getPeriodOffsetByDays(Period reference, int daysBack) {
if (reference == null || reference.getStartDate() == null) return null;
LocalDate targetDate = reference.getStartDate().minusDays(daysBack);
return getPeriodByDate(targetDate);
}
}
\ No newline at end of file
package com.aps.macroplanner.data;
/**
* 单元周期 (UnitPeriod) — 对应 Quintiq 中的 UnitPeriod
* 表示某个单元在某个周期的产能可用情况
*/
public class UnitPeriod {
private final String unitId;
private final Period period;
private final double minCapacity; // 最小产能
private final double maxCapacity; // 最大可用产能
private final boolean hasMinCapacity; // 是否有最小产能约束
public UnitPeriod(String unitId, Period period, double minCapacity, double maxCapacity, boolean hasMinCapacity) {
this.unitId = unitId;
this.period = period;
this.minCapacity = minCapacity;
this.maxCapacity = maxCapacity;
this.hasMinCapacity = hasMinCapacity;
}
public String getUnitId() { return unitId; }
public Period getPeriod() { return period; }
public double getMinCapacity() { return minCapacity; }
public double getMaxCapacity() { return maxCapacity; }
public boolean hasMinCapacity() { return hasMinCapacity; }
public String getKey() {
return unitId + "_" + period.getIndex();
}
@Override
public String toString() {
return "UnitPeriod[" + unitId + ", " + period + "]";
}
}
\ No newline at end of file
package com.aps.macroplanner.model;
import com.google.ortools.linearsolver.MPSolver;
import com.google.ortools.linearsolver.MPVariable;
import java.util.HashMap;
import java.util.Map;
/**
* 优化模型容器 — 持有求解器、所有决策变量和 KPI 汇总变量。
*
* <p>设计原则: 所有变量通过此类的 getter 方法暴露,
* 各约束构建器和目标函数构建器通过此模型相互协作。</p>
*
* <h3>变量索引键命名规则</h3>
* <pre>
* 生产变量: {operationId}_{periodIndex}
* 库存变量: {productId}_{spId}_{periodIndex}
* 准能松弛: {unitId}_{periodIndex}
* 供应松弛: {specName}
* 批次松弛: {operationId}_{periodIndex}
* BOM 变量: {operationId}_{productId}_{spId}_{periodIndex}
* </pre>
*/
public class MacroPlannerModel {
// ==================== 求解器 ====================
/** MIP 求解器实例 (SCIP) */
private final MPSolver solver;
// ==================== 生产变量 ====================
/** PTQty[operationId_periodIndex] — 生产量, 范围 [0, +∞) */
private final Map<String, MPVariable> ptQtyVars = new HashMap<>();
// ==================== 库存变量 ====================
/** InvQty[productId_spId_periodIndex] — 期末库存, 范围 [0, +∞) */
private final Map<String, MPVariable> invQtyVars = new HashMap<>();
// ==================== 需求变量 ====================
/** SalesDemandQty[productId_spId_periodIndex] — 销售需求满足量, 范围 [0, 需求量] */
private final Map<String, MPVariable> salesDemandQtyVars = new HashMap<>();
/** DemandSlack[productId_spId_periodIndex] — 需求松弛, 范围 [0, +∞) */
private final Map<String, MPVariable> demandSlackVars = new HashMap<>();
/** OperationDemandQty[inputKey_periodIndex] — BOM 消耗量, 范围 [0, +∞) */
private final Map<String, MPVariable> operationDemandQtyVars = new HashMap<>();
/** DependentDemandInPISPIP[productId_spId_periodIndex] — PISPIP 总依赖需求, 范围 [0, +∞) */
private final Map<String, MPVariable> dependentDemandVars = new HashMap<>();
/** DemandFulfillmentInPISPIP[productId_spId_periodIndex] — PISPIP 总需求满足量,
* 用于安全库存天数计算。等于 SalesDemandQty + OperationDemandQty 之和。
* 范围 [0, +∞) */
private final Map<String, MPVariable> demandFulfillmentVars = new HashMap<>();
// ==================== 产能松弛变量 ====================
/** CapacityOverloaded[unitId_periodIndex] — 产能超载, 范围 [0, +∞) */
private final Map<String, MPVariable> capacityOverloadedVars = new HashMap<>();
/** CapacityNotMet[unitId_periodIndex] — 产能未满足, 范围 [0, +∞) */
private final Map<String, MPVariable> capacityNotMetVars = new HashMap<>();
// ==================== 库存松弛变量 ====================
/** MinInvQtyUnder[productId_spId_periodIndex] — 低于最小库存, 范围 [0, +∞) */
private final Map<String, MPVariable> minInvQtyUnderVars = new HashMap<>();
/** MaxInvQtyOver[productId_spId_periodIndex] — 超过最大库存, 范围 [0, +∞) */
private final Map<String, MPVariable> maxInvQtyOverVars = new HashMap<>();
/** InvQtyUnderTarget[productId_spId_periodIndex] — 低于目标库存, 范围 [0, +∞) */
private final Map<String, MPVariable> invQtyUnderTargetVars = new HashMap<>();
// ==================== 供应松弛变量 ====================
/** SupplyTargetQtyUnder[specName] — 供应目标不足, 范围 [0, +∞) */
private final Map<String, MPVariable> supplyTargetQtyUnderVars = new HashMap<>();
/** MinSupplyQtyUnder[specName] — 最小供应不足, 范围 [0, +∞) */
private final Map<String, MPVariable> minSupplyQtyUnderVars = new HashMap<>();
/** MaxSupplyQtyOver[specName] — 最大供应超出, 范围 [0, +∞) */
private final Map<String, MPVariable> maxSupplyQtyOverVars = new HashMap<>();
// ==================== 批次松弛变量 ====================
/** PTLotSizeOver[operationId_periodIndex] — 批次超出, 范围 [0, +∞) */
private final Map<String, MPVariable> ptLotSizeOverVars = new HashMap<>();
/** PTLotSizeUnder[operationId_periodIndex] — 批次不足, 范围 [0, +∞) */
private final Map<String, MPVariable> ptLotSizeUnderVars = new HashMap<>();
// ==================== KPI 汇总变量 ====================
private MPVariable totalFulfillment;
private MPVariable totalLotSize;
private MPVariable totalMaxInventoryLevel;
private MPVariable totalMinInventoryLevel;
private MPVariable totalTargetInvLevel;
private MPVariable totalUnitCapacity;
private MPVariable totalSupplyTarget;
private MPVariable totalMinSupply;
private MPVariable totalMaxSupply;
private MPVariable totalSalesDemandPriority;
private MPVariable totalPostponementPenalty;
private MPVariable totalProcessMaxQuantity;
// ==================== 构造 ====================
/**
* 创建模型容器, 初始化 SCIP 求解器。
* 变量创建由 {@link VariableFactory} 负责。
*/
public MacroPlannerModel() {
this.solver = MPSolver.createSolver("SCIP");
if (solver == null) {
throw new RuntimeException("无法加载 SCIP 求解器,请检查 OR-Tools 依赖");
}
solver.enableOutput();
}
// ==================== KPI 变量 setter (由 KpiAggregator 调用) ====================
public void setTotalFulfillment(MPVariable v) { this.totalFulfillment = v; }
public void setTotalLotSize(MPVariable v) { this.totalLotSize = v; }
public void setTotalMaxInventoryLevel(MPVariable v) { this.totalMaxInventoryLevel = v; }
public void setTotalMinInventoryLevel(MPVariable v) { this.totalMinInventoryLevel = v; }
public void setTotalTargetInvLevel(MPVariable v) { this.totalTargetInvLevel = v; }
public void setTotalUnitCapacity(MPVariable v) { this.totalUnitCapacity = v; }
public void setTotalSupplyTarget(MPVariable v) { this.totalSupplyTarget = v; }
public void setTotalMinSupply(MPVariable v) { this.totalMinSupply = v; }
public void setTotalMaxSupply(MPVariable v) { this.totalMaxSupply = v; }
public void setTotalSalesDemandPriority(MPVariable v) { this.totalSalesDemandPriority = v; }
public void setTotalPostponementPenalty(MPVariable v) { this.totalPostponementPenalty = v; }
public void setTotalProcessMaxQuantity(MPVariable v) { this.totalProcessMaxQuantity = v; }
// ==================== Getters ====================
public MPSolver getSolver() { return solver; }
public Map<String, MPVariable> getPtQtyVars() { return ptQtyVars; }
public Map<String, MPVariable> getInvQtyVars() { return invQtyVars; }
public Map<String, MPVariable> getSalesDemandQtyVars() { return salesDemandQtyVars; }
public Map<String, MPVariable> getDemandSlackVars() { return demandSlackVars; }
public Map<String, MPVariable> getOperationDemandQtyVars() { return operationDemandQtyVars; }
public Map<String, MPVariable> getDependentDemandVars() { return dependentDemandVars; }
public Map<String, MPVariable> getDemandFulfillmentVars() { return demandFulfillmentVars; }
public Map<String, MPVariable> getCapacityOverloadedVars() { return capacityOverloadedVars; }
public Map<String, MPVariable> getCapacityNotMetVars() { return capacityNotMetVars; }
public Map<String, MPVariable> getMinInvQtyUnderVars() { return minInvQtyUnderVars; }
public Map<String, MPVariable> getMaxInvQtyOverVars() { return maxInvQtyOverVars; }
public Map<String, MPVariable> getInvQtyUnderTargetVars() { return invQtyUnderTargetVars; }
public Map<String, MPVariable> getSupplyTargetQtyUnderVars() { return supplyTargetQtyUnderVars; }
public Map<String, MPVariable> getMinSupplyQtyUnderVars() { return minSupplyQtyUnderVars; }
public Map<String, MPVariable> getMaxSupplyQtyOverVars() { return maxSupplyQtyOverVars; }
public Map<String, MPVariable> getPtLotSizeOverVars() { return ptLotSizeOverVars; }
public Map<String, MPVariable> getPtLotSizeUnderVars() { return ptLotSizeUnderVars; }
public MPVariable getTotalFulfillment() { return totalFulfillment; }
public MPVariable getTotalLotSize() { return totalLotSize; }
public MPVariable getTotalMaxInventoryLevel() { return totalMaxInventoryLevel; }
public MPVariable getTotalMinInventoryLevel() { return totalMinInventoryLevel; }
public MPVariable getTotalTargetInvLevel() { return totalTargetInvLevel; }
public MPVariable getTotalUnitCapacity() { return totalUnitCapacity; }
public MPVariable getTotalSupplyTarget() { return totalSupplyTarget; }
public MPVariable getTotalMinSupply() { return totalMinSupply; }
public MPVariable getTotalMaxSupply() { return totalMaxSupply; }
public MPVariable getTotalSalesDemandPriority() { return totalSalesDemandPriority; }
public MPVariable getTotalPostponementPenalty() { return totalPostponementPenalty; }
public MPVariable getTotalProcessMaxQuantity() { return totalProcessMaxQuantity; }
}
\ No newline at end of file
package com.aps.macroplanner.model;
import com.aps.macroplanner.data.TestDataBuilder;
import com.aps.macroplanner.variable.*;
/**
* 变量工厂 — 编排器,按构建顺序调度各变量构建器。
*
* <p>设计原则: 与 {@link com.aps.macroplanner.constraint.ConstraintFactory} 保持对称,
* 每个变量类别由独立的 Builder 类负责, 本类仅负责编排调度。</p>
*
* <h3>构建顺序</h3>
* <pre>
* 1. 生产变量 → ProductionVariableBuilder
* 2. 库存变量 → InventoryVariableBuilder
* 3. 需求变量 → DemandVariableBuilder
* 4. 产能松弛变量 → CapacityVariableBuilder
* 5. 库存松弛变量 → InventorySlackVariableBuilder
* 6. 供应松弛变量 → SupplySlackVariableBuilder
* 7. 批次松弛变量 → LotSizeVariableBuilder
* 8. BOM 依赖需求变量 → BomVariableBuilder
* 9. 需求满足量变量 → DemandFulfillmentVariableBuilder
* </pre>
*
* <h3>扩展方式</h3>
* 新增变量类型时:
* <ol>
* <li>在 {@code com.aps.macroplanner.variable} 包创建新的 Builder 类</li>
* <li>在 {@link MacroPlannerModel} 添加对应的变量 Map</li>
* <li>在本类的 {@link #createAll} 方法中注册调用</li>
* </ol>
*/
public class VariableFactory {
/**
* 创建所有决策变量并注册到模型。
*
* @param model 模型容器 (通过 getter 获取 Map 引用, 直接 put 变量)
* @param data 测试数据 (提供索引维度)
*/
public static void createAll(MacroPlannerModel model, TestDataBuilder data) {
ProductionVariableBuilder.create(model, data);
InventoryVariableBuilder.create(model, data);
DemandVariableBuilder.create(model, data);
CapacityVariableBuilder.create(model, data);
InventorySlackVariableBuilder.create(model, data);
SupplySlackVariableBuilder.create(model, data);
LotSizeVariableBuilder.create(model, data);
BomVariableBuilder.create(model, data);
DemandFulfillmentVariableBuilder.create(model, data);
}
}
\ No newline at end of file
package com.aps.macroplanner.objective;
import com.google.ortools.linearsolver.MPConstraint;
import com.google.ortools.linearsolver.MPObjective;
import com.google.ortools.linearsolver.MPSolver;
import com.aps.macroplanner.data.KPIWeights;
import com.aps.macroplanner.data.TestDataBuilder;
import com.aps.macroplanner.model.MacroPlannerModel;
/**
* 目标函数构建器 — 加权惩罚最小化
*
* <p>将多个 KPI 汇总变量按权重加权求和, 通过最小化该加权和来优化。
* 这是原始 Quintiq 模型中"非层级化目标"的等价实现。</p>
*
* <h3>数学公式</h3>
* <pre>
* Minimize Σ (weight × KPI_TotalVariable)
*
* 展开:
* obj = 100.0 × TotalFulfillment (需求满足 — 最高优先级)
* + 10.0 × TotalLotSize (批次偏差)
* + 5.0 × TotalMaxInventoryLevel (超库存)
* + 5.0 × TotalMinInventoryLevel (欠库存)
* + 8.0 × TotalTargetInvLevel (目标库存偏差)
* + 3.0 × TotalUnitCapacity (产能超载)
* + 8.0 × TotalSupplyTarget (供应目标偏差)
* + 5.0 × TotalMinSupply (最小供应不足)
* + 5.0 × TotalMaxSupply (最大供应超出)
* - 1.0 × TotalSalesDemandPriority (销售需求优先级 — 最大化, 加负号)
* + 20.0 × TotalPostponementPenalty (推迟惩罚 — 简化版=0)
* + 5.0 × TotalProcessMaxQuantity (过程最大量 — 简化版=0)
* </pre>
*
* <h3>方向说明</h3>
* 所有 KPI 汇总变量都是非负的"惩罚量", 所以目标是最小化。
* 唯一例外: TotalSalesDemandPriority 是"收益量" (越大越好),
* 所以使用负系数 (-1.0), 在最小化目标中等价于最大化。
*
* <h3>权重调优原则</h3>
* - 权重越大, 该项在目标函数中优先级越高
* - 例如: Fulfillment=100 远大于 LotSize=10,
* 优化器会优先满足需求, 即使这意味着批次偏差更大
* - 权重为 0 表示该项不影响目标 (但约束仍然存在)
*/
public class ObjectiveBuilder {
/**
* 构建目标函数。
* 必须在所有 KPI 汇总变量创建之后调用 (即 KpiAggregator.build() 之后)。
*
* @param model 模型容器 (提供所有 KPI 汇总变量)
* @param data 测试数据 (提供 KPI 权重)
*/
public static void build(MacroPlannerModel model, TestDataBuilder data) {
MPObjective objective = model.getSolver().objective();
KPIWeights w = data.getKpiWeights();
// ===== 惩罚项 (最小化: 正系数) =====
// Fulfillment: 需求松弛惩罚, 权重 100.0 (最高优先级)
// 含义: 所有周期所有产品的需求松弛总和。值越小表示需求满足越好。
objective.setCoefficient(model.getTotalFulfillment(), w.getFulfillmentWeight());
// LotSize: 批次偏差惩罚, 权重 10.0
// 含义: 所有操作所有周期的批次大小偏差总和。惩罚不按批次生产的量。
objective.setCoefficient(model.getTotalLotSize(), w.getLotSizeWeight());
// MaxInventoryLevel: 超库存惩罚, 权重 5.0
// 含义: 所有超标库存的总和。惩罚库存超过最大水平。
objective.setCoefficient(model.getTotalMaxInventoryLevel(), w.getMaxInventoryLevelWeight());
// MinInventoryLevel: 欠库存(安全库存)惩罚, 权重 5.0
// 含义: 所有低于最小库存的总和。惩罚库存低于安全水平。
objective.setCoefficient(model.getTotalMinInventoryLevel(), w.getMinInventoryLevelWeight());
// TargetInventoryLevel: 目标库存偏差惩罚, 权重 8.0
// 含义: 所有低于目标库存的总和。惩罚库存偏离目标水平。
objective.setCoefficient(model.getTotalTargetInvLevel(), w.getTargetInventoryLevelWeight());
// UnitCapacity: 产能超载惩罚, 权重 3.0
// 含义: 所有设备所有周期的产能超载总和。惩罚超负荷生产。
objective.setCoefficient(model.getTotalUnitCapacity(), w.getUnitCapacityWeight());
// SupplyTarget: 供应目标偏差惩罚, 权重 8.0
// 含义: 供应低于目标的总缺口。惩罚供应不足。
objective.setCoefficient(model.getTotalSupplyTarget(), w.getSupplyTargetWeight());
// MinSupply: 最小供应不足惩罚, 权重 5.0
// 含义: 供应低于最小值的总缺口。惩罚供应严重不足。
objective.setCoefficient(model.getTotalMinSupply(), w.getMinSupplyWeight());
// MaxSupply: 最大供应超出惩罚, 权重 5.0
// 含义: 供应超出最大值的总量。惩罚过度供应。
objective.setCoefficient(model.getTotalMaxSupply(), w.getMaxSupplyWeight());
// ===== 收益项 (最大化: 负系数, 因为 OR-Tools 默认最小化) =====
// SalesDemandPriority: 销售需求优先级, 权重 1.0, 负系数 = 最大化
// 含义: 按优先级加权的销售需求满足量。值越大表示满足的需求越多越好。
// 因目标函数是最小化, 此项使用负系数 (最大化 = 加负号后最小化)。
objective.setCoefficient(model.getTotalSalesDemandPriority(), -w.getSalesDemandPriorityWeight());
// PostponementPenalty: 推迟惩罚, 权重 20.0 (简化版固定为 0)
// 含义: 对应原模型中的延迟销售需求惩罚。当前版本未实现推迟逻辑。
objective.setCoefficient(model.getTotalPostponementPenalty(), w.getPostponementPenaltyWeight());
// ProcessMaxQuantity: 过程最大量惩罚, 权重 5.0 (简化版固定为 0)
// 含义: 对应原模型中超出最大过程量的惩罚。当前版本未实现。
objective.setCoefficient(model.getTotalProcessMaxQuantity(), w.getProcessMaxQuantityWeight());
// 设置为最小化目标
objective.setMinimization();
}
// ==================== 分层优化方法 ====================
/**
* 清除目标函数中所有 KPI 变量的系数 (置零)。
*
* <p>在分层优化中, 每次切换层级时需要先清除上一层的目标,
* 然后设置当前层的目标系数。OR-Tools 旧版 API 没有 clear() 方法,
* 因此显式将所有 KPI 系数置零。</p>
*
* @param model 模型容器
*/
public static void clearObjective(MacroPlannerModel model) {
MPObjective objective = model.getSolver().objective();
// 将所有 KPI 变量的系数置零
objective.setCoefficient(model.getTotalFulfillment(), 0.0);
objective.setCoefficient(model.getTotalLotSize(), 0.0);
objective.setCoefficient(model.getTotalMaxInventoryLevel(), 0.0);
objective.setCoefficient(model.getTotalMinInventoryLevel(), 0.0);
objective.setCoefficient(model.getTotalTargetInvLevel(), 0.0);
objective.setCoefficient(model.getTotalUnitCapacity(), 0.0);
objective.setCoefficient(model.getTotalSupplyTarget(), 0.0);
objective.setCoefficient(model.getTotalMinSupply(), 0.0);
objective.setCoefficient(model.getTotalMaxSupply(), 0.0);
objective.setCoefficient(model.getTotalSalesDemandPriority(), 0.0);
objective.setCoefficient(model.getTotalPostponementPenalty(), 0.0);
objective.setCoefficient(model.getTotalProcessMaxQuantity(), 0.0);
}
/**
* 设置当前层级的目标函数 — 只包含该层级的 KPI。
*
* <p>调用前应先调用 {@link #clearObjective(MacroPlannerModel)} 清除上层目标。</p>
*
* @param model 模型容器
* @param level 策略层级 (包含该层级的 KPI 列表)
*/
public static void setLevelObjective(MacroPlannerModel model, StrategyLevel level) {
MPObjective objective = model.getSolver().objective();
for (StrategyLevel.KPIEntry kpi : level.getKpis()) {
objective.setCoefficient(kpi.variable, kpi.effectiveCoefficient());
}
objective.setMinimization();
}
/**
* 添加层级边界约束 — 限制上层目标值不超过最优值 × (1 + slack)。
*
* <p>该约束确保在求解下层 KPI 时, 上层 KPI 不会退化超过允许范围。
* 对应 Quintiq 中 StrategyLevel 的 HierarchicalSolver 约束。</p>
*
* <h3>数学公式</h3>
* <pre>
* Σ (effectiveCoeff × KPI_variable) ≤ optimalValue × (1 + relativeGoalSlack)
* </pre>
*
* @param model 模型容器
* @param level 策略层级 (提供 KPI 列表和松弛比例)
* @param optimalValue 该层级在上一轮求解中的最优目标值
*/
public static void addLevelBoundConstraint(MacroPlannerModel model,
StrategyLevel level,
double optimalValue) {
if (level.getRelativeGoalSlack() < 0.0) return; // 负松弛表示不约束
MPSolver solver = model.getSolver();
double upperBound = optimalValue * (1.0 + level.getRelativeGoalSlack());
MPConstraint bound = solver.makeConstraint(
-MPSolver.infinity(), upperBound,
"HierLevel" + level.getLevel() + "_Bound");
for (StrategyLevel.KPIEntry kpi : level.getKpis()) {
bound.setCoefficient(kpi.variable, kpi.effectiveCoefficient());
}
}
}
\ No newline at end of file
package com.aps.macroplanner.output;
import com.aps.macroplanner.data.*;
import com.aps.macroplanner.model.MacroPlannerModel;
import com.aps.macroplanner.objective.StrategyLevel;
import java.util.*;
import java.time.LocalDate;
import java.util.logging.Logger;
/**
* 求解结果输出器 — 按日输出产线视图、产品视图、物料需求展开、期末库存。
*
* <p>输出格式参照供应链排产日志, 包含以下视图:</p>
* <ul>
* <li>BOM 关系验证 — 展示产品间的消耗与被消耗关系</li>
* <li>产线视图 — 按设备分组, 展示各操作的生产量和产能利用率</li>
* <li>产品视图 — 汇总各产品库存、生产、销售情况</li>
* <li>物料需求展开 — 按 BOM 逐级展开物料消耗</li>
* <li>期末库存 — 简洁的库存快照</li>
* <li>KPI 汇总 — 目标函数各惩罚项明细</li>
* <li>求解统计 — 变量数、约束数、耗时</li>
* </ul>
*/
public class SolutionPrinter {
private static final Logger LOG = Logger.getLogger(SolutionPrinter.class.getName());
private final MacroPlannerModel model;
private final TestDataBuilder data;
private final long startTimeMs;
public SolutionPrinter(MacroPlannerModel model, TestDataBuilder data, long startTimeMs) {
this.model = model;
this.data = data;
this.startTimeMs = startTimeMs;
}
// ==================== 主入口 ====================
/** 输出完整求解结果 */
public void printAll() {
printBomStructure();
printHeader();
printDailyViews();
printKpiSummary();
printStatistics();
}
// ==================== BOM 关系验证 ====================
private void printBomStructure() {
System.out.println("📋 BOM关系验证:");
for (Product prod : data.getProducts()) {
System.out.printf(" %s → 消耗: %s, 被消耗于: %s%n",
prod.getId(),
formatConsumes(prod),
formatConsumedBy(prod));
}
System.out.println();
}
/** 某产品消耗了哪些物料 (BOM输入, 含库存点) */
private String formatConsumes(Product prod) {
List<String> items = new ArrayList<>();
for (Operation op : data.getOperations()) {
if (!op.producesProduct(prod.getId())) continue;
int inputCount = 0;
for (OperationInput input : data.getOperationInputs()) {
if (!input.getOperation().getId().equals(op.getId())) continue;
items.add(input.getInputProduct().getId() + "@" + input.getInputSp().getId()
+ "×" + formatFactor(input.getFactor()));
inputCount++;
}
if (inputCount > 0) {
LOG.fine(String.format("[结果输出] BOM消耗: '%s'(产出%s) 消耗 %d 种物料",
op.getId(), formatOutputProducts(op), inputCount));
}
}
return items.isEmpty() ? "[]" : "[" + String.join(", ", items) + "]";
}
/** 某产品被哪些产品消耗 (BOM输出, 含库存点) */
private String formatConsumedBy(Product prod) {
List<String> items = new ArrayList<>();
for (OperationInput input : data.getOperationInputs()) {
if (!input.getInputProduct().getId().equals(prod.getId())) continue;
// 遍历消费者工序的所有产出 (支持联产品/副产品)
List<OperationOutput> outputs = input.getOperation().getOutputs();
if (outputs.size() > 1) {
LOG.fine(String.format("[结果输出] 多产出消耗: '%s' 被工序 '%s' 消耗, 该工序有 %d 个产出",
prod.getId(), input.getOperation().getId(), outputs.size()));
}
for (OperationOutput oo : outputs) {
items.add(oo.getProductId() + "@" + oo.getSpId()
+ "×" + formatFactor(input.getFactor()));
}
}
return items.isEmpty() ? "[]" : "[" + String.join(", ", items) + "]";
}
/** 格式化工序的所有产出产品 ID (用于显示) */
private String formatOutputProducts(Operation op) {
List<String> ids = new ArrayList<>();
for (OperationOutput oo : op.getOutputs()) {
ids.add(oo.getProductId());
}
if (ids.size() > 1) {
LOG.fine(String.format("[结果输出] 多产出工序 '%s': 产出=%s",
op.getId(), String.join("+", ids)));
}
return ids.isEmpty() ? "-" : String.join("+", ids);
}
// ==================== 头部信息 ====================
private void printHeader() {
// 成品: 不被任何操作消耗的产品
Set<String> consumedProducts = new HashSet<>();
for (OperationInput input : data.getOperationInputs()) {
consumedProducts.add(input.getInputProduct().getId());
}
List<String> finishedGoods = new ArrayList<>();
List<String> producedGoods = new ArrayList<>();
List<String> rawMaterials = new ArrayList<>();
for (Product prod : data.getProducts()) {
boolean hasOperation = false;
boolean hasBomInput = false;
for (Operation op : data.getOperations()) {
if (op.producesProduct(prod.getId())) {
hasOperation = true;
}
}
for (OperationInput input : data.getOperationInputs()) {
if (input.getOperation().producesProduct(prod.getId())) {
hasBomInput = true;
}
}
if (!consumedProducts.contains(prod.getId())) {
finishedGoods.add(prod.getId());
}
if (hasOperation) {
producedGoods.add(prod.getId());
} else {
rawMaterials.add(prod.getId());
}
}
System.out.println("========== 网状BOM + MRP 排产求解 ==========");
System.out.printf("成品(根节点) %d 种:%s%n", finishedGoods.size(),
String.join(" ", finishedGoods));
System.out.printf("生产品 %d 种:%s%n", producedGoods.size(),
String.join(" ", producedGoods));
if (!rawMaterials.isEmpty()) {
System.out.printf("采购品 %d 种:%s%n", rawMaterials.size(),
String.join(" ", rawMaterials));
}
System.out.printf("周期 %d 天%n", data.getPeriods().size());
System.out.println();
}
// ==================== 每日视图 ====================
private void printDailyViews() {
for (Period p : data.getPeriods()) {
System.out.println();
System.out.printf("═══════════════════ 第 %d 天 ═══════════════════%n",
p.getIndex() + 1);
printLineView(p);
printProductView(p);
printBomExplosion(p);
printEndingInventory(p);
}
}
// --- 产线视图 ---
private void printLineView(Period p) {
System.out.println(" ▶ 产线视图(按产线分组)");
// 收集所有 unit
Set<String> unitIds = new LinkedHashSet<>();
for (UnitPeriod up : data.getUnitPeriods()) {
if (up.getPeriod().getIndex() == p.getIndex()) {
unitIds.add(up.getUnitId());
}
}
for (String unitId : unitIds) {
UnitPeriod up = data.getUnitPeriod(unitId, p);
if (up == null) continue;
String minStr = up.hasMinCapacity() ? String.format("最小%.0fh/", up.getMinCapacity()) : "";
System.out.printf(" 【%s】产能%s%.0fh %s%n", unitId, minStr, up.getMaxCapacity(),
up.hasMinCapacity() ? "(强制最小)" : "");
boolean hasProduction = false;
for (Operation op : data.getOperations()) {
for (UnitOperation uo : op.getUnitOperations()) {
if (!uo.getUnitId().equals(unitId)) continue;
String key = op.ptQtyKey(uo, p.getIndex());
double ptQty = solutionValue(model.getPtQtyVars(), key);
if (ptQty > 0.001) {
hasProduction = true;
double capacityUsed = ptQty * uo.getCapacityCoeff();
double utilPercent = up.getMaxCapacity() > 0
? capacityUsed / up.getMaxCapacity() * 100 : 0;
System.out.printf(" %s(%s): %.0f件 (耗时%.1fh) %s%n",
op.getName(), formatOutputProducts(op), ptQty, capacityUsed,
uo.hasLotSize() ? String.format(", 批次%.0f", uo.getLotSize()) : "");
System.out.printf(" 利用率: %.0f/%.0f h (%.0f%%)%n",
capacityUsed, up.getMaxCapacity(), utilPercent);
}
}
}
if (!hasProduction) {
System.out.println(" (休息)");
}
}
}
// --- 产品视图 ---
private void printProductView(Period p) {
System.out.println(" ▶ 产品视图(汇总各产线产量)");
for (Product prod : data.getProducts()) {
for (StockingPoint sp : data.getStockingPointsForProduct(prod.getId())) {
String invKey = prod.getId() + "_" + sp.getId() + "_" + p.getIndex();
double invQty = solutionValue(model.getInvQtyVars(), invKey);
// 期初库存 (第1周期=初始库存, 后续=上周期末库存)
double openingInv;
if (p.getIndex() == 0) {
openingInv = data.getInitialInventory(prod.getId(), sp.getId());
} else {
String prevInvKey = prod.getId() + "_" + sp.getId() + "_" + (p.getIndex() - 1);
openingInv = solutionValue(model.getInvQtyVars(), prevInvKey);
}
// 库存规格
InventorySpec spec = data.getInventorySpecFor(prod, sp, p);
String specStr = formatInventorySpec(spec);
// 生产量汇总 (到货: PTQty[productionDate], 生产日期 = p.startDate - leadTimeDays)
// 关键: 只统计产出到当前库存点(sp)的操作, 避免多工序路由中跨库存点汇总
StringBuilder prodStr = new StringBuilder();
double totalArrived = 0;
double totalInProduction = 0;
int matchedUnitOpCount = 0;
for (Operation op : data.getOperations()) {
if (!op.producesProductAtSp(prod.getId(), sp.getId())) continue;
int leadTimeDays = op.getLeadTimeDays();
// 到货: 在 productionDate = p.startDate - leadTimeDays 生产的此刻到货
Period arrivalSrcPeriod = data.getPeriodOffsetByDays(p, leadTimeDays);
for (UnitOperation uo : op.getUnitOperations()) {
matchedUnitOpCount++;
if (arrivalSrcPeriod != null) {
String ptKey = op.ptQtyKey(uo, arrivalSrcPeriod.getIndex());
double ptQty = solutionValue(model.getPtQtyVars(), ptKey);
if (ptQty > 0.001) {
prodStr.append(String.format("%s(到货%.0f件), ", uo.getUnitId(), ptQty));
totalArrived += ptQty;
}
}
// 生产中: PTQty[t] (当前正在生产, 将来到货)
String ptKey = op.ptQtyKey(uo, p.getIndex());
double ptQtyNow = solutionValue(model.getPtQtyVars(), ptKey);
if (ptQtyNow > 0.001) {
totalInProduction += ptQtyNow;
}
}
}
if (matchedUnitOpCount > 1) {
LOG.fine(String.format("[结果输出] 多Unit产出: %s@%s P%d 有 %d 个UnitOp产出到该库存点 (到货=%.0f, 生产中=%.0f)",
prod.getId(), sp.getId(), p.getIndex(), matchedUnitOpCount,
totalArrived, totalInProduction));
}
// 销售需求满足
double totalSales = 0;
double totalDemand = 0;
for (SalesDemand sd : data.getSalesDemandsFor(prod, sp, p)) {
double sdQty = solutionValue(model.getSalesDemandQtyVars(), sd.getKey());
totalSales += sdQty;
totalDemand += sd.getQuantity();
}
// 需求缺口
double demandSlack = solutionValue(model.getDemandSlackVars(), invKey);
// 在途到货
double inTransitQty = 0;
for (InTransitSupply its : data.getInTransitSupplies()) {
if (its.getProduct().getId().equals(prod.getId())
&& its.getStockingPoint().getId().equals(sp.getId())
&& p.equals(data.getPeriodByDate(its.getArrivalDate()))) {
inTransitQty += its.getQuantity();
}
}
String inTransitStr = inTransitQty > 0.001
? String.format(" | 📦在途到货%.0f件", inTransitQty) : "";
// 期初库存显示
String openingStr = p.getIndex() == 0
? String.format("期初库存%.0f件 → ", openingInv)
: String.format("上期库存%.0f件 → ", openingInv);
System.out.printf(" %s@%s: %s期末库存%.0f件 %s | %s%s%s | 销售%.0f/%.0f件",
prod.getId(), sp.getId(), openingStr, invQty, specStr,
totalArrived > 0.001
? "到货共" + formatNum(totalArrived) + "件 ["
+ prodStr.toString().replaceAll(", $", "") + "]"
+ (totalInProduction > 0.001
? " 生产中" + formatNum(totalInProduction) + "件"
: "")
: "无到货",
demandSlack > 0.001 ? " ⚠需求缺口" + formatNum(demandSlack) + "件" : "",
inTransitStr,
totalSales, totalDemand);
double unmet = totalDemand - totalSales;
if (unmet > 0.001) {
System.out.printf(" ⚠未满足%.0f件", unmet);
// 分析未满足原因
System.out.print(" " + analyzeUnmetReason(prod, sp, p, openingInv, totalArrived, unmet));
}
System.out.println();
}
}
}
/**
* 分析需求未满足的原因。
*
* <p>从以下维度排查:</p>
* <ol>
* <li>期初库存 + 到货量 是否足以覆盖需求? 否 → 库存耗尽</li>
* <li>所有生产该产品的单元是否满负荷? 是 → 产能瓶颈</li>
* <li>BOM 原材料是否耗尽? 是 → 原材料短缺</li>
* <li>提前期是否导致生产来不及到货? 是 → 提前期限制</li>
* </ol>
*/
private String analyzeUnmetReason(Product prod, StockingPoint sp, Period p,
double openingInv, double totalArrived, double unmet) {
List<String> reasons = new ArrayList<>();
// 1. 库存耗尽: 期初库存 + 到货 = 0, 或不足以覆盖需求
double available = openingInv + totalArrived;
// 用 totalDemand 来判断, 但这里只传了 unmet, 需要重新获取 totalDemand
double totalDemand = 0;
for (SalesDemand sd : data.getSalesDemandsFor(prod, sp, p)) {
totalDemand += sd.getQuantity();
}
if (available < totalDemand) {
reasons.add("库存耗尽(可用" + formatNum(available) + "件, 需求" + formatNum(totalDemand) + "件)");
}
// 2. 产能瓶颈: 所有生产该产品的单元都满负荷
boolean allUnitsAtMax = true;
boolean hasAnyUnit = false;
for (Operation op : data.getOperations()) {
if (!op.producesProductAtSp(prod.getId(), sp.getId())) continue;
for (UnitOperation uo : op.getUnitOperations()) {
hasAnyUnit = true;
UnitPeriod up = data.getUnitPeriod(uo.getUnitId(), p);
if (up == null) continue;
double totalUsed = 0;
for (Operation unitOp : data.getOperations()) {
for (UnitOperation unitUo : unitOp.getUnitOperations()) {
if (!unitUo.getUnitId().equals(uo.getUnitId())) continue;
String ptKey = unitOp.ptQtyKey(unitUo, p.getIndex());
double ptQty = solutionValue(model.getPtQtyVars(), ptKey);
totalUsed += ptQty * unitUo.getCapacityCoeff();
}
}
double utilPercent = up.getMaxCapacity() > 0
? totalUsed / up.getMaxCapacity() * 100 : 0;
if (utilPercent < 95.0) { // 95% 以下不算满负荷
allUnitsAtMax = false;
break;
}
}
if (!allUnitsAtMax) break;
}
if (hasAnyUnit && allUnitsAtMax) {
reasons.add("产能瓶颈(所有产线满负荷)");
}
// 3. 原材料短缺: BOM 输入物料库存为 0 或处于最小值
if (p.getIndex() > 0) { // 第1周期可能只是初始库存不足, 不算原材料短缺
for (Operation op : data.getOperations()) {
if (!op.producesProduct(prod.getId())) continue;
for (OperationInput input : data.getOperationInputs()) {
if (!input.getOperation().getId().equals(op.getId())) continue;
Product inputProd = input.getInputProduct();
StockingPoint inputSp = input.getInputSp();
String inputInvKey = inputProd.getId() + "_" + inputSp.getId() + "_" + (p.getIndex() - 1);
double prevInputInv = solutionValue(model.getInvQtyVars(), inputInvKey);
InventorySpec inputSpec = data.getInventorySpecFor(inputProd, inputSp, p);
double minInv = inputSpec != null ? inputSpec.getMinLevel() : 0;
if (prevInputInv <= minInv + 0.001) {
if (!reasons.contains("原材料短缺(" + inputProd.getId() + "库存见底)")) {
reasons.add("原材料短缺(" + inputProd.getId() + "库存见底)");
}
}
}
}
}
// 4. 提前期限制: 该产品有 leadTime, 当期生产无法当期到货
if (p.getIndex() == 0) {
for (Operation op : data.getOperations()) {
if (!op.producesProduct(prod.getId())) continue;
if (op.getLeadTimeDays() > 0) {
double totalProduced = 0;
for (UnitOperation uo : op.getUnitOperations()) {
String ptKey = op.ptQtyKey(uo, p.getIndex());
totalProduced += solutionValue(model.getPtQtyVars(), ptKey);
}
if (totalProduced > 0.001) {
reasons.add("提前期限制(第1周期生产" + formatNum(totalProduced) + "件, 需" + op.getLeadTimeDays() + "天后到货)");
}
}
}
}
// 5. 无生产工序: 该产品没有任何 Operation
boolean hasOperation = false;
for (Operation op : data.getOperations()) {
if (op.producesProduct(prod.getId())) {
hasOperation = true;
break;
}
}
if (!hasOperation) {
reasons.add("无生产工序(纯采购品, 依赖库存/在途)");
}
if (reasons.isEmpty()) {
reasons.add("供需缺口(可用库存不足)");
}
return "[" + String.join(", ", reasons) + "]";
}
// --- 物料需求展开 ---
private void printBomExplosion(Period p) {
System.out.println(" ▶ 物料需求展开(按BOM逐级展开)");
for (Operation op : data.getOperations()) {
// 跨 UnitOperations 求和总产量
double ptQty = 0;
for (UnitOperation uo : op.getUnitOperations()) {
String ptKey = op.ptQtyKey(uo, p.getIndex());
ptQty += solutionValue(model.getPtQtyVars(), ptKey);
}
if (ptQty < 0.001) continue;
String outputLabel = formatOutputProducts(op);
System.out.printf(" %s(%s)生产%.0f件 →%n", op.getName(), outputLabel, ptQty);
// 找到该操作的所有输入物料
int inputMatched = 0;
for (OperationInput input : data.getOperationInputs()) {
if (!input.getOperation().getId().equals(op.getId())) continue;
inputMatched++;
Product inputProd = input.getInputProduct();
StockingPoint inputSp = input.getInputSp();
String invKey = inputProd.getId() + "_" + inputSp.getId() + "_" + p.getIndex();
// 期初库存
double prevInv;
if (p.getIndex() > 0) {
String prevInvKey = inputProd.getId() + "_" + inputSp.getId() + "_"
+ (p.getIndex() - 1);
prevInv = solutionValue(model.getInvQtyVars(), prevInvKey);
} else {
prevInv = data.getInitialInventory(inputProd.getId(), inputSp.getId());
}
// 期末库存
double endInv = solutionValue(model.getInvQtyVars(), invKey);
// 生产量 (该物料自己的操作, 跨所有UnitOperations求和)
double inputProdQty = 0;
int matchedInputOps = 0;
for (Operation inputOp : data.getOperations()) {
if (!inputOp.producesProductAtSp(inputProd.getId(), inputSp.getId())) continue;
matchedInputOps++;
for (UnitOperation uo : inputOp.getUnitOperations()) {
String key = inputOp.ptQtyKey(uo, p.getIndex());
inputProdQty += solutionValue(model.getPtQtyVars(), key);
}
}
if (matchedInputOps > 1) {
LOG.fine(String.format("[结果输出] 物料多源生产: %s@%s P%d 有 %d 个工序产出 (总量=%.0f)",
inputProd.getId(), inputSp.getId(), p.getIndex(),
matchedInputOps, inputProdQty));
}
// 操作需求 (该操作消耗的物料量)
String opDemandKey = input.getKey() + "_" + p.getIndex();
double opDemand = solutionValue(model.getOperationDemandQtyVars(), opDemandKey);
// 外部销售需求
double externalSales = 0;
for (SalesDemand sd : data.getSalesDemandsFor(inputProd, inputSp, p)) {
externalSales += solutionValue(model.getSalesDemandQtyVars(), sd.getKey());
}
// 在途到货
double inTransit = 0;
for (InTransitSupply its : data.getInTransitSupplies()) {
if (its.getProduct().getId().equals(inputProd.getId())
&& its.getStockingPoint().getId().equals(inputSp.getId())
&& p.equals(data.getPeriodByDate(its.getArrivalDate()))) {
inTransit += its.getQuantity();
}
}
String inTransitNote = inTransit > 0.001
? String.format("(+在途%.0f)", inTransit) : "";
System.out.printf(" ─── 物料需求汇总 ───%n");
System.out.printf(" %s(%s): 期初库存%.0f件, 今日生产%.0f件%s, 今日需求%.0f件, 期末库存%.0f件%n",
inputProd.getId(),
inputProd.getId().startsWith("R") ? "采购" : "生产",
prevInv, inputProdQty, inTransitNote,
opDemand + externalSales, endInv);
System.out.printf(" └ %s需求%.0f件(由%s×%s系数)%n",
inputProd.getId(), opDemand,
formatOutputProducts(op), formatFactor(input.getFactor()));
}
if (inputMatched == 0) {
LOG.fine(String.format("[结果输出] 无BOM输入: '%s'(产出=%s) P%d 生产%.0f件, 无物料消耗",
op.getId(), outputLabel, p.getIndex(), ptQty));
}
}
}
// --- 期末库存 ---
private void printEndingInventory(Period p) {
System.out.print(" ▶ 期末库存");
// 收集所有产品在本周期的库存
List<String> items = new ArrayList<>();
for (Product prod : data.getProducts()) {
for (StockingPoint sp : data.getStockingPointsForProduct(prod.getId())) {
String invKey = prod.getId() + "_" + sp.getId() + "_" + p.getIndex();
double invQty = solutionValue(model.getInvQtyVars(), invKey);
items.add(String.format("%s@%s=%.0f", prod.getId(), sp.getId(), invQty));
}
}
System.out.println(" " + String.join(" ", items));
}
// ==================== KPI 汇总 ====================
private void printKpiSummary() {
System.out.println();
System.out.println("═══════════════════ KPI 汇总 ═══════════════════");
double objValue = model.getSolver().objective().value();
System.out.printf("目标函数值 (最小总惩罚): %.2f%n", objValue);
double fulfillment = model.getTotalFulfillment().solutionValue();
double lotSize = model.getTotalLotSize().solutionValue();
double maxInv = model.getTotalMaxInventoryLevel().solutionValue();
double minInv = model.getTotalMinInventoryLevel().solutionValue();
double targetInv = model.getTotalTargetInvLevel().solutionValue();
double capacity = model.getTotalUnitCapacity().solutionValue();
double supplyTarget = model.getTotalSupplyTarget().solutionValue();
double minSupply = model.getTotalMinSupply().solutionValue();
double maxSupply = model.getTotalMaxSupply().solutionValue();
double salesPriority = model.getTotalSalesDemandPriority().solutionValue();
KPIWeights w = data.getKpiWeights();
System.out.printf("需求缺口惩罚: %.2f (权重%.0f × %.2f)%n",
fulfillment * w.getFulfillmentWeight(), w.getFulfillmentWeight(), fulfillment);
System.out.printf("批次偏差惩罚: %.2f (权重%.0f × %.2f)%n",
lotSize * w.getLotSizeWeight(), w.getLotSizeWeight(), lotSize);
System.out.printf("超库存惩罚: %.2f (权重%.0f × %.2f)%n",
maxInv * w.getMaxInventoryLevelWeight(), w.getMaxInventoryLevelWeight(), maxInv);
System.out.printf("欠库存惩罚: %.2f (权重%.0f × %.2f)%n",
minInv * w.getMinInventoryLevelWeight(), w.getMinInventoryLevelWeight(), minInv);
System.out.printf("目标库存偏差: %.2f (权重%.0f × %.2f)%n",
targetInv * w.getTargetInventoryLevelWeight(), w.getTargetInventoryLevelWeight(), targetInv);
System.out.printf("产能超载惩罚: %.2f (权重%.0f × %.2f)%n",
capacity * w.getUnitCapacityWeight(), w.getUnitCapacityWeight(), capacity);
System.out.printf("供应目标偏差: %.2f (权重%.0f × %.2f)%n",
supplyTarget * w.getSupplyTargetWeight(), w.getSupplyTargetWeight(), supplyTarget);
System.out.printf("最小供应不足: %.2f (权重%.0f × %.2f)%n",
minSupply * w.getMinSupplyWeight(), w.getMinSupplyWeight(), minSupply);
System.out.printf("最大供应超出: %.2f (权重%.0f × %.2f)%n",
maxSupply * w.getMaxSupplyWeight(), w.getMaxSupplyWeight(), maxSupply);
System.out.printf("销售优先级(负): %.2f (权重%.0f × %.2f)%n",
salesPriority * w.getSalesDemandPriorityWeight(),
w.getSalesDemandPriorityWeight(), salesPriority);
if (fulfillment > 0.001) {
System.out.println();
System.out.println("⚠ 注意:存在需求缺口,部分订单未满足。");
}
}
// ==================== 求解统计 ====================
private void printStatistics() {
long elapsedMs = System.currentTimeMillis() - startTimeMs;
System.out.println();
System.out.println("═══════════════════ 求解统计 ═══════════════════");
System.out.printf("变量数:%d%n", model.getSolver().numVariables());
System.out.printf("约束数:%d%n", model.getSolver().numConstraints());
System.out.printf("耗时:%.3f 秒%n", elapsedMs / 1000.0);
System.out.println();
}
// ==================== 分层优化汇总 ====================
/**
* 输出分层优化汇总 — 展示各层级的求解结果和 KPI 值。
*
* <p>在分层优化模式下, 每个层级的 KPI 依次求解,
* 上层最优值作为下层约束。本方法展示各层求解结果,
* 以及最终加权总惩罚 (等效于单目标函数值)。</p>
*
* @param levels 策略层级列表 (按优先级排序)
* @param levelObjValues 各层级的最优目标值
*/
public void printHierarchicalSummary(List<StrategyLevel> levels,
List<Double> levelObjValues) {
System.out.println();
System.out.println("═══════════════════ 分层优化汇总 ═══════════════════");
// 表头
System.out.printf("%-6s %-14s %-12s %-12s %s%n",
"层级", "名称", "松弛比例", "最优值", "包含KPI");
double totalWeightedPenalty = 0.0;
KPIWeights w = data.getKpiWeights();
for (int i = 0; i < levels.size(); i++) {
StrategyLevel level = levels.get(i);
double objValue = (i < levelObjValues.size()) ? levelObjValues.get(i) : 0.0;
// 构建 KPI 列表字符串
List<String> kpiNames = new ArrayList<>();
for (StrategyLevel.KPIEntry kpi : level.getKpis()) {
kpiNames.add(kpi.name);
}
System.out.printf("%-6d %-14s %-12s %-12.2f %s%n",
level.getLevel(),
level.getName(),
String.format("%.0f%%", level.getRelativeGoalSlack() * 100),
objValue,
String.join(", ", kpiNames));
}
// 计算并输出加权总惩罚 (等价于单目标函数值)
System.out.println();
System.out.println("--- 最终 KPI 值 (加权总惩罚) ---");
double fulfillment = model.getTotalFulfillment().solutionValue();
double lotSize = model.getTotalLotSize().solutionValue();
double maxInv = model.getTotalMaxInventoryLevel().solutionValue();
double minInv = model.getTotalMinInventoryLevel().solutionValue();
double targetInv = model.getTotalTargetInvLevel().solutionValue();
double capacity = model.getTotalUnitCapacity().solutionValue();
double supplyTarget = model.getTotalSupplyTarget().solutionValue();
double minSupply = model.getTotalMinSupply().solutionValue();
double maxSupply = model.getTotalMaxSupply().solutionValue();
double salesPriority = model.getTotalSalesDemandPriority().solutionValue();
double totalPenalty = fulfillment * w.getFulfillmentWeight()
+ lotSize * w.getLotSizeWeight()
+ maxInv * w.getMaxInventoryLevelWeight()
+ minInv * w.getMinInventoryLevelWeight()
+ targetInv * w.getTargetInventoryLevelWeight()
+ capacity * w.getUnitCapacityWeight()
+ supplyTarget * w.getSupplyTargetWeight()
+ minSupply * w.getMinSupplyWeight()
+ maxSupply * w.getMaxSupplyWeight()
- salesPriority * w.getSalesDemandPriorityWeight();
System.out.printf(" 加权总惩罚: %.2f%n", totalPenalty);
System.out.printf(" 需求缺口: %.2f (权重%.0f × %.2f)%n",
fulfillment * w.getFulfillmentWeight(), w.getFulfillmentWeight(), fulfillment);
System.out.printf(" 产能超载: %.2f (权重%.0f × %.2f)%n",
capacity * w.getUnitCapacityWeight(), w.getUnitCapacityWeight(), capacity);
System.out.printf(" 批次偏差: %.2f (权重%.0f × %.2f)%n",
lotSize * w.getLotSizeWeight(), w.getLotSizeWeight(), lotSize);
System.out.printf(" 目标库存偏差: %.2f (权重%.0f × %.2f)%n",
targetInv * w.getTargetInventoryLevelWeight(), w.getTargetInventoryLevelWeight(), targetInv);
System.out.printf(" 供应目标偏差: %.2f (权重%.0f × %.2f)%n",
supplyTarget * w.getSupplyTargetWeight(), w.getSupplyTargetWeight(), supplyTarget);
System.out.printf(" 超库存: %.2f (权重%.0f × %.2f)%n",
maxInv * w.getMaxInventoryLevelWeight(), w.getMaxInventoryLevelWeight(), maxInv);
System.out.printf(" 欠库存: %.2f (权重%.0f × %.2f)%n",
minInv * w.getMinInventoryLevelWeight(), w.getMinInventoryLevelWeight(), minInv);
System.out.printf(" 最小供应不足: %.2f (权重%.0f × %.2f)%n",
minSupply * w.getMinSupplyWeight(), w.getMinSupplyWeight(), minSupply);
System.out.printf(" 最大供应超出: %.2f (权重%.0f × %.2f)%n",
maxSupply * w.getMaxSupplyWeight(), w.getMaxSupplyWeight(), maxSupply);
System.out.printf(" 销售优先级(负): %.2f (权重%.0f × %.2f)%n",
salesPriority * w.getSalesDemandPriorityWeight(),
w.getSalesDemandPriorityWeight(), salesPriority);
}
// ==================== 辅助方法 ====================
/** 获取变量的解值, 缺失时返回 0 */
private static double solutionValue(Map<String, ?> varMap, String key) {
Object var = varMap.get(key);
if (var instanceof com.google.ortools.linearsolver.MPVariable) {
return ((com.google.ortools.linearsolver.MPVariable) var).solutionValue();
}
return 0.0;
}
/** 格式化 BOM 因子 (整数时不显示小数) */
private static String formatFactor(double factor) {
if (factor == Math.floor(factor) && !Double.isInfinite(factor)) {
return String.format("%.0f", factor);
}
return String.format("%.1f", factor);
}
/** 格式化数量 (整数时不显示小数) */
private static String formatNum(double val) {
if (Math.abs(val - Math.round(val)) < 0.001) {
return String.format("%.0f", val);
}
return String.format("%.1f", val);
}
/** 格式化库存规格 */
private String formatInventorySpec(InventorySpec spec) {
if (spec == null) return "";
StringBuilder sb = new StringBuilder("[");
if (spec.hasTarget()) {
sb.append("目标").append(formatNum(spec.getTargetLevel()));
} else if (spec.hasTargetInDays()) {
sb.append("目标").append(formatNum(spec.getTargetInDays())).append("天");
}
if (spec.hasMinLevel()) {
sb.append("/最小").append(formatNum(spec.getMinLevel()));
} else if (spec.hasMinLevelInDays()) {
sb.append("/最小").append(formatNum(spec.getMinLevelInDays())).append("天");
}
if (spec.hasMaxLevel()) {
sb.append("/最大").append(formatNum(spec.getMaxLevel()));
} else if (spec.hasMaxLevelInDays()) {
sb.append("/最大").append(formatNum(spec.getMaxLevelInDays())).append("天");
}
sb.append("]");
return sb.toString();
}
}
\ No newline at end of file
package com.aps.macroplanner.util;
import com.aps.macroplanner.data.Period;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* 安全库存天数折算计算器 — 将 "N 天安全库存" 折算为对未来周期 DemandFulfillment 的加权求和。
*
* <h2>核心公式</h2>
* <pre>
* 安全库存天数 = N 天
* 折算结果: Σ(ratio_i × DemandFulfillment_i)
*
* 其中 ratio_i = min(remainingDays, p_i.duration) / p_i.duration
* </pre>
*
* <h2>使用场景</h2>
* <ul>
* <li>库存规格约束 (InventorySpec) — 最小/最大/目标库存以天为单位</li>
* <li>KPI 汇总 — 安全库存天数偏差计算</li>
* <li>结果分析 — 排查库存异常时追溯需求预测的来源</li>
* </ul>
*
* <h2>日志输出</h2>
* 当启用 FINE 级别日志时, 会输出每个周期的折算详情, 便于排查:
* <pre>
* [SafetyStock] PISPIP=PA_SP1, 当前周期=P0, 安全库存天数=3.00
* P1: 剩余 3.0天 → 消耗 1.0天/全覆盖周期(1.00) → 剩余 2.0天
* P2: 剩余 2.0天 → 消耗 1.0天/全覆盖周期(1.00) → 剩余 1.0天
* P3: 剩余 1.0天 → 消耗 1.0天/全覆盖周期(1.00) → 剩余 0.0天
* → 总覆盖天数: 3.0天, 涉及 3 个未来周期
* [SafetyStock] 折算结果: DFulfill_PA_SP1_1×1.00 + DFulfill_PA_SP1_2×1.00 + DFulfill_PA_SP1_3×1.00
* </pre>
*/
public class SafetyStockCalculator {
private static final Logger LOG = Logger.getLogger(SafetyStockCalculator.class.getName());
/**
* 计算安全库存天数对应的 DemandFulfillment 折算项。
*
* <p>从当前周期的下一个周期开始向后遍历, 直到覆盖完指定的安全库存天数,
* 或遍历完所有可用周期。每个未来周期按比例贡献其 DemandFulfillment。</p>
*
* @param currentPeriodIdx 当前周期在 periods 列表中的索引
* @param safetyStockDays 安全库存天数 (如 3.0 表示 3 天)
* @param periods 全部周期列表 (按时间顺序)
* @param invKey 当前库存的 key (productId_spId_periodIndex),
* 用于生成 DemandFulfillment 变量 key
* @return 折算项列表, 按周期顺序排列。如果所有未来周期都用完了仍不够覆盖,
* 返回已覆盖的项 (末尾周期可能 ratio < 1.0)
*/
public static List<SafetyStockTerm> calculate(
int currentPeriodIdx,
double safetyStockDays,
List<Period> periods,
String invKey) {
if (safetyStockDays <= 0) {
LOG.fine("[SafetyStock] 安全库存天数 <= 0, 跳过计算");
return Collections.emptyList();
}
List<SafetyStockTerm> terms = new ArrayList<>();
double remainingDays = safetyStockDays;
double totalCoveredDays = 0;
if (LOG.isLoggable(Level.FINE)) {
LOG.fine(String.format(
"[SafetyStock] PISPIP=%s, 当前周期=%s(%d), 安全库存天数=%.2f天, 开始折算...",
stripPeriodIndex(invKey), getPeriodName(periods, currentPeriodIdx),
currentPeriodIdx, safetyStockDays));
}
// 从下一个周期开始遍历
for (int i = currentPeriodIdx + 1; i < periods.size() && remainingDays > 0; i++) {
Period nextPeriod = periods.get(i);
double periodDuration = nextPeriod.getDurationInDays();
// ratio = 该周期对安全库存天数的贡献比例
// 如果剩余天数 >= 周期长度, 整个周期都算进去 (ratio = 1.0)
// 如果剩余天数 < 周期长度, 只算部分 (ratio = remainingDays / periodDuration)
double ratio = Math.min(remainingDays, periodDuration) / periodDuration;
double contributedDays = ratio * periodDuration;
double remainingBefore = remainingDays;
remainingDays -= periodDuration;
String dfKey = replacePeriodIndex(invKey, nextPeriod.getIndex());
SafetyStockTerm term = new SafetyStockTerm(
nextPeriod, ratio, remainingBefore, Math.max(0, remainingDays), dfKey);
terms.add(term);
totalCoveredDays += contributedDays;
LOG.fine(term.toString());
}
// 汇总日志
if (remainingDays > 0) {
LOG.warning(String.format(
"[SafetyStock] PISPIP=%s, 安全库存天数=%.2f天, 但未来周期不足! "
+ "已覆盖=%.2f天, 缺口=%.2f天 (未来只有 %d 个周期可用)",
stripPeriodIndex(invKey), safetyStockDays, totalCoveredDays,
remainingDays, periods.size() - currentPeriodIdx - 1));
} else {
if (LOG.isLoggable(Level.FINE)) {
LOG.fine(String.format(
"[SafetyStock] PISPIP=%s → 总覆盖天数: %.2f天, 涉及 %d 个未来周期",
stripPeriodIndex(invKey), totalCoveredDays, terms.size()));
}
}
// 折算公式汇总
if (LOG.isLoggable(Level.FINE)) {
StringBuilder sb = new StringBuilder();
sb.append(String.format("[SafetyStock] 折算公式: InvQty ≥ "));
for (int i = 0; i < terms.size(); i++) {
if (i > 0) sb.append(" + ");
SafetyStockTerm t = terms.get(i);
sb.append(String.format("DFulfill_%s×%.2f",
t.getDemandFulfillmentKey(), t.getRatio()));
}
LOG.fine(sb.toString());
}
return terms;
}
/**
* 替换 key 中的周期索引。
* 例如: "PA_SP1_0" + 新索引 2 → "PA_SP1_2"
*/
static String replacePeriodIndex(String key, int newIndex) {
int lastUnderscore = key.lastIndexOf('_');
if (lastUnderscore < 0) return key;
return key.substring(0, lastUnderscore + 1) + newIndex;
}
/**
* 去掉 key 中的周期索引, 只保留 productId_spId。
* 例如: "PA_SP1_0" → "PA_SP1"
*/
private static String stripPeriodIndex(String key) {
int lastUnderscore = key.lastIndexOf('_');
if (lastUnderscore < 0) return key;
return key.substring(0, lastUnderscore);
}
/**
* 根据周期索引获取周期名称。
*/
private static String getPeriodName(List<Period> periods, int index) {
if (index >= 0 && index < periods.size()) {
return periods.get(index).getName();
}
return "?";
}
}
\ No newline at end of file
package com.aps.macroplanner.util;
import com.aps.macroplanner.data.Period;
/**
* 安全库存天数折算结果 — 单个周期项。
*
* <p>每次安全库存天数折算会在一个或多个未来周期上产生折算项。
* 每个项包含: 周期信息、折算比例、对应的 DemandFulfillment 变量 key。</p>
*
* <h3>示例</h3>
* 安全库存天数 = 10, 每周期 = 7 天, 当前周期 = P0:
* <pre>
* P1: ratio=1.0 (7/7, 全覆盖, remainingBefore=10, remainingAfter=3)
* P2: ratio=0.43 (3/7, 部分覆盖, remainingBefore=3, remainingAfter=0)
* </pre>
*/
public class SafetyStockTerm {
/** 周期索引 */
private final int periodIndex;
/** 周期名称 */
private final String periodName;
/** 周期长度 (天) */
private final double periodDuration;
/** 折算比例: 该周期贡献了多少天到安全库存中 */
private final double ratio;
/** 折算前剩余天数 */
private final double remainingDaysBefore;
/** 折算后剩余天数 */
private final double remainingDaysAfter;
/** DemandFulfillment 变量 key (productId_spId_periodIndex) */
private final String demandFulfillmentKey;
public SafetyStockTerm(Period period, double ratio,
double remainingDaysBefore, double remainingDaysAfter,
String demandFulfillmentKey) {
this.periodIndex = period.getIndex();
this.periodName = period.getName();
this.periodDuration = period.getDurationInDays();
this.ratio = ratio;
this.remainingDaysBefore = remainingDaysBefore;
this.remainingDaysAfter = remainingDaysAfter;
this.demandFulfillmentKey = demandFulfillmentKey;
}
public int getPeriodIndex() { return periodIndex; }
public String getPeriodName() { return periodName; }
public double getPeriodDuration() { return periodDuration; }
public double getRatio() { return ratio; }
public double getRemainingDaysBefore() { return remainingDaysBefore; }
public double getRemainingDaysAfter() { return remainingDaysAfter; }
public String getDemandFulfillmentKey() { return demandFulfillmentKey; }
/**
* 该周期对安全库存天数的实际贡献天数。
*/
public double getContributionDays() {
return ratio * periodDuration;
}
@Override
public String toString() {
String coverage = (ratio >= 0.999) ? "全覆盖" : String.format("部分(%.1f%%)", ratio * 100);
return String.format(" %s: 剩余 %5.1f天 → 消耗 %5.1f天/%s 周期(%s) → 剩余 %5.1f天",
periodName, remainingDaysBefore, getContributionDays(), coverage,
(ratio >= 0.999) ? "1.00" : String.format("%.2f", ratio),
remainingDaysAfter);
}
}
\ No newline at end of file
package com.aps.macroplanner.variable;
import com.google.ortools.linearsolver.MPSolver;
import com.aps.macroplanner.data.*;
import com.aps.macroplanner.model.MacroPlannerModel;
/**
* BOM 依赖需求变量构建器 — 创建 OperationDemandQty 和 DependentDemandInPISPIP 变量。
*
* <h3>OperationDemandQty[inputKey_periodIndex]</h3>
* <ul>
* <li>含义: 某操作在某周期对某输入物料的消耗量</li>
* <li>范围: [0, +∞)</li>
* </ul>
*
* <h3>DependentDemandInPISPIP[productId_spId_periodIndex]</h3>
* <ul>
* <li>含义: 某个产品/库存点/周期被所有操作作为原材料消耗的总量</li>
* <li>范围: [0, +∞)</li>
* </ul>
*/
public class BomVariableBuilder {
public static void create(MacroPlannerModel model, TestDataBuilder data) {
double inf = MPSolver.infinity();
// OperationDemandQty[inputKey, period]
for (OperationInput input : data.getOperationInputs()) {
for (Period p : data.getPeriods()) {
String key = input.getKey() + "_" + p.getIndex();
model.getOperationDemandQtyVars().put(key,
model.getSolver().makeNumVar(0.0, inf, "OpDemand_" + key));
}
}
// DependentDemandInPISPIP[productId, spId, period]
for (Product prod : data.getProducts()) {
for (StockingPoint sp : data.getStockingPointsForProduct(prod.getId())) {
for (Period p : data.getPeriods()) {
String key = prod.getId() + "_" + sp.getId() + "_" + p.getIndex();
model.getDependentDemandVars().put(key,
model.getSolver().makeNumVar(0.0, inf, "DepDemand_" + key));
}
}
}
}
}
\ No newline at end of file
package com.aps.macroplanner.variable;
import com.google.ortools.linearsolver.MPSolver;
import com.aps.macroplanner.data.*;
import com.aps.macroplanner.model.MacroPlannerModel;
/**
* 产能松弛变量构建器 — 创建 CapacityOverloaded 和 CapacityNotMet 变量。
*
* <h3>CapacityOverloaded[unitId_periodIndex]</h3>
* <ul>
* <li>含义: 设备产能超出最大限制的量</li>
* <li>范围: [0, +∞)</li>
* </ul>
*
* <h3>CapacityNotMet[unitId_periodIndex]</h3>
* <ul>
* <li>含义: 设备产能低于最小使用的量</li>
* <li>范围: [0, +∞)</li>
* </ul>
*/
public class CapacityVariableBuilder {
public static void create(MacroPlannerModel model, TestDataBuilder data) {
double inf = MPSolver.infinity();
for (UnitPeriod up : data.getUnitPeriods()) {
String key = up.getKey();
model.getCapacityOverloadedVars().put(key,
model.getSolver().makeNumVar(0.0, inf, "CapOver_" + key));
model.getCapacityNotMetVars().put(key,
model.getSolver().makeNumVar(0.0, inf, "CapNotMet_" + key));
}
}
}
\ No newline at end of file
package com.aps.macroplanner.variable;
import com.google.ortools.linearsolver.MPSolver;
import com.aps.macroplanner.data.*;
import com.aps.macroplanner.model.MacroPlannerModel;
/**
* 需求满足量变量构建器 — 创建 DemandFulfillmentInPISPIP 变量。
*
* <p>DemandFulfillmentInPISPIP[productId_spId_periodIndex] — PISPIP 总需求满足量</p>
* <ul>
* <li>含义: 某个产品/库存点/周期内所有需求的总满足量</li>
* <li>用途: 安全库存天数计算的基础 — 库存 ≥ 未来N天需求满足量</li>
* <li>组成: SalesDemandQty + OperationDemandQty (BOM消耗)</li>
* <li>范围: [0, +∞)</li>
* </ul>
*/
public class DemandFulfillmentVariableBuilder {
public static void create(MacroPlannerModel model, TestDataBuilder data) {
double inf = MPSolver.infinity();
for (Product prod : data.getProducts()) {
for (StockingPoint sp : data.getStockingPointsForProduct(prod.getId())) {
for (Period p : data.getPeriods()) {
String key = prod.getId() + "_" + sp.getId() + "_" + p.getIndex();
model.getDemandFulfillmentVars().put(key,
model.getSolver().makeNumVar(0.0, inf, "DFulfill_" + key));
}
}
}
}
}
\ No newline at end of file
package com.aps.macroplanner.variable;
import com.google.ortools.linearsolver.MPSolver;
import com.aps.macroplanner.data.*;
import com.aps.macroplanner.model.MacroPlannerModel;
/**
* 需求变量构建器 — 创建 SalesDemandQty 和 DemandSlack 变量。
*
* <h3>SalesDemandQty[productId_spId_periodIndex]</h3>
* <ul>
* <li>索引: SalesDemand (Product × StockingPoint × Period)</li>
* <li>含义: 实际满足的独立销售需求量 (≤ 原始需求总量)</li>
* <li>范围: [0, 需求总量]</li>
* </ul>
*
* <h3>DemandSlack[productId_spId_periodIndex]</h3>
* <ul>
* <li>索引: Product × StockingPoint × Period</li>
* <li>含义: 物料平衡约束中的松弛量,用于防止模型不可行。
* 当供应无法满足需求时,可由 DemandSlack "虚拟供应"来填补缺口。</li>
* <li>范围: [0, +∞)</li>
* <li>在目标函数中以高权重惩罚,确保只在必要时使用</li>
* </ul>
*/
public class DemandVariableBuilder {
public static void create(MacroPlannerModel model, TestDataBuilder data) {
createSalesDemandQty(model, data);
createDemandSlack(model, data);
}
private static void createSalesDemandQty(MacroPlannerModel model, TestDataBuilder data) {
for (SalesDemand sd : data.getSalesDemands()) {
model.getSalesDemandQtyVars().put(sd.getKey(),
model.getSolver().makeNumVar(0.0, sd.getQuantity(), "SDQty_" + sd.getKey()));
}
}
private static void createDemandSlack(MacroPlannerModel model, TestDataBuilder data) {
double inf = MPSolver.infinity();
for (Product prod : data.getProducts()) {
for (StockingPoint sp : data.getStockingPointsForProduct(prod.getId())) {
for (Period p : data.getPeriods()) {
String key = prod.getId() + "_" + sp.getId() + "_" + p.getIndex();
model.getDemandSlackVars().put(key,
model.getSolver().makeNumVar(0.0, inf, "DemandSlack_" + key));
}
}
}
}
}
\ No newline at end of file
package com.aps.macroplanner.variable;
import com.google.ortools.linearsolver.MPSolver;
import com.aps.macroplanner.data.*;
import com.aps.macroplanner.model.MacroPlannerModel;
/**
* 库存松弛变量构建器 — 创建 MinInvQtyUnder, MaxInvQtyOver, InvQtyUnderTarget 变量。
*
* <p>仅在对应库存规格定义时才创建对应的松弛变量
* (例如: 没有定义 MaxLevel 则不创建 MaxInvQtyOver)。</p>
*
* <h3>MinInvQtyUnder[productId_spId_periodIndex]</h3>
* <ul><li>含义: 低于最小库存的量</li><li>范围: [0, +∞)</li></ul>
*
* <h3>MaxInvQtyOver[productId_spId_periodIndex]</h3>
* <ul><li>含义: 超过最大库存的量</li><li>范围: [0, +∞)</li></ul>
*
* <h3>InvQtyUnderTarget[productId_spId_periodIndex]</h3>
* <ul><li>含义: 低于目标库存的量</li><li>范围: [0, +∞)</li></ul>
*/
public class InventorySlackVariableBuilder {
public static void create(MacroPlannerModel model, TestDataBuilder data) {
double inf = MPSolver.infinity();
for (InventorySpec spec : data.getInventorySpecs()) {
String key = spec.getKey();
if (spec.hasMinLevel() || spec.hasMinLevelInDays()) {
model.getMinInvQtyUnderVars().put(key,
model.getSolver().makeNumVar(0.0, inf, "MinInvUnder_" + key));
}
if (spec.hasMaxLevel() || spec.hasMaxLevelInDays()) {
model.getMaxInvQtyOverVars().put(key,
model.getSolver().makeNumVar(0.0, inf, "MaxInvOver_" + key));
}
if (spec.hasTarget() || spec.hasTargetInDays()) {
model.getInvQtyUnderTargetVars().put(key,
model.getSolver().makeNumVar(0.0, inf, "InvUnderTarget_" + key));
}
}
}
}
\ No newline at end of file
package com.aps.macroplanner.variable;
import com.google.ortools.linearsolver.MPSolver;
import com.aps.macroplanner.data.*;
import com.aps.macroplanner.model.MacroPlannerModel;
/**
* 库存变量构建器 — 创建 InvQty 变量。
*
* <p>InvQty[productId_spId_periodIndex] — 期末库存变量</p>
* <ul>
* <li>索引: Product × StockingPoint × Period</li>
* <li>含义: 某个产品在某个库存点、某个周期结束时的库存量</li>
* <li>范围: [0, +∞)</li>
* <li>注意: 第 0 周期的期初库存由初始库存常量给定,
* 之后每个周期的期初库存 = 上一周期的期末库存 (由 Balance 约束保证)</li>
* </ul>
*/
public class InventoryVariableBuilder {
public static void create(MacroPlannerModel model, TestDataBuilder data) {
double inf = MPSolver.infinity();
for (Product prod : data.getProducts()) {
for (StockingPoint sp : data.getStockingPointsForProduct(prod.getId())) {
for (Period p : data.getPeriods()) {
String key = prod.getId() + "_" + sp.getId() + "_" + p.getIndex();
model.getInvQtyVars().put(key,
model.getSolver().makeNumVar(0.0, inf, "InvQty_" + key));
}
}
}
}
}
\ No newline at end of file
package com.aps.macroplanner.variable;
import com.google.ortools.linearsolver.MPSolver;
import com.aps.macroplanner.data.*;
import com.aps.macroplanner.model.MacroPlannerModel;
/**
* 批次松弛变量构建器 — 创建 PTLotSizeOver 和 PTLotSizeUnder 变量。
*
* <p>仅在操作定义了批次大小时创建。</p>
*
* <h3>PTLotSizeOver[operationId_unitId_periodIndex]</h3>
* <ul><li>含义: 生产量超出批次大小的量</li><li>范围: [0, +∞)</li></ul>
*
* <h3>PTLotSizeUnder[operationId_unitId_periodIndex]</h3>
* <ul><li>含义: 生产量不足批次大小的量</li><li>范围: [0, +∞)</li></ul>
*/
public class LotSizeVariableBuilder {
public static void create(MacroPlannerModel model, TestDataBuilder data) {
double inf = MPSolver.infinity();
for (Operation op : data.getOperations()) {
for (UnitOperation uo : op.getUnitOperations()) {
if (!uo.hasLotSize()) continue;
for (Period p : data.getPeriods()) {
String key = op.ptQtyKey(uo, p.getIndex());
model.getPtLotSizeOverVars().put(key,
model.getSolver().makeNumVar(0.0, inf, "LotOver_" + key));
model.getPtLotSizeUnderVars().put(key,
model.getSolver().makeNumVar(0.0, inf, "LotUnder_" + key));
}
}
}
}
}
\ No newline at end of file
package com.aps.macroplanner.variable;
import com.google.ortools.linearsolver.MPSolver;
import com.aps.macroplanner.data.*;
import com.aps.macroplanner.model.MacroPlannerModel;
/**
* 生产变量构建器 — 创建 PTQty 变量。
*
* <p>PTQty[operationId_unitId_periodIndex] — 生产量变量</p>
* <ul>
* <li>索引: Operation × UnitOperation × Period</li>
* <li>含义: 某个操作在某个单元、某个周期内的生产数量</li>
* <li>范围: [0, +∞)</li>
* </ul>
*/
public class ProductionVariableBuilder {
public static void create(MacroPlannerModel model, TestDataBuilder data) {
double inf = MPSolver.infinity();
for (Operation op : data.getOperations()) {
for (UnitOperation uo : op.getUnitOperations()) {
for (Period p : data.getPeriods()) {
String key = op.ptQtyKey(uo, p.getIndex());
model.getPtQtyVars().put(key,
model.getSolver().makeNumVar(0.0, inf, "PTQty_" + key));
}
}
}
}
}
\ No newline at end of file
package com.aps.macroplanner.variable;
import com.google.ortools.linearsolver.MPSolver;
import com.aps.macroplanner.data.*;
import com.aps.macroplanner.model.MacroPlannerModel;
import java.util.HashSet;
import java.util.Set;
import java.util.logging.Logger;
/**
* 供应松弛变量构建器 — 创建 SupplyTargetQtyUnder, MinSupplyQtyUnder, MaxSupplyQtyOver 变量。
*
* <h3>SupplyTargetQtyUnder[specName]</h3>
* <ul><li>含义: 供应目标不足的量</li><li>范围: [0, +∞)</li></ul>
*
* <h3>MinSupplyQtyUnder[specName]</h3>
* <ul><li>含义: 最小供应不足的量</li><li>范围: [0, +∞)</li></ul>
*
* <h3>MaxSupplyQtyOver[specName]</h3>
* <ul><li>含义: 最大供应超出的量</li><li>范围: [0, +∞)</li></ul>
*
* <p><b>注意:</b> 每个供应规格必须有唯一的名称, 否则会导致松弛变量被覆盖,
* 多个约束共享同一个松弛变量, 造成数学模型错误。</p>
*/
public class SupplySlackVariableBuilder {
private static final Logger LOG = Logger.getLogger(SupplySlackVariableBuilder.class.getName());
public static void create(MacroPlannerModel model, TestDataBuilder data) {
double inf = MPSolver.infinity();
Set<String> seenNames = new HashSet<>();
for (SupplySpec spec : data.getSupplySpecs()) {
String name = spec.getName();
// 检测重复名称: 如果已存在则覆盖, 导致多个约束共享同一个松弛变量
if (!seenNames.add(name)) {
LOG.warning(String.format(
"⚠️ 供应规格名称重复: '%s' 已存在! "
+ "这将导致不同供应规格的约束共享同一个松弛变量, 造成数学模型错误。"
+ "请确保每个 SupplySpec 有唯一的名称。",
name));
}
model.getSupplyTargetQtyUnderVars().put(name,
model.getSolver().makeNumVar(0.0, inf, "SupTargetUnder_" + name));
model.getMinSupplyQtyUnderVars().put(name,
model.getSolver().makeNumVar(0.0, inf, "MinSupUnder_" + name));
if (spec.hasMaxQuantity()) {
model.getMaxSupplyQtyOverVars().put(name,
model.getSolver().makeNumVar(0.0, inf, "MaxSupOver_" + name));
}
}
}
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment