# ============================================================ # 预测 / 长周期订单(moduleId: domain-forecast, OR-05 首切片,可重生 ✅) # 规则:预测需求独立于销售订单;默认不进正式排产;可试排纳入 / 转正 # ============================================================ from __future__ import annotations from datetime import datetime from typing import Any from server.aps_domain.orders import apply_order_action, find_product_by_hint from server.timeutil import add_minutes, fmt_date, fmt_dt, parse_dt, today0 World = dict[str, Any] FORECAST_STATUSES = {"DRAFT", "ACTIVE", "CONSUMED", "CANCELLED"} FORECAST_BUCKETS = {"DAY", "WEEK", "MONTH"} SCHEDULABLE_FORECAST = {"ACTIVE"} def _now() -> str: return fmt_dt(datetime.now()) def ensure_forecast_table(world: World) -> None: if "forecastOrders" not in world or not isinstance(world.get("forecastOrders"), list): world["forecastOrders"] = [] def list_forecasts(world: World) -> list[dict[str, Any]]: ensure_forecast_table(world) rows = list(world["forecastOrders"]) rows.sort(key=lambda f: (f.get("status") != "ACTIVE", f.get("dueDate") or "", f.get("id") or 0)) return rows def forecast_summary(world: World) -> dict[str, int]: ensure_forecast_table(world) counts = {"all": 0, "draft": 0, "active": 0, "consumed": 0, "cancelled": 0} for f in world["forecastOrders"]: counts["all"] += 1 st = str(f.get("status") or "").lower() if st in counts: counts[st] += 1 return counts def find_forecast(world: World, *, forecast_id: int | None = None, forecast_no: str | None = None) -> dict[str, Any] | None: ensure_forecast_table(world) if forecast_id: return next((f for f in world["forecastOrders"] if f["id"] == forecast_id), None) if forecast_no: no = forecast_no.strip().upper() return next((f for f in world["forecastOrders"] if f.get("forecastNo") == no), None) return None def _product(world: World, product_id: int) -> dict[str, Any]: product = next((m for m in world["materials"] if m["id"] == product_id and m["type"] == "FINISHED_PRODUCT"), None) if product is None: raise ValueError(f"产品不存在或不是成品:{product_id}") return product def normalize_forecast_payload(world: World, payload: dict[str, Any]) -> dict[str, Any]: out = dict(payload or {}) hint = str(out.get("productCode") or out.get("productName") or "").strip() if not out.get("productId") and hint: product = find_product_by_hint(world, hint) if product is None: raise ValueError(f"找不到成品:{hint}") out["productId"] = product["id"] if not out.get("productId"): raise ValueError("预测订单必须指定 productId / productCode") product = _product(world, int(out["productId"])) qty = int(out.get("quantity") or 0) if qty <= 0: raise ValueError("预测数量必须为正整数") bucket = str(out.get("bucket") or "WEEK").upper() if bucket not in FORECAST_BUCKETS: raise ValueError("bucket 必须是 DAY/WEEK/MONTH") period_start = str(out.get("periodStart") or "").strip() if not period_start: period_start = fmt_date(add_minutes(today0(), 7 * 24 * 60)) # 默认展望:日+0 / 周+6 / 月+29 span = 0 if bucket == "DAY" else 6 if bucket == "WEEK" else 29 period_end = str(out.get("periodEnd") or "").strip() if not period_end: period_end = fmt_date(add_minutes(parse_dt(period_start + " 00:00"), span * 24 * 60)) due = str(out.get("dueDate") or period_end).strip() status = str(out.get("status") or "ACTIVE").upper() if status not in FORECAST_STATUSES: raise ValueError("状态必须是 DRAFT/ACTIVE/CONSUMED/CANCELLED") conf = float(out.get("confidence") if out.get("confidence") is not None else 0.7) conf = max(0.0, min(1.0, conf)) return { "id": int(out["id"]) if out.get("id") else None, "productId": product["id"], "productCode": product["code"], "productName": product["name"], "quantity": qty, "unit": product.get("unit") or "件", "bucket": bucket, "periodStart": period_start[:10], "periodEnd": period_end[:10], "dueDate": due[:10], "confidence": round(conf, 2), "status": status, "customerName": str(out.get("customerName") or "预测需求").strip() or "预测需求", "customerLevel": str(out.get("customerLevel") or "C").upper(), "note": str(out.get("note") or "").strip(), "source": str(out.get("source") or "MANUAL").upper(), } def confirmation_for_forecast_action(world: World, action: str, payload: dict[str, Any]) -> tuple[str, list[str]]: if action == "forecast.upsert": p = normalize_forecast_payload(world, payload) verb = "编辑" if p.get("id") else "新建" return f"{verb}预测订单", [ f"产品:{p['productName']} × {p['quantity']} {p['unit']}", f"时段:{p['periodStart']} ~ {p['periodEnd']}({p['bucket']})· 交期 {p['dueDate']}", f"置信度 {int(p['confidence'] * 100)}% · 状态 {p['status']}", "预测默认不进正式排产;可「预测纳入试排」或「预测转正」(P2)", ] if action == "forecast.delete": f = find_forecast(world, forecast_id=int(payload.get("id") or 0), forecast_no=payload.get("forecastNo")) if f is None: raise ValueError("目标预测订单不存在") return f"删除预测 {f['forecastNo']}", [ f"产品:{f.get('productName')} × {f.get('quantity')}", "仅删除预测台账,不影响已转正销售订单与世界排产版本", ] if action == "forecast.convert": f = find_forecast(world, forecast_id=int(payload.get("id") or 0), forecast_no=payload.get("forecastNo")) if f is None: raise ValueError("目标预测订单不存在") if f.get("status") == "CONSUMED": raise ValueError(f"{f['forecastNo']} 已转正,勿重复操作") if f.get("status") == "CANCELLED": raise ValueError(f"{f['forecastNo']} 已取消,不能转正") return f"预测转正 {f['forecastNo']}", [ f"将生成 APPROVED 销售订单:{f.get('productName')} × {f.get('quantity')}", f"交期 {f.get('dueDate')};预测状态 → CONSUMED", "转正后参与正式排产;执行前自动建档(P2)", ] raise ValueError(f"不支持的预测动作:{action}") def apply_forecast_action(world: World, next_id, action: str, payload: dict[str, Any]) -> dict[str, Any]: ensure_forecast_table(world) if action == "forecast.upsert": p = normalize_forecast_payload(world, payload) if p.get("id"): row = find_forecast(world, forecast_id=p["id"]) if row is None: raise ValueError("目标预测订单不存在") before = row.get("status") row.update({ "productId": p["productId"], "productCode": p["productCode"], "productName": p["productName"], "quantity": p["quantity"], "unit": p["unit"], "bucket": p["bucket"], "periodStart": p["periodStart"], "periodEnd": p["periodEnd"], "dueDate": p["dueDate"], "confidence": p["confidence"], "status": p["status"], "customerName": p["customerName"], "customerLevel": p["customerLevel"], "note": p["note"], "updatedAt": _now(), }) return {"forecast": row, "created": False, "beforeStatus": before} fid = next_id("forecastOrder") row = { "id": fid, "forecastNo": "FC" + today0().strftime("%Y%m%d") + f"{fid:03d}", "productId": p["productId"], "productCode": p["productCode"], "productName": p["productName"], "quantity": p["quantity"], "unit": p["unit"], "bucket": p["bucket"], "periodStart": p["periodStart"], "periodEnd": p["periodEnd"], "dueDate": p["dueDate"], "confidence": p["confidence"], "status": p["status"], "customerName": p["customerName"], "customerLevel": p["customerLevel"], "note": p["note"], "source": p["source"], "createdAt": _now(), "updatedAt": _now(), "convertedOrderNo": None, } world["forecastOrders"].append(row) return {"forecast": row, "created": True, "beforeStatus": None} if action == "forecast.delete": row = find_forecast(world, forecast_id=int(payload.get("id") or 0), forecast_no=payload.get("forecastNo")) if row is None: raise ValueError("目标预测订单不存在") before = row.get("status") world["forecastOrders"] = [f for f in world["forecastOrders"] if f["id"] != row["id"]] return {"forecast": {"id": row["id"], "forecastNo": row["forecastNo"], "status": "DELETED"}, "created": False, "beforeStatus": before} if action == "forecast.convert": row = find_forecast(world, forecast_id=int(payload.get("id") or 0), forecast_no=payload.get("forecastNo")) if row is None: raise ValueError("目标预测订单不存在") if row.get("status") in ("CONSUMED", "CANCELLED"): raise ValueError(f"{row['forecastNo']} 状态为 {row['status']},不能转正") applied = apply_order_action(world, next_id, "order.upsert", { "customerName": row.get("customerName") or "预测转正客户", "customerLevel": row.get("customerLevel") or "B", "deliveryDate": row.get("dueDate"), "priority": 5, "status": "APPROVED", "productId": row["productId"], "quantity": row["quantity"], "isRush": False, "specialRequirements": f"由预测 {row['forecastNo']} 转正", }) order = applied["order"] before = row.get("status") row["status"] = "CONSUMED" row["convertedOrderNo"] = order["orderNo"] row["updatedAt"] = _now() return { "forecast": row, "order": order, "created": False, "beforeStatus": before, "convertedOrderNo": order["orderNo"], } raise ValueError(f"不支持的预测动作:{action}") def forecasts_as_schedule_entries(world: World) -> list[dict[str, Any]]: """把 ACTIVE 预测投影为引擎可消费的伪销售订单项(不改主干表)。""" ensure_forecast_table(world) entries: list[dict[str, Any]] = [] for fc in world["forecastOrders"]: if fc.get("status") not in SCHEDULABLE_FORECAST: continue so = { "id": 900000 + int(fc["id"]), "orderNo": fc["forecastNo"], "customerName": fc.get("customerName") or "预测需求", "customerLevel": fc.get("customerLevel") or "C", "orderDate": fc.get("periodStart"), "deliveryDate": fc.get("dueDate"), "priority": 9, "status": "APPROVED", "isRush": False, "rushStrategy": None, "isForecast": True, "forecastId": fc["id"], "items": [{ "id": 900000 + int(fc["id"]), "orderId": 900000 + int(fc["id"]), "lineNo": 1, "productId": fc["productId"], "productName": fc.get("productName"), "productCode": fc.get("productCode"), "quantity": fc["quantity"], "unit": fc.get("unit") or "件", "status": "PENDING", }], } entries.append({"so": so, "item": so["items"][0]}) return entries