apply_sop_row_hidden.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. #!/usr/bin/env python3
  2. """为 SOP 模板正文表非主操作区行添加 sop-row-hidden,并重新生成 seed SQL。"""
  3. from __future__ import annotations
  4. import re
  5. from pathlib import Path
  6. ROOT = Path(__file__).resolve().parents[2]
  7. TEMPLATES_DIR = ROOT / "docs" / "sop-templates"
  8. SQL_DIR = ROOT / "docs" / "sql"
  9. SOP_CODES = [
  10. "SOP-CC-001-E01-01",
  11. "SOP-CC-002-E01-01",
  12. "SOP-CC-003-E01-01",
  13. "SOP-CC-006-E01-01",
  14. "SOP-SC-003-E01-01",
  15. "SOP-SC-004-E01-01",
  16. "SOP-SC-005-E01-01",
  17. "SOP-SC-006-E01-01",
  18. "SOP-SC-007-E01-01",
  19. "SOP-SC-008-E01-01",
  20. "SOP-SC-009-E01-01",
  21. "SOP-SC-010-E01-01",
  22. "SOP-SC-011-E01-01",
  23. "SOP-SC-012-E01-01",
  24. "SOP-SC-013-E01-01",
  25. "SOP-SC-014-E01-01",
  26. ]
  27. SC014_NK_ROW_OLD = (
  28. '<tr>\n'
  29. ' <td style="border:1px solid #334155; padding:8px; text-align:center; vertical-align:middle;">'
  30. "NK细胞<br />培养</td>\n"
  31. ' <td colspan="11" style="border:1px solid #334155; padding:10px; vertical-align:top; word-wrap:break-word; overflow-wrap:break-word;">'
  32. )
  33. SC014_NK_ROW_NEW = (
  34. '<tr class="sop-fill-row">\n'
  35. ' <td class="sop-fill-label" style="border:1px solid #334155; padding:8px; text-align:center; vertical-align:middle; height:100%;">'
  36. "NK细胞<br />培养</td>\n"
  37. ' <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;">'
  38. )
  39. def _is_tr_open(html: str, pos: int) -> bool:
  40. if not html.startswith("<tr", pos):
  41. return False
  42. nxt = html[pos + 3 : pos + 4]
  43. return nxt in ("", " ", ">", "/", "\n", "\t", "\r")
  44. def _split_top_level_trs(html: str) -> list[str]:
  45. rows: list[str] = []
  46. i = 0
  47. n = len(html)
  48. while i < n:
  49. start = html.find("<tr", i)
  50. if start < 0 or not _is_tr_open(html, start):
  51. break
  52. depth = 0
  53. pos = start
  54. while pos < n:
  55. if _is_tr_open(html, pos):
  56. depth += 1
  57. pos += 3
  58. continue
  59. if html.startswith("</tr>", pos):
  60. depth -= 1
  61. pos += 5
  62. if depth == 0:
  63. rows.append(html[start:pos])
  64. i = pos
  65. break
  66. continue
  67. pos += 1
  68. else:
  69. raise ValueError("unclosed <tr> in sop-body-table tbody")
  70. return rows
  71. def _add_hidden_class(tr_html: str) -> str:
  72. open_tag = tr_html.split(">", 1)[0] + ">"
  73. if "sop-fill-row" in open_tag:
  74. return tr_html
  75. def _patch_tr_tag(match: re.Match[str]) -> str:
  76. tag = match.group(0)
  77. if 'class="' in tag:
  78. return re.sub(
  79. r'class="([^"]*)"',
  80. lambda m: f'class="{m.group(1)} sop-row-hidden"',
  81. tag,
  82. count=1,
  83. )
  84. return tag.replace("<tr", '<tr class="sop-row-hidden"', 1)
  85. return re.sub(r"<tr[^>]*>", _patch_tr_tag, tr_html, count=1)
  86. def _find_body_tbody_span(html: str) -> tuple[int, int] | None:
  87. body_start = html.find('<table class="sop-body-table"')
  88. if body_start < 0:
  89. return None
  90. tbody_open = html.find("<tbody>", body_start)
  91. if tbody_open < 0:
  92. return None
  93. inner_start = tbody_open + len("<tbody>")
  94. table_depth = 1
  95. tbody_depth = 1
  96. pos = inner_start
  97. n = len(html)
  98. while pos < n:
  99. if html.startswith("<table", pos):
  100. table_depth += 1
  101. pos += 6
  102. continue
  103. if html.startswith("</table>", pos):
  104. table_depth -= 1
  105. pos += 8
  106. continue
  107. if html.startswith("<tbody>", pos):
  108. tbody_depth += 1
  109. pos += 7
  110. continue
  111. if html.startswith("</tbody>", pos):
  112. tbody_depth -= 1
  113. pos += 8
  114. if tbody_depth == 0:
  115. return inner_start, pos - 8
  116. continue
  117. pos += 1
  118. raise ValueError("无法定位 sop-body-table 的 tbody 结束位置")
  119. def process_body_table(html: str) -> str:
  120. span = _find_body_tbody_span(html)
  121. if span is None:
  122. return html
  123. inner_start, inner_end = span
  124. tbody_inner = html[inner_start:inner_end]
  125. rows = _split_top_level_trs(tbody_inner)
  126. processed = [_add_hidden_class(row) for row in rows]
  127. return html[:inner_start] + "".join(processed) + html[inner_end:]
  128. def process_template(html: str, code: str) -> str:
  129. html = html.replace(
  130. '<table class="sop-header-table"',
  131. '<table class="sop-header-table sop-row-hidden"',
  132. 1,
  133. )
  134. if code == "SOP-SC-014-E01-01":
  135. html = html.replace(SC014_NK_ROW_OLD, SC014_NK_ROW_NEW)
  136. html = html.replace("sop-a4-pages-2", "sop-a4-pages-1")
  137. html = re.sub(
  138. r"min-height:\s*594mm;\s*height:\s*auto;",
  139. "height: 297mm; min-height: 297mm;",
  140. html,
  141. )
  142. return process_body_table(html)
  143. def sql_escape(html: str) -> str:
  144. return html.replace("\\", "\\\\").replace("'", "''")
  145. def write_seed_sql(code: str, html: str) -> None:
  146. short = code.replace("SOP-", "").replace("-E01-01", "").lower()
  147. sql_path = SQL_DIR / f"2026-08-25-seed-sop-{short}-content.sql"
  148. comment = html.split("\n", 1)[0].strip().removeprefix("<!-- ").removesuffix(" -->")
  149. escaped = sql_escape(html)
  150. sql_path.write_text(
  151. "\n".join(
  152. [
  153. f"-- 种子数据: {code} {comment.split('(')[0].strip()}",
  154. f"-- 源文件: docs/sop-templates/{code}.html",
  155. "",
  156. "UPDATE `biz_process`",
  157. "SET `sop_content` = '"
  158. f"{escaped}"
  159. "',",
  160. " `update_time` = NOW()",
  161. f"WHERE `process_code` = '{code}'",
  162. " AND `del_flag` = 0;",
  163. "",
  164. ]
  165. ),
  166. encoding="utf-8",
  167. )
  168. def write_deploy_bundle() -> Path:
  169. """合并 seed + sync SQL,供测试/生产一次性执行。"""
  170. deploy_path = SQL_DIR / "2026-08-28-sop-visible-section-only-deploy.sql"
  171. parts = [
  172. "-- =============================================================================",
  173. "-- SOP 精简展示:仅保留主操作记录区(页眉与其余正文行均已隐藏)",
  174. "-- 生成时间: 由 backend/scripts/apply_sop_row_hidden.py 自动生成",
  175. "-- 执行顺序: 先 seed 工艺库 → 再 sync 路线/任务步骤",
  176. "--",
  177. "-- 【生产环境】推荐方式(二选一):",
  178. "-- A) mysql -h <host> -u <user> -p <dbname> < docs/sql/2026-08-28-sop-visible-section-only-deploy.sql",
  179. "-- B) ENV_FILE=.env.production python3 backend/scripts/run_sop_visible_deploy.py",
  180. "-- =============================================================================",
  181. "",
  182. ]
  183. for code in SOP_CODES:
  184. short = code.replace("SOP-", "").replace("-E01-01", "").lower()
  185. seed = SQL_DIR / f"2026-08-25-seed-sop-{short}-content.sql"
  186. sync = SQL_DIR / f"2026-08-25-sync-sop-{short}-content-to-routes-tasks.sql"
  187. parts.append(f"-- ---------- {code} seed ----------")
  188. parts.append(seed.read_text(encoding="utf-8").strip())
  189. parts.append("")
  190. if sync.exists():
  191. parts.append(f"-- ---------- {code} sync ----------")
  192. parts.append(sync.read_text(encoding="utf-8").strip())
  193. parts.append("")
  194. deploy_path.write_text("\n".join(parts) + "\n", encoding="utf-8")
  195. return deploy_path
  196. def main() -> None:
  197. for code in SOP_CODES:
  198. path = TEMPLATES_DIR / f"{code}.html"
  199. html = path.read_text(encoding="utf-8")
  200. updated = process_template(html, code)
  201. path.write_text(updated, encoding="utf-8")
  202. write_seed_sql(code, updated)
  203. print(f"{code}: done")
  204. deploy = write_deploy_bundle()
  205. print(f"deploy bundle: {deploy}")
  206. if __name__ == "__main__":
  207. main()