Files
bid-doctor/server/tests/test_price_caps.py
YANG JIANKUAN 81c58798d2 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>
2026-11-04 17:31:32 +08:00

94 lines
4.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""「报价」类清单项(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