aps-agent/server/aps_domain/inventory.py

303 lines
11 KiB
Python
Raw Permalink Normal View History

# ============================================================
# 库存投影(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 毛需求扣减;采购建议按预计到货增加。"
),
}