# ============================================================ # 工程目录数据包分析(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": "区域", } _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 _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, ) -> dict[str, Any]: """深度解析工程目录:每张表读内容 + 动态字段映射 + 汇总能否开排。 force_sql_replace=True 时(「根据这个排产」)强制用 SQL 包整表覆盖项目世界, 避免旧世界残留错误工艺映射后永远不更新。 """ proj, work = _project_work_dir(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, 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] = {} 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) flex = prev.get("flex") or {} st = (flex.get("stats") or {}) sql_stats = st if 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 = _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 cover_kind = primary_kind or kind if n_ok: kind_ok[cover_kind] = kind_ok.get(cover_kind, 0) + n_ok if kind != cover_kind: kind_ok[kind] = kind_ok.get(kind, 0) + 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({ "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({ "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)} for f in file_reports: k = f.get("kind") if k and int(f.get("okCount") or 0) > 0: coverage[k] = True kind_ok[k] = max(kind_ok.get(k, 0), int(f.get("okCount") or 0)) 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) 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, "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 = [ f"目录:{report.get('workDir') or ''}", f"有效行合计:{report.get('totalOk') or 0}", ] for k in _REQUIRED: n = (report.get("kindCounts") or {}).get(k, 0) if n: lines.append(f"· {KIND_CN.get(k, k)} {n} 行") lines.append("确认后写入柔性主数据并生成试排方案(支持回滚)") return title, lines