"""查标的规则检查(报价形式、形式要件):纯函数,投标文件用虚构的句子拼(rules.py,GUIDELINES §3.2)。 措辞故意和评测集的开发词库不同:规则按招投标文件的通用写法写,不按评测集的句子写。 """ from __future__ import annotations from datetime import date from decimal import Decimal from uuid import uuid4 from bid_doctor.check import match, rules from bid_doctor.document.pipeline.text_layer import Sentence def cell(seq: int, text: str, x: float, y: float, page: int = 1, w: float = 60) -> Sentence: return Sentence(key=f"b:p{page}:s{seq}", page_no=page, seq=seq, text=text, quads=[[x, y, x + w, y + 12]]) def para(seq: int, text: str, page: int = 1) -> Sentence: return cell(seq, text, 74, 30 + seq * 24, page, 440) def doc(sentences: list[Sentence], header: list[Sentence] | None = None) -> match.BidDoc: return match.BidDoc(uuid4(), 1, "商务分册", sentences, [], header or []) def codes(hits: list[rules.Hit]) -> list[str]: return sorted(h.code for h in hits) def test_upper_amount_and_day_counts() -> None: assert rules.upper_amount("壹佰贰拾贰万柒仟陆佰元整") == Decimal(1_227_600) assert rules.upper_amount("壹亿零伍佰万元零伍角") == Decimal("105000000.5") assert rules.upper_amount("拾万元整") == Decimal(100_000) assert rules.validity_days("投标有效期为开标之日起一百二十(120)天。") == [120] assert rules.validity_days("本投标文件自投标截止之日起三个月内有效。") == [90] assert rules.validity_days("投标有效期内中标的,须在30日内签订合同。") == [] # 「有效期内」后面的天数不是有效期 def opening_table(lower: str, upper: str) -> list[Sentence]: return [ cell(1, "项目名称", 80, 100), cell(2, "投标总价(小写,元)", 220, 100), cell(3, "投标总价(大写)", 380, 100), cell(4, "某市数据中心扩容项目", 80, 124), cell(5, lower, 220, 124), cell(6, upper, 380, 124), ] def test_capital_mismatch_and_over_ceiling() -> None: d = doc(opening_table("2,050,000.00", "贰佰万元整")) hits, _ = rules.run(rules.Facts(ceiling=Decimal(2_000_000)), [d], []) assert codes(hits) == ["PRICE.CAPITAL_MISMATCH", "PRICE.OVER_CEILING_TOTAL"] over = next(h for h in hits if h.code == "PRICE.OVER_CEILING_TOTAL") assert over.severity == "fatal" and over.basis == "ceiling" and over.evidence[0][1].text == "2,050,000.00" assert "建议" not in over.explanation and "应改为" not in over.explanation # 红线 6:不写数字建议 same = doc(opening_table("2,050,000.00", "贰佰零伍万元整")) assert rules.run(rules.Facts(ceiling=Decimal(3_000_000)), [same], [])[0] == [] def item_table(rows: list[tuple[str, str, str, str]], total: str, page: int = 1) -> list[Sentence]: out = [ cell(1, "序号", 80, 100, page, 20), cell(2, "货物名称", 110, 100, page, 100), cell(3, "数量", 260, 100, page, 30), cell(4, "单价(元)", 320, 100, page), cell(5, "合价(元)", 420, 100, page), ] for i, (name, qty, unit, line) in enumerate(rows): y, n = 124 + i * 22, 10 + i * 5 out += [ cell(n, str(i + 1), 82, y, page, 8), cell(n + 1, name, 110, y, page, 100), cell(n + 2, qty, 272, y, page, 8), # 窄数字居中,和表头「数量」不重叠 cell(n + 3, unit, 320, y, page), cell(n + 4, line, 420, y, page), ] out.append(cell(99, f"合计:{total}元", 80, 124 + len(rows) * 22 + 6, page, 300)) return out def test_item_table_arithmetic() -> None: ok = item_table( [("服务器", "3", "100,000.33", "300,001.00"), ("存储", "1", "50,000.00", "50,000.00")], "350,001.00" ) assert rules.run(rules.Facts(), [doc(ok)], [])[0] == [] # 单价舍入到分,合价差几分钱不算错 bad = item_table( [("服务器", "3", "100,000.00", "310,000.00"), ("存储", "1", "50,000.00", "50,000.00")], "350,000.00" ) (h,) = rules.run(rules.Facts(), [doc(bad)], [])[0] assert h.code == "PRICE.SUM_MISMATCH" and "服务器" in h.explanation and "合计行" in h.explanation assert "310,000.00" in [s.text for _, s in h.evidence] def test_foreign_project_in_header_and_filled_fields() -> None: facts = rules.Facts(name="某市数据中心扩容项目", code="ZC-2026-0815-A", purchaser="某市大数据局") header = [ Sentence( key=f"b:p{p}:s0", page_no=p, seq=0, text="某县人民医院信息化改造工程", quads=[[0, 0, 1, 1]], location="header_footer", ) for p in (1, 2) ] body = [ para(1, "致:某市大数据局(采购人名称)"), para(2, "参加ZC-2026-0815-A(项目编号)某市数据中心扩容项目(项目名称)的投标。"), para(3, "我方承诺参加ZC-2025-0311-B(项目编号)的投标活动。"), para(4, "类似业绩:项目名称:某省政务云平台运维服务项目,合同金额200万元。"), # 业绩不在填写位里,不查 ] hits, _ = rules.run(facts, [doc(body, header)], []) assert codes(hits) == ["FORM.FOREIGN_PROJECT", "FORM.FOREIGN_PROJECT"] head = next(h for h in hits if h.target.startswith("header:")) assert head.detail["pages"] == [1, 2] and head.basis == "name" assert any("ZC-2025-0311-B" in h.explanation for h in hits) def test_blank_fields_and_kept_annotations() -> None: d = doc( [ para(1, "投标人名称(盖单位章):某某科技有限公司"), # 括注保留、名称已填:不算 para(2, "本授权书于年月日签署生效。"), para(3, "被授权人:____(签字)"), para(4, "法定代表人身份证号:/"), # 划除:不算 ] ) hits, _ = rules.run(rules.Facts(), [d], []) assert [h.evidence[0][1].seq for h in hits] == [2, 3] and {h.severity for h in hits} == {"hint"} def test_sign_dates_validity_conditions_and_extra_quotes() -> None: facts = rules.Facts(deadline=date(2026, 12, 1), validity_days=90) d = doc( [ para(1, "签署日期:2026年12月3日"), para(2, "日期:2026年11月31日"), para(3, "证书发证日期:2027年1月1日"), # 不是落款日期 para(4, "本投标有效期为自投标截止之日起六十日。"), para(5, "供货数量按实际需求结算,安装调试费用由采购人负责。"), para(6, "培训费用已包含在报价中,不另行收费。"), # 承诺,不是附加条件 para(7, "第二套报价方案总价:1,880,000.00元"), para(8, "我方不提交备选方案报价。"), ] ) hits, _ = rules.run(facts, [d], []) got = {(h.code, h.evidence[0][1].seq) for h in hits} assert got == { ("FORM.DATE_AFTER_DEADLINE", 1), ("FORM.DATE_AFTER_DEADLINE", 2), ("FORM.VALIDITY_SHORT", 4), ("FORM.CONDITION_ADDED", 5), ("PRICE.MULTIPLE_QUOTES", 7), } bad_date = next(h for h in hits if h.evidence[0][1].seq == 2) assert bad_date.title == "落款日期写法有误" def test_response_table_negative_and_dedupe() -> None: rows = [ cell(1, "实质性要求响应表", 74, 76, w=200), cell(2, "序号", 80, 100, w=20), cell(3, "实质性要求", 110, 100, w=150), cell(4, "响应情况", 300, 100), cell(5, "1", 82, 124, w=8), cell(6, "“交货期”的响应情况", 110, 124, w=150), cell(7, "完全响应", 300, 124), cell(8, "2", 82, 146, w=8), cell(9, "“付款方式”的响应情况", 110, 146, w=150), cell(10, "部分满足", 300, 146), cell(11, "3", 82, 168, w=8), cell(12, "“质保期”的响应情况", 110, 168, w=150), ] d = doc(rows) hits, _ = rules.run(rules.Facts(), [d], []) assert [(h.title, h.severity) for h in hits] == [ ("响应表里写了「部分满足」", "fatal"), ("响应表里有一行没有填响应情况", "important"), # 「空着」可能是版面没认准:不给致命 ] covered = {("商务分册", 1, "部分满足")} # 响应匹配已经按清单项报了这一句 assert [h.title for h in rules.drop_covered(hits, covered)] == ["响应表里有一行没有填响应情况"] def test_missing_facts_go_to_coverage() -> None: _, cov = rules.run(rules.Facts(), [doc([para(1, "我方承诺按招标文件要求提供服务。")])], []) text = ";".join(cov["not_checked"]) for want in ("没有认出开标一览表", "最高限价", "投标截止时间", "投标有效期", "项目名称"): assert want in text def boxed(seq: int, text: str, cell: list[float], dy: float = 0) -> Sentence: """有框线表格里的句子:文字框可以比单元格上沿低(垂直居中),分行按单元格。""" x0, top, x1, _ = cell s = cell_sentence = Sentence( key=f"b:p1:s{seq}", page_no=1, seq=seq, text=text, quads=[[x0 + 2, top + 2 + dy, x1 - 2, top + 14 + dy]] ) cell_sentence.cell = cell return s def test_real_layout_negatives() -> None: """真实投标文件常见、但不该报的写法(Fable 审读提出的反例)。""" # 分组小计不算进明细;金额栏叫「投标报价」的分项表不是开标一览表,几行金额不算「多个总价」 rows = item_table([("服务器", "2", "10,000.00", "20,000.00"), ("交换机", "1", "5,000.00", "5,000.00")], "25,000.00") rows.insert(-1, cell(90, "硬件小计", 110, 175, w=100)) rows.insert(-1, cell(91, "25,000.00", 420, 175)) multi = [ cell(1, "年度", 80, 300, page=2), cell(2, "投标报价(元)", 220, 300, page=2), cell(3, "第一年", 80, 324, page=2), cell(4, "100,000.00", 220, 324, page=2), cell(5, "第二年", 80, 348, page=2), cell(6, "120,000.00", 220, 348, page=2), ] # 响应表:短格垂直居中,文字比编号低半行,但在同一个单元格行里 resp = [ boxed(20, "序号", [80, 400, 100, 420]), boxed(21, "符合性审查要求", [100, 400, 300, 420]), boxed(22, "响应情况", [300, 400, 380, 420]), boxed(23, "1", [80, 420, 100, 460]), boxed(24, "投标人须承诺按期交货,交货期不超过合同签订后三十日", [100, 420, 300, 460]), boxed(25, "完全响应", [300, 420, 380, 460], dy=14), ] header = [ Sentence( key="b:p1:s0", page_no=1, seq=0, text="第三章 技术服务方案", quads=[[0, 0, 1, 1]], location="header_footer" ) ] body = [ para(30, "致:贵单位"), para(31, "附件:原厂报价单。本报价有效期30天,过期另行报价。", page=3), para(32, "优惠后报价:25,000.00元", page=3), para(33, "日 期:二〇二六年十一月二十日", page=3), ] facts = rules.Facts( name="某市数据中心扩容项目", purchaser="某市大数据局", deadline=date(2026, 12, 1), validity_days=60 ) hits, _ = rules.run(facts, [doc([*rows, *multi, *resp, *body], header)], []) assert [(h.code, h.severity) for h in hits] == [("FORM.CONDITION_ADDED", "important")] # 「过期另行报价」照报 def test_validity_in_letter_table_and_chinese_dates() -> None: page = [ para(1, "投标函"), cell(2, "投标有效期", 80, 200), cell(3, "四十五天", 220, 200), para(4, "落款日期:二〇二六年十二月三日"), ] hits, _ = rules.run(rules.Facts(deadline=date(2026, 12, 1), validity_days=60), [doc(page)], []) assert {(h.code, h.severity) for h in hits} == { ("FORM.VALIDITY_SHORT", "fatal"), ("FORM.DATE_AFTER_DEADLINE", "hint"), }