aps-agent/server/aps_domain/folder_pack.py

668 lines
28 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 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"
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 _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