"""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