check_field_consistency.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573
  1. #!/usr/bin/env python3
  2. """字段一致性检查器 — 检测 CRUD → API Schema → 前端 formData 字段缺失问题。
  3. 用法:
  4. python backend/scripts/check_field_consistency.py
  5. python backend/scripts/check_field_consistency.py --entity consumable
  6. python backend/scripts/check_field_consistency.py --json
  7. 退出码:
  8. 0 — 全部通过
  9. 1 — 存在警告(字段缺失)
  10. 2 — 存在错误(文件/类找不到等)
  11. """
  12. from __future__ import annotations
  13. import argparse
  14. import json
  15. import os
  16. import re
  17. import sys
  18. from typing import Any, Dict, List, Optional, Set, Tuple
  19. # ---------------------------------------------------------------------------
  20. # 项目根目录(从脚本位置推断)
  21. # ---------------------------------------------------------------------------
  22. _SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
  23. _PROJECT_ROOT = os.path.abspath(os.path.join(_SCRIPT_DIR, "..", ".."))
  24. # ---------------------------------------------------------------------------
  25. # 只读字段 — 这些字段出现在 _row_to_dict 但不应出现在 CreateBody/UpdateBody/formData
  26. # ---------------------------------------------------------------------------
  27. READ_ONLY_FIELDS: Set[str] = {
  28. "id",
  29. "createTime",
  30. "updateTime",
  31. "stockQty",
  32. "tenantNo",
  33. "attachmentFiles", # 计算字段,由后端从 URLs 生成
  34. }
  35. # ---------------------------------------------------------------------------
  36. # 实体注册表
  37. # ---------------------------------------------------------------------------
  38. ENTITIES: List[Dict[str, str]] = [
  39. {
  40. "name": "consumable",
  41. "crud_file": "backend/app/crud/consumable.py",
  42. "schema_file": "backend/app/api/production.py",
  43. "create_body": "ConsumableCreateBody",
  44. "update_body": "ConsumableUpdateBody",
  45. "frontend_file": "web-admin/src/views/production/consumable/index.vue",
  46. },
  47. {
  48. "name": "reagent",
  49. "crud_file": "backend/app/crud/reagent.py",
  50. "schema_file": "backend/app/api/production.py",
  51. "create_body": "ReagentCreateBody",
  52. "update_body": "ReagentUpdateBody",
  53. "frontend_file": "web-admin/src/views/production/reagent/index.vue",
  54. },
  55. {
  56. "name": "material (market)",
  57. "crud_file": "backend/app/crud/material.py",
  58. "schema_file": "backend/app/api/material.py",
  59. "create_body": "MaterialBody",
  60. "update_body": "MaterialUpdateBody",
  61. "frontend_file": "web-admin/src/views/market/material/index.vue",
  62. },
  63. {
  64. "name": "qc_material",
  65. "crud_file": "backend/app/crud/qc_material.py",
  66. "schema_file": "backend/app/api/qc.py",
  67. "create_body": "QcMaterialBody",
  68. "update_body": "QcMaterialUpdateBody",
  69. "frontend_file": "web-admin/src/views/quality/material/index.vue",
  70. },
  71. {
  72. "name": "sop_document",
  73. "crud_file": "backend/app/crud/sop_document.py",
  74. "schema_file": "backend/app/api/production.py",
  75. "create_body": "SopDocumentCreateBody",
  76. "update_body": "SopDocumentUpdateBody",
  77. "frontend_file": "web-admin/src/views/production/sop/index.vue",
  78. },
  79. ]
  80. # ===================================================================
  81. # 终端颜色辅助
  82. # ===================================================================
  83. class _C:
  84. """ANSI 颜色常量"""
  85. RESET = "\033[0m"
  86. GREEN = "\033[32m"
  87. YELLOW = "\033[33m"
  88. RED = "\033[31m"
  89. CYAN = "\033[36m"
  90. BOLD = "\033[1m"
  91. DIM = "\033[2m"
  92. def _supports_color() -> bool:
  93. """检测终端是否支持颜色"""
  94. if os.environ.get("NO_COLOR"):
  95. return False
  96. if not hasattr(sys.stdout, "isatty"):
  97. return False
  98. return sys.stdout.isatty()
  99. _COLOR = _supports_color()
  100. def cprint(text: str, color: str = "") -> None:
  101. if _COLOR and color:
  102. print("{}{}{}".format(color, text, _C.RESET))
  103. else:
  104. print(text)
  105. # ===================================================================
  106. # 工具函数
  107. # ===================================================================
  108. def _read_file(rel_path: str) -> Optional[str]:
  109. """读取项目根目录下的相对路径文件"""
  110. full = os.path.join(_PROJECT_ROOT, rel_path)
  111. if not os.path.isfile(full):
  112. return None
  113. with open(full, "r", encoding="utf-8", errors="replace") as f:
  114. return f.read()
  115. def _snake_to_camel(name: str) -> str:
  116. """snake_case → camelCase: warning_qty → warningQty"""
  117. parts = name.split("_")
  118. return parts[0] + "".join(p.capitalize() for p in parts[1:])
  119. # ===================================================================
  120. # 提取逻辑 — _row_to_dict
  121. # ===================================================================
  122. _RE_DICT_KEY = re.compile(r'"([a-zA-Z][a-zA-Z0-9]*)"\s*:')
  123. def extract_row_to_dict_fields(source: str) -> List[str]:
  124. """从 CRUD 文件提取 _row_to_dict 函数的字典键(camelCase)。
  125. 支持一个文件中有多个 _row_to_dict 的情况 — 只取第一个。
  126. """
  127. # 定位函数起始
  128. func_match = re.search(
  129. r"def\s+_row_to_dict\s*\([^)]*\)\s*->\s*[^:]+:", source
  130. )
  131. if not func_match:
  132. return []
  133. # 从函数开始往后找到第一个 return { ... } 块
  134. start = func_match.end()
  135. # 找到 return {
  136. ret_match = re.search(r"return\s*\{", source[start:])
  137. if not ret_match:
  138. return []
  139. brace_start = start + ret_match.end() - 1 # 指向 {
  140. # 匹配大括号
  141. depth = 0
  142. end = brace_start
  143. for i in range(brace_start, len(source)):
  144. ch = source[i]
  145. if ch == "{":
  146. depth += 1
  147. elif ch == "}":
  148. depth -= 1
  149. if depth == 0:
  150. end = i
  151. break
  152. block = source[brace_start : end + 1]
  153. keys = _RE_DICT_KEY.findall(block)
  154. # 去重但保持顺序
  155. seen = set()
  156. result = []
  157. for k in keys:
  158. if k not in seen:
  159. seen.add(k)
  160. result.append(k)
  161. return result
  162. # ===================================================================
  163. # 提取逻辑 — Pydantic Schema 类
  164. # ===================================================================
  165. _RE_FIELD_ALIAS = re.compile(r'alias\s*=\s*"([^"]+)"')
  166. _RE_FIELD_LINE = re.compile(
  167. r"^\s+([a-z_][a-z0-9_]*)\s*:\s*"
  168. )
  169. def extract_schema_fields(source: str, class_name: str) -> Optional[List[str]]:
  170. """从 Pydantic 类提取字段名(返回 camelCase 列表)。
  171. 优先使用 alias,否则 snake_case 转 camelCase。
  172. 返回 None 表示类未找到。
  173. """
  174. # 找到类定义
  175. class_match = re.search(
  176. r"class\s+{}\s*\([^)]*\)\s*:".format(re.escape(class_name)),
  177. source,
  178. )
  179. if not class_match:
  180. return None
  181. class_start = class_match.end()
  182. # 收集类体 — 到下一个顶层 class 或 def(不缩进)或文件末尾
  183. body_lines = []
  184. for line in source[class_start:].split("\n"):
  185. stripped = line.lstrip()
  186. if stripped and not stripped.startswith("#") and not line[0].isspace():
  187. # 到达下一个顶层定义
  188. break
  189. if stripped.startswith("class ") or (stripped.startswith("def ") and not line.startswith(" ")):
  190. break
  191. body_lines.append(line)
  192. body = "\n".join(body_lines)
  193. fields = []
  194. # 按行解析
  195. for line in body.split("\n"):
  196. field_match = _RE_FIELD_LINE.match(line)
  197. if not field_match:
  198. continue
  199. field_name = field_match.group(1)
  200. if field_name == "model_config":
  201. continue
  202. # 检查是否有 alias
  203. alias_match = _RE_FIELD_ALIAS.search(line)
  204. if alias_match:
  205. fields.append(alias_match.group(1))
  206. else:
  207. fields.append(_snake_to_camel(field_name))
  208. return fields
  209. # ===================================================================
  210. # 提取逻辑 — 前端 formData
  211. # ===================================================================
  212. _RE_FORMDATA_KEY = re.compile(r"^\s+([a-zA-Z][a-zA-Z0-9]*)\s*:")
  213. def extract_frontend_formdata(source: str) -> List[str]:
  214. """从 Vue 文件提取 formData 的键名(camelCase)。"""
  215. # 匹配多种写法: reactive({ ... }), reactive<SomeType>({ ... })
  216. match = re.search(
  217. r"(?:const|let|var)\s+formData\s*=\s*reactive(?:<[^>]*>)?\s*\(\s*\{",
  218. source,
  219. )
  220. if not match:
  221. return []
  222. start = match.end() - 1 # 指向 {
  223. depth = 0
  224. end = start
  225. for i in range(start, len(source)):
  226. ch = source[i]
  227. if ch == "{":
  228. depth += 1
  229. elif ch == "}":
  230. depth -= 1
  231. if depth == 0:
  232. end = i
  233. break
  234. block = source[start : end + 1]
  235. fields = []
  236. for line in block.split("\n"):
  237. m = _RE_FORMDATA_KEY.match(line)
  238. if m:
  239. fields.append(m.group(1))
  240. return fields
  241. # ===================================================================
  242. # 单个实体检查
  243. # ===================================================================
  244. def check_entity(entity: Dict[str, str]) -> Dict[str, Any]:
  245. """检查单个实体的字段一致性,返回报告字典。"""
  246. name = entity["name"]
  247. report: Dict[str, Any] = {
  248. "entity": name,
  249. "status": "ok",
  250. "crud_fields": [],
  251. "create_body_fields": None,
  252. "update_body_fields": None,
  253. "frontend_fields": [],
  254. "warnings": [],
  255. "errors": [],
  256. }
  257. # 1. 读取 CRUD 文件
  258. crud_src = _read_file(entity["crud_file"])
  259. if crud_src is None:
  260. report["status"] = "error"
  261. report["errors"].append("CRUD file not found: {}".format(entity["crud_file"]))
  262. return report
  263. crud_fields = extract_row_to_dict_fields(crud_src)
  264. report["crud_fields"] = crud_fields
  265. if not crud_fields:
  266. report["status"] = "error"
  267. report["errors"].append(
  268. "No _row_to_dict fields found in {}".format(entity["crud_file"])
  269. )
  270. return report
  271. writable_fields = [f for f in crud_fields if f not in READ_ONLY_FIELDS]
  272. # 2. 读取 Schema 文件
  273. schema_src = _read_file(entity["schema_file"])
  274. if schema_src is None:
  275. report["status"] = "error"
  276. report["errors"].append(
  277. "Schema file not found: {}".format(entity["schema_file"])
  278. )
  279. return report
  280. # 3. 提取 CreateBody
  281. create_fields = extract_schema_fields(schema_src, entity["create_body"])
  282. report["create_body_fields"] = create_fields
  283. if create_fields is None:
  284. report["status"] = "warning"
  285. report["warnings"].append(
  286. "Create body class '{}' not found in {}".format(
  287. entity["create_body"], entity["schema_file"]
  288. )
  289. )
  290. else:
  291. missing = [f for f in writable_fields if f not in create_fields]
  292. if missing:
  293. report["status"] = "warning"
  294. report["warnings"].append(
  295. "CreateBody missing fields: {}".format(", ".join(sorted(missing)))
  296. )
  297. # 4. 提取 UpdateBody
  298. update_fields = extract_schema_fields(schema_src, entity["update_body"])
  299. report["update_body_fields"] = update_fields
  300. if update_fields is None:
  301. report["status"] = "warning"
  302. report["warnings"].append(
  303. "Update body class '{}' not found in {}".format(
  304. entity["update_body"], entity["schema_file"]
  305. )
  306. )
  307. else:
  308. missing = [f for f in writable_fields if f not in update_fields]
  309. if missing:
  310. report["status"] = "warning"
  311. report["warnings"].append(
  312. "UpdateBody missing fields: {}".format(", ".join(sorted(missing)))
  313. )
  314. # 5. 读取前端文件
  315. fe_src = _read_file(entity["frontend_file"])
  316. if fe_src is None:
  317. report["status"] = "warning"
  318. report["warnings"].append(
  319. "Frontend file not found: {}".format(entity["frontend_file"])
  320. )
  321. return report
  322. fe_fields = extract_frontend_formdata(fe_src)
  323. report["frontend_fields"] = fe_fields
  324. if not fe_fields:
  325. report["status"] = "warning"
  326. report["warnings"].append(
  327. "No formData found in {}".format(entity["frontend_file"])
  328. )
  329. return report
  330. missing_fe = [f for f in writable_fields if f not in fe_fields]
  331. if missing_fe:
  332. report["status"] = "warning"
  333. report["warnings"].append(
  334. "Frontend formData missing fields: {}".format(
  335. ", ".join(sorted(missing_fe))
  336. )
  337. )
  338. # 6. 反向检查: Schema 有但 _row_to_dict 没有的字段
  339. if create_fields is not None:
  340. extra_create = [f for f in create_fields if f not in crud_fields]
  341. if extra_create:
  342. report.setdefault("info", []).append(
  343. "CreateBody has extra fields (not in _row_to_dict): {}".format(
  344. ", ".join(sorted(extra_create))
  345. )
  346. )
  347. # 7. 反向检查: formData 有但 _row_to_dict 没有的字段
  348. extra_fe = [f for f in fe_fields if f not in crud_fields]
  349. if extra_fe:
  350. report.setdefault("info", []).append(
  351. "Frontend formData has extra fields (not in _row_to_dict): {}".format(
  352. ", ".join(sorted(extra_fe))
  353. )
  354. )
  355. return report
  356. # ===================================================================
  357. # 报告输出
  358. # ===================================================================
  359. def print_report(report: Dict[str, Any]) -> None:
  360. """打印单个实体的检查报告(终端彩色输出)"""
  361. name = report["entity"]
  362. status = report["status"]
  363. if status == "ok":
  364. cprint(" [OK] {}".format(name), _C.GREEN)
  365. elif status == "warning":
  366. cprint(" [!!] {} — warnings".format(name), _C.YELLOW)
  367. else:
  368. cprint(" [XX] {} — errors".format(name), _C.RED)
  369. # CRUD fields
  370. crud_fields = report.get("crud_fields", [])
  371. if crud_fields:
  372. writable = [f for f in crud_fields if f not in READ_ONLY_FIELDS]
  373. read_only = [f for f in crud_fields if f in READ_ONLY_FIELDS]
  374. cprint(
  375. " _row_to_dict ({} total, {} writable): {}".format(
  376. len(crud_fields),
  377. len(writable),
  378. ", ".join(writable),
  379. ),
  380. _C.DIM,
  381. )
  382. if read_only:
  383. cprint(
  384. " read-only: {}".format(", ".join(sorted(read_only))),
  385. _C.DIM,
  386. )
  387. # Schema fields
  388. for body_label, body_key in [
  389. ("CreateBody", "create_body_fields"),
  390. ("UpdateBody", "update_body_fields"),
  391. ]:
  392. fields = report.get(body_key)
  393. if fields is not None:
  394. cprint(
  395. " {}: {}".format(body_label, ", ".join(fields)),
  396. _C.DIM,
  397. )
  398. # Frontend fields
  399. fe_fields = report.get("frontend_fields", [])
  400. if fe_fields:
  401. cprint(
  402. " formData: {}".format(", ".join(fe_fields)),
  403. _C.DIM,
  404. )
  405. # Warnings
  406. for w in report.get("warnings", []):
  407. cprint(" WARN: {}".format(w), _C.YELLOW)
  408. # Errors
  409. for e in report.get("errors", []):
  410. cprint(" ERR: {}".format(e), _C.RED)
  411. # Info
  412. for info in report.get("info", []):
  413. cprint(" INFO: {}".format(info), _C.CYAN)
  414. print()
  415. def print_json_report(reports: List[Dict[str, Any]]) -> None:
  416. """输出 JSON 格式报告"""
  417. print(json.dumps(reports, indent=2, ensure_ascii=False))
  418. # ===================================================================
  419. # 主入口
  420. # ===================================================================
  421. def main() -> int:
  422. parser = argparse.ArgumentParser(
  423. description="字段一致性检查器 — CRUD ↔ Schema ↔ 前端 formData"
  424. )
  425. parser.add_argument(
  426. "--entity",
  427. type=str,
  428. default=None,
  429. help="只检查指定实体名称(如 consumable)",
  430. )
  431. parser.add_argument(
  432. "--json",
  433. action="store_true",
  434. dest="json_output",
  435. help="输出 JSON 格式",
  436. )
  437. args = parser.parse_args()
  438. # 筛选实体
  439. entities = ENTITIES
  440. if args.entity:
  441. entities = [e for e in ENTITIES if e["name"] == args.entity]
  442. if not entities:
  443. cprint(
  444. "Entity '{}' not found. Available: {}".format(
  445. args.entity, ", ".join(e["name"] for e in ENTITIES)
  446. ),
  447. _C.RED,
  448. )
  449. return 2
  450. if not args.json_output:
  451. cprint(
  452. "Field Consistency Checker — checking {} entities".format(
  453. len(entities)
  454. ),
  455. _C.BOLD,
  456. )
  457. cprint("Project root: {}".format(_PROJECT_ROOT), _C.DIM)
  458. print()
  459. reports = []
  460. has_warnings = False
  461. has_errors = False
  462. for entity in entities:
  463. report = check_entity(entity)
  464. reports.append(report)
  465. if report["status"] == "warning":
  466. has_warnings = True
  467. elif report["status"] == "error":
  468. has_errors = True
  469. if args.json_output:
  470. print_json_report(reports)
  471. else:
  472. for report in reports:
  473. print_report(report)
  474. # 汇总
  475. ok_count = sum(1 for r in reports if r["status"] == "ok")
  476. warn_count = sum(1 for r in reports if r["status"] == "warning")
  477. err_count = sum(1 for r in reports if r["status"] == "error")
  478. cprint(
  479. "Summary: {} OK, {} warnings, {} errors (total {})".format(
  480. ok_count, warn_count, err_count, len(reports)
  481. ),
  482. _C.BOLD,
  483. )
  484. if has_errors:
  485. return 2
  486. if has_warnings:
  487. return 1
  488. return 0
  489. if __name__ == "__main__":
  490. sys.exit(main())