aps-agent/server/aps_domain/forecast.py

261 lines
12 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-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