feat(ui): 查标第 6 步前端补齐
- 读标页:每条清单项写最近一次检查的结论,点投标侧出处看投标原文;
「已响应」也能反馈判断有误(漏报);证书要求写资质库里的情况
(新接口 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:
@@ -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:
|
||||
|
||||
@@ -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} 页扫描件,系统读不出上面的文字,"
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)) == {}
|
||||
|
||||
@@ -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" # 技术参数不是证明材料,照常报未找到
|
||||
|
||||
Reference in New Issue
Block a user