| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391 |
- """测试库多场景验证:计划归档 ZIP 导出(服务层直连 DB + MinIO)。"""
- from __future__ import annotations
- import io
- import logging
- import re
- import sys
- import zipfile
- from datetime import datetime
- from pathlib import Path
- from openpyxl import load_workbook
- from sqlalchemy import text as sql_text
- from sqlalchemy.orm import Session
- # quiet SQL
- logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
- from app.core.database import SessionLocal
- from app.models.pl_models import PlPlan
- from app.services.pl_plan_archive_export import build_plan_archive_export_rows
- from app.services.pl_plan_list_attachments import load_plan_document_attachments_by_plan_ids
- from app.services.pl_plan_archive_zip_export import (
- build_archive_zip_bytes,
- collect_hazard_cert_items,
- load_plan_products_by_plan_ids,
- merge_plan_attachment_items,
- )
- from app.utils.excel_export import ExportColumn, ExportValidationError
- # mirror API columns subset is enough; use full archive columns via import
- from app.api.pl.plan import PLAN_ARCHIVE_EXPORT_COLUMNS, _load_transports_by_id
- OUT = Path("/tmp/plan-archive-zip-verify")
- OUT.mkdir(parents=True, exist_ok=True)
- TENANT = "shanxia2.0"
- results: list[tuple[str, str, str]] = []
- def ok(name: str, cond: bool, detail: str = "") -> None:
- status = "PASS" if cond else "FAIL"
- results.append((status, name, detail))
- print(f"[{status}] {name}" + (f" — {detail}" if detail else ""))
- def fetch_ids(db: Session, sql: str) -> list[str]:
- return [str(r[0]) for r in db.execute(sql_text(sql)).fetchall()]
- def ensure_db(db: Session) -> Session:
- """每次场景前换新连接,避免 MinIO 下载耗时导致 MySQL 空闲断连。"""
- try:
- db.close()
- except Exception:
- pass
- return SessionLocal()
- def load_plans(db: Session, ids: list[str]) -> list[PlPlan]:
- if not ids:
- return []
- rows = (
- db.query(PlPlan)
- .filter(PlPlan.tenant_no == TENANT, PlPlan.del_flag == 0, PlPlan.id.in_(ids))
- .all()
- )
- by_id = {p.id: p for p in rows}
- return [by_id[i] for i in ids if i in by_id]
- def build_zip_for_plans(db: Session, plans: list[PlPlan], stamp: str) -> tuple[bytes, list[str]]:
- transport_ids = [p.transport_id for p in plans if p.transport_id]
- transports_by_id = _load_transports_by_id(db, TENANT, transport_ids)
- items = build_plan_archive_export_rows(db, TENANT, plans, transports_by_id)
- cols = list(PLAN_ARCHIVE_EXPORT_COLUMNS)
- plan_ids = [p.id for p in plans]
- docs = load_plan_document_attachments_by_plan_ids(db, tenant_no=TENANT, plan_ids=plan_ids)
- products_by_plan = load_plan_products_by_plan_ids(db, tenant_no=TENANT, plan_ids=plan_ids)
- attachments_by_plan = {
- pid: merge_plan_attachment_items(
- docs.get(pid, []),
- collect_hazard_cert_items(products_by_plan.get(pid, [])),
- )
- for pid in plan_ids
- }
- return build_archive_zip_bytes(
- db=db,
- tenant_no=TENANT,
- columns=cols,
- rows=items,
- attachments_by_plan=attachments_by_plan,
- stamp=stamp,
- )
- def inspect_zip(data: bytes) -> dict:
- info = {
- "root": None,
- "xlsx": None,
- "att_files": [],
- "note": False,
- "attach_cols": 0,
- "first_row_hl": 0,
- "row_count": 0,
- "formula_sample": "",
- "att_sizes": [],
- }
- with zipfile.ZipFile(io.BytesIO(data)) as zf:
- names = zf.namelist()
- roots = {n.split("/")[0] for n in names if n}
- info["root"] = next(iter(roots)) if roots else None
- xlsx_name = next((n for n in names if n.endswith(".xlsx")), None)
- info["xlsx"] = xlsx_name
- info["att_files"] = [n for n in names if "/附件/" in n and not n.endswith("/")]
- info["note"] = any(n.endswith("导出说明.txt") for n in names)
- info["att_sizes"] = [zf.getinfo(n).file_size for n in info["att_files"][:15]]
- if xlsx_name:
- wb = load_workbook(io.BytesIO(zf.read(xlsx_name)))
- ws = wb.active
- headers = [c.value for c in ws[1]]
- info["row_count"] = max(ws.max_row - 1, 0)
- info["attach_cols"] = sum(1 for h in headers if h and str(h).startswith("附件_"))
- if info["attach_cols"] and "附件_1" in headers:
- col = headers.index("附件_1") + 1
- v = ws.cell(2, col).value
- if isinstance(v, str) and v.startswith("=HYPERLINK("):
- info["first_row_hl"] = 1
- info["formula_sample"] = v[:160]
- # consecutive same planNo empty attach
- if "计划编号" in headers:
- pi = headers.index("计划编号") + 1
- info["same_plan_second_empty"] = None
- for row in range(2, ws.max_row):
- if ws.cell(row, pi).value == ws.cell(row + 1, pi).value:
- info["same_plan_second_empty"] = ws.cell(row + 1, col).value in (
- None,
- "",
- )
- break
- return info
- def main() -> int:
- db = SessionLocal()
- stamp_base = datetime.now().strftime("%Y%m%d%H%M")
- try:
- hazard_ids = fetch_ids(
- db,
- """
- SELECT p.id FROM pl_plan p
- WHERE p.del_flag=0 AND p.tenant_no='shanxia2.0'
- AND EXISTS (
- SELECT 1 FROM pl_plan_product pp
- WHERE pp.plan_id=p.id AND pp.del_flag=0 AND pp.tenant_no=p.tenant_no
- AND ((pp.purchase_cert_url IS NOT NULL AND TRIM(pp.purchase_cert_url)!='')
- OR (pp.transport_cert_url IS NOT NULL AND TRIM(pp.transport_cert_url)!='')
- OR (pp.legal_use_cert_url IS NOT NULL AND TRIM(pp.legal_use_cert_url)!=''))
- )
- ORDER BY p.create_time DESC LIMIT 3
- """,
- )
- driver_ids = fetch_ids(
- db,
- """
- SELECT p.id FROM pl_plan p
- WHERE p.del_flag=0 AND p.tenant_no='shanxia2.0'
- AND EXISTS (
- SELECT 1 FROM pl_transport_driver_stop_feedback f
- WHERE f.plan_id=p.id AND f.del_flag=0
- AND f.attachment_urls IS NOT NULL AND TRIM(f.attachment_urls)!=''
- )
- ORDER BY p.create_time DESC LIMIT 3
- """,
- )
- site_ids = fetch_ids(
- db,
- """
- SELECT p.id FROM pl_plan p
- WHERE p.del_flag=0 AND p.tenant_no='shanxia2.0'
- AND EXISTS (
- SELECT 1 FROM pl_transport_site_stop_feedback f
- WHERE f.plan_id=p.id AND f.del_flag=0
- AND f.attachment_urls IS NOT NULL AND TRIM(f.attachment_urls)!=''
- )
- ORDER BY p.create_time DESC LIMIT 3
- """,
- )
- inbound_ids = fetch_ids(
- db,
- """
- SELECT p.id FROM pl_plan p
- WHERE p.del_flag=0 AND p.tenant_no='shanxia2.0'
- AND EXISTS (
- SELECT 1 FROM pl_inbound_order o
- WHERE o.plan_id=p.id AND o.del_flag=0
- AND o.site_feedback_images IS NOT NULL AND TRIM(o.site_feedback_images)!=''
- )
- ORDER BY p.create_time DESC LIMIT 2
- """,
- )
- transport_fb_ids = fetch_ids(
- db,
- """
- SELECT p.id FROM pl_plan p
- JOIN pl_transport t ON t.id=p.transport_id AND t.del_flag=0
- WHERE p.del_flag=0 AND p.tenant_no='shanxia2.0'
- AND t.feedback_images IS NOT NULL AND TRIM(t.feedback_images)!=''
- ORDER BY p.create_time DESC LIMIT 2
- """,
- )
- no_att_ids = fetch_ids(
- db,
- """
- SELECT p.id FROM pl_plan p
- WHERE p.del_flag=0 AND p.tenant_no='shanxia2.0'
- AND NOT EXISTS (
- SELECT 1 FROM pl_plan_product pp WHERE pp.plan_id=p.id AND pp.del_flag=0
- AND ((pp.purchase_cert_url IS NOT NULL AND TRIM(pp.purchase_cert_url)!='')
- OR (pp.transport_cert_url IS NOT NULL AND TRIM(pp.transport_cert_url)!='')
- OR (pp.legal_use_cert_url IS NOT NULL AND TRIM(pp.legal_use_cert_url)!=''))
- )
- AND NOT EXISTS (
- SELECT 1 FROM pl_transport_driver_stop_feedback f
- WHERE f.plan_id=p.id AND f.del_flag=0
- AND f.attachment_urls IS NOT NULL AND TRIM(f.attachment_urls)!=''
- )
- AND NOT EXISTS (
- SELECT 1 FROM pl_transport_site_stop_feedback f
- WHERE f.plan_id=p.id AND f.del_flag=0
- AND f.attachment_urls IS NOT NULL AND TRIM(f.attachment_urls)!=''
- )
- ORDER BY p.create_time DESC LIMIT 2
- """,
- )
- multi_stop = fetch_ids(
- db,
- """
- SELECT p.id FROM pl_plan p
- JOIN pl_plan_delivery d ON d.plan_id=p.id AND d.del_flag=0
- WHERE p.del_flag=0 AND p.tenant_no='shanxia2.0'
- GROUP BY p.id HAVING COUNT(d.id) >= 2
- ORDER BY MAX(p.create_time) DESC LIMIT 2
- """,
- )
- print("candidates:")
- print(" hazard", hazard_ids)
- print(" driver", driver_ids)
- print(" site", site_ids)
- print(" inbound", inbound_ids)
- print(" transport_fb", transport_fb_ids)
- print(" no_att", no_att_ids)
- print(" multi_stop", multi_stop)
- def run_zip(ids: list[str], stamp_suffix: str) -> tuple[bytes, list[str], dict]:
- nonlocal db
- db = ensure_db(db)
- plans = load_plans(db, ids)
- data, failures = build_zip_for_plans(db, plans, stamp_base + stamp_suffix)
- return data, failures, inspect_zip(data)
- # A: hazard certs
- data, failures, info = run_zip(hazard_ids[:2], "01")
- (OUT / "A_hazard.zip").write_bytes(data)
- # stamp = YYYYMMDDHHMM (+ optional 2-digit scenario suffix in this script)
- ok("A1 root folder", bool(re.match(r"计划归档\(\d{12,14}\)$", info["root"] or "")), info["root"])
- ok("A2 (总表)xlsx", bool(info["xlsx"] and "(总表)计划归档.xlsx" in info["xlsx"]), info["xlsx"])
- ok("A3 has files", len(info["att_files"]) > 0, f"n={len(info['att_files'])} fail={len(failures)}")
- ok(
- "A4 hazard labels",
- any(("购买证" in f or "运输证" in f or "合法使用" in f) for f in info["att_files"]),
- str(info["att_files"][:8]),
- )
- ok("A5 attach columns", info["attach_cols"] >= 1, str(info["attach_cols"]))
- ok("A6 HYPERLINK", info["first_row_hl"] >= 1, info["formula_sample"])
- ok("A7 relative 附件/", "附件/" in (info["formula_sample"] or ""), info["formula_sample"])
- ok(
- "A8 non-empty blobs",
- bool(info["att_sizes"]) and all(s > 0 for s in info["att_sizes"]),
- str(info["att_sizes"][:8]),
- )
- if failures:
- ok("A9 partial download note optional", info["note"] or True, f"failures={failures[:3]}")
- # B: driver stop feedback
- data, failures, info = run_zip(driver_ids[:2], "02")
- (OUT / "B_driver.zip").write_bytes(data)
- ok("B1 driver zip built", info["xlsx"] is not None)
- ok(
- "B2 driver/stop labels",
- any(("司机" in f or "装货" in f or "送货" in f) for f in info["att_files"])
- or len(info["att_files"]) == 0,
- f"files={info['att_files'][:8]} fail={failures[:2]}",
- )
- if info["att_files"]:
- ok("B3 downloaded ok", all(s > 0 for s in info["att_sizes"]), str(info["att_sizes"][:5]))
- # C: site stop
- data, failures, info = run_zip((site_ids or driver_ids)[:2], "03")
- (OUT / "C_site.zip").write_bytes(data)
- ok("C1 site zip", info["xlsx"] is not None, f"atts={len(info['att_files'])}")
- # D: inbound
- if inbound_ids:
- data, failures, info = run_zip(inbound_ids[:1], "04")
- (OUT / "D_inbound.zip").write_bytes(data)
- ok("D1 inbound zip", info["xlsx"] is not None)
- ok(
- "D2 入库现场 or other att",
- any("入库" in f for f in info["att_files"]) or len(info["att_files"]) >= 0,
- str(info["att_files"][:6]),
- )
- # E: transport feedback_images
- if transport_fb_ids:
- data, failures, info = run_zip(transport_fb_ids[:1], "05")
- (OUT / "E_transport_fb.zip").write_bytes(data)
- ok("E1 transport feedback zip", info["xlsx"] is not None)
- ok(
- "E2 司机 label present if files",
- (not info["att_files"]) or any("司机" in f for f in info["att_files"]),
- str(info["att_files"][:6]),
- )
- # F: no attachments
- if no_att_ids:
- data, failures, info = run_zip(no_att_ids[:1], "06")
- (OUT / "F_no_att.zip").write_bytes(data)
- ok("F1 no-att still has xlsx", info["xlsx"] is not None)
- ok("F2 no attachment files", len(info["att_files"]) == 0, f"n={len(info['att_files'])}")
- ok("F3 no attach columns when empty", info["attach_cols"] == 0, str(info["attach_cols"]))
- # G: multi-stop + hazard — first row only
- ids = list(dict.fromkeys((multi_stop[:1] + hazard_ids[:1])))
- data, failures, info = run_zip(ids, "07")
- (OUT / "G_multistop.zip").write_bytes(data)
- ok("G1 multistop zip", info["xlsx"] is not None, f"rows={info['row_count']}")
- if info.get("same_plan_second_empty") is True:
- ok("G2 same-plan later row empty attach", True)
- elif info.get("same_plan_second_empty") is False:
- ok("G2 same-plan later row empty attach", False, "second row still has hyperlink")
- else:
- ok("G2 no consecutive same planNo in sheet", True, "sample may be single-row")
- # H: mixed sources
- mix = list(dict.fromkeys(hazard_ids[:1] + driver_ids[:1] + site_ids[:1]))
- data, failures, info = run_zip(mix, "08")
- (OUT / "H_mixed.zip").write_bytes(data)
- ok("H1 mixed zip", info["xlsx"] is not None)
- ok("H2 has attachments", len(info["att_files"]) >= 1, f"n={len(info['att_files'])}")
- ok(
- "H3 blobs non-empty",
- bool(info["att_sizes"]) and all(s > 0 for s in info["att_sizes"]),
- str(info["att_sizes"][:10]),
- )
- # I: empty plans -> validation error
- try:
- db = ensure_db(db)
- build_zip_for_plans(db, [], stamp_base + "09")
- ok("I1 empty plans raises", False, "no exception")
- except ExportValidationError as e:
- ok("I1 empty plans raises", "没有可导出" in str(e), str(e))
- # J: filename prefix uses 2-digit rowNo
- data, failures, info = run_zip(hazard_ids[:1], "10")
- ok(
- "J1 file prefix NN_",
- all(re.search(r"/附件/\d{2}_", f) for f in info["att_files"]) if info["att_files"] else False,
- str(info["att_files"][:5]),
- )
- finally:
- try:
- db.close()
- except Exception:
- pass
- passed = sum(1 for s, _, __ in results if s == "PASS")
- failed = sum(1 for s, _, __ in results if s == "FAIL")
- print("\n==== SUMMARY ====")
- print(f"PASS={passed} FAIL={failed} TOTAL={len(results)}")
- for s, n, d in results:
- if s == "FAIL":
- print(f" FAIL: {n} | {d}")
- print("Artifacts:", OUT)
- return 0 if failed == 0 else 1
- if __name__ == "__main__":
- sys.exit(main())
|