aps-agent/server/importers/planning_workbook.py

492 lines
29 KiB
Python
Raw Normal View History

"""Complete planning workbooks: canonical business roles plus validated JSON adapters."""
from __future__ import annotations
import copy
import hashlib
import io
import math
from datetime import date, datetime
from typing import Any
from server.importers.workbook_profiles import ROLE_FIELDS, get_profile, select_profile
ROLE_KINDS = {role: "materials" if role == "products" else role for role in ROLE_FIELDS}
def _enum(profile: dict, enum: str, value: Any) -> Any:
key = _text(value)
aliases = profile["enums"][enum]
if key not in aliases:
raise ValueError(f"{enum}的值“{key}”没有配置标准映射")
return aliases[key]
def _list(profile: dict, value: Any) -> list[str]:
return [item.strip() for item in _text(value).split(profile["separators"]["list"]) if item.strip()]
def _canonical_row(role: str, raw: dict, profile: dict) -> dict:
"""Physical columns are resolved before this function; only roles occur here."""
row = dict(raw)
if role in ("sourceNotes", "planningParameters", "sandboxScenario"):
row["key"] = profile["metadataKeys"][role].get(_text(row["key"]), _text(row["key"]))
if role == "factoryResources":
row["resourceType"] = row["resourceKind"]
row["resourceKind"] = _enum(profile, "resourceKind", row["resourceKind"])
row["status"] = _enum(profile, "resourceStatus", row["status"])
row["timeZone"] = profile["planning"]["timeZone"]
row["isBottleneck"] = any(marker in _text(row["description"])
for marker in profile["provenance"]["bottleneckMarkers"])
elif role == "equipment":
row["status"] = _enum(profile, "equipmentStatus", row["status"])
row["availabilityRate"] = _number(row["availabilityRate"], "可用率", positive=True)
if row["availabilityRate"] > 1:
raise ValueError("可用率不能大于1")
row["capabilities"] = _list(profile, row["capabilities"])
if not row["capabilities"]:
raise ValueError("设备能力没有填写")
elif role == "personnel":
row["skills"] = _list(profile, row["skills"])
if not row["skills"] or row["skillLevel"] not in profile["planning"]["skillLevelOrder"]:
raise ValueError("人员技能缺失或技能等级未配置")
elif role == "calendar":
event = profile["enums"]["calendarEvent"].get(_text(row["eventCode"]))
if event == "MAINTENANCE":
start, end = _instant(row["start"], "维护开始"), _instant(row["end"], "维护结束")
if end <= start:
raise ValueError("维护结束必须晚于开始")
return {"entityType": "maintenance", "code": f"{row['workdaysOrEquipment']}:{start}",
"name": row["name"], "equipmentCode": row["workdaysOrEquipment"],
"start": start, "end": end, "reason": row["statusOrReason"]}
start, end = _text(row["start"]), _text(row["end"])
datetime.strptime(start, "%H:%M") # noqa: DTZ007 - source local shift clock
datetime.strptime(end, "%H:%M") # noqa: DTZ007 - source local shift clock
days = [int(day) for day in _list(profile, row["workdaysOrEquipment"])]
if not days or any(day not in range(1, 8) for day in days):
raise ValueError("工作日必须为1至7")
breaks = []
for interval in _list(profile, row["breaks"]):
bs, be = interval.split(profile["separators"]["interval"])
datetime.strptime(bs, "%H:%M") # noqa: DTZ007 - source local break clock
datetime.strptime(be, "%H:%M") # noqa: DTZ007 - source local break clock
breaks.append({"start": bs, "end": be})
return {"entityType": "shift", "shiftCode": row["eventCode"], "name": row["name"],
"startTime": start, "endTime": end, "breaks": breaks, "workdays": days,
"sourceStatus": row["statusOrReason"], "enabled": _enum(profile, "enabled", row["statusOrReason"])}
elif role in ("products", "materials"):
row["type"] = _enum(profile, "materialType", row["type"])
row["sourcingType"] = _enum(profile, "sourcingType", row["sourcingType"])
if not _text(row["unit"]):
raise ValueError("物料单位缺失")
for key in ("stock", "safetyStock", "procurementLeadTime"):
if key in row:
row[key] = _number(row[key], key)
elif role == "inventory":
# A snapshot never carries authority to replace the material name/unit.
row.pop("name")
for key in ("stock", "inTransit", "safetyStock", "procurementLeadTime"):
row[key] = _number(row[key], key)
row["expectedArrivalDate"] = _date(row["expectedArrivalDate"], "预计到货", optional=True)
row["inventorySource"] = profile["provenance"]["inventorySource"]
elif role == "bom":
row["quantity"] = _number(row["quantity"], "单套用量", positive=True)
row["lossRate"] = _number(row["lossRate"], "损耗率")
if row["lossRate"] >= 1:
raise ValueError("损耗率必须小于1")
row["isKey"] = _enum(profile, "yesNo", row["isKey"])
elif role == "routing":
seq = _number(row["seq"], "工序顺序", positive=True)
if seq != int(seq):
raise ValueError("工序顺序必须是整数")
row["seq"] = int(seq)
row["stdTimePerUnit"] = _number(row["stdTimePerUnit"], "标准工时", positive=True)
row["isExternal"] = _enum(profile, "yesNo", row["isExternal"])
row["stdTimeSource"] = "demo" if any(marker in _text(row["sourceText"])
for marker in profile["provenance"]["demoMarkers"]) else profile["provenance"]["defaultTimeSource"]
row["timeConfirmed"] = False
elif role == "partners":
row["partnerType"] = _enum(profile, "partnerType", row["partnerType"])
elif role == "orders":
row["orderType"] = _enum(profile, "orderType", row["orderType"])
row["sourceStatus"] = row["status"]
row["status"] = _enum(profile, "orderStatus", row["status"])
row["isSandbox"] = row["orderType"] == "SANDBOX"
expected_status = "PENDING_EVALUATION" if row["isSandbox"] else "RELEASED"
if row["status"] != expected_status:
raise ValueError("订单状态与正式/沙盒分类不一致")
row["quantity"] = _number(row["quantity"], "订单数量", positive=True)
row["priority"] = _number(row["priority"], "优先级", positive=True)
row["orderDate"] = _date(row["orderDate"], "订单日期")
row["dueDate"] = _date(row["dueDate"], "交期")
row["deliveryDate"] = row["dueDate"]
elif role == "wip":
row["sourceStatus"] = row["status"]
row["status"] = _enum(profile, "wipStatus", row["status"])
row["completedQuantity"] = _number(row["completedQuantity"], "完成数量")
row["completionTime"] = _instant(row["completionTime"], "实际/预计完成", optional=True)
row["completionTimeMeaning"] = "actual_or_expected_unspecified"
elif role == "sourceValidation":
row["verification"] = "source_reported_only"
return row
def _build_context(rows: dict, profile: dict, digest: str) -> dict:
notes = {r["key"]: r["value"] for r in rows["sourceNotes"]}
params = {r["key"]: r for r in rows["planningParameters"]}
data_date = _date(notes.get("dataDate"), "资料日期")
if not _text(notes.get("boundary")):
raise ValueError("缺少资料来源边界说明")
day_shifts = [r for r in rows["calendar"] if r["entityType"] == "shift" and r["enabled"]]
if not day_shifts:
raise ValueError("没有启用的班次,不能创建计划起点")
for key in ("horizonDays", "freezeHours", "nightShiftEnabled"):
if params[key]["delivery"] != params[key]["bottleneck"]:
raise ValueError(f"两种方案的{key}不同,需分别配置")
modes = profile["planning"]["sortModeAliases"]
strategies = [params["sortMode"][key] for key in ("delivery", "bottleneck")]
if any(strategy not in modes for strategy in strategies):
raise ValueError("资料策略没有配置标准排序模式")
return {"sourceProfile": profile["id"], "sourceSha256": digest, "profileDigest": profile["profileDigest"],
"capabilities": profile["capabilities"], "dataDate": data_date,
"planStart": f"{data_date}T{min(r['startTime'] for r in day_shifts)}:00",
"planStartSource": "data_date_and_first_enabled_shift",
"horizonDays": _number(params["horizonDays"]["delivery"], "计划周期", positive=True),
"freezeHours": _number(params["freezeHours"]["delivery"], "冻结窗口"),
"nightShiftEnabled": _enum(profile, "enabled", params["nightShiftEnabled"]["delivery"]),
"strategies": strategies, "defaultSortMode": modes[strategies[0]],
"comparisonMetrics": params["comparisonMetrics"]["delivery"],
"trialOnly": True, "boundary": notes["boundary"],
"timeZone": profile["planning"]["timeZone"], "skillLevelOrder": profile["planning"]["skillLevelOrder"],
"planningDefaultsSource": "profile-config",
"sourceRef": {"sha256": digest, "sheet": profile["sheets"]["planningParameters"]["name"],
"role": "planningParameters"}}
def preview_planning_workbook(filename: str, raw: bytes) -> dict | None:
import openpyxl
workbook = openpyxl.load_workbook(io.BytesIO(raw), read_only=True, data_only=True)
try:
physical_headers = {sheet.title: [_text(v) for v in next(sheet.values, ())] for sheet in workbook}
profile = select_profile(physical_headers)
if profile is None:
return None
digest = hashlib.sha256(raw).hexdigest()
batches, diagnostics = [], []
physical_roles = {spec["name"]: role for role, spec in profile["sheets"].items()}
def issue(sheet, row, message, code="INVALID_PROFILE_DATA", severity="blocking"):
role = physical_roles.get(sheet)
diagnostics.append({"sheet": sheet, "excelRow": row, "role": role,
"kind": ROLE_KINDS.get(role), "code": code, "severity": severity, "message": message})
for role, spec in profile["sheets"].items():
sheet, columns = spec["name"], spec["columns"]
batch = {"sheet": sheet, "physicalSheet": sheet, "role": role, "kind": ROLE_KINDS[role],
"sourceProfile": profile["id"], "profileDigest": profile["profileDigest"],
"capabilities": profile["capabilities"], "sourceFile": filename, "sourceSha256": digest,
"contractSheets": list(physical_roles), "contractRoles": list(ROLE_FIELDS),
"okRows": [], "headersRaw": list(columns.values()),
"fieldMap": [{"source": v, "target": k} for k, v in columns.items()]}
batches.append(batch)
if sheet not in physical_headers:
issue(sheet, None, f"缺少工作表“{sheet}”,整份资料暂不导入")
continue
headers = physical_headers[sheet]
required = list(columns.values())
if any(key not in headers for key in required) or len(headers) != len(set(headers)):
issue(sheet, 1, "必要列缺失或表头重复:" + "、".join(key for key in required if key not in headers))
continue
for number, line in enumerate(list(workbook[sheet].values)[1:], 2):
if all(v is None or _text(v) == "" for v in line):
continue
source = {key: _json_value(line[i]) if i < len(line) else None for i, key in enumerate(headers) if key}
try:
row = _canonical_row(role, {key: source[physical] for key, physical in columns.items()}, profile)
identity = next((row[k] for k in ("code", "orderNo", "taskNo", "productCode", "key", "shiftCode") if k in row), None)
if not _text(identity):
raise ValueError("业务编码或字段名缺失")
row.update(sourceProfile=profile["id"],
sourceRef={"sha256": digest, "sheet": sheet, "role": role, "excelRow": number},
sourceValues=source, provenance={"confirmation": "unconfirmed", "trialOnly": True})
batch["okRows"].append(row)
except (ValueError, TypeError, KeyError) as exc:
issue(sheet, number, str(exc))
for name in set(physical_headers) - set(physical_roles):
issue(name, None, f"工作表“{name}”未配置用途,不能宣称完整采用", "UNMAPPED_WORKSHEET")
by_role = {b["role"]: b["okRows"] for b in batches}
_validate_references(by_role, issue)
context = None
try:
context = _build_context(by_role, profile, digest)
except (ValueError, TypeError, KeyError) as exc:
issue(profile["sheets"]["planningParameters"]["name"], None, str(exc))
note = next((r for r in by_role["sourceNotes"] if r["key"] == "boundary"), {})
boundary = _text(note.get("value"))
issue(profile["sheets"]["sourceNotes"]["name"], note.get("sourceRef", {}).get("excelRow"),
boundary + ";本次只用于试排,工时、库存和订单仍需现场确认。", "UNCONFIRMED_DEMO_VALUES", "warning")
counts = _entity_counts(by_role)
blocking = sum(d["severity"] == "blocking" for d in diagnostics)
for batch in batches:
ds = [d for d in diagnostics if d["sheet"] == batch["sheet"]]
batch.update(diagnostics=ds, diagnosticCounts={s: sum(d["severity"] == s for d in ds)
for s in ("blocking", "warning", "ignored")}, errors=[d["message"] for d in ds if d["severity"] == "blocking"],
warnings=[d["message"] for d in ds if d["severity"] == "warning"], okCount=len(batch["okRows"]),
errorCount=sum(d["severity"] == "blocking" for d in ds), canCommit=blocking == 0)
batch["entityCount"] = counts[batch["role"]] if batch["role"] in ("orders", "calendar") else len(batch["okRows"])
return {"filename": filename, "profile": profile["id"], "profileDigest": profile["profileDigest"],
"capabilities": profile["capabilities"], "batches": batches, "planningContext": context,
"source": {"sha256": digest, "profileDigest": profile["profileDigest"],
"dataDate": (context or {}).get("dataDate"), "boundary": boundary, "trialOnly": True},
"entityCounts": counts, "totalOk": sum(b["okCount"] for b in batches), "totalErrors": blocking,
"canCommit": blocking == 0, "diagnostics": diagnostics, "diagnosticsVersion": 1,
"canSchedule": blocking == 0 and not any(d["code"] in ("NO_PERSONNEL_SKILL", "UNKNOWN_WIP_EQUIPMENT",
"UNCONFIRMED_WIP_HISTORY") for d in diagnostics),
"diagnosticCounts": {s: sum(d["severity"] == s for d in diagnostics) for s in ("blocking", "warning", "ignored")},
"sheetSummary": [{"physicalSheet": b["sheet"], "role": b["role"], "count": b["okCount"],
"usable": b["errorCount"] == 0, "unsupported": False, "needsReview": bool(b["diagnostics"])} for b in batches]}
finally:
workbook.close()
def apply_planning_batches(world: dict, batches: list[dict]) -> dict:
"""Only a reviewed complete snapshot with its current mapping can be adopted."""
ids = {b.get("sourceProfile") for b in batches}
if len(ids) != 1 or not next(iter(ids)):
raise ValueError("完整资料必须来自同一已注册格式")
profile = get_profile(next(iter(ids)))
sheets = {b.get("role"): b for b in batches}
if set(sheets) != set(ROLE_FIELDS) or len(batches) != len(ROLE_FIELDS):
raise ValueError(f"必须一次确认完整的{len(ROLE_FIELDS)}张资料表,不能拆分导入")
if any(b.get("profileDigest") != profile["profileDigest"] for b in batches):
raise ValueError("工作簿映射配置已变化或确认缺少配置版本,请重新检查并发起确认;本次未写入")
if any(b.get("physicalSheet") != profile["sheets"][b["role"]]["name"] for b in batches):
raise ValueError("确认批次的物理工作表与注册配置不一致")
if any(b.get("canCommit") is not True or b.get("errorCount", 0) for b in batches):
raise ValueError("资料检查未通过,整份工作簿没有写入")
hashes = {b.get("sourceSha256") for b in batches}
if len(hashes) != 1 or not next(iter(hashes)):
raise ValueError("资料表必须来自同一份已检查文件")
digest = next(iter(hashes))
adopted = [(s.get("profile"), s.get("sha256"), s.get("profileDigest")) for s in world.get("intakeSources", [])]
context = world.get("planningContext") or {}
if context.get("sourceProfile"):
adopted.append((context["sourceProfile"], context.get("sourceSha256") or (context.get("sourceRef") or {}).get("sha256"),
context.get("profileDigest")))
if any(sha and (pid != profile["id"] or sha != digest) for pid, sha, _ in adopted):
raise ValueError("当前项目已采用另一版本的完整排产资料。新版需在独立项目核对或经过差异审核后替换;本次未写入。")
if any(pid == profile["id"] and sha == digest and pd != profile["profileDigest"] for pid, sha, pd in adopted):
raise ValueError("已采用资料未记录当前映射版本或配置已变化,请先核对映射差异;原数据保持不变")
by_role = {role: copy.deepcopy(b["okRows"]) for role, b in sheets.items()}
counts = _entity_counts(by_role)
if any(pid == profile["id"] and sha == digest for pid, sha, _ in adopted):
return {"summary": counts, "total": 0, "unchanged": True, "sourceSha256": digest, "profile": profile["id"]}
candidate = copy.deepcopy(world)
tables = {"factoryResources": ("flexFactoryResources", ("code",)), "equipment": ("flexEquipment", ("code",)),
"personnel": ("flexPersonnel", ("code",)), "products": ("flexMaterials", ("code",)),
"materials": ("flexMaterials", ("code",)), "bom": ("flexBom", ("productCode", "materialCode")),
"routing": ("flexRoutings", ("productCode", "seq")), "partners": ("flexPartners", ("code",)),
"wip": ("flexWip", ("taskNo",))}
for role, (table, keys) in tables.items():
for row in by_role[role]:
_upsert(candidate, table, row, keys)
for row in by_role["inventory"]:
inventory = {k: v for k, v in row.items() if k not in ("sourceRef", "sourceValues", "provenance")}
inventory.update(inventorySourceRef=row["sourceRef"], inventorySourceValues=row["sourceValues"])
_upsert(candidate, "flexMaterials", inventory, ("code",))
for row in by_role["orders"]:
if row["isSandbox"] and any(o.get("orderNo") == row["orderNo"] for o in candidate.get("flexOrders", [])):
raise ValueError(f"{row['orderNo']}已存在正式订单,不能静默当作沙盒重复导入,请核对订单来源")
_upsert(candidate, "flexSandboxOrders" if row["isSandbox"] else "flexOrders", row, ("orderNo",))
for row in by_role["calendar"]:
maintenance = row["entityType"] == "maintenance"
_upsert(candidate, "flexMaintenance" if maintenance else "flexCalendar", row, ("code",) if maintenance else ("shiftCode",))
for row in by_role["factoryResources"]:
if row["resourceKind"] == "OPERATION":
_upsert(candidate, "flexOperations", row, ("code",))
elif row["resourceKind"] == "ZONE":
_upsert(candidate, "flexZones", row, ("code",))
groups = {}
for row in by_role["personnel"]:
groups.setdefault((row["teamName"], tuple(sorted(row["skills"])), row["shiftCode"]), []).append(row)
for (name, skills, shift), people in groups.items():
code = "TEAM-" + hashlib.sha256(f"{name}|{','.join(skills)}|{shift}".encode()).hexdigest()[:12]
_upsert(candidate, "flexTeams", {"code": code, "name": name, "supportOps": list(skills), "memberCount": len(people),
"personCodes": [p["code"] for p in people], "shiftCode": shift, "capacitySource": "distinct_personnel",
"sourceProfile": profile["id"]}, ("code",))
candidate["planningContext"] = _build_context(by_role, profile, digest)
scenario = {r["key"]: r["value"] for r in by_role["sandboxScenario"]}
if scenario:
_upsert(candidate, "flexScenarios", {**scenario, "sourceProfile": profile["id"],
"status": "PENDING_EVALUATION", "sourceSha256": digest}, ("orderNo",))
candidate.setdefault("flexParams", {}).update(siteProfile=profile["id"], demoDataCleared=True)
from server.aps_domain.orders import sync_flex_orders_to_sales
from server.importers.sql_pack import sync_flex_to_classic_master
sync_flex_to_classic_master(candidate)
sync_flex_orders_to_sales(candidate)
diagnostics = [copy.deepcopy(d) for b in batches for d in b.get("diagnostics", [])]
candidate.setdefault("intakeSources", []).append({"profile": profile["id"], "profileDigest": profile["profileDigest"],
"capabilities": profile["capabilities"], "sha256": digest, "filename": batches[0].get("sourceFile"),
"dataDate": candidate["planningContext"]["dataDate"], "trialOnly": True, "entityCounts": counts,
"diagnostics": diagnostics, "notes": by_role["sourceNotes"], "parameters": by_role["planningParameters"],
"sourceValidation": by_role["sourceValidation"]})
world.clear()
world.update(candidate)
return {"summary": counts, "total": sum(counts.values()), "profile": profile["id"],
"unchanged": False, "sourceSha256": digest, "profileDigest": profile["profileDigest"], "diagnostics": diagnostics}
def _text(value: Any) -> str:
return str(value).strip() if value is not None else ""
def _number(value: Any, label: str, *, positive: bool = False) -> float:
if value is None or isinstance(value, bool):
raise ValueError(f"{label}缺失或不是数字")
try:
number = float(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{label}不是数字") from exc
if not math.isfinite(number) or number < 0 or (positive and number == 0):
raise ValueError(f"{label}必须是{'正' if positive else '非负'}有限数字")
return number
def _date(value: Any, label: str, *, optional: bool = False) -> str | None:
if not _text(value) and optional:
return None
try:
return datetime.fromisoformat(_text(value)).date().isoformat()
except ValueError as exc:
raise ValueError(f"{label}不是有效日期") from exc
def _instant(value: Any, label: str, *, optional: bool = False) -> str | None:
if not _text(value) and optional:
return None
try:
return datetime.fromisoformat(_text(value)).isoformat(timespec="seconds")
except ValueError as exc:
raise ValueError(f"{label}不是有效日期时间") from exc
def _json_value(value: Any) -> Any:
return value.isoformat() if isinstance(value, (date, datetime)) else value
def _iso_date(value: Any) -> str:
"""日期字段归一化:Excel 日期单元格序列化成 ISO 时间戳时仍按日期比较。"""
if isinstance(value, datetime):
return value.date().isoformat()
if isinstance(value, date):
return value.isoformat()
text = str(value or "").strip()
return text[:10] if ("T" in text or " " in text) else text
def _validate_references(sheets: dict, issue) -> None:
def rows(name):
return sheets[name]
def warn(row, message, code, severity="blocking"):
ref = row["sourceRef"]
issue(ref["sheet"], ref["excelRow"], message, code, severity)
unique_keys = {"factoryResources": ("code",), "equipment": ("code",), "personnel": ("code",),
"products": ("code",), "materials": ("code",), "bom": ("productCode", "materialCode"),
"routing": ("productCode", "seq"), "inventory": ("code",),
"orders": ("orderNo",), "wip": ("taskNo",),
"planningParameters": ("key",), "sandboxScenario": ("key",), "sourceNotes": ("key",)}
for sheet, keys in unique_keys.items():
seen = set()
for row in rows(sheet):
key = tuple(row[k] for k in keys)
if key in seen:
warn(row, f"重复业务编码:{key}", "DUPLICATE_BUSINESS_KEY")
seen.add(key)
materials = {r["code"] for s in ("products", "materials") for r in rows(s)}
products = {r["code"] for r in rows("products")}
equipment = {r["code"] for r in rows("equipment")}
orders = {r["orderNo"]: r for r in rows("orders")}
for row in rows("materials"):
if row["code"] in products:
warn(row, "同一编码同时出现在产品和物料档案", "DUPLICATE_MATERIAL")
for sheet in ("bom", "routing", "orders"):
for row in rows(sheet):
if row["productCode"] not in products:
warn(row, f"产品{row['productCode']}没有档案", "UNKNOWN_PRODUCT")
for row in rows("bom"):
if row["materialCode"] not in materials:
warn(row, f"材料{row['materialCode']}没有档案", "UNKNOWN_MATERIAL")
for row in rows("inventory"):
if row["code"] not in materials:
warn(row, f"库存材料{row['code']}没有档案", "UNKNOWN_MATERIAL")
if row["inTransit"] > 0 and not row["expectedArrivalDate"]:
warn(row, "有在途数量,但没有预计到货日期;排产不能假定已经到货", "UNKNOWN_ARRIVAL", "warning")
skills = {skill for row in rows("personnel") for skill in row["skills"]}
for row in rows("routing"):
if row["operationCode"] not in skills and not row["isExternal"]:
warn(row, f"{row['operationName']}没有对应技能人员,需核对才能安排", "NO_PERSONNEL_SKILL", "warning")
for row in rows("wip"):
candidates = [r for r in rows("routing") if r["productCode"] == row["productCode"]
and r["operationName"] == row["operationName"]]
if len(candidates) != 1:
warn(row, "在制任务的工序不能唯一关联到产品工艺", "INVALID_WIP_OPERATION")
else:
row.update(operationCode=candidates[0]["operationCode"], seq=candidates[0]["seq"])
order = orders.get(row["orderNo"])
if not order or order["productCode"] != row["productCode"] or order["isSandbox"]:
warn(row, "在制任务不能关联到对应正式订单", "INVALID_WIP_ORDER")
elif row["completedQuantity"] > order["quantity"]:
warn(row, "在制完成数量超过订单数量", "INVALID_WIP_QUANTITY")
if row["equipmentCode"] not in equipment:
warn(row, f"在制任务引用设备{row['equipmentCode']},设备档案未提供,需核对",
"UNKNOWN_WIP_EQUIPMENT", "warning")
if row["status"] == "RUNNING":
row.update(expectedEnd=row["completionTime"], endTimeSource="status_and_source_completion_column")
warn(row, "执行中任务按原表时间保留预计结束;实际开工和前序完成事实仍需确认",
"UNCONFIRMED_WIP_HISTORY", "warning")
elif row["status"] == "DONE":
row.update(actualEnd=row["completionTime"], endTimeSource="status_and_source_completion_column")
scenario = {r["key"]: r["value"] for r in rows("sandboxScenario")}
if rows("sandboxScenario"):
order = orders.get(scenario.get("orderNo"))
if not order or not order["isSandbox"]:
warn(rows("sandboxScenario")[0], "插单场景必须关联到一张沙盒订单", "INVALID_SANDBOX")
else:
for key in ("productCode", "dueDate", "quantity", "priority", "customerName", "customerLevel"):
value = scenario.get(key)
try:
if key in ("quantity", "priority"):
equal = float(value) == float(order[key])
elif key == "dueDate":
equal = _iso_date(value) == _iso_date(order[key])
else:
equal = value == order[key]
except (ValueError, TypeError):
equal = False
if not equal:
warn(rows("sandboxScenario")[0], f"插单场景的{key}与销售订单不一致", "SANDBOX_CONFLICT")
def _entity_counts(sheets: dict) -> dict:
return {"materials": len({r["code"] for s in ("products", "materials") for r in sheets[s]}),
"equipment": len(sheets["equipment"]), "bom": len(sheets["bom"]), "routing": len(sheets["routing"]),
"orders": sum(not r["isSandbox"] for r in sheets["orders"]),
"sandboxOrders": sum(r["isSandbox"] for r in sheets["orders"]),
"personnel": len(sheets["personnel"]), "wip": len(sheets["wip"]),
"calendar": sum(r["entityType"] == "shift" for r in sheets["calendar"]),
"maintenance": sum(r["entityType"] == "maintenance" for r in sheets["calendar"]),
"inventory": len(sheets["inventory"])}
def _upsert(world: dict, table: str, row: dict, keys: tuple[str, ...]) -> dict:
values = world.setdefault(table, [])
existing = next((r for r in values if all(r.get(k) == row.get(k) for k in keys)), None)
if existing is None:
identifier = max((r.get("id", 0) for r in values if isinstance(r.get("id"), int)), default=0) + 1
existing = {"id": identifier}
values.append(existing)
existing.update(copy.deepcopy(row))
return existing