aps-agent/server/aps_domain/orders.py

795 lines
35 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-orders, 可重生 ✅)
# 职责:销售订单只读投影 + P2 订单写入动作的纯应用逻辑
# 规则:新增/编辑/取消/完成/删除订单均写主干世界,必须由调用方走 Harness 确认卡
# ============================================================
from __future__ import annotations # 前向类型引用
from datetime import datetime # 时间戳
from typing import Any # 类型标注
from server.timeutil import fmt_dt, today0 # 时间格式化
World = dict[str, Any] # 世界状态类型别名
# 当前数据模型的订单状态。后续订单池审批会扩展为更完整状态机。
ORDER_STATUSES = {
"DRAFT", "SUBMITTED", "APPROVED", "REJECTED", "CHANGED",
"CANCELLED", "COMPLETED",
"CONFIRMED", # 遗留别名 ≡ APPROVED(排产资格相同)
}
# 正式排产默认纳入的状态(OR-03)
SCHEDULABLE_STATUSES = {"APPROVED", "CONFIRMED"}
# 可提交审核
SUBMITTABLE_STATUSES = {"DRAFT", "REJECTED", "CHANGED"}
# 可批准/驳回
REVIEWABLE_STATUSES = {"SUBMITTED", "CHANGED"}
CUSTOMER_LEVELS = {"VIP", "A", "B", "C"}
ORDER_ACTIONS = (
"order.upsert", "order.cancel", "order.complete", "order.delete", "order.clear",
"order.submit", "order.approve", "order.reject",
)
POOL_LANES = {
"pending": ("SUBMITTED",),
"approved": ("APPROVED", "CONFIRMED"),
"changed": ("CHANGED",),
"draft": ("DRAFT", "REJECTED"),
"closed": ("CANCELLED", "COMPLETED"),
}
def normalize_status(status: str | None, *, default: str = "DRAFT") -> str:
s = str(status or default).upper()
if s == "CONFIRMED":
return "APPROVED" # 新写入统一为 APPROVED
if s not in ORDER_STATUSES:
raise ValueError(
"订单状态必须是 DRAFT/SUBMITTED/APPROVED/REJECTED/CHANGED/CANCELLED/COMPLETED")
return s
def is_schedulable(status: str | None, *, include_unapproved: bool = False) -> bool:
s = str(status or "")
if s in SCHEDULABLE_STATUSES:
return True
if include_unapproved and s in ("DRAFT", "SUBMITTED", "CHANGED", "REJECTED"):
return True
return False
def _now() -> str:
"""当前时间字符串(与世界状态 createdAt/updatedAt 口径一致)。"""
return fmt_dt(datetime.now())
def _product(world: World, product_id: int) -> dict[str, Any]:
"""取成品物料;不存在则抛出 ValueError,阻止脏订单进入世界状态。"""
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 _next_item_id(world: World) -> int:
"""销售订单明细发号:扫描现有 items,避免改 WorldStore 计数器契约。"""
max_id = 0
for so in world.get("salesOrders", []):
for item in so.get("items", []):
if isinstance(item.get("id"), int):
max_id = max(max_id, item["id"])
return max_id + 1
def list_products(world: World) -> list[dict[str, Any]]:
"""成品下拉选项(P0 只读)。"""
return [{
"id": m["id"],
"code": m["code"],
"name": m["name"],
"unit": m["unit"],
} for m in world["materials"] if m["type"] == "FINISHED_PRODUCT"]
def _flex_status_to_sales(status: str | None) -> str:
"""现场/SQL 柔性订单状态 → 销售订单状态机。"""
s = str(status or "").upper()
if s in ("RELEASED", "RELEASE", "OPEN", "IN_PROGRESS", "PRODUCING", "PLANNED"):
return "APPROVED"
if s in ("DONE", "COMPLETED", "FINISHED", "CLOSED"):
return "COMPLETED"
if s in ("CANCELLED", "CANCELED"):
return "CANCELLED"
if s in ("DRAFT", "CREATED"):
return "DRAFT"
if s in ORDER_STATUSES or s == "CONFIRMED":
try:
return normalize_status(s)
except ValueError:
return "APPROVED"
return "APPROVED"
def sync_flex_orders_to_sales(world: World) -> int:
"""Idempotently project flexible shop-floor orders into sales orders."""
demo_mat = {
"HV-HARNESS", "PDU-UNIT", "CHARGE-GUN", "HV-CONN",
"CTRL-A", "CTRL-B", "CTRL-C",
}
flex: list[dict[str, Any]] = []
for order in world.get("flexOrders") or []:
if str(order.get("source") or "").upper() in {"CLOSED_LOOP", "MRP"}:
continue
if str(order.get("status") or "").upper() in {"DONE", "CANCELLED", "COMPLETED"}:
continue
order_no = str(order.get("orderNo") or "")
product_code = str(order.get("productCode") or "")
if order_no.startswith(("FO-26", "SO-DEMO")) or product_code in demo_mat:
continue
flex.append(order)
existing_rows = list(world.get("salesOrders") or [])
projected_sources = {"SQL", "FLEX", "SITE"}
manual_rows = [
row for row in existing_rows
if str(row.get("source") or "MANUAL").upper() not in projected_sources
]
existing_projected = {
str(row.get("orderNo") or ""): row
for row in existing_rows
if str(row.get("source") or "MANUAL").upper() in projected_sources
and str(row.get("orderNo") or "")
}
mats = {material.get("code"): material for material in (world.get("materials") or []) if material.get("code")}
flex_mats = {material.get("code"): material for material in (world.get("flexMaterials") or []) if material.get("code")}
next_mat = max(
(material.get("id", 0) for material in (world.get("materials") or []) if isinstance(material.get("id"), int)),
default=0,
) + 1
next_so = max(
(row.get("id", 0) for row in existing_rows if isinstance(row.get("id"), int)),
default=0,
) + 1
next_item = _next_item_id(world)
business_day = str(world.get("businessDate") or fmt_dt(today0())[:10])[:10]
projected_rows: list[dict[str, Any]] = []
changed = 0
seen_order_nos: set[str] = set()
for flex_order in flex:
order_no = str(flex_order.get("orderNo") or "").strip()
product_code = str(flex_order.get("productCode") or "").strip()
if not order_no or not product_code or order_no in seen_order_nos:
continue
seen_order_nos.add(order_no)
material = mats.get(product_code)
if material is None:
flex_material = flex_mats.get(product_code) or {
"code": product_code,
"name": flex_order.get("productName") or product_code,
"type": "FINISHED_PRODUCT",
"unit": "件",
}
material = {
"id": next_mat,
"code": product_code,
"name": flex_material.get("name") or product_code,
"spec": flex_material.get("spec") or "",
"type": flex_material.get("type") or "FINISHED_PRODUCT",
"unit": flex_material.get("unit") or "件",
"productFamily": "",
"safetyStock": float(flex_material.get("safetyStock") or 0),
"procurementLeadTime": float(flex_material.get("procurementLeadTime") or 0),
"stock": float(flex_material.get("stock") or 0),
"inTransit": float(flex_material.get("inTransit") or 0),
"status": "ACTIVE",
}
world.setdefault("materials", []).append(material)
mats[product_code] = material
next_mat += 1
product_id = material.get("id")
if not isinstance(product_id, int) or product_id <= 0:
continue
status = _flex_status_to_sales(flex_order.get("status"))
quantity = float(flex_order.get("quantity") or 0) or 1
desired_order = {
"orderNo": order_no,
"customerName": flex_order.get("customerName") or "现场客户",
"customerLevel": flex_order.get("customerLevel") or "B",
"orderDate": str(flex_order.get("orderDate") or business_day)[:10],
"deliveryDate": str(flex_order.get("dueDate") or business_day)[:10],
"priority": int(flex_order.get("priority") or 5),
"status": status,
"source": "SQL" if flex_order.get("craftlCode") else "FLEX",
"isRush": bool(flex_order.get("isRush")),
"specialRequirements": flex_order.get("drawingNo") or "",
"totalAmount": 0,
}
desired_item = {
"productId": product_id,
"productCode": product_code,
"productName": flex_order.get("productName") or material.get("name") or product_code,
"quantity": quantity,
"unit": material.get("unit") or "件",
"status": "PENDING" if status == "APPROVED" else status,
}
existing = existing_projected.get(order_no)
existing_item = ((existing or {}).get("items") or [{}])[0]
order_matches = bool(existing) and all(existing.get(key) == value for key, value in desired_order.items())
item_matches = bool(existing) and all(existing_item.get(key) == value for key, value in desired_item.items())
if order_matches and item_matches and len((existing or {}).get("items") or []) == 1:
projected_rows.append(existing)
continue
now = _now()
if existing:
row = {**existing, **desired_order}
item_id = existing_item.get("id") if isinstance(existing_item.get("id"), int) else next_item
if item_id == next_item:
next_item += 1
row["items"] = [{**existing_item, **desired_item, "id": item_id}]
row["createdAt"] = existing.get("createdAt") or now
row["updatedAt"] = now
else:
row = {
"id": next_so,
**desired_order,
"createdAt": now,
"updatedAt": now,
"items": [{"id": next_item, **desired_item}],
}
next_so += 1
next_item += 1
projected_rows.append(row)
changed += 1
removed = set(existing_projected) - seen_order_nos
changed += len(removed)
next_rows = manual_rows + projected_rows
if changed or next_rows != existing_rows:
if not changed:
changed = 1
world["salesOrders"] = next_rows
return changed
def list_orders(world: World) -> list[dict[str, Any]]:
"""销售订单列表投影(含已投影的 SQL/柔性单;只读兜底不写盘)。"""
rows: list[dict[str, Any]] = []
for so in world.get("salesOrders") or []:
items = so.get("items", [])
first = items[0] if items else {}
rows.append({
"id": so["id"],
"orderNo": so["orderNo"],
"customerName": so["customerName"],
"customerLevel": so.get("customerLevel") or "B",
"orderDate": so.get("orderDate") or "",
"deliveryDate": so.get("deliveryDate") or so.get("dueDate") or "",
"priority": so.get("priority") or 5,
"status": _flex_status_to_sales(so.get("status")),
"source": so.get("source", "MANUAL"),
"isRush": so.get("isRush", False),
"rushStrategy": so.get("rushStrategy"),
"specialRequirements": so.get("specialRequirements", ""),
"totalAmount": so.get("totalAmount", 0),
"itemCount": len(items),
"productId": first.get("productId"),
"productCode": first.get("productCode", ""),
"productName": first.get("productName", ""),
"quantity": first.get("quantity", 0),
"unit": first.get("unit", "件"),
"updatedAt": so.get("updatedAt") or so.get("createdAt"),
"reviewNote": so.get("reviewNote") or "",
"schedulable": is_schedulable(_flex_status_to_sales(so.get("status"))),
})
# 兜底:销售表空时只读展柔性订单(不写 salesOrders,避免污染演示世界)
if not rows:
mats = {m.get("code"): m for m in (world.get("materials") or [])}
for i, fo in enumerate(world.get("flexOrders") or []):
if (fo.get("status") or "") in ("DONE", "CANCELLED", "COMPLETED"):
continue
ono = str(fo.get("orderNo") or "")
if ono.startswith(("FO-26", "SO-DEMO")):
continue
pc = fo.get("productCode") or ""
st = _flex_status_to_sales(fo.get("status"))
rows.append({
"id": 10_000_000 + int(fo.get("id") or i),
"orderNo": fo.get("orderNo"),
"customerName": fo.get("customerName") or "现场客户",
"customerLevel": "B",
"orderDate": "",
"deliveryDate": fo.get("dueDate") or "",
"priority": fo.get("priority") or 5,
"status": st,
"source": "FLEX",
"isRush": False,
"itemCount": 1,
"productId": (mats.get(pc) or {}).get("id"),
"productCode": pc,
"productName": fo.get("productName") or (mats.get(pc) or {}).get("name") or pc,
"quantity": fo.get("quantity") or 0,
"unit": "件",
"schedulable": is_schedulable(st),
})
rows.sort(key=lambda x: (
x["status"] in ("CANCELLED", "COMPLETED"),
x["status"] not in ("SUBMITTED", "CHANGED", "APPROVED"),
x["deliveryDate"], x["priority"],
))
return rows
def pool_summary(world: World) -> dict[str, Any]:
"""订单池看板计数(只读,不写盘)。"""
counts = {k: 0 for k in POOL_LANES}
counts["all"] = 0
counts["schedulable"] = 0
for row in list_orders(world):
counts["all"] += 1
st = row.get("status")
if is_schedulable(st):
counts["schedulable"] += 1
for lane, statuses in POOL_LANES.items():
if st in statuses:
counts[lane] += 1
break
return counts
def list_production_orders(world: World) -> list[dict[str, Any]]:
"""生产订单/虚拟产线投影:优先最新柔性排产版本,其次固定轨 productionOrders。"""
rows: list[dict[str, Any]] = []
flex_vers = world.get("flexScheduleVersions") or []
if flex_vers:
ver = flex_vers[-1]
vid = ver.get("id")
orders_by_no = {o.get("orderNo"): o for o in (world.get("flexOrders") or [])}
for vl in world.get("flexVirtualLines") or []:
if vl.get("versionId") != vid:
continue
ono = vl.get("orderNo")
o = orders_by_no.get(ono) or {}
wos = [w for w in (world.get("flexWorkOrders") or [])
if w.get("versionId") == vid and w.get("vlId") == vl.get("id")]
rows.append({
"id": vl.get("id"),
"orderNo": ono,
"vlNo": vl.get("vlNo"),
"productCode": vl.get("productCode") or o.get("productCode"),
"productName": o.get("productName") or vl.get("productCode"),
"quantity": vl.get("quantity") or o.get("quantity"),
"dueDate": o.get("dueDate"),
"plannedStart": vl.get("plannedStart"),
"plannedEnd": vl.get("plannedEnd"),
"stepCount": len(wos) or vl.get("stepCount") or 0,
"status": vl.get("status") or "PLANNED",
"versionNo": ver.get("versionNo"),
"track": "flex",
})
return rows
for po in world.get("productionOrders") or []:
rows.append({
"id": po.get("id"),
"orderNo": po.get("orderNo") or po.get("salesOrderNo"),
"vlNo": po.get("poNo") or po.get("code"),
"productCode": po.get("productCode"),
"productName": po.get("productName"),
"quantity": po.get("quantity"),
"dueDate": po.get("dueDate"),
"plannedStart": po.get("plannedStart"),
"plannedEnd": po.get("plannedEnd"),
"stepCount": po.get("woCount") or 0,
"status": po.get("status") or "PLANNED",
"versionNo": po.get("schedulingVersionId"),
"track": "fixed",
})
return rows
def find_product_by_hint(world: World, hint: str) -> dict[str, Any] | None:
"""按编码/名称模糊解析成品(对话建单用):先精确编码,再名称包含匹配。"""
h = (hint or "").strip()
if not h:
return None
products = [m for m in world.get("materials") or [] if m.get("type") == "FINISHED_PRODUCT"]
if not products:
products = [m for m in world.get("flexMaterials") or [] if m.get("type") == "FINISHED_PRODUCT"]
exact = next((m for m in products if str(m.get("code") or "").lower() == h.lower()), None)
if exact:
return exact
return next(
(m for m in products
if h in str(m.get("name") or "") or str(m.get("name") or "") in h
or h.lower() in str(m.get("code") or "").lower()),
None,
)
def find_order(world: World, *, order_id: int | None = None, order_no: str | None = None) -> dict[str, Any] | None:
"""按 ID 或订单号查订单(P0 只读;含柔性表只读回退)。"""
if order_id is not None:
hit = next((so for so in world.get("salesOrders") or [] if so["id"] == order_id), None)
if hit:
return hit
if order_no:
hit = next((so for so in world.get("salesOrders") or []
if str(so.get("orderNo") or "").lower() == order_no.lower()), None)
if hit:
return hit
fo = next((o for o in world.get("flexOrders") or []
if str(o.get("orderNo") or "").lower() == order_no.lower()), None)
if fo:
return {
"id": fo.get("id"), "orderNo": fo.get("orderNo"),
"customerName": fo.get("customerName") or "现场客户",
"customerLevel": "B", "deliveryDate": fo.get("dueDate"),
"priority": fo.get("priority") or 5,
"status": _flex_status_to_sales(fo.get("status")),
"source": "FLEX", "items": [{
"productCode": fo.get("productCode"),
"productName": fo.get("productName"),
"quantity": fo.get("quantity"), "unit": "件",
}],
}
return None
def normalize_order_payload(world: World, payload: dict[str, Any]) -> dict[str, Any]:
"""订单写入载荷归一化与校验(P0 纯校验)。"""
out = dict(payload)
if out.get("id") in ("", None):
out.pop("id", None)
elif not isinstance(out["id"], int):
out["id"] = int(out["id"])
out["customerName"] = str(out.get("customerName") or "").strip()
if not out["customerName"]:
raise ValueError("客户名称不能为空")
out["customerLevel"] = str(out.get("customerLevel") or "A").upper()
if out["customerLevel"] not in CUSTOMER_LEVELS:
raise ValueError("客户等级必须是 VIP/A/B/C")
out["deliveryDate"] = str(out.get("deliveryDate") or "").strip()
if not out["deliveryDate"]:
raise ValueError("交期不能为空")
out["priority"] = max(1, min(9, int(out.get("priority") or 5)))
out["productId"] = int(out.get("productId") or 0)
product = _product(world, out["productId"])
out["productName"] = product["name"]
out["productCode"] = product["code"]
out["unit"] = product["unit"]
out["quantity"] = max(1, int(float(out.get("quantity") or 1)))
# 新建默认 DRAFT;遗留 CONFIRMED 写入时归一为 APPROVED
default_status = "DRAFT" if not out.get("id") else "APPROVED"
out["status"] = normalize_status(out.get("status"), default=default_status)
out["isRush"] = bool(out.get("isRush", False))
out["rushStrategy"] = "STRATEGY_SHIFT" if out["isRush"] else None
out["specialRequirements"] = str(out.get("specialRequirements") or "").strip()
out["reviewNote"] = str(out.get("reviewNote") or "").strip()
return out
def confirmation_for_order_action(world: World, action: str, payload: dict[str, Any]) -> tuple[str, list[str]]:
"""生成订单 P2 确认卡标题与影响摘要。"""
if action == "order.upsert":
p = normalize_order_payload(world, payload)
verb = "编辑" if p.get("id") else "新增"
title = f"{verb}销售订单"
lines = [
f"客户:{p['customerName']}({p['customerLevel']})",
f"产品:{p['productName']} × {p['quantity']} {p['unit']},交期 {p['deliveryDate']}",
"批准后写入主干订单池,并影响后续新排产版本(P2)",
]
return title, lines
if action == "order.clear":
n = len(world.get("salesOrders", []))
po = len(world.get("purchaseOrders", []))
os = len(world.get("outsourceOrders", []))
if n == 0 and po == 0 and os == 0:
raise ValueError("订单池已空,无需清理")
return "一键清理全部订单", [
f"将清空销售订单 {n} 条",
f"同步清空采购建议 {po} / 委外建议 {os} 条",
"历史排产版本与工单保留不回写;后续试排将无订单可排",
"执行前自动建档,可回滚(P2)",
]
if action == "order.approve":
ids = _resolve_order_ids(world, payload)
if not ids:
raise ValueError("没有可批准的订单(需 SUBMITTED/CHANGED)")
nos = [find_order(world, order_id=i)["orderNo"] for i in ids] # type: ignore[index]
return f"批准订单 {len(ids)} 条", [
"、".join(nos[:5]) + ("…" if len(nos) > 5 else ""),
"批准后状态 → APPROVED,可进入正式排产",
"执行前自动建档,可回滚(P2)",
]
if action == "order.reject":
ids = _resolve_order_ids(world, payload, for_reject=True)
if not ids:
raise ValueError("没有可驳回的订单(需 SUBMITTED/CHANGED)")
note = str(payload.get("reviewNote") or "").strip() or "(未填写原因)"
nos = [find_order(world, order_id=i)["orderNo"] for i in ids] # type: ignore[index]
return f"驳回订单 {len(ids)} 条", [
"、".join(nos[:5]) + ("…" if len(nos) > 5 else ""),
f"驳回原因:{note}",
"驳回后状态 → REJECTED,不参与排产;可修改后重新提交",
"执行前自动建档,可回滚(P2)",
]
order = find_order(world, order_id=int(payload.get("id") or 0), order_no=payload.get("orderNo"))
if order is None:
raise ValueError("目标订单不存在")
if action == "order.cancel":
return f"取消订单 {order['orderNo']}", [
f"客户:{order['customerName']},当前状态 {order['status']}",
"批准后该订单不再参与后续排产,历史版本不回写",
"执行前自动建档,可回滚",
]
if action == "order.complete":
return f"完成订单 {order['orderNo']}", [
f"客户:{order['customerName']},当前状态 {order['status']}",
"批准后订单及明细置为 COMPLETED,不再参与后续排产",
"执行前自动建档,可回滚",
]
if action == "order.delete":
draft_po = sum(1 for p in world.get("purchaseOrders", [])
if p.get("salesOrderId") == order["id"] and p.get("status") == "DRAFT")
draft_os = sum(1 for o in world.get("outsourceOrders", [])
if o.get("salesOrderId") == order["id"] and o.get("status") == "DRAFT")
hist = sum(1 for po in world.get("productionOrders", []) if po.get("salesOrderId") == order["id"])
lines = [
f"客户:{order['customerName']} · {order['orderNo']}({order['status']})",
"批准后从订单池物理删除,后续排产不再纳入",
]
if draft_po or draft_os:
lines.append(f"同步清除草稿采购 {draft_po} / 委外 {draft_os} 条")
if hist:
lines.append(f"历史生产订单 {hist} 条保留不回写(仅断联销售单)")
lines.append("执行前自动建档,可回滚")
return f"删除订单 {order['orderNo']}", lines
raise ValueError(f"不支持的订单动作:{action}")
def _resolve_order_ids(world: World, payload: dict[str, Any], *, for_reject: bool = False) -> list[int]:
"""解析单笔/批量订单 ID,并过滤到可审核状态。"""
allowed = REVIEWABLE_STATUSES
raw_ids = payload.get("orderIds") or payload.get("ids") or []
ids: list[int] = []
if raw_ids == "pending" or raw_ids == ["pending"]:
return [so["id"] for so in world.get("salesOrders", []) if so.get("status") in allowed]
if isinstance(raw_ids, list) and raw_ids:
ids = [int(x) for x in raw_ids]
elif payload.get("id"):
ids = [int(payload["id"])]
elif payload.get("orderNo"):
o = find_order(world, order_no=str(payload["orderNo"]))
if o:
ids = [o["id"]]
out = []
for oid in ids:
o = find_order(world, order_id=oid)
if o and o["status"] in allowed:
out.append(oid)
return out
def apply_order_action(world: World, next_id, action: str, payload: dict[str, Any]) -> dict[str, Any]:
"""应用订单写入动作到内存世界(P2 动作的业务侧实现;调用方负责门禁/快照/审计/落盘)。"""
if action == "order.clear":
n = len(world.get("salesOrders", []))
po_n = len(world.get("purchaseOrders", []))
os_n = len(world.get("outsourceOrders", []))
world["salesOrders"] = []
world["purchaseOrders"] = []
world["outsourceOrders"] = []
return {
"order": {"id": 0, "orderNo": "ALL", "status": "CLEARED"},
"created": False, "beforeStatus": None,
"clearedOrders": n, "clearedPurchase": po_n, "clearedOutsource": os_n,
}
if action == "order.upsert":
p = normalize_order_payload(world, payload)
if p.get("id"):
order = find_order(world, order_id=p["id"])
if order is None:
raise ValueError("目标订单不存在")
before_status = order["status"]
# 已批准订单被改内容 → CHANGED,需重新审核(除非显式仍写 APPROVED 且字段未变)
next_status = p["status"]
content_changed = any([
order.get("customerName") != p["customerName"],
order.get("customerLevel") != p["customerLevel"],
order.get("deliveryDate") != p["deliveryDate"],
order.get("priority") != p["priority"],
(order.get("items") or [{}])[0].get("productId") != p["productId"],
(order.get("items") or [{}])[0].get("quantity") != p["quantity"],
bool(order.get("isRush")) != bool(p["isRush"]),
])
if before_status in SCHEDULABLE_STATUSES and content_changed and next_status in SCHEDULABLE_STATUSES:
next_status = "CHANGED"
order.update({
"customerName": p["customerName"],
"customerLevel": p["customerLevel"],
"deliveryDate": p["deliveryDate"],
"priority": p["priority"],
"status": next_status,
"isRush": p["isRush"],
"rushStrategy": p["rushStrategy"],
"specialRequirements": p["specialRequirements"],
"updatedAt": _now(),
})
if next_status != before_status:
order.setdefault("changes", []).append({
"at": _now(), "action": "order.upsert",
"from": before_status, "to": next_status,
})
if not order.get("items"):
order["items"] = []
item = order["items"][0] if order["items"] else None
if item is None:
item = {"id": _next_item_id(world), "orderId": order["id"], "lineNo": 1}
order["items"].append(item)
item.update({
"productId": p["productId"],
"productName": p["productName"],
"productCode": p["productCode"],
"quantity": p["quantity"],
"unit": p["unit"],
"bomVersion": item.get("bomVersion", "V1.0"),
"routingVersion": item.get("routingVersion", "V1.0"),
"status": "COMPLETED" if next_status == "COMPLETED"
else "CANCELLED" if next_status == "CANCELLED"
else "PENDING",
"note": item.get("note", ""),
})
return {"order": order, "created": False, "beforeStatus": before_status}
order_id = next_id("salesOrder")
order_date = today0().strftime("%Y-%m-%d")
product = _product(world, p["productId"])
order = {
"id": order_id,
"orderNo": "SO" + today0().strftime("%Y%m%d") + f"{order_id:03d}",
"customerId": f"CUST{order_id:03d}",
"customerName": p["customerName"],
"customerLevel": p["customerLevel"],
"orderDate": order_date,
"deliveryDate": p["deliveryDate"],
"priority": p["priority"],
"manualPriority": None,
"status": p["status"],
"source": "MANUAL",
"specialRequirements": p["specialRequirements"],
"totalAmount": p["quantity"] * 100,
"isRush": p["isRush"],
"rushStrategy": p["rushStrategy"],
"changes": [],
"createdBy": "planner",
"createdAt": _now(),
"updatedAt": _now(),
"items": [{
"id": _next_item_id(world),
"orderId": order_id,
"lineNo": 1,
"productId": product["id"],
"productName": product["name"],
"productCode": product["code"],
"quantity": p["quantity"],
"unit": product["unit"],
"bomVersion": "V1.0",
"routingVersion": "V1.0",
"status": "PENDING",
"note": "",
}],
}
world["salesOrders"].append(order)
return {"order": order, "created": True, "beforeStatus": None}
# 批准/驳回支持 orderIds / pending,须在单笔 id 查找之前
if action == "order.approve":
ids = _resolve_order_ids(world, payload)
if not ids:
raise ValueError("没有可批准的订单")
updated = []
for oid in ids:
o = find_order(world, order_id=oid)
if not o:
continue
before = o["status"]
o["status"] = "APPROVED"
o["reviewNote"] = str(payload.get("reviewNote") or "").strip()
o["updatedAt"] = _now()
o.setdefault("changes", []).append({
"at": _now(), "action": "order.approve", "from": before, "to": "APPROVED",
})
updated.append(o)
first = updated[0]
return {
"order": first, "created": False,
"beforeStatus": first.get("changes", [{}])[-1].get("from"),
"approvedCount": len(updated),
"orderNos": [o["orderNo"] for o in updated],
}
if action == "order.reject":
ids = _resolve_order_ids(world, payload, for_reject=True)
if not ids:
raise ValueError("没有可驳回的订单")
note = str(payload.get("reviewNote") or payload.get("reason") or "").strip()
updated = []
for oid in ids:
o = find_order(world, order_id=oid)
if not o:
continue
before = o["status"]
o["status"] = "REJECTED"
o["reviewNote"] = note or "已驳回"
o["updatedAt"] = _now()
o.setdefault("changes", []).append({
"at": _now(), "action": "order.reject", "from": before, "to": "REJECTED",
"note": note,
})
updated.append(o)
first = updated[0]
return {
"order": first, "created": False, "beforeStatus": "SUBMITTED",
"rejectedCount": len(updated),
"orderNos": [o["orderNo"] for o in updated],
}
order = find_order(world, order_id=int(payload.get("id") or 0), order_no=payload.get("orderNo"))
if order is None:
raise ValueError("目标订单不存在")
before_status = order["status"]
if action == "order.delete":
oid = order["id"]
order_no = order["orderNo"]
# 清除该单关联的草稿 MRP;已下达建议保留但断开关联语义由业务自行处理
world["purchaseOrders"] = [
p for p in world.get("purchaseOrders", [])
if not (p.get("salesOrderId") == oid and p.get("status") == "DRAFT")
]
world["outsourceOrders"] = [
o for o in world.get("outsourceOrders", [])
if not (o.get("salesOrderId") == oid and o.get("status") == "DRAFT")
]
world["salesOrders"] = [so for so in world["salesOrders"] if so["id"] != oid]
return {"order": {"id": oid, "orderNo": order_no, "status": "DELETED"},
"created": False, "beforeStatus": before_status}
if action == "order.cancel":
order["status"] = "CANCELLED"
for item in order.get("items", []):
item["status"] = "CANCELLED"
elif action == "order.complete":
order["status"] = "COMPLETED"
for item in order.get("items", []):
item["status"] = "COMPLETED"
else:
raise ValueError(f"不支持的订单动作:{action}")
order["updatedAt"] = _now()
order.setdefault("changes", []).append({
"at": _now(),
"action": action,
"from": before_status,
"to": order["status"],
})
return {"order": order, "created": False, "beforeStatus": before_status}
def apply_order_submit(world: World, payload: dict[str, Any]) -> dict[str, Any]:
"""提交审核(P1):DRAFT/REJECTED/CHANGED → SUBMITTED。"""
order = find_order(world, order_id=int(payload.get("id") or 0), order_no=payload.get("orderNo"))
if order is None:
raise ValueError("目标订单不存在")
before = order["status"]
if before not in SUBMITTABLE_STATUSES:
raise ValueError(f"订单 {order['orderNo']} 状态为 {before},不能提交审核")
order["status"] = "SUBMITTED"
order["reviewNote"] = ""
order["updatedAt"] = _now()
order.setdefault("changes", []).append({
"at": _now(), "action": "order.submit", "from": before, "to": "SUBMITTED",
})
return {"order": order, "created": False, "beforeStatus": before}