185 lines
7.1 KiB
Python
185 lines
7.1 KiB
Python
# ============================================================
|
||
# 批次/战役合并(moduleId: domain-campaign, SC-08,可重生 ✅)
|
||
# 同产品 + 交期窗口内合并为一场战役:一次准备、累计数量、钉扎原销售单
|
||
# ============================================================
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime
|
||
from typing import Any
|
||
|
||
from server.aps_domain.changeover import product_family
|
||
from server.timeutil import parse_dt
|
||
|
||
World = dict[str, Any]
|
||
|
||
DEFAULT_CAMPAIGN_WINDOW_DAYS = 7
|
||
|
||
|
||
def _due(so: dict[str, Any]) -> datetime:
|
||
return parse_dt(str(so.get("deliveryDate") or "")[:10] + " 00:00")
|
||
|
||
|
||
def _can_join_campaign(entries: list[dict[str, Any]], candidate: dict[str, Any], window_days: int) -> bool:
|
||
"""候选能否并入当前战役:同产品已在组外校验;窗口看最早~最晚交期跨度。"""
|
||
dues = [_due(e["so"]) for e in entries] + [_due(candidate["so"])]
|
||
return (max(dues) - min(dues)).days <= window_days
|
||
|
||
|
||
def _build_campaign_entry(members: list[dict[str, Any]]) -> dict[str, Any]:
|
||
"""多订单项合成一个排产入口(数量相加;主单取最早交期)。"""
|
||
members = sorted(members, key=lambda e: (_due(e["so"]), e["so"].get("priority", 99), e["so"].get("orderNo") or ""))
|
||
primary = members[0]
|
||
item0 = primary["item"]
|
||
total_qty = sum(float(m["item"].get("quantity") or 0) for m in members)
|
||
sources = []
|
||
for m in members:
|
||
so, it = m["so"], m["item"]
|
||
sources.append({
|
||
"orderNo": so.get("orderNo"),
|
||
"salesOrderId": so.get("id"),
|
||
"itemId": it.get("id"),
|
||
"quantity": it.get("quantity"),
|
||
"deliveryDate": so.get("deliveryDate"),
|
||
"customerName": so.get("customerName"),
|
||
"priority": so.get("priority"),
|
||
})
|
||
merged_item = dict(item0)
|
||
merged_item["quantity"] = total_qty
|
||
# 虚拟明细 id:保留主项 id,附加战役标记
|
||
merged_item["campaignSources"] = sources
|
||
dues = [s["deliveryDate"] for s in sources]
|
||
return {
|
||
"so": primary["so"],
|
||
"item": merged_item,
|
||
"isCampaign": len(members) > 1,
|
||
"campaign": {
|
||
"productId": item0.get("productId"),
|
||
"productCode": item0.get("productCode"),
|
||
"productName": item0.get("productName"),
|
||
"memberCount": len(members),
|
||
"totalQty": total_qty,
|
||
"earliestDue": min(dues) if dues else None,
|
||
"latestDue": max(dues) if dues else None,
|
||
"sourceOrderNos": [s["orderNo"] for s in sources],
|
||
"windowDays": None, # 由调用方回填
|
||
},
|
||
}
|
||
|
||
|
||
def merge_entries_to_campaigns(
|
||
world: World,
|
||
entries: list[dict[str, Any]],
|
||
*,
|
||
window_days: int = DEFAULT_CAMPAIGN_WINDOW_DAYS,
|
||
) -> list[dict[str, Any]]:
|
||
"""
|
||
将待排入口按「同产品 + 交期窗口」合并。
|
||
插单 / 预测单独成战役,不与普通确定单合并。
|
||
"""
|
||
window_days = max(0, int(window_days))
|
||
singles: list[dict[str, Any]] = []
|
||
buckets: dict[int, list[dict[str, Any]]] = {}
|
||
|
||
for e in entries:
|
||
so = e["so"]
|
||
if so.get("isRush") or so.get("isForecast"):
|
||
singles.append(_build_campaign_entry([e]))
|
||
continue
|
||
pid = int(e["item"]["productId"])
|
||
buckets.setdefault(pid, []).append(e)
|
||
|
||
merged: list[dict[str, Any]] = list(singles)
|
||
for _pid, group in buckets.items():
|
||
group = sorted(group, key=lambda e: (_due(e["so"]), e["so"].get("priority", 99), e["so"].get("orderNo") or ""))
|
||
current: list[dict[str, Any]] = []
|
||
for e in group:
|
||
if not current:
|
||
current = [e]
|
||
continue
|
||
if _can_join_campaign(current, e, window_days):
|
||
current.append(e)
|
||
else:
|
||
camp = _build_campaign_entry(current)
|
||
camp["campaign"]["windowDays"] = window_days
|
||
merged.append(camp)
|
||
current = [e]
|
||
if current:
|
||
camp = _build_campaign_entry(current)
|
||
camp["campaign"]["windowDays"] = window_days
|
||
merged.append(camp)
|
||
|
||
# 按最早交期排出场顺序(再交给 CHANGEOVER / 其他策略二次排序亦可)
|
||
merged.sort(key=lambda e: (
|
||
1 if e["so"].get("isForecast") else 0,
|
||
0 if e["so"].get("isRush") else 1,
|
||
e["campaign"].get("earliestDue") or e["so"].get("deliveryDate") or "",
|
||
e["so"].get("priority", 99),
|
||
))
|
||
return merged
|
||
|
||
|
||
def preview_campaigns(
|
||
world: World,
|
||
*,
|
||
window_days: int = DEFAULT_CAMPAIGN_WINDOW_DAYS,
|
||
include_unapproved: bool = False,
|
||
include_forecast: bool = False,
|
||
) -> dict[str, Any]:
|
||
"""P0:预览战役合并方案(不写主干)。"""
|
||
from server.aps_domain.orders import is_schedulable
|
||
|
||
entries: list[dict[str, Any]] = []
|
||
for so in world.get("salesOrders") or []:
|
||
if so.get("status") in ("CANCELLED", "COMPLETED"):
|
||
continue
|
||
if not is_schedulable(so.get("status"), include_unapproved=include_unapproved):
|
||
continue
|
||
for item in so.get("items") or []:
|
||
if item.get("status") == "COMPLETED":
|
||
continue
|
||
entries.append({"so": so, "item": item})
|
||
if include_forecast:
|
||
from server.aps_domain.forecast import forecasts_as_schedule_entries
|
||
entries.extend(forecasts_as_schedule_entries(world))
|
||
|
||
before = len(entries)
|
||
camps = merge_entries_to_campaigns(world, entries, window_days=window_days)
|
||
rows = []
|
||
merged_orders = 0
|
||
for c in camps:
|
||
meta = c.get("campaign") or {}
|
||
members = meta.get("memberCount") or 1
|
||
if members > 1:
|
||
merged_orders += members
|
||
mat = next((m for m in world.get("materials", []) if m["id"] == meta.get("productId")), None)
|
||
rows.append({
|
||
"productCode": meta.get("productCode") or (c["item"].get("productCode")),
|
||
"productName": meta.get("productName") or (c["item"].get("productName")),
|
||
"productFamily": product_family(mat) if mat else None,
|
||
"memberCount": members,
|
||
"totalQty": meta.get("totalQty") or c["item"].get("quantity"),
|
||
"earliestDue": meta.get("earliestDue"),
|
||
"latestDue": meta.get("latestDue"),
|
||
"sourceOrderNos": meta.get("sourceOrderNos") or [c["so"].get("orderNo")],
|
||
"isCampaign": bool(c.get("isCampaign")),
|
||
"isRush": bool(c["so"].get("isRush")),
|
||
"isForecast": bool(c["so"].get("isForecast")),
|
||
})
|
||
saved_pos = before - len(camps)
|
||
return {
|
||
"windowDays": window_days,
|
||
"includeForecast": include_forecast,
|
||
"summary": {
|
||
"inputOrders": before,
|
||
"campaignCount": len(camps),
|
||
"mergedCampaigns": sum(1 for r in rows if r["isCampaign"]),
|
||
"ordersInMerged": merged_orders,
|
||
"poSaved": max(0, saved_pos),
|
||
},
|
||
"campaigns": rows,
|
||
"hint": (
|
||
f"交期窗口 {window_days} 天内同产品合并为一场战役;插单/预测单独保留。"
|
||
"试排请用策略 CAMPAIGN 或口令「战役合并试排」。"
|
||
),
|
||
}
|