aps-agent/server/aps_domain/campaign.py

185 lines
7.1 KiB
Python
Raw Permalink 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-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 或口令「战役合并试排」。"
),
}