- 需人工核对:份数、密封、递交、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>
94 lines
4.0 KiB
Python
94 lines
4.0 KiB
Python
"""「报价」类清单项(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
|