verify_plan_archive_zip_export.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  1. """测试库多场景验证:计划归档 ZIP 导出(服务层直连 DB + MinIO)。"""
  2. from __future__ import annotations
  3. import io
  4. import logging
  5. import re
  6. import sys
  7. import zipfile
  8. from datetime import datetime
  9. from pathlib import Path
  10. from openpyxl import load_workbook
  11. from sqlalchemy import text as sql_text
  12. from sqlalchemy.orm import Session
  13. # quiet SQL
  14. logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
  15. from app.core.database import SessionLocal
  16. from app.models.pl_models import PlPlan
  17. from app.services.pl_plan_archive_export import build_plan_archive_export_rows
  18. from app.services.pl_plan_list_attachments import load_plan_document_attachments_by_plan_ids
  19. from app.services.pl_plan_archive_zip_export import (
  20. build_archive_zip_bytes,
  21. collect_hazard_cert_items,
  22. load_plan_products_by_plan_ids,
  23. merge_plan_attachment_items,
  24. )
  25. from app.utils.excel_export import ExportColumn, ExportValidationError
  26. # mirror API columns subset is enough; use full archive columns via import
  27. from app.api.pl.plan import PLAN_ARCHIVE_EXPORT_COLUMNS, _load_transports_by_id
  28. OUT = Path("/tmp/plan-archive-zip-verify")
  29. OUT.mkdir(parents=True, exist_ok=True)
  30. TENANT = "shanxia2.0"
  31. results: list[tuple[str, str, str]] = []
  32. def ok(name: str, cond: bool, detail: str = "") -> None:
  33. status = "PASS" if cond else "FAIL"
  34. results.append((status, name, detail))
  35. print(f"[{status}] {name}" + (f" — {detail}" if detail else ""))
  36. def fetch_ids(db: Session, sql: str) -> list[str]:
  37. return [str(r[0]) for r in db.execute(sql_text(sql)).fetchall()]
  38. def ensure_db(db: Session) -> Session:
  39. """每次场景前换新连接,避免 MinIO 下载耗时导致 MySQL 空闲断连。"""
  40. try:
  41. db.close()
  42. except Exception:
  43. pass
  44. return SessionLocal()
  45. def load_plans(db: Session, ids: list[str]) -> list[PlPlan]:
  46. if not ids:
  47. return []
  48. rows = (
  49. db.query(PlPlan)
  50. .filter(PlPlan.tenant_no == TENANT, PlPlan.del_flag == 0, PlPlan.id.in_(ids))
  51. .all()
  52. )
  53. by_id = {p.id: p for p in rows}
  54. return [by_id[i] for i in ids if i in by_id]
  55. def build_zip_for_plans(db: Session, plans: list[PlPlan], stamp: str) -> tuple[bytes, list[str]]:
  56. transport_ids = [p.transport_id for p in plans if p.transport_id]
  57. transports_by_id = _load_transports_by_id(db, TENANT, transport_ids)
  58. items = build_plan_archive_export_rows(db, TENANT, plans, transports_by_id)
  59. cols = list(PLAN_ARCHIVE_EXPORT_COLUMNS)
  60. plan_ids = [p.id for p in plans]
  61. docs = load_plan_document_attachments_by_plan_ids(db, tenant_no=TENANT, plan_ids=plan_ids)
  62. products_by_plan = load_plan_products_by_plan_ids(db, tenant_no=TENANT, plan_ids=plan_ids)
  63. attachments_by_plan = {
  64. pid: merge_plan_attachment_items(
  65. docs.get(pid, []),
  66. collect_hazard_cert_items(products_by_plan.get(pid, [])),
  67. )
  68. for pid in plan_ids
  69. }
  70. return build_archive_zip_bytes(
  71. db=db,
  72. tenant_no=TENANT,
  73. columns=cols,
  74. rows=items,
  75. attachments_by_plan=attachments_by_plan,
  76. stamp=stamp,
  77. )
  78. def inspect_zip(data: bytes) -> dict:
  79. info = {
  80. "root": None,
  81. "xlsx": None,
  82. "att_files": [],
  83. "note": False,
  84. "attach_cols": 0,
  85. "first_row_hl": 0,
  86. "row_count": 0,
  87. "formula_sample": "",
  88. "att_sizes": [],
  89. }
  90. with zipfile.ZipFile(io.BytesIO(data)) as zf:
  91. names = zf.namelist()
  92. roots = {n.split("/")[0] for n in names if n}
  93. info["root"] = next(iter(roots)) if roots else None
  94. xlsx_name = next((n for n in names if n.endswith(".xlsx")), None)
  95. info["xlsx"] = xlsx_name
  96. info["att_files"] = [n for n in names if "/附件/" in n and not n.endswith("/")]
  97. info["note"] = any(n.endswith("导出说明.txt") for n in names)
  98. info["att_sizes"] = [zf.getinfo(n).file_size for n in info["att_files"][:15]]
  99. if xlsx_name:
  100. wb = load_workbook(io.BytesIO(zf.read(xlsx_name)))
  101. ws = wb.active
  102. headers = [c.value for c in ws[1]]
  103. info["row_count"] = max(ws.max_row - 1, 0)
  104. info["attach_cols"] = sum(1 for h in headers if h and str(h).startswith("附件_"))
  105. if info["attach_cols"] and "附件_1" in headers:
  106. col = headers.index("附件_1") + 1
  107. v = ws.cell(2, col).value
  108. if isinstance(v, str) and v.startswith("=HYPERLINK("):
  109. info["first_row_hl"] = 1
  110. info["formula_sample"] = v[:160]
  111. # consecutive same planNo empty attach
  112. if "计划编号" in headers:
  113. pi = headers.index("计划编号") + 1
  114. info["same_plan_second_empty"] = None
  115. for row in range(2, ws.max_row):
  116. if ws.cell(row, pi).value == ws.cell(row + 1, pi).value:
  117. info["same_plan_second_empty"] = ws.cell(row + 1, col).value in (
  118. None,
  119. "",
  120. )
  121. break
  122. return info
  123. def main() -> int:
  124. db = SessionLocal()
  125. stamp_base = datetime.now().strftime("%Y%m%d%H%M")
  126. try:
  127. hazard_ids = fetch_ids(
  128. db,
  129. """
  130. SELECT p.id FROM pl_plan p
  131. WHERE p.del_flag=0 AND p.tenant_no='shanxia2.0'
  132. AND EXISTS (
  133. SELECT 1 FROM pl_plan_product pp
  134. WHERE pp.plan_id=p.id AND pp.del_flag=0 AND pp.tenant_no=p.tenant_no
  135. AND ((pp.purchase_cert_url IS NOT NULL AND TRIM(pp.purchase_cert_url)!='')
  136. OR (pp.transport_cert_url IS NOT NULL AND TRIM(pp.transport_cert_url)!='')
  137. OR (pp.legal_use_cert_url IS NOT NULL AND TRIM(pp.legal_use_cert_url)!=''))
  138. )
  139. ORDER BY p.create_time DESC LIMIT 3
  140. """,
  141. )
  142. driver_ids = fetch_ids(
  143. db,
  144. """
  145. SELECT p.id FROM pl_plan p
  146. WHERE p.del_flag=0 AND p.tenant_no='shanxia2.0'
  147. AND EXISTS (
  148. SELECT 1 FROM pl_transport_driver_stop_feedback f
  149. WHERE f.plan_id=p.id AND f.del_flag=0
  150. AND f.attachment_urls IS NOT NULL AND TRIM(f.attachment_urls)!=''
  151. )
  152. ORDER BY p.create_time DESC LIMIT 3
  153. """,
  154. )
  155. site_ids = fetch_ids(
  156. db,
  157. """
  158. SELECT p.id FROM pl_plan p
  159. WHERE p.del_flag=0 AND p.tenant_no='shanxia2.0'
  160. AND EXISTS (
  161. SELECT 1 FROM pl_transport_site_stop_feedback f
  162. WHERE f.plan_id=p.id AND f.del_flag=0
  163. AND f.attachment_urls IS NOT NULL AND TRIM(f.attachment_urls)!=''
  164. )
  165. ORDER BY p.create_time DESC LIMIT 3
  166. """,
  167. )
  168. inbound_ids = fetch_ids(
  169. db,
  170. """
  171. SELECT p.id FROM pl_plan p
  172. WHERE p.del_flag=0 AND p.tenant_no='shanxia2.0'
  173. AND EXISTS (
  174. SELECT 1 FROM pl_inbound_order o
  175. WHERE o.plan_id=p.id AND o.del_flag=0
  176. AND o.site_feedback_images IS NOT NULL AND TRIM(o.site_feedback_images)!=''
  177. )
  178. ORDER BY p.create_time DESC LIMIT 2
  179. """,
  180. )
  181. transport_fb_ids = fetch_ids(
  182. db,
  183. """
  184. SELECT p.id FROM pl_plan p
  185. JOIN pl_transport t ON t.id=p.transport_id AND t.del_flag=0
  186. WHERE p.del_flag=0 AND p.tenant_no='shanxia2.0'
  187. AND t.feedback_images IS NOT NULL AND TRIM(t.feedback_images)!=''
  188. ORDER BY p.create_time DESC LIMIT 2
  189. """,
  190. )
  191. no_att_ids = fetch_ids(
  192. db,
  193. """
  194. SELECT p.id FROM pl_plan p
  195. WHERE p.del_flag=0 AND p.tenant_no='shanxia2.0'
  196. AND NOT EXISTS (
  197. SELECT 1 FROM pl_plan_product pp WHERE pp.plan_id=p.id AND pp.del_flag=0
  198. AND ((pp.purchase_cert_url IS NOT NULL AND TRIM(pp.purchase_cert_url)!='')
  199. OR (pp.transport_cert_url IS NOT NULL AND TRIM(pp.transport_cert_url)!='')
  200. OR (pp.legal_use_cert_url IS NOT NULL AND TRIM(pp.legal_use_cert_url)!=''))
  201. )
  202. AND NOT EXISTS (
  203. SELECT 1 FROM pl_transport_driver_stop_feedback f
  204. WHERE f.plan_id=p.id AND f.del_flag=0
  205. AND f.attachment_urls IS NOT NULL AND TRIM(f.attachment_urls)!=''
  206. )
  207. AND NOT EXISTS (
  208. SELECT 1 FROM pl_transport_site_stop_feedback f
  209. WHERE f.plan_id=p.id AND f.del_flag=0
  210. AND f.attachment_urls IS NOT NULL AND TRIM(f.attachment_urls)!=''
  211. )
  212. ORDER BY p.create_time DESC LIMIT 2
  213. """,
  214. )
  215. multi_stop = fetch_ids(
  216. db,
  217. """
  218. SELECT p.id FROM pl_plan p
  219. JOIN pl_plan_delivery d ON d.plan_id=p.id AND d.del_flag=0
  220. WHERE p.del_flag=0 AND p.tenant_no='shanxia2.0'
  221. GROUP BY p.id HAVING COUNT(d.id) >= 2
  222. ORDER BY MAX(p.create_time) DESC LIMIT 2
  223. """,
  224. )
  225. print("candidates:")
  226. print(" hazard", hazard_ids)
  227. print(" driver", driver_ids)
  228. print(" site", site_ids)
  229. print(" inbound", inbound_ids)
  230. print(" transport_fb", transport_fb_ids)
  231. print(" no_att", no_att_ids)
  232. print(" multi_stop", multi_stop)
  233. def run_zip(ids: list[str], stamp_suffix: str) -> tuple[bytes, list[str], dict]:
  234. nonlocal db
  235. db = ensure_db(db)
  236. plans = load_plans(db, ids)
  237. data, failures = build_zip_for_plans(db, plans, stamp_base + stamp_suffix)
  238. return data, failures, inspect_zip(data)
  239. # A: hazard certs
  240. data, failures, info = run_zip(hazard_ids[:2], "01")
  241. (OUT / "A_hazard.zip").write_bytes(data)
  242. # stamp = YYYYMMDDHHMM (+ optional 2-digit scenario suffix in this script)
  243. ok("A1 root folder", bool(re.match(r"计划归档\(\d{12,14}\)$", info["root"] or "")), info["root"])
  244. ok("A2 (总表)xlsx", bool(info["xlsx"] and "(总表)计划归档.xlsx" in info["xlsx"]), info["xlsx"])
  245. ok("A3 has files", len(info["att_files"]) > 0, f"n={len(info['att_files'])} fail={len(failures)}")
  246. ok(
  247. "A4 hazard labels",
  248. any(("购买证" in f or "运输证" in f or "合法使用" in f) for f in info["att_files"]),
  249. str(info["att_files"][:8]),
  250. )
  251. ok("A5 attach columns", info["attach_cols"] >= 1, str(info["attach_cols"]))
  252. ok("A6 HYPERLINK", info["first_row_hl"] >= 1, info["formula_sample"])
  253. ok("A7 relative 附件/", "附件/" in (info["formula_sample"] or ""), info["formula_sample"])
  254. ok(
  255. "A8 non-empty blobs",
  256. bool(info["att_sizes"]) and all(s > 0 for s in info["att_sizes"]),
  257. str(info["att_sizes"][:8]),
  258. )
  259. if failures:
  260. ok("A9 partial download note optional", info["note"] or True, f"failures={failures[:3]}")
  261. # B: driver stop feedback
  262. data, failures, info = run_zip(driver_ids[:2], "02")
  263. (OUT / "B_driver.zip").write_bytes(data)
  264. ok("B1 driver zip built", info["xlsx"] is not None)
  265. ok(
  266. "B2 driver/stop labels",
  267. any(("司机" in f or "装货" in f or "送货" in f) for f in info["att_files"])
  268. or len(info["att_files"]) == 0,
  269. f"files={info['att_files'][:8]} fail={failures[:2]}",
  270. )
  271. if info["att_files"]:
  272. ok("B3 downloaded ok", all(s > 0 for s in info["att_sizes"]), str(info["att_sizes"][:5]))
  273. # C: site stop
  274. data, failures, info = run_zip((site_ids or driver_ids)[:2], "03")
  275. (OUT / "C_site.zip").write_bytes(data)
  276. ok("C1 site zip", info["xlsx"] is not None, f"atts={len(info['att_files'])}")
  277. # D: inbound
  278. if inbound_ids:
  279. data, failures, info = run_zip(inbound_ids[:1], "04")
  280. (OUT / "D_inbound.zip").write_bytes(data)
  281. ok("D1 inbound zip", info["xlsx"] is not None)
  282. ok(
  283. "D2 入库现场 or other att",
  284. any("入库" in f for f in info["att_files"]) or len(info["att_files"]) >= 0,
  285. str(info["att_files"][:6]),
  286. )
  287. # E: transport feedback_images
  288. if transport_fb_ids:
  289. data, failures, info = run_zip(transport_fb_ids[:1], "05")
  290. (OUT / "E_transport_fb.zip").write_bytes(data)
  291. ok("E1 transport feedback zip", info["xlsx"] is not None)
  292. ok(
  293. "E2 司机 label present if files",
  294. (not info["att_files"]) or any("司机" in f for f in info["att_files"]),
  295. str(info["att_files"][:6]),
  296. )
  297. # F: no attachments
  298. if no_att_ids:
  299. data, failures, info = run_zip(no_att_ids[:1], "06")
  300. (OUT / "F_no_att.zip").write_bytes(data)
  301. ok("F1 no-att still has xlsx", info["xlsx"] is not None)
  302. ok("F2 no attachment files", len(info["att_files"]) == 0, f"n={len(info['att_files'])}")
  303. ok("F3 no attach columns when empty", info["attach_cols"] == 0, str(info["attach_cols"]))
  304. # G: multi-stop + hazard — first row only
  305. ids = list(dict.fromkeys((multi_stop[:1] + hazard_ids[:1])))
  306. data, failures, info = run_zip(ids, "07")
  307. (OUT / "G_multistop.zip").write_bytes(data)
  308. ok("G1 multistop zip", info["xlsx"] is not None, f"rows={info['row_count']}")
  309. if info.get("same_plan_second_empty") is True:
  310. ok("G2 same-plan later row empty attach", True)
  311. elif info.get("same_plan_second_empty") is False:
  312. ok("G2 same-plan later row empty attach", False, "second row still has hyperlink")
  313. else:
  314. ok("G2 no consecutive same planNo in sheet", True, "sample may be single-row")
  315. # H: mixed sources
  316. mix = list(dict.fromkeys(hazard_ids[:1] + driver_ids[:1] + site_ids[:1]))
  317. data, failures, info = run_zip(mix, "08")
  318. (OUT / "H_mixed.zip").write_bytes(data)
  319. ok("H1 mixed zip", info["xlsx"] is not None)
  320. ok("H2 has attachments", len(info["att_files"]) >= 1, f"n={len(info['att_files'])}")
  321. ok(
  322. "H3 blobs non-empty",
  323. bool(info["att_sizes"]) and all(s > 0 for s in info["att_sizes"]),
  324. str(info["att_sizes"][:10]),
  325. )
  326. # I: empty plans -> validation error
  327. try:
  328. db = ensure_db(db)
  329. build_zip_for_plans(db, [], stamp_base + "09")
  330. ok("I1 empty plans raises", False, "no exception")
  331. except ExportValidationError as e:
  332. ok("I1 empty plans raises", "没有可导出" in str(e), str(e))
  333. # J: filename prefix uses 2-digit rowNo
  334. data, failures, info = run_zip(hazard_ids[:1], "10")
  335. ok(
  336. "J1 file prefix NN_",
  337. all(re.search(r"/附件/\d{2}_", f) for f in info["att_files"]) if info["att_files"] else False,
  338. str(info["att_files"][:5]),
  339. )
  340. finally:
  341. try:
  342. db.close()
  343. except Exception:
  344. pass
  345. passed = sum(1 for s, _, __ in results if s == "PASS")
  346. failed = sum(1 for s, _, __ in results if s == "FAIL")
  347. print("\n==== SUMMARY ====")
  348. print(f"PASS={passed} FAIL={failed} TOTAL={len(results)}")
  349. for s, n, d in results:
  350. if s == "FAIL":
  351. print(f" FAIL: {n} | {d}")
  352. print("Artifacts:", OUT)
  353. return 0 if failed == 0 else 1
  354. if __name__ == "__main__":
  355. sys.exit(main())