2026-07-21 11:05:57 +08:00
|
|
|
|
# ============================================================
|
|
|
|
|
|
# 订单管理领域服务(moduleId: domain-orders, 可重生 ✅)
|
|
|
|
|
|
# 职责:销售订单只读投影 + P2 订单写入动作的纯应用逻辑
|
2026-07-23 13:38:43 +08:00
|
|
|
|
# 规则:新增/编辑/取消/完成/删除订单均写主干世界,必须由调用方走 Harness 确认卡
|
2026-07-21 11:05:57 +08:00
|
|
|
|
# ============================================================
|
|
|
|
|
|
from __future__ import annotations # 前向类型引用
|
|
|
|
|
|
|
|
|
|
|
|
from datetime import datetime # 时间戳
|
|
|
|
|
|
from typing import Any # 类型标注
|
|
|
|
|
|
|
|
|
|
|
|
from server.timeutil import fmt_dt, today0 # 时间格式化
|
|
|
|
|
|
|
|
|
|
|
|
World = dict[str, Any] # 世界状态类型别名
|
|
|
|
|
|
|
|
|
|
|
|
# 当前数据模型的订单状态。后续订单池审批会扩展为更完整状态机。
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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"}
|
|
|
|
|
|
|
2026-07-21 11:05:57 +08:00
|
|
|
|
CUSTOMER_LEVELS = {"VIP", "A", "B", "C"}
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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
|
2026-07-21 11:05:57 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 02:12:46 +08:00
|
|
|
|
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:
|
2026-08-11 00:54:05 +08:00
|
|
|
|
"""Idempotently project flexible shop-floor orders into sales orders."""
|
2026-07-28 02:12:46 +08:00
|
|
|
|
demo_mat = {
|
|
|
|
|
|
"HV-HARNESS", "PDU-UNIT", "CHARGE-GUN", "HV-CONN",
|
|
|
|
|
|
"CTRL-A", "CTRL-B", "CTRL-C",
|
|
|
|
|
|
}
|
2026-08-11 00:54:05 +08:00
|
|
|
|
flex: list[dict[str, Any]] = []
|
|
|
|
|
|
for order in world.get("flexOrders") or []:
|
|
|
|
|
|
if str(order.get("source") or "").upper() in {"CLOSED_LOOP", "MRP"}:
|
2026-07-28 02:12:46 +08:00
|
|
|
|
continue
|
2026-08-11 00:54:05 +08:00
|
|
|
|
if str(order.get("status") or "").upper() in {"DONE", "CANCELLED", "COMPLETED"}:
|
2026-07-28 02:12:46 +08:00
|
|
|
|
continue
|
2026-08-11 00:54:05 +08:00
|
|
|
|
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:
|
2026-07-28 02:12:46 +08:00
|
|
|
|
continue
|
2026-08-11 00:54:05 +08:00
|
|
|
|
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:
|
2026-07-28 02:12:46 +08:00
|
|
|
|
continue
|
2026-08-11 00:54:05 +08:00
|
|
|
|
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": "件",
|
2026-07-28 02:12:46 +08:00
|
|
|
|
}
|
2026-08-11 00:54:05 +08:00
|
|
|
|
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 "件",
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"productFamily": "",
|
2026-08-11 00:54:05 +08:00
|
|
|
|
"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),
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"status": "ACTIVE",
|
|
|
|
|
|
}
|
2026-08-11 00:54:05 +08:00
|
|
|
|
world.setdefault("materials", []).append(material)
|
|
|
|
|
|
mats[product_code] = material
|
2026-07-28 02:12:46 +08:00
|
|
|
|
next_mat += 1
|
2026-08-11 00:54:05 +08:00
|
|
|
|
|
|
|
|
|
|
product_id = material.get("id")
|
|
|
|
|
|
if not isinstance(product_id, int) or product_id <= 0:
|
2026-07-28 02:12:46 +08:00
|
|
|
|
continue
|
2026-08-11 00:54:05 +08:00
|
|
|
|
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 "",
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"totalAmount": 0,
|
2026-08-11 00:54:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
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
|
2026-07-28 02:12:46 +08:00
|
|
|
|
|
2026-08-11 00:54:05 +08:00
|
|
|
|
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
|
2026-07-28 02:12:46 +08:00
|
|
|
|
|
2026-07-21 11:05:57 +08:00
|
|
|
|
def list_orders(world: World) -> list[dict[str, Any]]:
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"""销售订单列表投影(含已投影的 SQL/柔性单;只读兜底不写盘)。"""
|
2026-07-21 11:05:57 +08:00
|
|
|
|
rows: list[dict[str, Any]] = []
|
2026-07-28 02:12:46 +08:00
|
|
|
|
for so in world.get("salesOrders") or []:
|
2026-07-21 11:05:57 +08:00
|
|
|
|
items = so.get("items", [])
|
|
|
|
|
|
first = items[0] if items else {}
|
|
|
|
|
|
rows.append({
|
|
|
|
|
|
"id": so["id"],
|
|
|
|
|
|
"orderNo": so["orderNo"],
|
|
|
|
|
|
"customerName": so["customerName"],
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"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")),
|
2026-07-21 11:05:57 +08:00
|
|
|
|
"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"),
|
2026-07-23 13:38:43 +08:00
|
|
|
|
"reviewNote": so.get("reviewNote") or "",
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"schedulable": is_schedulable(_flex_status_to_sales(so.get("status"))),
|
2026-07-21 11:05:57 +08:00
|
|
|
|
})
|
2026-07-28 02:12:46 +08:00
|
|
|
|
# 兜底:销售表空时只读展柔性订单(不写 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),
|
|
|
|
|
|
})
|
2026-07-23 13:38:43 +08:00
|
|
|
|
rows.sort(key=lambda x: (
|
|
|
|
|
|
x["status"] in ("CANCELLED", "COMPLETED"),
|
2026-07-28 02:12:46 +08:00
|
|
|
|
x["status"] not in ("SUBMITTED", "CHANGED", "APPROVED"),
|
2026-07-23 13:38:43 +08:00
|
|
|
|
x["deliveryDate"], x["priority"],
|
|
|
|
|
|
))
|
2026-07-21 11:05:57 +08:00
|
|
|
|
return rows
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 13:38:43 +08:00
|
|
|
|
def pool_summary(world: World) -> dict[str, Any]:
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"""订单池看板计数(只读,不写盘)。"""
|
2026-07-23 13:38:43 +08:00
|
|
|
|
counts = {k: 0 for k in POOL_LANES}
|
|
|
|
|
|
counts["all"] = 0
|
|
|
|
|
|
counts["schedulable"] = 0
|
2026-07-28 02:12:46 +08:00
|
|
|
|
for row in list_orders(world):
|
2026-07-23 13:38:43 +08:00
|
|
|
|
counts["all"] += 1
|
2026-07-28 02:12:46 +08:00
|
|
|
|
st = row.get("status")
|
2026-07-23 13:38:43 +08:00
|
|
|
|
if is_schedulable(st):
|
|
|
|
|
|
counts["schedulable"] += 1
|
|
|
|
|
|
for lane, statuses in POOL_LANES.items():
|
|
|
|
|
|
if st in statuses:
|
|
|
|
|
|
counts[lane] += 1
|
|
|
|
|
|
break
|
|
|
|
|
|
return counts
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 02:12:46 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 13:38:43 +08:00
|
|
|
|
def find_product_by_hint(world: World, hint: str) -> dict[str, Any] | None:
|
|
|
|
|
|
"""按编码/名称模糊解析成品(对话建单用):先精确编码,再名称包含匹配。"""
|
|
|
|
|
|
h = (hint or "").strip()
|
|
|
|
|
|
if not h:
|
|
|
|
|
|
return None
|
2026-07-28 02:12:46 +08:00
|
|
|
|
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)
|
2026-07-23 13:38:43 +08:00
|
|
|
|
if exact:
|
|
|
|
|
|
return exact
|
2026-07-28 02:12:46 +08:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
2026-07-23 13:38:43 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 11:05:57 +08:00
|
|
|
|
def find_order(world: World, *, order_id: int | None = None, order_no: str | None = None) -> dict[str, Any] | None:
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"""按 ID 或订单号查订单(P0 只读;含柔性表只读回退)。"""
|
2026-07-21 11:05:57 +08:00
|
|
|
|
if order_id is not None:
|
2026-07-28 02:12:46 +08:00
|
|
|
|
hit = next((so for so in world.get("salesOrders") or [] if so["id"] == order_id), None)
|
|
|
|
|
|
if hit:
|
|
|
|
|
|
return hit
|
2026-07-21 11:05:57 +08:00
|
|
|
|
if order_no:
|
2026-07-28 02:12:46 +08:00
|
|
|
|
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": "件",
|
|
|
|
|
|
}],
|
|
|
|
|
|
}
|
2026-07-21 11:05:57 +08:00
|
|
|
|
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)))
|
2026-07-23 13:38:43 +08:00
|
|
|
|
# 新建默认 DRAFT;遗留 CONFIRMED 写入时归一为 APPROVED
|
|
|
|
|
|
default_status = "DRAFT" if not out.get("id") else "APPROVED"
|
|
|
|
|
|
out["status"] = normalize_status(out.get("status"), default=default_status)
|
2026-07-21 11:05:57 +08:00
|
|
|
|
out["isRush"] = bool(out.get("isRush", False))
|
|
|
|
|
|
out["rushStrategy"] = "STRATEGY_SHIFT" if out["isRush"] else None
|
|
|
|
|
|
out["specialRequirements"] = str(out.get("specialRequirements") or "").strip()
|
2026-07-23 13:38:43 +08:00
|
|
|
|
out["reviewNote"] = str(out.get("reviewNote") or "").strip()
|
2026-07-21 11:05:57 +08:00
|
|
|
|
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
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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)",
|
|
|
|
|
|
]
|
2026-07-21 11:05:57 +08:00
|
|
|
|
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,不再参与后续排产",
|
|
|
|
|
|
"执行前自动建档,可回滚",
|
|
|
|
|
|
]
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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
|
2026-07-21 11:05:57 +08:00
|
|
|
|
raise ValueError(f"不支持的订单动作:{action}")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 11:05:57 +08:00
|
|
|
|
def apply_order_action(world: World, next_id, action: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
|
"""应用订单写入动作到内存世界(P2 动作的业务侧实现;调用方负责门禁/快照/审计/落盘)。"""
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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,
|
|
|
|
|
|
}
|
2026-07-21 11:05:57 +08:00
|
|
|
|
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"]
|
2026-07-23 13:38:43 +08:00
|
|
|
|
# 已批准订单被改内容 → 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"
|
2026-07-21 11:05:57 +08:00
|
|
|
|
order.update({
|
|
|
|
|
|
"customerName": p["customerName"],
|
|
|
|
|
|
"customerLevel": p["customerLevel"],
|
|
|
|
|
|
"deliveryDate": p["deliveryDate"],
|
|
|
|
|
|
"priority": p["priority"],
|
2026-07-23 13:38:43 +08:00
|
|
|
|
"status": next_status,
|
2026-07-21 11:05:57 +08:00
|
|
|
|
"isRush": p["isRush"],
|
|
|
|
|
|
"rushStrategy": p["rushStrategy"],
|
|
|
|
|
|
"specialRequirements": p["specialRequirements"],
|
|
|
|
|
|
"updatedAt": _now(),
|
|
|
|
|
|
})
|
2026-07-23 13:38:43 +08:00
|
|
|
|
if next_status != before_status:
|
|
|
|
|
|
order.setdefault("changes", []).append({
|
|
|
|
|
|
"at": _now(), "action": "order.upsert",
|
|
|
|
|
|
"from": before_status, "to": next_status,
|
|
|
|
|
|
})
|
2026-07-21 11:05:57 +08:00
|
|
|
|
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"),
|
2026-07-23 13:38:43 +08:00
|
|
|
|
"status": "COMPLETED" if next_status == "COMPLETED"
|
|
|
|
|
|
else "CANCELLED" if next_status == "CANCELLED"
|
2026-07-21 11:05:57 +08:00
|
|
|
|
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}
|
|
|
|
|
|
|
2026-07-23 13:38:43 +08:00
|
|
|
|
# 批准/驳回支持 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],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 11:05:57 +08:00
|
|
|
|
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"]
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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}
|
2026-07-21 11:05:57 +08:00
|
|
|
|
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}
|
2026-07-23 13:38:43 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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}
|