aps-agent/server/aps_domain/campaign.py

185 lines
7.1 KiB
Python
Raw Normal View History

# ============================================================
# 批次/战役合并(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 或口令「战役合并试排」。"
),
}