"""资质库上传识别(vision-plan 第 2 步):上传 → 识别 → 核对保存;识别值留痕、原件挂成附件; 打码、置信度低、对不上的字段不当成确定值;重复证书、主体不一致、只写了有效期长度的提醒;限次;没开 OCR 时说明。 OCR 服务换成桩(按图片返回预先准备的行),模型用确定性的假模型(按「证书编号:」这类写法取值)。 """ from __future__ import annotations import io from datetime import date from typing import Any from uuid import UUID import httpx import pytest from PIL import Image from bid_doctor.document.pipeline import ocr from bid_doctor.qualification import recognize from bid_doctor.qualification import service as qualifications from tests.conftest import User def png(w: int = 800, h: int = 560) -> bytes: buf = io.BytesIO() Image.new("RGB", (w, h), "white").save(buf, "PNG") return buf.getvalue() def webp() -> bytes: buf = io.BytesIO() Image.new("RGB", (400, 300), "white").save(buf, "WEBP") return buf.getvalue() def line(text: str, y: float, score: float = 0.99) -> dict[str, Any]: return { "box": [[40, y], [600, y], [600, y + 30], [40, y + 30]], "upright": [40, y, 600, y + 30], "text": text, "score": score, } CERT = [ line("信息系统建设和服务能力等级证书", 20), line("企业名称:测试集成公司", 80), line("证书编号:CS3-3201-2024-0417", 120), line("等级:CS3", 160), line("发证机构:中国电子信息行业联合会", 200), line("发证日期:2024年04月18日", 240), line("有效期至:2028年04月17日", 280), ] @pytest.fixture def fake_ocr(monkeypatch: pytest.MonkeyPatch) -> list[list[dict[str, Any]]]: """打开 OCR,识别换成桩:按调用顺序依次返回准备好的行(每页一组)。""" queue: list[list[dict[str, Any]]] = [] monkeypatch.setattr(ocr, "enabled", lambda: True) async def recognize_(client: httpx.AsyncClient, image: bytes, *, full_page: bool) -> dict[str, Any]: assert full_page return {"rotation": 0, "lines": queue.pop(0) if queue else [], "stamps": []} monkeypatch.setattr(ocr, "recognize", recognize_) return queue async def upload(u: User, filename: str, data: bytes) -> str: r = await u.post("/uploads", json={"filename": filename, "size_bytes": len(data), "purpose": "qualification_file"}) assert r.status_code == 201, r.text up = r.json() assert (await u.put(f"/uploads/{up['id']}/parts/1", content=data)).status_code == 204 done = await u.post(f"/uploads/{up['id']}/complete") assert done.status_code == 200, done.text return up["id"] async def scan(u: User, filename: str = "cs3.png", data: bytes | None = None) -> dict[str, Any]: r = await u.post("/qualification-scans", json={"upload_id": await upload(u, filename, data or png())}) assert r.status_code == 201, r.text assert r.json()["status"] == "queued" await recognize.run(UUID(u.tenant_id), UUID(r.json()["id"])) # 测试进程里直接跑,不经过 worker return (await u.get(f"/qualification-scans/{r.json()['id']}")).json() async def test_scan_then_save(user: User, fake_ocr: list[list[dict[str, Any]]]) -> None: fake_ocr.append(CERT) s = await scan(user) assert s["status"] == "succeeded" and s["error"] is None (page,) = s["pages"] assert (page["width"], page["height"]) == (800, 560) and "sig=" in page["url"] (c,) = s["certificates"] assert c["category"] == "enterprise_qualification" and c["kind"] == "certificate" and c["page"] == 1 f = c["fields"] assert f["cert_no"]["value"] == "CS3-3201-2024-0417" and f["cert_no"]["check"] == "ok" assert f["valid_to"]["day"] == "2028-04-17" and f["valid_to"]["boxes"] == [[40, 280, 600, 310]] assert c["notes"] == [] and c["existing"] is None # 证书主体就是本企业 # 页面预览图(签名地址,不带鉴权头) img = await user.client.get("http://test" + page["url"].split("8009", 1)[-1]) assert img.status_code == 200 and img.headers["content-type"] == "image/jpeg" body = { "name": c["name"], "kind": c["kind"], "level": f["level"]["value"], "cert_no": f["cert_no"]["value"], "issuer": f["issuer"]["value"], "holder": f["holder"]["value"], "valid_from": None, "valid_to": f["valid_to"]["day"], "scan": {"scan_id": s["id"], "index": 0}, } r = await user.post("/qualifications", json=body) assert r.status_code == 201, r.text q = r.json() assert q["holder"] == "测试集成公司" and q["recognized"] is True and len(q["files"]) == 1 assert q["files"][0]["filename"] == "cs3.png" from sqlalchemy import select from bid_doctor.core.db import TenantContext, tenant_tx from bid_doctor.qualification.models import Qualification ctx = TenantContext(tenant_id=UUID(user.tenant_id), user_id=UUID(str(user.me["user"]["id"]))) async with tenant_tx(ctx) as db: row = (await db.execute(select(Qualification).where(Qualification.id == UUID(q["id"])))).scalar_one() assert row.ai_values is not None and row.ai_values["scan_id"] == s["id"] assert row.ai_values["fields"]["cert_no"] == {"value": "CS3-3201-2024-0417", "check": "ok"} # 同一张证书再识别一次:提示资质库里已有编号相同的 fake_ocr.append(CERT) again = await scan(user) assert again["certificates"][0]["existing"] == {"id": q["id"], "name": q["name"]} # 用新识别结果更新已有的一条:同一个原件不重复挂 r = await user.put(f"/qualifications/{q['id']}", json={**body, "scan": {"scan_id": again["id"], "index": 0}}) assert r.status_code == 200 and len(r.json()["files"]) == 1 assert (await user.delete(f"/qualification-scans/{again['id']}")).status_code == 204 assert (await user.get(f"/qualification-scans/{again['id']}")).status_code == 404 async def test_masked_low_confidence_and_mismatched_fields(user: User, fake_ocr: list[list[dict[str, Any]]]) -> None: fake_ocr.append( [ line("职业技能等级证书", 20), line("姓名:■■■", 80), line("证书编号:FRX11032M897", 120, score=0.71), # 马赛克上认出的字 line("发证日期:2024年02月19日", 160), ] ) (c,) = (await scan(user))["certificates"] assert c["category"] == "personnel" and c["kind"] == "other" and c["notes"] == [{"code": "personnel"}] assert c["masked"] == ["holder"] and "holder" not in c["fields"] assert c["fields"]["cert_no"]["check"] == "low_conf" async def test_holder_mismatch_and_validity_suggestion(user: User, fake_ocr: list[list[dict[str, Any]]]) -> None: fake_ocr.append( [ line("信息安全服务资质证书", 20), line("企业名称:南京某某信息技术有限公司", 80), line("证书编号:CCRC-2025-ISV-SI-0001", 120), line("发证日期:2025-08-27", 160), ] ) (c,) = (await scan(user))["certificates"] assert c["notes"] == [{"code": "holder_mismatch", "holder": "南京某某信息技术有限公司", "tenant": "测试集成公司"}] assert c["suggest"] == {} # 没写有效期长度,不推算 async def test_scan_needs_ocr_and_is_rate_limited(user: User, monkeypatch: pytest.MonkeyPatch) -> None: r = await user.post("/qualification-scans", json={"upload_id": await upload(user, "a.png", png())}) assert r.status_code == 503 and r.json()["code"] == "ocr_unavailable" monkeypatch.setattr(ocr, "enabled", lambda: True) monkeypatch.setattr(qualifications, "SCAN_DAILY_LIMIT", 1) assert ( await user.post("/qualification-scans", json={"upload_id": await upload(user, "a.webp", webp())}) ).status_code == 201 r = await user.post("/qualification-scans", json={"upload_id": await upload(user, "b.png", png())}) assert r.status_code == 429 and r.json()["code"] == "rate_limited" async def test_failed_ocr_is_reported(user: User, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(ocr, "enabled", lambda: True) async def down(client: httpx.AsyncClient, image: bytes, *, full_page: bool) -> dict[str, Any]: raise ocr.OcrUnavailable("connection refused") monkeypatch.setattr(ocr, "recognize", down) s = await scan(user) assert s["status"] == "failed" and s["error"]["code"] == "ocr_unavailable" and s["certificates"] == [] # —— 校验(不经过数据库) —— def L(text: str, score: float = 0.99) -> dict[str, Any]: return {"text": text, "score": score, "box": [[0, 0], [10, 0], [10, 10], [0, 10]]} def test_check_field() -> None: lines = {"L1": L("软件名称:建筑工程结构优化设计辅助系统"), "L2": L("V1.0"), "L3": L("登记日期:2024年05月28日")} check = recognize.check_field assert check("software", "建筑工程结构优化设计辅助系统 V1.0", ["L1", "L2"], lines, "software_copyright") == "ok" assert check("issued_on", "2024-5-28", ["L3"], lines, "software_copyright") == "date_ok" assert check("issued_on", "2024年05月29日", ["L3"], lines, "software_copyright") == "mismatch" assert check("issued_on", "五月", ["L3"], lines, "software_copyright") == "bad_format" assert check("cert_no", "X", ["L9"], lines, "software_copyright") == "bad_line" assert check("holder", "甲方", ["L1"], {"L1": L("甲方(用人单位):")}, "personnel") == "bad_format" assert check("holder", "张麒征", ["L1"], {"L1": L("张麒征", 0.69)}, "personnel") == "low_conf" def test_suggest_valid_to() -> None: assert recognize.suggest_valid_to(date(2025, 8, 27), "五年") == date(2030, 8, 26) assert recognize.suggest_valid_to(date(2024, 2, 29), "3年") == date(2027, 2, 27) assert recognize.suggest_valid_to(None, "五年") is None assert recognize.suggest_valid_to(date(2025, 8, 27), "长期") is None def test_build_names_and_numbers() -> None: """名称带上软件名;软著用登记号;两张证书分开。""" lines = { "L1": L("计算机软件著作权登记证书"), "L2": L("软件名称:宇通R系列手机通信软件V1.0"), "L3": L("登记号:2015SR022340"), } raw = { "certificates": [ { "category": "software_copyright", "fields": [ {"field": "name", "value": "计算机软件著作权登记证书", "lines": ["L1"]}, {"field": "software", "value": "宇通R系列手机通信软件V1.0", "lines": ["L2"]}, {"field": "reg_no", "value": "2015SR022340", "lines": ["L3"]}, ], "masked": [], }, {"category": "product", "fields": [], "masked": []}, ] } a, b = recognize.build(1, raw, lines, "某公司", []) assert a["name"] == "计算机软件著作权登记证书(宇通R系列手机通信软件V1.0)" and a["cert_no_field"] == "reg_no" assert a["kind"] == "software_copyright" and b["notes"] == [{"code": "product"}] async def test_attachment_previews(user: User) -> None: """附件预览:图片一张、PDF 每页一张;签名地址不带鉴权头也能打开( 用)。""" from tests.test_qualification_flow import attach q = (await user.post("/qualifications", json={"name": "ISO 9001", "kind": "system_cert", "cert_no": "Q1"})).json() pdf = io.BytesIO() pages = [Image.new("RGB", (600, 800), "white"), Image.new("RGB", (800, 600), "white")] pages[0].save(pdf, "PDF", save_all=True, append_images=pages[1:]) await attach(user, q["id"], "a.png", png()) r = await attach(user, q["id"], "b.pdf", pdf.getvalue()) files = r.json()["files"] counts = [] for f in files: p = (await user.get(f"/qualifications/{q['id']}/files/{f['id']}/previews")).json() counts.append(len(p["pages"])) img = await user.client.get("http://test" + p["pages"][-1]["url"].split("8009", 1)[-1]) assert img.status_code == 200 and img.headers["content-type"] == "image/jpeg" assert counts == [1, 2] bad = p["pages"][0]["url"].replace("sig=", "sig=x") assert (await user.client.get("http://test" + bad.split("8009", 1)[-1])).status_code == 404