aps-agent/server/aps_domain/folder_pack.py

1015 lines
43 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 工程目录数据包分析(moduleId: domain-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