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