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:
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