303 lines
11 KiB
Python
303 lines
11 KiB
Python
|
|
# ============================================================
|
|||
|
|
# 库存投影(moduleId: domain-inventory, PL-04 首切片,可重生 ✅)
|
|||
|
|
# 规则:期初库存+在途 → 按日/周扣减需求、加上计划到货 → 投影可用量;
|
|||
|
|
# 低于安全库存 / 断料告警。只读,不写主干。
|
|||
|
|
# ============================================================
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
from typing import Any
|
|||
|
|
|
|||
|
|
from server.aps_domain.orders import SCHEDULABLE_STATUSES
|
|||
|
|
from server.engines.queries import find_bom_items
|
|||
|
|
from server.timeutil import add_minutes, fmt_date, parse_dt, today0
|
|||
|
|
|
|||
|
|
World = dict[str, Any]
|
|||
|
|
|
|||
|
|
BUCKETS = {"DAY", "WEEK"}
|
|||
|
|
PO_RECEIPT_STATUSES = {"DRAFT", "RELEASED", "CONFIRMED", "ORDERED", "OPEN"}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _as_date(s: str):
|
|||
|
|
return parse_dt(s[:10] + " 00:00")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _add_days(d, n: int):
|
|||
|
|
return add_minutes(d, n * 24 * 60)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _day_list(start, days: int) -> list[str]:
|
|||
|
|
return [fmt_date(_add_days(start, i)) for i in range(max(1, days))]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _week_keys(start, days: int) -> list[tuple[str, str, str]]:
|
|||
|
|
"""返回 (key, start, end) 按周桶,覆盖 horizon。"""
|
|||
|
|
from datetime import timedelta
|
|||
|
|
end = _add_days(start, days - 1)
|
|||
|
|
out: list[tuple[str, str, str]] = []
|
|||
|
|
cur = start - timedelta(days=start.weekday())
|
|||
|
|
while cur <= end:
|
|||
|
|
w_end = cur + timedelta(days=6)
|
|||
|
|
b_s = max(cur, start)
|
|||
|
|
b_e = min(w_end, end)
|
|||
|
|
if b_s <= b_e:
|
|||
|
|
iso = b_s.isocalendar()
|
|||
|
|
out.append((f"W{iso.week:02d}", fmt_date(b_s), fmt_date(b_e)))
|
|||
|
|
cur = cur + timedelta(days=7)
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _collect_fg_demands(world: World, *, include_forecast: bool) -> list[dict[str, Any]]:
|
|||
|
|
"""成品需求事件:交期当日消耗成品库存(MTS/发货口径)。"""
|
|||
|
|
events: list[dict[str, Any]] = []
|
|||
|
|
for so in world.get("salesOrders", []):
|
|||
|
|
if so.get("status") not in SCHEDULABLE_STATUSES:
|
|||
|
|
continue
|
|||
|
|
due = str(so.get("deliveryDate") or "")[:10]
|
|||
|
|
if not due:
|
|||
|
|
continue
|
|||
|
|
for item in so.get("items") or []:
|
|||
|
|
if item.get("status") in ("CANCELLED", "COMPLETED"):
|
|||
|
|
continue
|
|||
|
|
qty = float(item.get("quantity") or 0)
|
|||
|
|
if qty <= 0:
|
|||
|
|
continue
|
|||
|
|
events.append({
|
|||
|
|
"date": due, "productId": int(item["productId"]),
|
|||
|
|
"qty": qty, "ref": so.get("orderNo"), "source": "FIRM",
|
|||
|
|
})
|
|||
|
|
if include_forecast:
|
|||
|
|
from server.aps_domain.forecast import ensure_forecast_table
|
|||
|
|
ensure_forecast_table(world)
|
|||
|
|
for fc in world.get("forecastOrders", []):
|
|||
|
|
if fc.get("status") != "ACTIVE":
|
|||
|
|
continue
|
|||
|
|
due = str(fc.get("dueDate") or fc.get("periodEnd") or "")[:10]
|
|||
|
|
if not due:
|
|||
|
|
continue
|
|||
|
|
qty = float(fc.get("quantity") or 0)
|
|||
|
|
conf = float(fc.get("confidence") if fc.get("confidence") is not None else 1.0)
|
|||
|
|
conf = max(0.0, min(1.0, conf))
|
|||
|
|
wqty = qty * conf
|
|||
|
|
if wqty <= 0:
|
|||
|
|
continue
|
|||
|
|
events.append({
|
|||
|
|
"date": due, "productId": int(fc["productId"]),
|
|||
|
|
"qty": wqty, "ref": fc.get("forecastNo"), "source": "FORECAST",
|
|||
|
|
})
|
|||
|
|
return events
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _explode_component_demand(world: World, fg_events: list[dict[str, Any]]) -> dict[int, dict[str, float]]:
|
|||
|
|
"""成品需求 → 组件毛需求(按日聚合)。"""
|
|||
|
|
by_mat: dict[int, dict[str, float]] = {}
|
|||
|
|
for ev in fg_events:
|
|||
|
|
for bi in find_bom_items(world, ev["productId"]):
|
|||
|
|
mid = int(bi["materialId"])
|
|||
|
|
need = float(bi.get("quantity") or 0) * float(ev["qty"])
|
|||
|
|
if need <= 0:
|
|||
|
|
continue
|
|||
|
|
slot = by_mat.setdefault(mid, {})
|
|||
|
|
slot[ev["date"]] = round(slot.get(ev["date"], 0.0) + need, 3)
|
|||
|
|
return by_mat
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _fg_demand_by_product(fg_events: list[dict[str, Any]]) -> dict[int, dict[str, float]]:
|
|||
|
|
by_p: dict[int, dict[str, float]] = {}
|
|||
|
|
for ev in fg_events:
|
|||
|
|
slot = by_p.setdefault(int(ev["productId"]), {})
|
|||
|
|
slot[ev["date"]] = round(slot.get(ev["date"], 0.0) + float(ev["qty"]), 3)
|
|||
|
|
return by_p
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _po_receipts(world: World) -> dict[int, dict[str, float]]:
|
|||
|
|
"""采购建议到货:到货日 = suggestedOrderDate + leadTimeDays(缺省用 requiredDate)。"""
|
|||
|
|
by_mat: dict[int, dict[str, float]] = {}
|
|||
|
|
for po in world.get("purchaseOrders", []):
|
|||
|
|
if str(po.get("status") or "").upper() not in PO_RECEIPT_STATUSES:
|
|||
|
|
continue
|
|||
|
|
mid = po.get("materialId")
|
|||
|
|
if mid is None:
|
|||
|
|
continue
|
|||
|
|
qty = float(po.get("quantity") or 0)
|
|||
|
|
if qty <= 0:
|
|||
|
|
continue
|
|||
|
|
lead = int(po.get("leadTimeDays") or 0)
|
|||
|
|
order_d = str(po.get("suggestedOrderDate") or po.get("requiredDate") or "")[:10]
|
|||
|
|
if not order_d:
|
|||
|
|
continue
|
|||
|
|
if po.get("suggestedOrderDate"):
|
|||
|
|
arrive = fmt_date(_add_days(_as_date(order_d), lead))
|
|||
|
|
else:
|
|||
|
|
arrive = str(po.get("requiredDate") or order_d)[:10]
|
|||
|
|
slot = by_mat.setdefault(int(mid), {})
|
|||
|
|
slot[arrive] = round(slot.get(arrive, 0.0) + qty, 3)
|
|||
|
|
return by_mat
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _project_series(
|
|||
|
|
*,
|
|||
|
|
opening: float,
|
|||
|
|
safety: float,
|
|||
|
|
days: list[str],
|
|||
|
|
demand_by_day: dict[str, float],
|
|||
|
|
receipt_by_day: dict[str, float],
|
|||
|
|
) -> list[dict[str, Any]]:
|
|||
|
|
bal = float(opening)
|
|||
|
|
series: list[dict[str, Any]] = []
|
|||
|
|
for d in days:
|
|||
|
|
rec = float(receipt_by_day.get(d, 0.0))
|
|||
|
|
dem = float(demand_by_day.get(d, 0.0))
|
|||
|
|
bal = round(bal + rec - dem, 3)
|
|||
|
|
if bal < 0:
|
|||
|
|
status = "STOCKOUT"
|
|||
|
|
elif bal < safety:
|
|||
|
|
status = "BELOW_SAFETY"
|
|||
|
|
else:
|
|||
|
|
status = "OK"
|
|||
|
|
series.append({
|
|||
|
|
"date": d, "receipt": round(rec, 3), "demand": round(dem, 3),
|
|||
|
|
"projected": bal, "safetyStock": safety, "status": status,
|
|||
|
|
})
|
|||
|
|
return series
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _aggregate_week(series: list[dict[str, Any]], weeks: list[tuple[str, str, str]]) -> list[dict[str, Any]]:
|
|||
|
|
out = []
|
|||
|
|
for key, ws, we in weeks:
|
|||
|
|
cells = [c for c in series if ws <= c["date"] <= we]
|
|||
|
|
if not cells:
|
|||
|
|
continue
|
|||
|
|
receipt = round(sum(c["receipt"] for c in cells), 3)
|
|||
|
|
demand = round(sum(c["demand"] for c in cells), 3)
|
|||
|
|
projected = cells[-1]["projected"]
|
|||
|
|
safety = cells[-1]["safetyStock"]
|
|||
|
|
if projected < 0:
|
|||
|
|
status = "STOCKOUT"
|
|||
|
|
elif projected < safety:
|
|||
|
|
status = "BELOW_SAFETY"
|
|||
|
|
else:
|
|||
|
|
status = "OK"
|
|||
|
|
out.append({
|
|||
|
|
"date": key, "periodStart": ws, "periodEnd": we,
|
|||
|
|
"receipt": receipt, "demand": demand,
|
|||
|
|
"projected": projected, "safetyStock": safety, "status": status,
|
|||
|
|
})
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
|
|||
|
|
def build_inventory_projection(
|
|||
|
|
world: World,
|
|||
|
|
*,
|
|||
|
|
material_code: str | None = None,
|
|||
|
|
material_type: str | None = None,
|
|||
|
|
horizon_days: int = 30,
|
|||
|
|
bucket: str = "DAY",
|
|||
|
|
include_forecast: bool = True,
|
|||
|
|
start_date: str | None = None,
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
"""
|
|||
|
|
PL-04:库存时间投影。
|
|||
|
|
- 成品:交期扣减成品库存
|
|||
|
|
- 原料:BOM 展开毛需求按交期扣减;采购建议按预计到货增加
|
|||
|
|
- 期初 = stock;在途计入起点日到货
|
|||
|
|
"""
|
|||
|
|
bucket = (bucket or "DAY").upper()
|
|||
|
|
if bucket not in BUCKETS:
|
|||
|
|
raise ValueError("bucket 必须是 DAY/WEEK")
|
|||
|
|
horizon_days = max(1, min(int(horizon_days or 30), 180))
|
|||
|
|
start = _as_date(start_date) if start_date else today0()
|
|||
|
|
start_s = fmt_date(start)
|
|||
|
|
days = _day_list(start, horizon_days)
|
|||
|
|
|
|||
|
|
mats = list(world.get("materials") or [])
|
|||
|
|
if material_code:
|
|||
|
|
code = material_code.strip().upper()
|
|||
|
|
mats = [m for m in mats if str(m.get("code") or "").upper() == code]
|
|||
|
|
if not mats:
|
|||
|
|
raise ValueError(f"找不到物料:{material_code}")
|
|||
|
|
if material_type:
|
|||
|
|
mt = material_type.strip().upper()
|
|||
|
|
mats = [m for m in mats if str(m.get("type") or "").upper() == mt]
|
|||
|
|
|
|||
|
|
fg_events = _collect_fg_demands(world, include_forecast=include_forecast)
|
|||
|
|
fg_demand = _fg_demand_by_product(fg_events)
|
|||
|
|
comp_demand = _explode_component_demand(world, fg_events)
|
|||
|
|
receipts = _po_receipts(world)
|
|||
|
|
|
|||
|
|
# 在途:计入起点日到货
|
|||
|
|
for m in mats:
|
|||
|
|
mid = m["id"]
|
|||
|
|
transit = float(m.get("inTransit") or 0)
|
|||
|
|
if transit > 0:
|
|||
|
|
slot = receipts.setdefault(mid, {})
|
|||
|
|
slot[start_s] = round(slot.get(start_s, 0.0) + transit, 3)
|
|||
|
|
|
|||
|
|
weeks = _week_keys(start, horizon_days) if bucket == "WEEK" else []
|
|||
|
|
materials_out: list[dict[str, Any]] = []
|
|||
|
|
alert_stockout = 0
|
|||
|
|
alert_safety = 0
|
|||
|
|
|
|||
|
|
for m in sorted(mats, key=lambda x: (x.get("type") != "FINISHED_PRODUCT", x.get("code") or "")):
|
|||
|
|
mid = int(m["id"])
|
|||
|
|
mtype = str(m.get("type") or "")
|
|||
|
|
opening = float(m.get("stock") or 0)
|
|||
|
|
safety = float(m.get("safetyStock") or 0)
|
|||
|
|
if mtype == "FINISHED_PRODUCT":
|
|||
|
|
dem = fg_demand.get(mid, {})
|
|||
|
|
else:
|
|||
|
|
dem = comp_demand.get(mid, {})
|
|||
|
|
rec = receipts.get(mid, {})
|
|||
|
|
series = _project_series(
|
|||
|
|
opening=opening, safety=safety, days=days,
|
|||
|
|
demand_by_day=dem, receipt_by_day=rec,
|
|||
|
|
)
|
|||
|
|
view = _aggregate_week(series, weeks) if bucket == "WEEK" else series
|
|||
|
|
has_stockout = any(c["status"] == "STOCKOUT" for c in view)
|
|||
|
|
has_safety = any(c["status"] == "BELOW_SAFETY" for c in view)
|
|||
|
|
if has_stockout:
|
|||
|
|
alert_stockout += 1
|
|||
|
|
alert = "STOCKOUT"
|
|||
|
|
elif has_safety:
|
|||
|
|
alert_safety += 1
|
|||
|
|
alert = "BELOW_SAFETY"
|
|||
|
|
else:
|
|||
|
|
alert = "OK"
|
|||
|
|
first_bad = next((c["date"] for c in view if c["status"] != "OK"), None)
|
|||
|
|
end_proj = view[-1]["projected"] if view else opening
|
|||
|
|
materials_out.append({
|
|||
|
|
"materialId": mid,
|
|||
|
|
"code": m.get("code"),
|
|||
|
|
"name": m.get("name"),
|
|||
|
|
"type": mtype,
|
|||
|
|
"unit": m.get("unit") or "件",
|
|||
|
|
"openingStock": opening,
|
|||
|
|
"inTransit": float(m.get("inTransit") or 0),
|
|||
|
|
"safetyStock": safety,
|
|||
|
|
"endingProjected": end_proj,
|
|||
|
|
"alert": alert,
|
|||
|
|
"firstAlertDate": first_bad,
|
|||
|
|
"totalDemand": round(sum(c["demand"] for c in view), 3),
|
|||
|
|
"totalReceipt": round(sum(c["receipt"] for c in view), 3),
|
|||
|
|
"series": view,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"startDate": start_s,
|
|||
|
|
"horizonDays": horizon_days,
|
|||
|
|
"bucket": bucket,
|
|||
|
|
"includeForecast": include_forecast,
|
|||
|
|
"materialFilter": material_code,
|
|||
|
|
"typeFilter": material_type,
|
|||
|
|
"summary": {
|
|||
|
|
"materialCount": len(materials_out),
|
|||
|
|
"stockoutCount": alert_stockout,
|
|||
|
|
"belowSafetyCount": alert_safety,
|
|||
|
|
"okCount": len(materials_out) - alert_stockout - alert_safety,
|
|||
|
|
},
|
|||
|
|
"materials": materials_out,
|
|||
|
|
"hint": (
|
|||
|
|
"库存投影:期初=现有库存,在途计入首日到货;"
|
|||
|
|
"成品按交期扣减,原料按 BOM 毛需求扣减;采购建议按预计到货增加。"
|
|||
|
|
),
|
|||
|
|
}
|