aps-agent/server/aps_domain/inventory.py

303 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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