feat(ui): 查标第 6 步前端补齐
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled

- 读标页:每条清单项写最近一次检查的结论,点投标侧出处看投标原文;
  「已响应」也能反馈判断有误(漏报);证书要求写资质库里的情况
  (新接口 GET /projects/{id}/qualification-matches,资质库空时不写)
- 概览:检查记录卡(历次检查、各级问题数、报告下载)、文件卡
- 工作台:「致命」列、统计可点筛选、待处理的致命问题卡(?f= 定位到问题)
- 日期输入加日历弹层(DateInput,Reka UI Calendar)
- 查标页:? 键显示快捷键说明;反馈对话框抽成 FeedbackDialog 共用
- 扫描件提示去掉多余空格

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-05 13:39:19 +08:00
parent 808259da87
commit 54cbb556d0
30 changed files with 956 additions and 143 deletions

View File

@@ -42,6 +42,12 @@ async def get_check_preview(project_id: UUID, ctx: Ctx) -> dict[str, Any]:
return await service.preview(s, ctx, project_id)
@router.get("/projects/{project_id}/qualification-matches")
async def get_qualification_matches(project_id: UUID, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
return await service.qualification_matches(s, ctx, project_id)
@router.get("/projects/{project_id}/check-runs")
async def list_check_runs(project_id: UUID, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:

View File

@@ -117,7 +117,7 @@ def _spec(result: Result, item: dict[str, Any]) -> Spec | None:
where = result.detail.get("scanned_where")
# 只说扫描件在哪几页,不说「材料可能在扫描件里」:扫描件上是不是这份材料,没有 OCR 分不出来
text = (
f"投标文件文字部分没有找到这份材料。{where} 是扫描件(共 {total} 页),系统读不出上面的文字;"
f"投标文件文字部分没有找到这份材料。{where}是扫描件(共 {total} 页),系统读不出上面的文字;"
"材料如果附在这些页上,请人工核对,没有附就是缺了。"
if where
else f"投标文件文字部分没有找到这份材料。投标文件里有 {total} 页扫描件,系统读不出上面的文字,"

View File

@@ -419,6 +419,43 @@ def check_item(
return issues, notes
def library_matches(
items: list[dict[str, Any]], library: list[Record], deadline: date
) -> dict[str, list[dict[str, Any]]]:
"""读标清单上显示资质库的情况(qualification-plan §4,并入第 6 步):资格条件、评分项里认出证书类别的条目,
每类给出资质库里有效期最晚的那一条(state:valid / expired / missing,按投标截止日算)。
认类别、排除产品和人员证书的口径和查标一样;资质库是空的不给(QL3:没录资质库时什么都不说)。"""
if not library:
return {}
by_family: dict[str, list[Record]] = {}
for r in library:
if fam := family_of_record(r):
by_family.setdefault(fam.key, []).append(r)
out: dict[str, list[dict[str, Any]]] = {}
for item in items:
if not set(item.get("categories") or []) & {"qualification", "scoring"}:
continue
requirement = " ".join(str(item.get(k) or "") for k in ("title", "requirement_text", "evidence_required"))
fams = families_in(requirement)
if not fams or EXCLUDE.search(text_of(requirement)):
continue
rows: list[dict[str, Any]] = []
for fam in fams:
recs = by_family.get(fam.key, [])
best = _best_valid(recs)
if best is None:
state = "missing"
elif fam.key != "softcopy" and best.valid_to is not None and best.valid_to < deadline:
state = "expired"
else:
state = "valid"
rows.append(
{"family": fam.label, "count": len(recs), "record": best.snapshot() if best else None, "state": state}
)
out[str(item["id"])] = rows
return out
def run(
items: dict[UUID, dict[str, Any]],
results: dict[UUID, Result],

View File

@@ -12,6 +12,7 @@ from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any
from uuid import UUID
from zoneinfo import ZoneInfo
from fastapi.encoders import jsonable_encoder
from sqlalchemy import func, select
@@ -19,7 +20,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from bid_doctor.audit import service as audit
from bid_doctor.billing import service as billing
from bid_doctor.check import manual, rules
from bid_doctor.check import manual, qualification, rules
from bid_doctor.check.models import CheckItemResult, CheckRun, CheckRunDocument, Finding, FindingCitation, ManualCheck
from bid_doctor.checklist import edit as checklist_edit
from bid_doctor.checklist import service as checklist
@@ -35,8 +36,10 @@ from bid_doctor.identity import service as identity
from bid_doctor.ops import service as feedback
from bid_doctor.project import service as projects
from bid_doctor.project.models import Project
from bid_doctor.qualification import service as qualifications
CHECK_TASK = "check_project"
LOCAL_TZ = ZoneInfo("Asia/Shanghai") # 投标截止时间按北京时间取日期(同 runner)
# 规则集有版本,检查批次记下当时用的版本(架构 §3.7);r3:留出集暴露的 5 处;r4:资质匹配;r5:国动真实文件暴露的 6 处
RULE_SET_VERSION = "2026.11-r5"
WAIVE_WINDOW = timedelta(hours=24)
@@ -124,6 +127,19 @@ async def preview(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> dict
}
async def qualification_matches(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> dict[str, Any]:
"""读标清单上每条资质要求在资质库里的情况。按投标截止日算有没有过期,没有截止时间按今天算。"""
p = await projects.get(s, ctx, project_id)
deadline = p.deadline_at.astimezone(LOCAL_TZ).date() if p.deadline_at else datetime.now(LOCAL_TZ).date()
items = await checklist.items(s, project_id, zone="formal")
matches = qualification.library_matches(items, await qualifications.records(s), deadline)
return {
"as_of": deadline,
"by_deadline": p.deadline_at is not None,
"items": [{"checklist_item_id": k, "families": v} for k, v in matches.items()],
}
async def start(
s: AsyncSession, ctx: TenantContext, project_id: UUID, document_ids: list[UUID], expected_charge: int
) -> CheckRun:

View File

@@ -285,3 +285,25 @@ def test_level_column_wins_over_certificate_number() -> None:
]
(h,) = run(items, {a: result("responded")}, sents, [])[0]
assert h.code == "QUAL.LEVEL_LOW" and "「CS2」" in h.explanation
def test_library_matches_for_checklist_page() -> None:
"""读标清单上的资质库情况:认出类别的资质 / 评分条目给出有效期最晚的那条,按截止日判断过期;
产品证书、技术参数不给;资质库空的什么都不给(QL3)。"""
iso_old = Record(uuid4(), "ISO9001质量管理体系认证证书", "system_cert", None, "Q-1", date(2025, 6, 30))
iso_new = Record(uuid4(), "质量管理体系认证证书(GB/T 19001)", "system_cert", None, "Q-2", date(2028, 6, 30))
itss = Record(uuid4(), "ITSS 信息技术服务标准符合性证书", "certificate", "三级", "ITSS-9", date(2026, 3, 1))
items: list[dict[str, Any]] = [
{"id": "a", "categories": ["qualification"], "title": "具有有效的 ISO9001 质量管理体系认证证书"},
{"id": "b", "categories": ["scoring"], "title": "具有 ITSS 运行维护三级及以上证书得 2 分"},
{"id": "c", "categories": ["scoring"], "title": "具有 CMMI 三级及以上证书得 1 分"},
{"id": "d", "categories": ["scoring"], "title": "所投产品具有节能产品认证证书"},
{"id": "e", "categories": ["tech_param"], "title": "支持 ISO9001 质量管理体系认证的流程模板"},
]
got = qualification.library_matches(items, [iso_old, iso_new, itss], date(2026, 11, 20))
assert set(got) == {"a", "b", "c"}
(a,) = got["a"]
assert (a["state"], a["count"], a["record"]["cert_no"]) == ("valid", 2, "Q-2")
assert got["b"][0]["state"] == "expired"
assert (got["c"][0]["state"], got["c"][0]["record"]) == ("missing", None)
assert qualification.library_matches(items, [], date(2026, 11, 20)) == {}

View File

@@ -167,7 +167,7 @@ def test_scanned_fallback_turns_missing_evidence_into_a_hint() -> None:
results[a].verdict == "scanned"
and spec is not None
and spec.severity == "hint"
and "资格证明册第 12–14、20 页 是扫描件(共 4 页)" in spec.explanation
and "资格证明册第 12–14、20 页是扫描件(共 4 页)" in spec.explanation
)
assert match.page_ranges([5, 3, 4, 9]) == "3–5、9"
assert results[b].verdict == "not_found" # 技术参数不是证明材料,照常报未找到