feat(qualification): 资质库和资质匹配(查标第 4 步,规则集 2026.11-r4)
- 资质库(FR-M6-08):增删改、证书扫描件(PDF/JPG/PNG,企业级密钥加密、签名下载)、 late_for 按进行中项目的投标截止日提醒、审计;前端资质库页和对话框,侧栏入口 - 资质匹配(FR-M3-05):证书类别词表(16 类,产品证书、人员证书不进匹配), QUAL.EXPIRED / LEVEL_LOW / NOT_COVERED;投标文字层按清单项定级,只有资质库记录的降一级, 出处显示「依据:资质库记录」(查标页、报告模板 v2);一类证书一个问题;评分项不逐条找响应的也查 - 响应错位规则不再把资质表的证书名称格当成照抄的要求 - 评测:qual_defects.py 派生资质情况表,D12 3/3、D18 2/2、规矩的资质表 0 误报;开发集不退化 - 决策 QL1–QL7;方案和落地记录 docs/architecture/qualification-plan.md,评测 check-eval.md §13 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,7 +18,7 @@ PLATFORM_FORMATS = {"kedt": "苏采云(江苏政府采购)"}
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Detected:
|
||||
format: str # pdf / docx / doc / ofd
|
||||
format: str # pdf / docx / doc / ofd;资质附件另有 jpeg / png
|
||||
page_count: int | None # 只有 PDF 能直接读出页数
|
||||
extension_mismatch: bool
|
||||
|
||||
@@ -43,6 +43,23 @@ def detect(data: bytes, filename: str) -> Detected:
|
||||
return Detected(format=fmt, page_count=pages, extension_mismatch=ext not in EXTENSIONS.get(fmt, set()))
|
||||
|
||||
|
||||
ATTACHMENT_EXTENSIONS = {"pdf": {"pdf"}, "jpeg": {"jpg", "jpeg"}, "png": {"png"}}
|
||||
|
||||
|
||||
def detect_attachment(data: bytes, filename: str) -> Detected:
|
||||
"""资质附件(证书扫描件):PDF、JPG、PNG,同样按文件头认。"""
|
||||
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
||||
if data.startswith(b"%PDF"):
|
||||
fmt, pages = "pdf", _pdf_pages(data)
|
||||
elif data.startswith(b"\xff\xd8\xff"):
|
||||
fmt, pages = "jpeg", 1
|
||||
elif data.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
fmt, pages = "png", 1
|
||||
else:
|
||||
raise AppError("file_format_unsupported", "证书附件支持 PDF、JPG、PNG")
|
||||
return Detected(format=fmt, page_count=pages, extension_mismatch=ext not in ATTACHMENT_EXTENSIONS[fmt])
|
||||
|
||||
|
||||
def _pdf_pages(data: bytes) -> int:
|
||||
try:
|
||||
doc = pdfium.PdfDocument(data)
|
||||
|
||||
@@ -22,9 +22,10 @@ from bid_doctor.core.errors import AppError
|
||||
from bid_doctor.core.ids import uuid7
|
||||
from bid_doctor.core.storage import storage, upload_prefix
|
||||
from bid_doctor.core.valkey import valkey
|
||||
from bid_doctor.document.formats import detect
|
||||
from bid_doctor.document.formats import detect, detect_attachment
|
||||
|
||||
TTL_S = 24 * 3600
|
||||
ATTACHMENT_MAX_BYTES = 20 * 1024 * 1024 # 资质附件(qualification-plan §2.3)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -55,6 +56,8 @@ async def _limits(ctx: TenantContext) -> dict[str, Any]:
|
||||
async def create(
|
||||
ctx: TenantContext, filename: str, size_bytes: int, purpose: str, sha256: str | None
|
||||
) -> dict[str, Any]:
|
||||
if purpose == "qualification_file" and size_bytes > ATTACHMENT_MAX_BYTES:
|
||||
raise AppError("validation_failed", "证书附件最大 20MB")
|
||||
limits = await _limits(ctx)
|
||||
if size_bytes > limits["max_file_bytes"]:
|
||||
raise AppError("plan_limit_exceeded", f"单个文件最大 {limits['max_file_bytes'] // (1024 * 1024)}MB")
|
||||
@@ -121,9 +124,11 @@ async def complete(ctx: TenantContext, upload_id: UUID) -> dict[str, Any]:
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
if meta.sha256 and meta.sha256 != digest:
|
||||
raise AppError("upload_checksum_mismatch")
|
||||
d = detect(data, meta.filename) # 格式、加密、损坏都在这里拦下,不建任务、不扣点
|
||||
# 格式、加密、损坏都在这里拦下,不建任务、不扣点
|
||||
attachment = meta.purpose == "qualification_file"
|
||||
d = detect_attachment(data, meta.filename) if attachment else detect(data, meta.filename)
|
||||
limits = await _limits(ctx)
|
||||
if d.page_count and d.page_count > limits["max_pages"]:
|
||||
if not attachment and d.page_count and d.page_count > limits["max_pages"]:
|
||||
raise AppError("plan_limit_exceeded", f"单个文件最多 {limits['max_pages']} 页,这份有 {d.page_count} 页")
|
||||
storage().put(f"{prefix}/file", seal_object(dk, data, f"{prefix}/file"))
|
||||
for n in range(1, meta.part_count + 1): # 分片用完即删
|
||||
|
||||
Reference in New Issue
Block a user