261 lines
12 KiB
Python
261 lines
12 KiB
Python
|
|
# ============================================================
|
|||
|
|
# 预测 / 长周期订单(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
|