Files
bid-doctor/evals/check/price_defects.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

119 lines
5.2 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.

"""从规矩的投标文件派生只带报价问题的投标文件(P01 超总价限价、P02 超采购包限价、P03 分项超单价上限)。
cd server && ../.venv/bin/python ../evals/check/price_defects.py <规矩的投标文件> <输出> <P01|P02|P03> [--variant ...]
然后 ../.venv/bin/python ../evals/check/render.py <输出>
- 只改一个分项的单价,再重算该行合价、合计、开标一览表的小写和大写,算术前后一致:只触发要测的那一条规则;
- 响应清单照抄规矩的那份(单元都是 expect=ok),rule_defects 记这一个问题;不挂标注单元,不需要复核(E1 只管单元);
- 限价、单价上限取招标档案(evals/out/check/tenders/<招标文件>.json),和读标抽出的值一致时才有意义,生成后自己核对一遍。
"""
from __future__ import annotations
import argparse
import json
import pathlib
import sys
from decimal import ROUND_UP, Decimal
from typing import Any
ROOT = pathlib.Path(__file__).resolve().parent
OUT = ROOT.parent / "out" / "check"
sys.path.insert(0, str(ROOT))
from build import rmb_upper # noqa: E402
VARIANTS = {"P01": ["超 1 元", "超 1%"], "P02": ["超 1 元", "超 1%"], "P03": ["单价超 1%"]}
def fmt(v: Decimal) -> str:
return f"{v:,.2f}"
def blocks(spec: dict[str, Any]) -> list[dict[str, Any]]:
return [b for v in spec["volumes"] for b in v["blocks"]]
def reprice(spec: dict[str, Any], index: int, unit_price: Decimal) -> Decimal:
"""把第 index 个分项的单价改成 unit_price,重算合价、合计、开标一览表;正文里出现的旧总价一并替换。返回新总价。"""
items = spec["price"]["items"]
old_total = Decimal(spec["price"]["total"])
items[index]["unit_price"] = f"{unit_price:.2f}"
lines = [Decimal(str(i["qty"])) * Decimal(i["unit_price"]) for i in items]
total = sum(lines, Decimal(0)).quantize(Decimal("0.01"))
spec["price"]["total"] = f"{total:.2f}"
table = next(b for b in blocks(spec) if b.get("price_items"))
row = table["rows"][index]
row["cells"][-2], row["cells"][-1] = fmt(unit_price), fmt(lines[index].quantize(Decimal("0.01")))
pairs = [(fmt(old_total), fmt(total)), (rmb_upper(old_total), rmb_upper(total))]
for b in blocks(spec):
if b.get("type") == "table":
for r in b["rows"]:
r["cells"] = [_swap(c, pairs) for c in r["cells"]]
elif "text" in b:
b["text"] = _swap(b["text"], pairs)
if "lines" in b:
b["lines"] = [_swap(x, pairs) for x in b["lines"]]
return total
def _swap(text: str, pairs: list[tuple[str, str]]) -> str:
for a, b in pairs:
text = text.replace(a, b)
return text
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("clean")
ap.add_argument("out")
ap.add_argument("kind", choices=sorted(VARIANTS))
ap.add_argument("--variant")
a = ap.parse_args()
variant = a.variant or VARIANTS[a.kind][0]
src = OUT / a.clean
spec = json.loads((src / "spec.json").read_text())
manifest = json.loads((src / "manifest.json").read_text())
profile = json.loads((OUT / "tenders" / f"{spec['tender']}.json").read_text())
items = spec["price"]["items"]
total = Decimal(spec["price"]["total"])
forms = [f for f in profile["forms"] if f.get("kind") == "price_items"]
caps = {x["name"]: Decimal(str(x["ceiling"])) for x in (forms[0].get("items") or []) if x.get("ceiling")}
if a.kind in ("P01", "P02"):
ceiling = Decimal(str(spec["ceiling_total"]))
target = ceiling + 1 if variant == "超 1 元" else (ceiling * Decimal("1.01")).quantize(Decimal("0.01"))
i = next((n for n, x in enumerate(items) if Decimal(str(x["qty"])) == 1), 0)
qty = Decimal(str(items[i]["qty"]))
unit = Decimal(items[i]["unit_price"]) + ((target - total) / qty).quantize(Decimal("0.01"), rounding=ROUND_UP)
new_total = reprice(spec, i, unit)
assert new_total > ceiling, (new_total, ceiling)
snippet = fmt(new_total)
else:
i = next(n for n, x in enumerate(items) if x["name"] in caps)
unit = (caps[items[i]["name"]] * Decimal("1.01")).quantize(Decimal("0.01"))
new_total = reprice(spec, i, unit)
snippet = fmt(unit)
out = OUT / a.out
out.mkdir(parents=True, exist_ok=True)
spec["bid"] = a.out
manifest.update(
bid=a.out,
kind="sloppy",
density="price",
rule_defects=[
{"id": f"{a.kind}-1", "type": a.kind, "variant": variant, "t": snippet},
# 分项上限加起来常常就是限价:总价超限价时,被抬高的分项也超了上限,一并记
*(
[{"id": "P03-2", "type": "P03", "variant": "随总价超限", "t": fmt(Decimal(x["unit_price"]))}]
if a.kind != "P03" and (x := items[i])["name"] in caps and Decimal(x["unit_price"]) > caps[x["name"]]
else []
),
],
)
(out / "spec.json").write_text(json.dumps(spec, ensure_ascii=False, indent=1))
(out / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=1))
print(f"{a.out}:{a.kind}({variant}),总价 {fmt(total)} → {fmt(new_total)},原文片段「{snippet}」")
if __name__ == "__main__":
main()