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:
@@ -297,7 +297,7 @@ async def test_rule_findings_are_written_with_citations_and_carried(user: User)
|
||||
# 「让利后总价」和投标总价不同:多半另报了一个价,但不是「备选方案」那种明确的两个报价,按重要
|
||||
assert rule["PRICE.MULTIPLE_QUOTES"]["severity"] == "important" and rule["FORM.BLANK_FIELD"]["severity"] == "hint"
|
||||
assert any("报价形式" in x for x in run["coverage"]["checked"])
|
||||
assert run["coverage"]["rule_set_version"] == "2026.11-r1"
|
||||
assert run["coverage"]["rule_set_version"] == "2026.11-r2"
|
||||
|
||||
await user.post(f"/findings/{cap['id']}/resolve", json={"status": "ignored", "note": "大写栏已手工更正"})
|
||||
again = await run_check(user, project_id, [bid["id"]])
|
||||
@@ -305,3 +305,37 @@ async def test_rule_findings_are_written_with_citations_and_carried(user: User)
|
||||
f for f in await findings(user, again["id"]) if (f["rule"] or {}).get("code") == "PRICE.CAPITAL_MISMATCH"
|
||||
)
|
||||
assert cap2["presence"] == "carried" and cap2["status"] == "ignored"
|
||||
|
||||
|
||||
async def test_manual_checks_are_kept_per_project(user: User) -> None:
|
||||
"""需人工核对:来自清单(按主题合并),勾选按项目存、写勾选人和时间,复查后还在(决策 P2)。"""
|
||||
project_id, _, _ = await checked_project(user)
|
||||
for title in ("须用CA数字证书加密投标文件", "开标后30分钟内在线解密投标文件", "本次采购不收取投标保证金"):
|
||||
r = await user.post(
|
||||
f"/projects/{project_id}/checklist/items", json={"categories": ["schedule"], "title": title}
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
items = (await user.get(f"/projects/{project_id}/manual-checks")).json()["items"]
|
||||
assert [(i["check_key"], i["checked"]) for i in items] == [
|
||||
("rule:MANUAL.DECRYPT", False),
|
||||
("rule:MANUAL.SIGN_ENCRYPT", False),
|
||||
]
|
||||
assert items[1]["requirement_text"] == "须用CA数字证书加密投标文件"
|
||||
|
||||
r = await user.put(
|
||||
f"/projects/{project_id}/manual-checks",
|
||||
json={"check_key": "rule:MANUAL.DECRYPT", "checked": True, "note": "已演练"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
m = r.json()
|
||||
assert m["checked"] and m["note"] == "已演练" and m["checked_by"]["name"] and m["checked_at"]
|
||||
bad = await user.put(
|
||||
f"/projects/{project_id}/manual-checks", json={"check_key": "rule:MANUAL.NOPE", "checked": True}
|
||||
)
|
||||
assert bad.status_code == 422
|
||||
|
||||
bid_id = (await user.get(f"/projects/{project_id}/check-preview")).json()["bid_documents"][0]["id"]
|
||||
again = await run_check(user, project_id, [bid_id])
|
||||
assert any("列入「需人工核对」2 条" in x for x in again["coverage"]["not_checked"])
|
||||
kept = (await user.get(f"/projects/{project_id}/manual-checks")).json()["items"]
|
||||
assert [i["checked"] for i in kept] == [True, False]
|
||||
|
||||
@@ -166,3 +166,28 @@ def test_scanned_fallback_turns_missing_evidence_into_a_hint() -> None:
|
||||
and "2 页扫描件" in spec.explanation
|
||||
)
|
||||
assert results[b].verdict == "not_found" # 技术参数不是证明材料,照常报未找到
|
||||
|
||||
|
||||
def test_manual_topics_merge_by_topic_and_skip_irrelevant() -> None:
|
||||
"""需人工核对(FR-M3-04):同一主题合并成一条;技术参数、免收保证金、围串标情形、点名要交的文件不算。"""
|
||||
from bid_doctor.check import manual
|
||||
|
||||
items = [
|
||||
{
|
||||
"title": "投标文件递交截止 2027年3月5日 9:30",
|
||||
"categories": ["schedule"],
|
||||
"spec": {"schedule": {"at": "2027-03-05T09:30"}},
|
||||
},
|
||||
{"title": "电子投标文件须在截止时间前上传", "categories": ["schedule"]},
|
||||
{"title": "未在规定时间内解密投标文件的投标无效", "categories": ["invalidity"]},
|
||||
{"title": "须提供加盖电子签章的《开标一览表》", "categories": ["format_doc"]},
|
||||
{"title": "须用CA数字证书加密投标文件", "categories": ["schedule"]},
|
||||
{"title": "本项目免收投标保证金", "categories": ["schedule"]},
|
||||
{"title": "数据库支持国密加密存储", "categories": ["tech_param"]},
|
||||
{"title": "不同投标人的投标文件装订明显相似的投标无效", "categories": ["invalidity"]},
|
||||
{"title": "纸质投标文件正本一份、副本四份,密封递交", "categories": ["schedule"]},
|
||||
]
|
||||
got = {t.key: [i["title"] for i in t.items] for t in manual.topics(items)}
|
||||
assert list(got) == ["DECRYPT", "SIGN_ENCRYPT", "PAPER", "SUBMIT"]
|
||||
assert got["SIGN_ENCRYPT"] == ["须用CA数字证书加密投标文件"]
|
||||
assert got["SUBMIT"][0].startswith("投标文件递交截止") # 写明截止时间的做代表
|
||||
|
||||
93
server/tests/test_price_caps.py
Normal file
93
server/tests/test_price_caps.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""「报价」类清单项(C7):从招标文件的清单表读分项 / 单价上限,查标时和分项报价表比对。内容虚构。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from uuid import uuid4
|
||||
|
||||
from bid_doctor.check import applicability, match, rules
|
||||
from bid_doctor.checklist import price
|
||||
from bid_doctor.document.pipeline.text_layer import Sentence
|
||||
|
||||
COLS = [(80, 110), (110, 300), (300, 340), (340, 380), (380, 470)]
|
||||
|
||||
|
||||
def row(seq: int, y: float, texts: list[str], page: int = 1) -> list[Sentence]:
|
||||
out = []
|
||||
for n, (t, (x0, x1)) in enumerate(zip(texts, COLS, strict=False)):
|
||||
s = Sentence(
|
||||
key=f"t:p{page}:s{seq + n}", page_no=page, seq=seq + n, text=t, quads=[[x0 + 2, y + 4, x1 - 2, y + 16]]
|
||||
)
|
||||
s.cell = [x0, y, x1, y + 20]
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
def line(seq: int, y: float, text: str, page: int = 1) -> Sentence:
|
||||
return Sentence(key=f"t:p{page}:s{seq}", page_no=page, seq=seq, text=text, quads=[[74, y, 500, y + 12]])
|
||||
|
||||
|
||||
def tender() -> list[Sentence]:
|
||||
return [
|
||||
line(1, 60, "采购包2:数据中心扩容"),
|
||||
*row(10, 80, ["序号", "分项名称", "数量", "单位", "单价上限(万元)"]),
|
||||
*row(20, 100, ["1", "服务器", "4", "台", "12.5"]),
|
||||
*row(30, 120, ["2", "服务器机柜", "2", "个", "0.8"]),
|
||||
line(40, 160, "采购包一览:各包的最高限价见下表,投标报价超过所投采购包最高限价的投标无效。"),
|
||||
*row(50, 180, ["序号", "采购包名称", "数量", "单位", "最高限价(元)"]), # 采购包一级:不是分项
|
||||
*row(60, 200, ["1", "采购包2", "1", "项", "600000"]),
|
||||
]
|
||||
|
||||
|
||||
def test_caps_from_tender_table() -> None:
|
||||
caps = price.caps(tender())
|
||||
assert [(c.lot_no, c.name, c.cap, c.per, c.qty) for c in caps] == [
|
||||
("2", "服务器", Decimal("125000.0"), "unit", Decimal(4)),
|
||||
("2", "服务器机柜", Decimal("8000.0"), "unit", Decimal(2)),
|
||||
]
|
||||
assert caps[0].title == "服务器:单价不超过 125,000.00 元" and caps[0].sentences[0].text == "服务器"
|
||||
assert caps[0].spec()["price"] == {
|
||||
"kind": "unit_cap",
|
||||
"name": "服务器",
|
||||
"cap": "125000.00",
|
||||
"qty": "4",
|
||||
"lot_no": "2",
|
||||
}
|
||||
|
||||
|
||||
def test_price_items_are_checked_by_rule_not_model() -> None:
|
||||
d = applicability.decide({"categories": ["price"], "title": "服务器:单价不超过 125,000.00 元"}, {})
|
||||
assert (d.match, d.skip) == (False, "price")
|
||||
|
||||
|
||||
def bid_row(seq: int, y: float, texts: list[str]) -> list[Sentence]:
|
||||
out = []
|
||||
for n, (t, (x0, x1)) in enumerate(
|
||||
zip(texts, [(80, 100), (100, 260), (260, 300), (300, 380), (380, 470)], strict=True)
|
||||
):
|
||||
s = Sentence(key=f"b:p1:s{seq + n}", page_no=1, seq=seq + n, text=t, quads=[[x0 + 2, y + 4, x1 - 2, y + 16]])
|
||||
s.cell = [x0, y, x1, y + 20]
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
def test_item_over_cap_is_fatal_and_similar_names_do_not_match() -> None:
|
||||
bid = [
|
||||
*bid_row(1, 80, ["序号", "货物名称", "数量", "单价(元)", "合价(元)"]),
|
||||
*bid_row(10, 100, ["1", "服务器", "4", "126,000.00", "504,000.00"]),
|
||||
*bid_row(20, 120, ["2", "服务器机柜", "2", "7,900.00", "15,800.00"]),
|
||||
]
|
||||
item = uuid4()
|
||||
facts = rules.Facts(
|
||||
caps=[
|
||||
rules.ItemCap("服务器", Decimal(125_000), "unit", item),
|
||||
rules.ItemCap("服务器机柜", Decimal(8_000), "unit"),
|
||||
]
|
||||
)
|
||||
hits, cov = rules.run(facts, [match.BidDoc(uuid4(), 1, "报价分册", bid)], [])
|
||||
(h,) = [h for h in hits if h.code == "PRICE.OVER_CEILING_ITEM"]
|
||||
assert h.severity == "fatal" and h.item == item and h.basis == f"cap:{item}" and "服务器" in h.title
|
||||
assert "126,000.00 元" in h.explanation and "125,000.00 元" in h.explanation
|
||||
assert not any("其余没有比对" in x for x in cov["not_checked"]) # 两条上限都对上了名称
|
||||
|
||||
assert rules._match_cap("服务器", [rules.ItemCap("服务器机柜", Decimal(1), "unit")]) is None
|
||||
Reference in New Issue
Block a user