aps-agent/server/aps_domain/forecast.py

261 lines
12 KiB
Python
Raw Permalink Normal View History

# ============================================================
# 预测 / 长周期订单(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