1053 lines
46 KiB
Python
1053 lines
46 KiB
Python
# ============================================================
|
||
# 工程目录数据包分析(moduleId: domain-folder-pack, 可重生 ✅)
|
||
# 读取项目 workDir 下 Excel/CSV:表头/行数/样例/角色 → 排产齐备度校验
|
||
# ============================================================
|
||
from __future__ import annotations
|
||
|
||
import copy
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import re
|
||
import tempfile
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from server.aps_domain.importers import preview_file
|
||
from server.aps_domain.readiness import check_readiness
|
||
|
||
World = dict[str, Any]
|
||
|
||
KIND_CN = {
|
||
"orders": "订单", "materials": "物料", "bom": "BOM 用料",
|
||
"routing": "工艺路线/工时", "equipment": "设备", "molds": "模具",
|
||
"operations": "工序库", "zones": "区域",
|
||
"calendar": "班次和维护", "inventory": "库存与在途", "personnel": "人员技能",
|
||
"factoryResources": "工厂资料", "wip": "在制任务", "partners": "客户供应商",
|
||
"planningParameters": "排产设置", "sandboxScenario": "待评估插单",
|
||
"sourceNotes": "资料来源", "sourceValidation": "原表核对记录",
|
||
}
|
||
|
||
_FIELD_CN = {
|
||
"productCode": "产品/料号", "quantity": "数量", "deliveryDate": "交期",
|
||
"customerName": "客户/项目", "orderNo": "订单号", "code": "编码",
|
||
"name": "名称", "operationCode": "工序", "seq": "顺序",
|
||
"stdTimePerUnit": "工时", "capabilities": "可执行工序",
|
||
"materialCode": "子件", "unit": "单位",
|
||
}
|
||
|
||
_NAME_KIND = [
|
||
(r"订单|order", "orders"),
|
||
(r"物料|材料|material", "materials"),
|
||
(r"^bom|用料|物料清单", "bom"),
|
||
(r"工艺|路线|routing|工时", "routing"),
|
||
(r"设备|机器|equipment", "equipment"),
|
||
(r"模具|mold", "molds"),
|
||
(r"工序", "operations"),
|
||
(r"区域|zone", "zones"),
|
||
]
|
||
|
||
_REQUIRED = ("orders", "materials", "routing", "equipment")
|
||
_HELPFUL = ("bom", "molds", "operations")
|
||
_FOLDER_SOURCE_EXTS = frozenset({".xlsx", ".xlsm", ".csv", ".txt", ".sql"})
|
||
_KANGNI_WORKBOOKS = (
|
||
"订单.xlsx",
|
||
"工艺路线.xlsx",
|
||
"工时.xlsx",
|
||
"BOM.xlsx",
|
||
"设备.xlsx",
|
||
"模具.xlsx",
|
||
"物料.xlsx",
|
||
"设备能力映射模板.xlsx",
|
||
"模具适配映射模板.xlsx",
|
||
)
|
||
|
||
# 锐扬 MOM 收集表等现场模板中的非排产块:不导入,其问题行不计为数据错误
|
||
_MOM_SKIP_BLOCKS = (
|
||
"生产模型", "质检方案", "仓储模型", "人力资源", "客户管理", "供应商管理",
|
||
"数据说明", "工厂资源", "人员技能", "客户供应商", "在制任务",
|
||
"排产参数", "插单场景", "数据校验",
|
||
)
|
||
|
||
|
||
def _is_mom_skip_block(sheet: str | None) -> bool:
|
||
s = str(sheet or "")
|
||
return any(b in s for b in _MOM_SKIP_BLOCKS)
|
||
|
||
|
||
def _guess_kind_by_name(name: str) -> str | None:
|
||
stem = re.sub(r"\.(xlsx|xlsm|csv|txt)$", "", name, flags=re.I)
|
||
for pat, kind in _NAME_KIND:
|
||
if re.search(pat, stem, re.I):
|
||
return kind
|
||
return None
|
||
|
||
|
||
def _project_work_dir(session_id: str | None) -> tuple[dict[str, Any] | None, str]:
|
||
try:
|
||
from server.state.projects import get_project_store
|
||
snap = get_project_store().snapshot(include_messages=False)
|
||
except Exception:
|
||
return None, ""
|
||
sessions = {s["id"]: s for s in (snap.get("sessions") or []) if isinstance(s, dict)}
|
||
projects = {p["id"]: p for p in (snap.get("projects") or []) if isinstance(p, dict)}
|
||
sess = sessions.get(session_id or "") or {}
|
||
proj = projects.get(sess.get("projectId") or "") if sess.get("projectId") else None
|
||
work = (proj.get("workDir") or "").strip() if proj else ""
|
||
return proj, work
|
||
|
||
|
||
def project_source_summary(session_id: str | None) -> dict[str, Any]:
|
||
"""当前项目的工程目录与可读数据文件清单(只读取目录名,不解析内容)。"""
|
||
project, work_dir = _project_work_dir(session_id)
|
||
if not project:
|
||
return {}
|
||
names: list[str] = []
|
||
if work_dir and os.path.isdir(work_dir):
|
||
try:
|
||
names = sorted(
|
||
name for name in os.listdir(work_dir)
|
||
if re.search(r"\.(xlsx|xlsm|xls|csv|txt|sql)$", name, re.IGNORECASE)
|
||
and not name.startswith(("~$", "."))
|
||
)
|
||
except OSError:
|
||
names = []
|
||
return {"name": project.get("name") or "", "workDir": work_dir, "files": names}
|
||
|
||
|
||
def _sample_cells(row: dict[str, Any], limit: int = 5) -> str:
|
||
parts = []
|
||
for k, v in list(row.items())[:limit]:
|
||
if v is None or v == "":
|
||
continue
|
||
parts.append(f"{k}={v}")
|
||
return ",".join(parts)
|
||
|
||
|
||
def _fmt_size(n: int) -> str:
|
||
if n < 1024:
|
||
return f"{n} B"
|
||
if n < 1024 * 1024:
|
||
return f"{n / 1024:.1f} KB"
|
||
return f"{n / (1024 * 1024):.1f} MB"
|
||
|
||
|
||
def _fmt_field_map(fmap: list[dict[str, str]]) -> str:
|
||
if not fmap:
|
||
return ""
|
||
return ";".join(
|
||
f"{m.get('source')}→{_FIELD_CN.get(m.get('target') or '', m.get('target'))}"
|
||
for m in fmap[:6]
|
||
)
|
||
|
||
|
||
def read_folder_source_snapshots(
|
||
work_dir: str,
|
||
) -> tuple[list[dict[str, Any]], dict[str, bytes]]:
|
||
"""Read each source once; parsing and manifests must share these immutable bytes."""
|
||
root = Path(work_dir).expanduser().resolve(strict=True)
|
||
if not root.is_dir():
|
||
raise PermissionError("工程目录不可访问,请重新选择项目目录")
|
||
manifest: list[dict[str, Any]] = []
|
||
snapshots: dict[str, bytes] = {}
|
||
for child in sorted(root.iterdir(), key=lambda item: item.name.lower()):
|
||
if child.name.startswith(("~$", ".")) or child.suffix.lower() not in _FOLDER_SOURCE_EXTS:
|
||
continue
|
||
resolved = child.resolve(strict=True)
|
||
try:
|
||
relative = resolved.relative_to(root)
|
||
except ValueError as exc:
|
||
raise PermissionError(f"工程目录源文件越界:{child.name}") from exc
|
||
if not resolved.is_file():
|
||
continue
|
||
before = resolved.stat()
|
||
raw = resolved.read_bytes()
|
||
after = resolved.stat()
|
||
if (before.st_size, before.st_mtime_ns) != (after.st_size, after.st_mtime_ns):
|
||
raise PermissionError(f"工程目录源文件正在变化:{child.name}")
|
||
relative_name = relative.as_posix()
|
||
snapshots[relative_name] = raw
|
||
manifest.append({
|
||
"name": relative_name,
|
||
"sizeBytes": len(raw),
|
||
"sha256": hashlib.sha256(raw).hexdigest(),
|
||
})
|
||
return manifest, snapshots
|
||
|
||
|
||
def folder_source_manifest(work_dir: str) -> list[dict[str, Any]]:
|
||
manifest, _snapshots = read_folder_source_snapshots(work_dir)
|
||
return manifest
|
||
|
||
|
||
def _source_bytes(
|
||
path: str,
|
||
name: str,
|
||
source_snapshots: dict[str, bytes] | None,
|
||
) -> bytes:
|
||
if source_snapshots is not None:
|
||
if name not in source_snapshots:
|
||
raise PermissionError(f"工程目录冻结源缺少文件:{name}")
|
||
return source_snapshots[name]
|
||
with open(path, "rb") as handle:
|
||
return handle.read()
|
||
|
||
|
||
def _preview_sql_bytes(raw: bytes) -> dict[str, Any]:
|
||
from server.importers.sql_pack import preview_sql_file
|
||
|
||
fd, temporary = tempfile.mkstemp(suffix=".sql")
|
||
try:
|
||
with os.fdopen(fd, "wb") as handle:
|
||
handle.write(raw)
|
||
return preview_sql_file(temporary)
|
||
finally:
|
||
if os.path.exists(temporary):
|
||
os.unlink(temporary)
|
||
|
||
|
||
def folder_source_manifest_digest(manifest: list[dict[str, Any]]) -> str:
|
||
payload = json.dumps(
|
||
manifest,
|
||
ensure_ascii=True,
|
||
sort_keys=True,
|
||
separators=(",", ":"),
|
||
).encode("utf-8")
|
||
return hashlib.sha256(payload).hexdigest()
|
||
|
||
|
||
def folder_schedule_world_fingerprint(world: World) -> str:
|
||
"""Normalize one lazy empty context container without hiding real policy changes."""
|
||
from server.agent_core.harness import world_fingerprint
|
||
|
||
normalized = copy.deepcopy(world)
|
||
if normalized.get("contextPolicies") is None:
|
||
normalized["contextPolicies"] = {}
|
||
return world_fingerprint(normalized)
|
||
|
||
|
||
def _contains_materialized_path(value: Any, root: Path) -> bool:
|
||
if isinstance(value, dict):
|
||
return any(_contains_materialized_path(item, root) for item in value.values())
|
||
if isinstance(value, list):
|
||
return any(_contains_materialized_path(item, root) for item in value)
|
||
if not isinstance(value, str):
|
||
return False
|
||
root_text = str(root.resolve()).casefold()
|
||
return root_text in value.casefold()
|
||
|
||
|
||
def _build_kangni_payload_from_snapshots(
|
||
source_snapshots: dict[str, bytes],
|
||
) -> dict[str, Any] | None:
|
||
"""Build the site payload only from the nine immutable uploaded workbook bytes."""
|
||
by_name: dict[str, tuple[str, bytes]] = {}
|
||
for relative_name, raw in source_snapshots.items():
|
||
filename = Path(relative_name).name
|
||
folded = filename.casefold()
|
||
if folded in by_name:
|
||
raise PermissionError(f"康尼数据包存在重名工作簿:{filename}")
|
||
by_name[folded] = (filename, raw)
|
||
required = {name.casefold(): name for name in _KANGNI_WORKBOOKS}
|
||
if not all(folded in by_name for folded in required):
|
||
return None
|
||
|
||
from server.aps_domain.kangni_intake import build_site_payload_from_data_dir
|
||
|
||
with tempfile.TemporaryDirectory(prefix="aps-kangni-folder-") as temporary:
|
||
materialized = Path(temporary)
|
||
for folded, stable_name in required.items():
|
||
_source_name, raw = by_name[folded]
|
||
(materialized / stable_name).write_bytes(raw)
|
||
payload = build_site_payload_from_data_dir(materialized)
|
||
try:
|
||
canonical = json.dumps(
|
||
payload,
|
||
ensure_ascii=False,
|
||
sort_keys=True,
|
||
separators=(",", ":"),
|
||
allow_nan=False,
|
||
)
|
||
except (TypeError, ValueError) as exc:
|
||
raise ValueError("康尼冻结载荷不是纯 JSON,拒绝创建确认卡") from exc
|
||
normalized = json.loads(canonical)
|
||
if _contains_materialized_path(normalized, materialized):
|
||
raise ValueError("康尼冻结载荷包含临时目录路径,拒绝创建长期证据")
|
||
|
||
if not isinstance(normalized, dict):
|
||
raise ValueError("康尼冻结载荷格式无效")
|
||
fixed = normalized.get("fixed")
|
||
flex = normalized.get("flex")
|
||
meta = normalized.get("meta")
|
||
if not isinstance(fixed, dict) or not isinstance(flex, dict) or not isinstance(meta, dict):
|
||
raise ValueError("康尼冻结载荷必须包含 fixed、flex、meta")
|
||
if int(meta.get("payloadSchemaVersion") or 0) != 1:
|
||
raise ValueError("康尼冻结载荷版本不受支持")
|
||
if meta.get("sourceMode") != "data-dir-only":
|
||
raise ValueError("康尼冻结载荷来源模式无效")
|
||
if int(meta.get("sourceWorkbookCount") or 0) != len(_KANGNI_WORKBOOKS):
|
||
raise ValueError("康尼冻结载荷未绑定完整 9 份工作簿")
|
||
return normalized
|
||
|
||
|
||
def folder_schedule_payload_digest(params: dict[str, Any]) -> str:
|
||
projection = {
|
||
key: value
|
||
for key, value in params.items()
|
||
if key != "folderPayloadDigest"
|
||
}
|
||
try:
|
||
payload = json.dumps(
|
||
projection,
|
||
ensure_ascii=True,
|
||
sort_keys=True,
|
||
separators=(",", ":"),
|
||
allow_nan=False,
|
||
).encode("utf-8")
|
||
except (TypeError, ValueError) as exc:
|
||
raise ValueError("工程目录冻结载荷不是纯 JSON") from exc
|
||
return hashlib.sha256(payload).hexdigest()
|
||
|
||
|
||
|
||
def _discover_drawing_files(world: World, work: str, project_id: str | None) -> list[dict[str, Any]]:
|
||
"""发现并解析工程目录中的 DXF;失败仅记录,不影响表格齐备度。"""
|
||
names = sorted(
|
||
n for n in os.listdir(work)
|
||
if n.lower().endswith(".dxf") and not n.startswith("~$") and not n.startswith(".")
|
||
)
|
||
reports: list[dict[str, Any]] = []
|
||
assets = world.setdefault("drawingAssets", [])
|
||
candidates_store = world.setdefault("drawingCandidates", [])
|
||
runs = world.setdefault("drawingParseRuns", [])
|
||
for name in names:
|
||
path = os.path.join(work, name)
|
||
try:
|
||
from server.aps_domain.drawing_dxf import inspect_dxf, build_drawing_master_candidates
|
||
parsed = inspect_dxf(path)
|
||
asset = dict(parsed.get("asset") or {})
|
||
asset["projectId"] = project_id
|
||
asset["status"] = "PARSED"
|
||
asset["parsedAt"] = parsed.get("parsedAt")
|
||
asset["parsed"] = parsed
|
||
change_set = build_drawing_master_candidates(parsed)
|
||
change_set["drawingAssetId"] = asset.get("id")
|
||
change_set["projectId"] = project_id
|
||
assets[:] = [row for row in assets if row.get("id") != asset.get("id")]
|
||
assets.append(asset)
|
||
candidates_store[:] = [row for row in candidates_store if row.get("drawingAssetId") != asset.get("id")]
|
||
candidates_store.append(change_set)
|
||
runs.append({
|
||
"drawingAssetId": asset.get("id"), "projectId": project_id,
|
||
"sourcePath": path, "status": "SUCCEEDED", "parsedAt": parsed.get("parsedAt"),
|
||
"entityCount": (parsed.get("drawing") or {}).get("modelspaceEntityCount", 0),
|
||
})
|
||
reports.append({
|
||
"id": asset.get("id"), "name": name, "path": path, "kind": "drawing",
|
||
"kindCn": "DXF 图纸", "status": "ok", "okCount": 0, "errorCount": 0,
|
||
"required": False, "drawing": parsed.get("drawing") or {},
|
||
"candidateCounts": {
|
||
"materials": len(change_set.get("materials") or []),
|
||
"bomReferences": len(change_set.get("bomReferences") or []),
|
||
"routingOperations": len(change_set.get("routingOperations") or []),
|
||
},
|
||
"issue": "图纸已解析为待工程审核候选;不计入排产必备覆盖。",
|
||
})
|
||
except Exception as exc: # noqa: BLE001 - 图纸故障不能阻断表格解析
|
||
runs.append({"projectId": project_id, "sourcePath": path, "status": "FAILED", "error": str(exc)})
|
||
reports.append({
|
||
"name": name, "path": path, "kind": "drawing", "kindCn": "DXF 图纸",
|
||
"status": "fail", "okCount": 0, "errorCount": 0, "required": False,
|
||
"errors": [str(exc)], "issue": f"图纸解析失败(不影响表格排产):{exc}",
|
||
})
|
||
return reports
|
||
|
||
def analyze_work_dir(
|
||
world: World,
|
||
session_id: str | None = None,
|
||
*,
|
||
force_sql_replace: bool = False,
|
||
source_snapshots: dict[str, bytes] | None = None,
|
||
apply_sql: bool = True,
|
||
) -> dict[str, Any]:
|
||
"""深度解析工程目录:每张表读内容 + 动态字段映射 + 汇总能否开排。
|
||
|
||
force_sql_replace=True 时(「根据这个排产」)强制用 SQL 包整表覆盖项目世界,
|
||
避免旧世界残留错误工艺映射后永远不更新。apply_sql=False 时仅解析 SQL
|
||
预览并返回 sqlStats/sqlPreview,不把 SQL 写进项目世界。
|
||
"""
|
||
if apply_sql:
|
||
proj, work = _project_work_dir(session_id)
|
||
else:
|
||
from server.aps_domain.project_analyze import _project_ctx_readonly
|
||
|
||
proj, work = _project_ctx_readonly(session_id)
|
||
if not proj:
|
||
return {
|
||
"ok": False, "error": "当前话题不在工程项目下,请先在左侧选一个项目。",
|
||
"files": [], "drawings": [], "coverage": {}, "missing": list(_REQUIRED),
|
||
"canSchedule": False, "batches": [], "markdown":
|
||
"当前话题不在工程项目下。请先在左侧选一个项目,或建项目时指定工程目录。",
|
||
}
|
||
if not work:
|
||
return {
|
||
"ok": False, "error": "这个项目还没设定工程目录。",
|
||
"projectName": proj.get("name"), "files": [], "drawings": [], "coverage": {},
|
||
"missing": list(_REQUIRED), "canSchedule": False, "batches": [],
|
||
"markdown": "这个项目还没设定工程目录。建项目时选一个放 Excel 的文件夹。",
|
||
}
|
||
if not os.path.isdir(work):
|
||
return {
|
||
"ok": False, "error": f"工程目录访问不到:{work}",
|
||
"projectName": proj.get("name"), "workDir": work,
|
||
"files": [], "drawings": [], "coverage": {}, "missing": list(_REQUIRED),
|
||
"canSchedule": False, "batches": [],
|
||
"markdown": f"工程目录访问不到:`{work}`(路径变了或盘符未挂上)。",
|
||
}
|
||
|
||
names = sorted(os.listdir(work))
|
||
drawing_reports = _discover_drawing_files(
|
||
world if apply_sql else copy.deepcopy(world),
|
||
work,
|
||
str(proj.get("id") or "") or None,
|
||
)
|
||
table_names = [n for n in names if re.search(r"\.(xlsx|xlsm|csv|txt)$", n, re.I)
|
||
and not n.startswith("~$") and not n.startswith(".")]
|
||
sql_names = [n for n in names if n.lower().endswith(".sql") and not n.startswith("~$")]
|
||
|
||
file_reports: list[dict[str, Any]] = []
|
||
all_batches: list[dict[str, Any]] = []
|
||
all_diagnostics: list[dict[str, Any]] = []
|
||
kind_ok: dict[str, int] = {}
|
||
kind_fields: dict[str, list[dict[str, str]]] = {}
|
||
|
||
# SQL 完整库优先:映射进当前世界并计入覆盖度(只取最大的一份完整库)
|
||
sql_applied = False
|
||
sql_stats: dict[str, Any] = {}
|
||
sql_preview: dict[str, Any] | None = None
|
||
if sql_names:
|
||
sql_names = sorted(
|
||
sql_names,
|
||
key=lambda n: os.path.getsize(os.path.join(work, n)) if os.path.isfile(os.path.join(work, n)) else 0,
|
||
reverse=True,
|
||
)
|
||
for name in sql_names[:1]:
|
||
path = os.path.join(work, name)
|
||
try:
|
||
size = (
|
||
len(source_snapshots[name])
|
||
if source_snapshots is not None and name in source_snapshots
|
||
else os.path.getsize(path)
|
||
)
|
||
except OSError:
|
||
size = 0
|
||
try:
|
||
from server.importers.sql_pack import apply_sql_pack_to_world
|
||
raw = _source_bytes(path, name, source_snapshots)
|
||
prev = _preview_sql_bytes(raw)
|
||
sql_preview = {**prev, "filename": name}
|
||
flex = prev.get("flex") or {}
|
||
st = (flex.get("stats") or {})
|
||
sql_stats = st
|
||
if apply_sql and (st.get("orders") or st.get("materials") or st.get("routing")):
|
||
# 工程目录里的 SQL = 项目真相源,整包覆盖(避免旧错误工艺残留)
|
||
apply_sql_pack_to_world(world, flex, replace=True)
|
||
sql_applied = True
|
||
for k, key in (("orders", "orders"), ("materials", "materials"),
|
||
("routing", "routing"), ("equipment", "equipment"), ("bom", "bom")):
|
||
n_ok = int(st.get(key) or 0)
|
||
if n_ok:
|
||
kind_ok[k] = kind_ok.get(k, 0) + n_ok
|
||
file_reports.append({
|
||
"name": name, "size": size, "status": "ok" if prev.get("okCount") else "warn",
|
||
"kind": "sql-pack", "kindCn": "SQL 数据包",
|
||
"okCount": prev.get("okCount") or 0, "errorCount": 0,
|
||
"errors": [], "warnings": [],
|
||
"headers": [t.get("table") for t in (prev.get("tables") or [])[:8]],
|
||
"samples": prev.get("samples") or [],
|
||
"sheets": prev.get("tables") or [],
|
||
"required": True,
|
||
"fieldMap": [], "fieldMapText": prev.get("fieldMapText") or "",
|
||
"issue": prev.get("fieldMapText") or (
|
||
f"SQL→订单{st.get('orders', 0)}/工艺{st.get('routing', 0)}"
|
||
f"/已挂工艺产品{st.get('routedProducts', 0)}"
|
||
),
|
||
})
|
||
except Exception as exc: # noqa: BLE001
|
||
file_reports.append({
|
||
"name": name, "size": size, "status": "fail",
|
||
"kind": "sql-pack", "kindCn": "SQL 数据包",
|
||
"okCount": 0, "errorCount": 1, "errors": [str(exc)],
|
||
"headers": [], "samples": [], "sheets": [], "required": True,
|
||
"fieldMap": [], "fieldMapText": "", "issue": str(exc),
|
||
})
|
||
|
||
# 第一遍:先读订单,建立 订单号→料号 映射,供工艺/BOM 的 sheet 名反查
|
||
product_by_order: dict[str, str] = {}
|
||
for name in table_names:
|
||
if _guess_kind_by_name(name) != "orders":
|
||
continue
|
||
path = os.path.join(work, name)
|
||
try:
|
||
raw = _source_bytes(path, name, source_snapshots)
|
||
preview = preview_file(name, raw, world, soft=True, kind_hint="orders")
|
||
except Exception:
|
||
continue
|
||
for b in preview.get("batches") or []:
|
||
for row in b.get("okRows") or []:
|
||
ono = str(row.get("orderNo") or "").strip()
|
||
pc = str(row.get("productCode") or "").strip()
|
||
if ono and pc:
|
||
product_by_order[ono] = pc
|
||
|
||
for name in table_names:
|
||
path = os.path.join(work, name)
|
||
try:
|
||
size = (
|
||
len(source_snapshots[name])
|
||
if source_snapshots is not None and name in source_snapshots
|
||
else os.path.getsize(path)
|
||
)
|
||
except OSError:
|
||
size = 0
|
||
kind_hint = _guess_kind_by_name(name)
|
||
try:
|
||
raw = _source_bytes(path, name, source_snapshots)
|
||
preview = preview_file(
|
||
name, raw, world, soft=True,
|
||
product_by_order=product_by_order, kind_hint=kind_hint,
|
||
)
|
||
except Exception as exc: # noqa: BLE001
|
||
file_reports.append({
|
||
"name": name, "size": size, "status": "fail",
|
||
"kind": kind_hint,
|
||
"kindCn": KIND_CN.get(kind_hint or "", "读失败"),
|
||
"okCount": 0, "errorCount": 1, "errors": [str(exc)],
|
||
"headers": [], "samples": [], "sheets": [], "required": False,
|
||
"fieldMap": [], "fieldMapText": "",
|
||
})
|
||
continue
|
||
|
||
sheets = []
|
||
headers: list[str] = []
|
||
samples: list[str] = []
|
||
errors: list[str] = []
|
||
warnings: list[str] = []
|
||
file_diagnostics: list[dict[str, Any]] = []
|
||
field_map: list[dict[str, str]] = []
|
||
ok_total = 0
|
||
err_total = 0
|
||
skip_total = 0
|
||
primary_kind = kind_hint
|
||
|
||
for b in preview.get("batches") or []:
|
||
kind = b.get("kind") or primary_kind or "materials"
|
||
n_ok = int(b.get("okCount") or 0)
|
||
is_skip = not b.get("sourceProfile") and _is_mom_skip_block(b.get("sheet"))
|
||
original_error_count = int(b.get("errorCount") or 0)
|
||
if not is_skip:
|
||
if not primary_kind:
|
||
primary_kind = kind
|
||
# A workbook can contain orders, materials, routes and equipment.
|
||
# Its first sheet/file-name hint is not the category of every row.
|
||
cover_kind = kind
|
||
if n_ok:
|
||
kind_ok[cover_kind] = kind_ok.get(cover_kind, 0) + int(b.get("entityCount", n_ok))
|
||
if not field_map and b.get("fieldMap"):
|
||
field_map = list(b["fieldMap"])
|
||
if primary_kind and b.get("fieldMap"):
|
||
kind_fields.setdefault(primary_kind, list(b["fieldMap"]))
|
||
batch_diagnostics = [
|
||
dict(item) for item in (b.get("diagnostics") or [])
|
||
if isinstance(item, dict)
|
||
]
|
||
if is_skip:
|
||
skip_total += n_ok + original_error_count
|
||
if not batch_diagnostics:
|
||
batch_diagnostics = [
|
||
{
|
||
"sheet": b.get("physicalSheet") or b.get("sheet") or name,
|
||
"excelRow": None,
|
||
"parserIndex": index,
|
||
"kind": kind,
|
||
"code": "NON_SCHEDULING_BLOCK",
|
||
"severity": "ignored",
|
||
"message": str(message),
|
||
"rawSummary": {},
|
||
}
|
||
for index, message in enumerate(b.get("errors") or [], 1)
|
||
]
|
||
else:
|
||
batch_diagnostics = [
|
||
{
|
||
**item,
|
||
"code": "NON_SCHEDULING_BLOCK",
|
||
"severity": "ignored",
|
||
"message": "非排产数据块,已跳过且不计入问题行",
|
||
}
|
||
for item in batch_diagnostics
|
||
]
|
||
real_err = 0
|
||
elif batch_diagnostics:
|
||
real_err = sum(
|
||
1 for item in batch_diagnostics
|
||
if item.get("severity") == "blocking"
|
||
)
|
||
else:
|
||
real_err = original_error_count
|
||
|
||
batch_counts = {
|
||
severity: sum(
|
||
1 for item in batch_diagnostics
|
||
if item.get("severity") == severity
|
||
)
|
||
for severity in ("ignored", "warning", "blocking")
|
||
}
|
||
file_diagnostics.extend(batch_diagnostics)
|
||
all_diagnostics.extend(batch_diagnostics)
|
||
|
||
sheets.append({
|
||
"sheet": b.get("sheet"), "kind": kind,
|
||
"kindCn": KIND_CN.get(kind, kind),
|
||
"okCount": n_ok,
|
||
"errorCount": real_err,
|
||
"skip": is_skip,
|
||
"diagnosticCounts": batch_counts,
|
||
})
|
||
if not is_skip:
|
||
ok_total += n_ok
|
||
err_total += real_err
|
||
for row in (b.get("okRows") or [])[:2]:
|
||
if isinstance(row, dict):
|
||
samples.append(_sample_cells(row))
|
||
if not headers:
|
||
headers = list(row.keys())[:8]
|
||
if not is_skip:
|
||
for e in (b.get("errors") or [])[:3]:
|
||
errors.append(str(e))
|
||
for w in (b.get("warnings") or [])[:2]:
|
||
warnings.append(str(w))
|
||
if not headers and b.get("headersRaw"):
|
||
headers = [str(h) for h in b["headersRaw"] if h][:8]
|
||
all_batches.append({
|
||
**{key: b[key] for key in ("sourceProfile", "sourceSha256", "contractSheets", "canCommit", "entityCount", "role", "contractRoles", "profileDigest", "capabilities") if key in b},
|
||
"kind": b["kind"], "sheet": b.get("sheet") or name,
|
||
"physicalSheet": b.get("physicalSheet") or b.get("sheet") or name,
|
||
"okRows": b.get("okRows") or [], "okCount": n_ok,
|
||
"errorCount": real_err,
|
||
"errors": (b.get("errors") or [])[:8],
|
||
"warnings": (b.get("warnings") or [])[:5],
|
||
"diagnostics": batch_diagnostics,
|
||
"diagnosticCounts": batch_counts,
|
||
"fieldMap": b.get("fieldMap") or [],
|
||
"sourceFile": name,
|
||
"skip": is_skip,
|
||
})
|
||
|
||
status = "ok" if ok_total > 0 and err_total == 0 else ("warn" if ok_total > 0 else "fail")
|
||
fmap_text = _fmt_field_map(field_map)
|
||
issue = ""
|
||
blocking_diagnostic = next((
|
||
item for item in file_diagnostics
|
||
if item.get("severity") == "blocking"
|
||
), None)
|
||
if blocking_diagnostic:
|
||
excel_row = blocking_diagnostic.get("excelRow")
|
||
row_prefix = f"Excel 第{excel_row}行:" if excel_row else ""
|
||
issue = row_prefix + str(blocking_diagnostic.get("message") or "数据校验未通过")
|
||
elif errors:
|
||
issue = errors[0]
|
||
elif warnings:
|
||
issue = warnings[0]
|
||
elif fmap_text:
|
||
issue = f"字段:{fmap_text}"
|
||
elif samples:
|
||
issue = f"样例:{samples[0]}"
|
||
file_counts = {
|
||
severity: sum(
|
||
1 for item in file_diagnostics
|
||
if item.get("severity") == severity
|
||
)
|
||
for severity in ("ignored", "warning", "blocking")
|
||
}
|
||
file_reports.append({
|
||
**{key: preview[key] for key in ("profile", "source", "entityCounts", "sheetSummary", "canCommit", "canSchedule", "profileDigest", "capabilities", "planningContext") if key in preview},
|
||
"name": name, "size": size, "status": status,
|
||
"kind": primary_kind, "kindCn": KIND_CN.get(primary_kind or "", primary_kind or "未识别"),
|
||
"okCount": ok_total, "errorCount": err_total, "skipRows": skip_total,
|
||
"errors": errors, "warnings": warnings,
|
||
"diagnostics": file_diagnostics, "diagnosticCounts": file_counts,
|
||
"headers": headers, "samples": samples[:3],
|
||
"sheets": sheets, "required": (primary_kind in _REQUIRED) if primary_kind else False,
|
||
"fieldMap": field_map, "fieldMapText": fmap_text, "issue": issue,
|
||
})
|
||
|
||
coverage = {k: kind_ok.get(k, 0) > 0 for k in (*_REQUIRED, *_HELPFUL)}
|
||
# Counts above come from individual sheets (or SQL entity statistics).
|
||
# Never promote the workbook total to its display category.
|
||
|
||
missing = [KIND_CN[k] for k in _REQUIRED if not coverage.get(k)]
|
||
soft_missing: list[str] = []
|
||
if not (world.get("flexCalendar") or []):
|
||
soft_missing.append("班次日历(没有可用系统默认班次)")
|
||
for k in _HELPFUL:
|
||
if not coverage.get(k):
|
||
soft_missing.append(f"{KIND_CN[k]}(有更好,没有也能先试)")
|
||
|
||
# 齐备度明细:每项带上识别到的关键字段
|
||
coverage_detail: list[dict[str, Any]] = []
|
||
for k in _REQUIRED:
|
||
hit = bool(coverage.get(k))
|
||
fmap = kind_fields.get(k) or []
|
||
for f in file_reports:
|
||
if f.get("kind") == k and f.get("fieldMap"):
|
||
fmap = f["fieldMap"]
|
||
break
|
||
note = _fmt_field_map(fmap) if fmap else ""
|
||
if hit:
|
||
detail = f"读到 {kind_ok.get(k, 0)} 行"
|
||
if sql_applied and int(sql_stats.get(k) or 0) > 0:
|
||
detail = f"来自 SQL {sql_stats.get(k, 0)} 行"
|
||
if note:
|
||
detail += f";识别 {note}"
|
||
else:
|
||
detail = "还没有有效数据"
|
||
if note:
|
||
detail += f"(已见表头 {note},但行校验未过)"
|
||
elif any(f.get("kind") == k for f in file_reports):
|
||
detail = "有文件但关键字段对不上,请看文件清单里的问题列"
|
||
coverage_detail.append({
|
||
"key": k, "label": KIND_CN[k], "ok": hit,
|
||
"count": kind_ok.get(k, 0), "fieldMap": fmap, "note": detail,
|
||
})
|
||
|
||
can_schedule = len(missing) == 0
|
||
# SQL 已挂订单工艺时,即使 Excel 缺表也可开排
|
||
if sql_applied and all(coverage.get(k) for k in ("orders", "routing", "equipment")):
|
||
can_schedule = True
|
||
missing = []
|
||
world_ready = check_readiness(world)
|
||
if any(f.get("profile") and not f.get("canSchedule") for f in file_reports):
|
||
can_schedule = False
|
||
md = _to_markdown(
|
||
proj_name=proj.get("name") or "未命名", work=work, files=file_reports,
|
||
coverage=coverage, coverage_detail=coverage_detail,
|
||
missing=missing, soft_missing=soft_missing,
|
||
can_schedule=can_schedule, kind_ok=kind_ok, world_ready=world_ready,
|
||
)
|
||
excel_ok = sum(
|
||
int(b.get("okCount") or 0)
|
||
for b in all_batches
|
||
if not b.get("skip")
|
||
)
|
||
sql_ok_n = int((file_reports[0].get("okCount") or 0) if sql_applied and file_reports else 0)
|
||
diagnostic_counts = {
|
||
severity: sum(
|
||
1 for item in all_diagnostics
|
||
if item.get("severity") == severity
|
||
)
|
||
for severity in ("ignored", "warning", "blocking")
|
||
}
|
||
return {
|
||
"ok": True, "projectName": proj.get("name"), "workDir": work,
|
||
"files": [*file_reports, *drawing_reports], "drawings": drawing_reports,
|
||
"coverage": coverage, "coverageDetail": coverage_detail,
|
||
"kindCounts": kind_ok,
|
||
"missing": missing, "softMissing": soft_missing,
|
||
"canSchedule": can_schedule,
|
||
"batches": [b for b in all_batches if b.get("okRows") and not b.get("skip")],
|
||
"totalOk": excel_ok + sql_ok_n,
|
||
"totalErrors": sum(int(b.get("errorCount") or 0) for b in all_batches),
|
||
"skippedRows": sum(int(f.get("skipRows") or 0) for f in file_reports),
|
||
"diagnostics": all_diagnostics,
|
||
"diagnosticCounts": diagnostic_counts,
|
||
"diagnosticsVersion": 1,
|
||
"sqlApplied": sql_applied,
|
||
"sqlStats": sql_stats,
|
||
"sqlPreview": sql_preview,
|
||
"worldReadySummary": world_ready.get("summary"),
|
||
"productByOrder": product_by_order,
|
||
"markdown": md,
|
||
"forceSqlReplace": force_sql_replace,
|
||
}
|
||
|
||
|
||
def prepare_folder_schedule(world: World, session_id: str | None) -> dict[str, Any]:
|
||
"""Build a frozen P2 payload while keeping analysis writes off the main world."""
|
||
project, work_dir = _project_work_dir(session_id)
|
||
if work_dir:
|
||
before_manifest, source_snapshots = read_folder_source_snapshots(work_dir)
|
||
else:
|
||
before_manifest, source_snapshots = [], {}
|
||
shadow = copy.deepcopy(world)
|
||
report = analyze_work_dir(
|
||
shadow,
|
||
session_id,
|
||
force_sql_replace=True,
|
||
source_snapshots=source_snapshots,
|
||
)
|
||
if not report.get("ok") or not work_dir:
|
||
return report
|
||
|
||
after_manifest = folder_source_manifest(work_dir)
|
||
if after_manifest != before_manifest:
|
||
raise PermissionError("工程目录在分析期间发生变化,请重新发起排产")
|
||
|
||
sql_payload: dict[str, Any] | None = None
|
||
if report.get("sqlApplied"):
|
||
sql_entries = [
|
||
row for row in after_manifest
|
||
if Path(str(row.get("name") or "")).suffix.lower() == ".sql"
|
||
]
|
||
if not sql_entries:
|
||
raise PermissionError("SQL 数据包已失效,请重新发起排产")
|
||
selected = min(
|
||
sql_entries,
|
||
key=lambda row: (-int(row.get("sizeBytes") or 0), str(row.get("name") or "")),
|
||
)
|
||
selected_name = str(selected["name"])
|
||
if selected_name not in source_snapshots:
|
||
raise PermissionError("SQL 数据包冻结源已失效,请重新发起排产")
|
||
preview = _preview_sql_bytes(source_snapshots[selected_name])
|
||
sql_payload = copy.deepcopy(preview.get("flex") or {})
|
||
if not isinstance(sql_payload, dict) or not (sql_payload.get("stats") or {}):
|
||
raise PermissionError("SQL 数据包冻结失败,请重新发起排产")
|
||
|
||
kangni_payload = _build_kangni_payload_from_snapshots(source_snapshots)
|
||
kangni_meta = copy.deepcopy((kangni_payload or {}).get("meta") or {})
|
||
resource_quality = kangni_meta.get("resourceQuality") or {}
|
||
trial_ready = bool(
|
||
kangni_payload
|
||
and int(kangni_meta.get("orderCount") or 0) > 0
|
||
and int(kangni_meta.get("routingRecordCount") or 0) > 0
|
||
and (kangni_payload.get("flex") or {}).get("flexEquipment")
|
||
)
|
||
production_ready = (
|
||
bool(resource_quality.get("productionReady"))
|
||
if kangni_payload
|
||
else None
|
||
)
|
||
|
||
final_manifest = folder_source_manifest(work_dir)
|
||
if final_manifest != before_manifest:
|
||
raise PermissionError("工程目录在冻结期间发生变化,请重新发起排产")
|
||
|
||
report.update({
|
||
"projectId": (project or {}).get("id"),
|
||
"sourceManifestVersion": 1,
|
||
"sourceManifest": final_manifest,
|
||
"sourceManifestDigest": folder_source_manifest_digest(final_manifest),
|
||
"folderWorldFingerprint": folder_schedule_world_fingerprint(world),
|
||
"sqlPayload": sql_payload,
|
||
"kangniDetected": kangni_payload is not None,
|
||
"trialReady": trial_ready,
|
||
"productionReady": production_ready,
|
||
"kangniPayload": kangni_payload,
|
||
"kangniMeta": kangni_meta,
|
||
})
|
||
return report
|
||
|
||
|
||
def verify_folder_schedule_binding(
|
||
params: dict[str, Any],
|
||
current_world: World,
|
||
) -> dict[str, Any]:
|
||
"""Fail closed when approved files, frozen payload, or target world drifted."""
|
||
if int(params.get("sourceManifestVersion") or 0) != 1:
|
||
raise PermissionError("工程目录确认缺少源文件清单,请重新发起")
|
||
expected_manifest = params.get("sourceManifest")
|
||
if not isinstance(expected_manifest, list) or not expected_manifest:
|
||
raise PermissionError("工程目录确认缺少源文件证据,请重新发起")
|
||
expected_digest = folder_source_manifest_digest(expected_manifest)
|
||
if expected_digest != str(params.get("sourceManifestDigest") or ""):
|
||
raise PermissionError("工程目录源文件证据已损坏,请重新发起")
|
||
work_dir = str(params.get("workDir") or "").strip()
|
||
if not work_dir:
|
||
raise PermissionError("工程目录确认缺少项目目录,请重新发起")
|
||
current_manifest = folder_source_manifest(work_dir)
|
||
if current_manifest != expected_manifest:
|
||
raise PermissionError("工程目录源文件已变化,请重新分析并确认")
|
||
|
||
expected_payload_digest = str(params.get("folderPayloadDigest") or "")
|
||
if not expected_payload_digest or folder_schedule_payload_digest(params) != expected_payload_digest:
|
||
raise PermissionError("工程目录冻结载荷已损坏,请重新发起")
|
||
if not str(params.get("projectId") or "").strip() or not str(params.get("sessionId") or "").strip():
|
||
raise PermissionError("工程目录确认缺少项目或会话绑定,请重新发起")
|
||
if params.get("boundAction") != "folder.schedule":
|
||
raise PermissionError("工程目录确认的动作绑定已损坏,请重新发起")
|
||
if str(params.get("targetWorldKey") or "") != str(params.get("projectId") or ""):
|
||
raise PermissionError("工程目录确认的目标世界绑定已损坏,请重新发起")
|
||
if not str(params.get("beforeSnapshot") or "").strip():
|
||
raise PermissionError("工程目录确认缺少审批前快照,请重新发起")
|
||
|
||
expected_world = str(params.get("folderWorldFingerprint") or "")
|
||
if not expected_world or folder_schedule_world_fingerprint(current_world) != expected_world:
|
||
raise PermissionError("工程目录确认的目标世界已变化,请重新发起")
|
||
sql_payload = params.get("sqlPayload")
|
||
batches = params.get("batches") or []
|
||
if params.get("sqlApplied") and not isinstance(sql_payload, dict):
|
||
raise PermissionError("工程目录确认缺少冻结 SQL 载荷,请重新发起")
|
||
kangni_detected = params.get("kangniDetected") is True
|
||
kangni_payload = params.get("kangniPayload")
|
||
if kangni_detected:
|
||
if params.get("trialReady") is not True:
|
||
raise PermissionError("康尼冻结载荷未达到本地试排条件,请重新发起")
|
||
if not isinstance(kangni_payload, dict):
|
||
raise PermissionError("工程目录确认缺少康尼冻结载荷,请重新发起")
|
||
fixed = kangni_payload.get("fixed")
|
||
flex = kangni_payload.get("flex")
|
||
meta = kangni_payload.get("meta")
|
||
if not isinstance(fixed, dict) or not isinstance(flex, dict) or not isinstance(meta, dict):
|
||
raise PermissionError("康尼冻结载荷结构已损坏,请重新发起")
|
||
if int(meta.get("payloadSchemaVersion") or 0) != 1 or meta.get("sourceMode") != "data-dir-only":
|
||
raise PermissionError("康尼冻结载荷版本或来源已损坏,请重新发起")
|
||
if params.get("kangniMeta") != meta:
|
||
raise PermissionError("康尼冻结载荷对账证据不一致,请重新发起")
|
||
resource_quality = meta.get("resourceQuality") or {}
|
||
if not isinstance(params.get("productionReady"), bool):
|
||
raise PermissionError("康尼冻结载荷缺少生产可用性结论,请重新发起")
|
||
if params["productionReady"] != bool(resource_quality.get("productionReady")):
|
||
raise PermissionError("康尼冻结载荷生产可用性证据不一致,请重新发起")
|
||
try:
|
||
json.dumps(kangni_payload, ensure_ascii=False, sort_keys=True, allow_nan=False)
|
||
except (TypeError, ValueError) as exc:
|
||
raise PermissionError("康尼冻结载荷不是纯 JSON,请重新发起") from exc
|
||
elif kangni_payload is not None or params.get("trialReady"):
|
||
raise PermissionError("康尼冻结载荷检测标记已损坏,请重新发起")
|
||
if not batches and not sql_payload and not kangni_detected:
|
||
raise PermissionError("工程目录确认没有可执行的冻结数据")
|
||
return {
|
||
"sourceManifestDigest": expected_digest,
|
||
"folderWorldFingerprint": expected_world,
|
||
"folderPayloadDigest": expected_payload_digest,
|
||
"beforeSnapshot": str(params["beforeSnapshot"]),
|
||
"fileCount": len(expected_manifest),
|
||
"kangniDetected": kangni_detected,
|
||
}
|
||
|
||
|
||
def _to_markdown(
|
||
*, proj_name: str, work: str, files: list[dict], coverage: dict,
|
||
coverage_detail: list[dict],
|
||
missing: list[str], soft_missing: list[str], can_schedule: bool,
|
||
kind_ok: dict[str, int], world_ready: dict,
|
||
) -> str:
|
||
lines = [
|
||
f"**工程目录数据包 · {proj_name}**",
|
||
"",
|
||
f"目录:`{work}`",
|
||
"",
|
||
"### 我读到的表格",
|
||
"",
|
||
]
|
||
if not files:
|
||
lines.append("目录里还没有 Excel/CSV。把订单、物料、工艺、设备表放进来再叫我。")
|
||
else:
|
||
lines += [
|
||
"| 文件 | 类型 | 大小 | 有效行 | 问题行 | 识别字段 / 问题 |",
|
||
"| --- | --- | ---: | ---: | ---: | --- |",
|
||
]
|
||
for f in files:
|
||
err = (f.get("issue") or ";".join((f.get("errors") or [])[:2])
|
||
or f.get("fieldMapText") or "—")
|
||
err = str(err).replace("|", "/")
|
||
if len(err) > 48:
|
||
err = err[:48] + "…"
|
||
lines.append(
|
||
f"| {f.get('name')} | {f.get('kindCn') or '—'} | {_fmt_size(int(f.get('size') or 0))} "
|
||
f"| {f.get('okCount', 0)} | {f.get('errorCount', 0)} | {err} |"
|
||
)
|
||
skip_total = sum(int(f.get("skipRows") or 0) for f in files)
|
||
if skip_total:
|
||
lines.append(
|
||
f"\n> 另有 {skip_total} 行来自非排产块(工厂/质检/仓储/人力/客户/供应商等表),"
|
||
"已跳过,不计入问题行。"
|
||
)
|
||
diagnostics = [
|
||
item
|
||
for file_report in files
|
||
for item in (file_report.get("diagnostics") or [])
|
||
if isinstance(item, dict)
|
||
]
|
||
structural_ignored = sum(
|
||
item.get("code") in {"SECONDARY_SEQUENCE_HEADER", "EMBEDDED_SECTION_ROW"}
|
||
for item in diagnostics
|
||
)
|
||
general_defaults = sum(
|
||
item.get("code") == "EQUIPMENT_CAPABILITIES_DEFAULTED"
|
||
for item in diagnostics
|
||
)
|
||
alias_recoveries = sum(
|
||
item.get("code") == "DUPLICATE_ALIAS_EMPTY_IGNORED"
|
||
for item in diagnostics
|
||
)
|
||
diagnostic_notes: list[str] = []
|
||
if structural_ignored:
|
||
diagnostic_notes.append(f"忽略 {structural_ignored} 行内嵌表头/字典结构")
|
||
if general_defaults:
|
||
diagnostic_notes.append(f"{general_defaults} 台设备暂按 GENERAL 工序能力导入")
|
||
if alias_recoveries:
|
||
diagnostic_notes.append(f"恢复 {alias_recoveries} 行重复编码列")
|
||
if diagnostic_notes:
|
||
lines.append("\n> 诊断说明:" + ";".join(diagnostic_notes) + "。")
|
||
sample_rows = [(f.get("name"), (f.get("samples") or [None])[0])
|
||
for f in files if f.get("samples")]
|
||
if sample_rows:
|
||
lines += ["", "### 样例数据(每表一行)", "",
|
||
"| 文件 | 样例 |", "| --- | --- |"]
|
||
for name, sample in sample_rows[:8]:
|
||
s = str(sample).replace("|", "/")
|
||
if len(s) > 64:
|
||
s = s[:64] + "…"
|
||
lines.append(f"| {name} | {s} |")
|
||
|
||
lines += [
|
||
"",
|
||
"### 排产必备齐不齐",
|
||
"",
|
||
"| 必备项 | 状态 | 说明 |",
|
||
"| --- | --- | --- |",
|
||
]
|
||
for d in coverage_detail:
|
||
st = "✓ 已有" if d.get("ok") else "× 缺少"
|
||
note = str(d.get("note") or "").replace("|", "/")
|
||
if len(note) > 56:
|
||
note = note[:56] + "…"
|
||
lines.append(f"| {d.get('label')} | {st} | {note} |")
|
||
for s in soft_missing[:4]:
|
||
lines.append(f"| (可选) | · | {s} |")
|
||
|
||
lines.append("")
|
||
if can_schedule:
|
||
lines.append("**结论:按文件内容看,已基本满足排产条件。**")
|
||
lines.append("下一步:确认导入这些数据,系统将继续校验并生成试排方案。")
|
||
else:
|
||
lines.append("**结论:当前不满足直接排产条件。**")
|
||
lines.append("缺失项:" + ("、".join(missing) if missing else "有效数据行"))
|
||
lines.append("请补充缺失数据或修正错误记录后重新执行目录分析。")
|
||
|
||
ws = (world_ready or {}).get("summary") or {}
|
||
if ws:
|
||
lines += [
|
||
"",
|
||
f"(系统当前已有待排订单 {ws.get('total', 0)} 张,"
|
||
f"可排 {ws.get('ready', 0)} 张,存在阻断 {ws.get('blocked', 0)} 张。"
|
||
f"导入目录后将基于新数据重新校验。)",
|
||
]
|
||
return "\n".join(lines)
|
||
|
||
|
||
def confirmation_for_folder_schedule(report: dict[str, Any]) -> tuple[str, list[str]]:
|
||
title = f"生成排产方案 · {report.get('projectName') or '当前项目'}"
|
||
lines = ["数据已检查,尚未生成方案。请确认使用本次文件中的数据。"]
|
||
labels = {"orders": "订单记录", "materials": "产品和材料记录",
|
||
"routing": "加工步骤记录", "equipment": "设备记录"}
|
||
for k in _REQUIRED:
|
||
n = (report.get("kindCounts") or {}).get(k, 0)
|
||
if n:
|
||
lines.append(f"{labels[k]}:{n} 条")
|
||
if report.get("totalErrors"):
|
||
lines.append(f"有 {report['totalErrors']} 条数据未通过检查,请核对是否影响本次订单。")
|
||
lines.append("确认后保存数据并生成一版试排方案,可撤回;这一步不会下发到车间。")
|
||
return title, lines
|