feat(check): 需人工核对(P2)和分项 / 单价上限(C7)

- 需人工核对:份数、密封、递交、CA 签章加密、解密、保证金这类文件里看不出来的要求,
  从正式清单按主题合并、带招标原文出处;manual-checks 接口按项目存勾选、写勾选人和时间,
  复查沿用;查标页加「需人工核对」一块,覆盖范围写明列了几条
- 「报价」类清单项:读标从采购需求的清单表规则读出分项 / 单价上限(checklist/price.py);
  查标交给规则 PRICE.OVER_CEILING_ITEM 比对分项报价表,不交给模型;规则集 2026.11-r2
- 版面工具(分页、分行、归栏)挪到 document/pipeline/layout.py,读标和查标共用
- 评测:price_defects.py 从规矩投标文件派生只带报价问题的 4 份(P01、P02、P03)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-04 17:31:32 +08:00
parent db1b1f6f9f
commit 81c58798d2
22 changed files with 888 additions and 84 deletions

View File

@@ -19,8 +19,8 @@ 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 rules
from bid_doctor.check.models import CheckItemResult, CheckRun, CheckRunDocument, Finding, FindingCitation
from bid_doctor.check import manual, 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
from bid_doctor.core import queue
@@ -36,9 +36,7 @@ from bid_doctor.project import service as projects
from bid_doctor.project.models import Project
CHECK_TASK = "check_project"
RULE_SET_VERSION = (
"2026.11-r1" # 规则集有版本,检查批次记下当时用的版本(架构 §3.7);r1:报价形式、形式要件(rules.py)
)
RULE_SET_VERSION = "2026.11-r2" # 规则集有版本,检查批次记下当时用的版本(架构 §3.7);r2:加分项 / 单价上限
WAIVE_WINDOW = timedelta(hours=24)
ERROR_MESSAGES = {
"bid_text_missing": "投标文件里几乎读不出文字(可能都是扫描件),扫描件识别还没有上线",
@@ -461,3 +459,66 @@ async def item_results(s: AsyncSession, run_id: UUID) -> list[dict[str, Any]]:
}
for r in rows
]
async def manual_checks(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> list[dict[str, Any]]:
"""需人工核对的条目(按主题合并,来自当前正式清单)和勾选结果(按项目存,复查沿用,P2)。"""
await projects.get(s, ctx, project_id)
topics = manual.topics(await checklist.items(s, project_id, zone="formal"))
rows = {
r.check_key: r
for r in (await s.execute(select(ManualCheck).where(ManualCheck.project_id == project_id))).scalars()
}
names = await identity.display_names(s, [r.checked_by for r in rows.values() if r.checked])
out = []
for t in topics:
first = t.items[0]
r = rows.get(t.check_key)
done = bool(r and r.checked)
out.append(
{
"check_key": t.check_key,
"label": t.label,
"requirement_text": first.get("title"),
"citation": (first.get("citations") or [None])[0],
"related_items": [{"id": i["id"], "title": i["title"]} for i in t.items],
"checked": done,
"note": r.note if r else None,
"checked_by": {"id": r.checked_by, "name": names.get(r.checked_by, "")} if r and done else None,
"checked_at": r.checked_at if r and done else None,
}
)
return out
async def set_manual_check(
s: AsyncSession, ctx: TenantContext, project_id: UUID, check_key: str, checked: bool, note: str | None
) -> dict[str, Any]:
p = await projects.get(s, ctx, project_id)
projects.ensure_editable(p)
current = {x["check_key"]: x for x in await manual_checks(s, ctx, project_id)}
if check_key not in current:
raise AppError("validation_failed", errors=[{"field": "check_key", "code": "unknown", "message": "没有这一条"}])
r = (
await s.execute(
select(ManualCheck).where(ManualCheck.project_id == project_id, ManualCheck.check_key == check_key)
)
).scalar_one_or_none()
before = {"checked": r.checked, "note": r.note} if r else None
note = (note or "").strip() or None
if r is None:
r = ManualCheck(tenant_id=ctx.tenant_id, project_id=project_id, check_key=check_key, checked_by=ctx.user_id)
s.add(r)
r.checked, r.note, r.checked_by, r.checked_at = checked, note, ctx.user_id, datetime.now(UTC)
await audit.record(
s,
ctx,
"manual_check.set",
"project",
project_id,
project_id=project_id,
before=before,
after={"check_key": check_key, "checked": checked, "note": note},
)
await s.flush()
return next(x for x in await manual_checks(s, ctx, project_id) if x["check_key"] == check_key)