aps-agent/server/aps_domain/folder_pack.py

660 lines
28 KiB
Python
Raw Normal View History

# ============================================================
# 工程目录数据包分析(moduleId: domain-folder-pack, 可重生 ✅)
# 读取项目 workDir 下 Excel/CSV:表头/行数/样例/角色 → 排产齐备度校验
# ============================================================
from __future__ import annotations
import os
import re
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")
# 锐扬 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 _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,
) -> 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 = os.path.getsize(path)
except OSError:
size = 0
try:
from server.importers.sql_pack import preview_sql_file, apply_sql_pack_to_world
prev = preview_sql_file(path)
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:
with open(path, "rb") as f:
raw = f.read()
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 = os.path.getsize(path)
except OSError:
size = 0
kind_hint = _guess_kind_by_name(name)
try:
with open(path, "rb") as f:
raw = f.read()
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"
if not primary_kind:
primary_kind = kind
cover_kind = primary_kind or kind
n_ok = int(b.get("okCount") or 0)
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"]))
is_skip = _is_mom_skip_block(b.get("sheet"))
original_error_count = int(b.get("errorCount") or 0)
batch_diagnostics = [
dict(item) for item in (b.get("diagnostics") or [])
if isinstance(item, dict)
]
if is_skip:
skip_total += 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,
})
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)
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")],
"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 _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