236 lines
7.8 KiB
Python
236 lines
7.8 KiB
Python
#!/usr/bin/env python3
|
|
"""为 SOP 模板正文表非主操作区行添加 sop-row-hidden,并重新生成 seed SQL。"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
TEMPLATES_DIR = ROOT / "docs" / "sop-templates"
|
|
SQL_DIR = ROOT / "docs" / "sql"
|
|
|
|
SOP_CODES = [
|
|
"SOP-CC-001-E01-01",
|
|
"SOP-CC-002-E01-01",
|
|
"SOP-CC-003-E01-01",
|
|
"SOP-CC-006-E01-01",
|
|
"SOP-SC-003-E01-01",
|
|
"SOP-SC-004-E01-01",
|
|
"SOP-SC-005-E01-01",
|
|
"SOP-SC-006-E01-01",
|
|
"SOP-SC-007-E01-01",
|
|
"SOP-SC-008-E01-01",
|
|
"SOP-SC-009-E01-01",
|
|
"SOP-SC-010-E01-01",
|
|
"SOP-SC-011-E01-01",
|
|
"SOP-SC-012-E01-01",
|
|
"SOP-SC-013-E01-01",
|
|
"SOP-SC-014-E01-01",
|
|
]
|
|
|
|
SC014_NK_ROW_OLD = (
|
|
'<tr>\n'
|
|
' <td style="border:1px solid #334155; padding:8px; text-align:center; vertical-align:middle;">'
|
|
"NK细胞<br />培养</td>\n"
|
|
' <td colspan="11" style="border:1px solid #334155; padding:10px; vertical-align:top; word-wrap:break-word; overflow-wrap:break-word;">'
|
|
)
|
|
SC014_NK_ROW_NEW = (
|
|
'<tr class="sop-fill-row">\n'
|
|
' <td class="sop-fill-label" style="border:1px solid #334155; padding:8px; text-align:center; vertical-align:middle; height:100%;">'
|
|
"NK细胞<br />培养</td>\n"
|
|
' <td class="sop-fill-cell" colspan="11" style="border:1px solid #334155; padding:10px; vertical-align:top; height:100%; word-wrap:break-word; overflow-wrap:break-word;">'
|
|
)
|
|
|
|
|
|
def _is_tr_open(html: str, pos: int) -> bool:
|
|
if not html.startswith("<tr", pos):
|
|
return False
|
|
nxt = html[pos + 3 : pos + 4]
|
|
return nxt in ("", " ", ">", "/", "\n", "\t", "\r")
|
|
|
|
|
|
def _split_top_level_trs(html: str) -> list[str]:
|
|
rows: list[str] = []
|
|
i = 0
|
|
n = len(html)
|
|
while i < n:
|
|
start = html.find("<tr", i)
|
|
if start < 0 or not _is_tr_open(html, start):
|
|
break
|
|
depth = 0
|
|
pos = start
|
|
while pos < n:
|
|
if _is_tr_open(html, pos):
|
|
depth += 1
|
|
pos += 3
|
|
continue
|
|
if html.startswith("</tr>", pos):
|
|
depth -= 1
|
|
pos += 5
|
|
if depth == 0:
|
|
rows.append(html[start:pos])
|
|
i = pos
|
|
break
|
|
continue
|
|
pos += 1
|
|
else:
|
|
raise ValueError("unclosed <tr> in sop-body-table tbody")
|
|
return rows
|
|
|
|
|
|
def _add_hidden_class(tr_html: str) -> str:
|
|
open_tag = tr_html.split(">", 1)[0] + ">"
|
|
if "sop-fill-row" in open_tag:
|
|
return tr_html
|
|
|
|
def _patch_tr_tag(match: re.Match[str]) -> str:
|
|
tag = match.group(0)
|
|
if 'class="' in tag:
|
|
return re.sub(
|
|
r'class="([^"]*)"',
|
|
lambda m: f'class="{m.group(1)} sop-row-hidden"',
|
|
tag,
|
|
count=1,
|
|
)
|
|
return tag.replace("<tr", '<tr class="sop-row-hidden"', 1)
|
|
|
|
return re.sub(r"<tr[^>]*>", _patch_tr_tag, tr_html, count=1)
|
|
|
|
|
|
def _find_body_tbody_span(html: str) -> tuple[int, int] | None:
|
|
body_start = html.find('<table class="sop-body-table"')
|
|
if body_start < 0:
|
|
return None
|
|
tbody_open = html.find("<tbody>", body_start)
|
|
if tbody_open < 0:
|
|
return None
|
|
inner_start = tbody_open + len("<tbody>")
|
|
table_depth = 1
|
|
tbody_depth = 1
|
|
pos = inner_start
|
|
n = len(html)
|
|
while pos < n:
|
|
if html.startswith("<table", pos):
|
|
table_depth += 1
|
|
pos += 6
|
|
continue
|
|
if html.startswith("</table>", pos):
|
|
table_depth -= 1
|
|
pos += 8
|
|
continue
|
|
if html.startswith("<tbody>", pos):
|
|
tbody_depth += 1
|
|
pos += 7
|
|
continue
|
|
if html.startswith("</tbody>", pos):
|
|
tbody_depth -= 1
|
|
pos += 8
|
|
if tbody_depth == 0:
|
|
return inner_start, pos - 8
|
|
continue
|
|
pos += 1
|
|
raise ValueError("无法定位 sop-body-table 的 tbody 结束位置")
|
|
|
|
|
|
def process_body_table(html: str) -> str:
|
|
span = _find_body_tbody_span(html)
|
|
if span is None:
|
|
return html
|
|
inner_start, inner_end = span
|
|
tbody_inner = html[inner_start:inner_end]
|
|
rows = _split_top_level_trs(tbody_inner)
|
|
processed = [_add_hidden_class(row) for row in rows]
|
|
return html[:inner_start] + "".join(processed) + html[inner_end:]
|
|
|
|
|
|
def process_template(html: str, code: str) -> str:
|
|
html = html.replace(
|
|
'<table class="sop-header-table"',
|
|
'<table class="sop-header-table sop-row-hidden"',
|
|
1,
|
|
)
|
|
if code == "SOP-SC-014-E01-01":
|
|
html = html.replace(SC014_NK_ROW_OLD, SC014_NK_ROW_NEW)
|
|
html = html.replace("sop-a4-pages-2", "sop-a4-pages-1")
|
|
html = re.sub(
|
|
r"min-height:\s*594mm;\s*height:\s*auto;",
|
|
"height: 297mm; min-height: 297mm;",
|
|
html,
|
|
)
|
|
return process_body_table(html)
|
|
|
|
|
|
def sql_escape(html: str) -> str:
|
|
return html.replace("\\", "\\\\").replace("'", "''")
|
|
|
|
|
|
def write_seed_sql(code: str, html: str) -> None:
|
|
short = code.replace("SOP-", "").replace("-E01-01", "").lower()
|
|
sql_path = SQL_DIR / f"2026-08-25-seed-sop-{short}-content.sql"
|
|
comment = html.split("\n", 1)[0].strip().removeprefix("<!-- ").removesuffix(" -->")
|
|
escaped = sql_escape(html)
|
|
sql_path.write_text(
|
|
"\n".join(
|
|
[
|
|
f"-- 种子数据: {code} {comment.split('(')[0].strip()}",
|
|
f"-- 源文件: docs/sop-templates/{code}.html",
|
|
"",
|
|
"UPDATE `biz_process`",
|
|
"SET `sop_content` = '"
|
|
f"{escaped}"
|
|
"',",
|
|
" `update_time` = NOW()",
|
|
f"WHERE `process_code` = '{code}'",
|
|
" AND `del_flag` = 0;",
|
|
"",
|
|
]
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def write_deploy_bundle() -> Path:
|
|
"""合并 seed + sync SQL,供测试/生产一次性执行。"""
|
|
deploy_path = SQL_DIR / "2026-08-28-sop-visible-section-only-deploy.sql"
|
|
parts = [
|
|
"-- =============================================================================",
|
|
"-- SOP 精简展示:仅保留主操作记录区(页眉与其余正文行均已隐藏)",
|
|
"-- 生成时间: 由 backend/scripts/apply_sop_row_hidden.py 自动生成",
|
|
"-- 执行顺序: 先 seed 工艺库 → 再 sync 路线/任务步骤",
|
|
"--",
|
|
"-- 【生产环境】推荐方式(二选一):",
|
|
"-- A) mysql -h <host> -u <user> -p <dbname> < docs/sql/2026-08-28-sop-visible-section-only-deploy.sql",
|
|
"-- B) ENV_FILE=.env.production python3 backend/scripts/run_sop_visible_deploy.py",
|
|
"-- =============================================================================",
|
|
"",
|
|
]
|
|
for code in SOP_CODES:
|
|
short = code.replace("SOP-", "").replace("-E01-01", "").lower()
|
|
seed = SQL_DIR / f"2026-08-25-seed-sop-{short}-content.sql"
|
|
sync = SQL_DIR / f"2026-08-25-sync-sop-{short}-content-to-routes-tasks.sql"
|
|
parts.append(f"-- ---------- {code} seed ----------")
|
|
parts.append(seed.read_text(encoding="utf-8").strip())
|
|
parts.append("")
|
|
if sync.exists():
|
|
parts.append(f"-- ---------- {code} sync ----------")
|
|
parts.append(sync.read_text(encoding="utf-8").strip())
|
|
parts.append("")
|
|
deploy_path.write_text("\n".join(parts) + "\n", encoding="utf-8")
|
|
return deploy_path
|
|
|
|
|
|
def main() -> None:
|
|
for code in SOP_CODES:
|
|
path = TEMPLATES_DIR / f"{code}.html"
|
|
html = path.read_text(encoding="utf-8")
|
|
updated = process_template(html, code)
|
|
path.write_text(updated, encoding="utf-8")
|
|
write_seed_sql(code, updated)
|
|
print(f"{code}: done")
|
|
deploy = write_deploy_bundle()
|
|
print(f"deploy bundle: {deploy}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|