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:
@@ -22,11 +22,16 @@ from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from bid_doctor.check.match import BidDoc
|
||||
from bid_doctor.document.pipeline import layout
|
||||
from bid_doctor.document.pipeline.layout import column as _col
|
||||
from bid_doctor.document.pipeline.layout import rows as rows_of
|
||||
from bid_doctor.document.pipeline.layout import span as _span
|
||||
from bid_doctor.document.pipeline.text_layer import Sentence, norm
|
||||
|
||||
LABELS = {
|
||||
"PRICE.OVER_CEILING_TOTAL": "报价和最高限价比对",
|
||||
"PRICE.OVER_CEILING_LOT": "报价和所投采购包的最高限价比对",
|
||||
"PRICE.OVER_CEILING_ITEM": "分项报价和分项 / 单价上限比对",
|
||||
"PRICE.CAPITAL_MISMATCH": "大写金额和小写金额核对",
|
||||
"PRICE.SUM_MISMATCH": "分项报价合计核对",
|
||||
"PRICE.TOTAL_INCONSISTENT": "投标总价前后一致",
|
||||
@@ -39,7 +44,7 @@ LABELS = {
|
||||
"FORM.RESPONSE_NEGATIVE": "响应表里的未响应、负偏离",
|
||||
}
|
||||
RULES_NOT_YET = (
|
||||
"分项报价表和采购需求清单逐项对照、分项 / 单价上限、格式文件固定文字被改、"
|
||||
"分项报价表和采购需求清单逐项对照、格式文件固定文字被改、"
|
||||
"授权代表和签字人一致、声明函填报、签章有无、低价说明、跨页的响应表"
|
||||
)
|
||||
|
||||
@@ -58,9 +63,33 @@ class Facts:
|
||||
ceiling_note: str | None = None # 没有可比的限价时的原因(写进覆盖范围)
|
||||
validity_days: int | None = None
|
||||
validity_item: UUID | None = None
|
||||
caps: list[ItemCap] = field(default_factory=list["ItemCap"]) # 所投采购包的分项 / 单价上限(「报价」类清单项)
|
||||
refs: dict[str, list[UUID]] = field(default_factory=dict[str, list[UUID]])
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ItemCap:
|
||||
"""分项上限(checklist/price.py 抽出的「报价」类清单项):per = unit 按单价比,item 按分项总价比。"""
|
||||
|
||||
name: str
|
||||
cap: Decimal
|
||||
per: str
|
||||
item: UUID | None = None
|
||||
|
||||
|
||||
def caps_of(items: dict[UUID, dict[str, Any]]) -> list[ItemCap]:
|
||||
out = []
|
||||
for item_id, p in items.items():
|
||||
spec = (p.get("spec") or {}).get("price") or {}
|
||||
if spec.get("kind") in ("unit_cap", "item_cap") and spec.get("name") and spec.get("cap"):
|
||||
try:
|
||||
cap = Decimal(str(spec["cap"]))
|
||||
except InvalidOperation:
|
||||
continue
|
||||
out.append(ItemCap(str(spec["name"]), cap, "unit" if spec["kind"] == "unit_cap" else "item", item_id))
|
||||
return out
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Hit:
|
||||
code: str
|
||||
@@ -164,55 +193,9 @@ def yuan(v: Decimal) -> str:
|
||||
return f"{v:,.2f} 元"
|
||||
|
||||
|
||||
# ---------- 版面:页、行、单元格 ----------
|
||||
def _box(s: Sentence) -> tuple[float, float, float, float]:
|
||||
return (
|
||||
min(q[0] for q in s.quads),
|
||||
min(q[1] for q in s.quads),
|
||||
max(q[2] for q in s.quads),
|
||||
max(q[3] for q in s.quads),
|
||||
)
|
||||
|
||||
|
||||
# ---------- 版面:页、行、单元格(document/pipeline/layout.py) ----------
|
||||
def pages_of(doc: BidDoc) -> dict[int, list[Sentence]]:
|
||||
out: dict[int, list[Sentence]] = {}
|
||||
for s in doc.sentences:
|
||||
out.setdefault(s.page_no, []).append(s)
|
||||
return out
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _Row:
|
||||
top: float
|
||||
bottom: float
|
||||
in_cell: bool
|
||||
items: list[Sentence]
|
||||
|
||||
|
||||
def rows_of(page: list[Sentence]) -> list[list[Sentence]]:
|
||||
"""把一页的句子分成行,行内从左到右。有框线的表格按单元格的上沿分行:同一行的格子上沿相同,
|
||||
垂直居中的短格、折成几句的长格、纵向合并的格(只算进它的第一行)都不会串行;其余按上下范围重叠。"""
|
||||
rows: list[_Row] = []
|
||||
|
||||
def top_of(s: Sentence) -> float:
|
||||
return s.cell[1] if s.cell else _box(s)[1]
|
||||
|
||||
for s in sorted((x for x in page if x.quads), key=top_of):
|
||||
if s.cell:
|
||||
r = next((r for r in rows if r.in_cell and abs(r.top - s.cell[1]) < 2), None)
|
||||
if r:
|
||||
r.items.append(s)
|
||||
else:
|
||||
rows.append(_Row(s.cell[1], s.cell[1], True, [s]))
|
||||
continue
|
||||
_, top, _, bottom = _box(s)
|
||||
last = rows[-1] if rows else None
|
||||
if last and not last.in_cell and top < last.bottom - 1:
|
||||
last.bottom = max(last.bottom, bottom)
|
||||
last.items.append(s)
|
||||
else:
|
||||
rows.append(_Row(top, bottom, False, [s]))
|
||||
return [sorted(r.items, key=lambda x: _box(x)[0]) for r in sorted(rows, key=lambda r: r.top)]
|
||||
return layout.pages(doc.sentences)
|
||||
|
||||
|
||||
def row_index(doc: BidDoc) -> dict[int, list[Sentence]]:
|
||||
@@ -220,25 +203,6 @@ def row_index(doc: BidDoc) -> dict[int, list[Sentence]]:
|
||||
return {id(c): row for page in pages_of(doc).values() for row in rows_of(page) for c in row}
|
||||
|
||||
|
||||
def _col(cell: Sentence, cols: dict[str, tuple[float, float]]) -> str | None:
|
||||
"""单元格落在哪一栏:和表头单元格左右范围重叠最多的那一栏;都不重叠(窄数字居中、表头字靠左)取中心最近的。
|
||||
cols 要包含表头的每一格,否则别的栏的数字会被归到最近的已知栏。"""
|
||||
x0, x1 = _span(cell)
|
||||
best, score = None, 0.0
|
||||
for name, (c0, c1) in cols.items():
|
||||
overlap = min(x1, c1) - max(x0, c0)
|
||||
if overlap > score:
|
||||
best, score = name, overlap
|
||||
if best is None and cols:
|
||||
mid = (x0 + x1) / 2
|
||||
best = min(cols, key=lambda n: abs((cols[n][0] + cols[n][1]) / 2 - mid))
|
||||
return best
|
||||
|
||||
|
||||
def _span(c: Sentence) -> tuple[float, float]:
|
||||
return (c.cell[0], c.cell[2]) if c.cell else (_box(c)[0], _box(c)[2])
|
||||
|
||||
|
||||
# ---------- 分项报价表、开标一览表 ----------
|
||||
@dataclass(slots=True)
|
||||
class ItemRow:
|
||||
@@ -507,6 +471,66 @@ def check_prices(
|
||||
)
|
||||
|
||||
|
||||
PAREN = re.compile(r"[((][^))]*[))]")
|
||||
|
||||
|
||||
def _match_cap(name: str, caps: list[ItemCap]) -> ItemCap | None:
|
||||
"""分项名称对上哪条上限。投标文件的分项名称通常照抄采购需求,所以要求很严:完全相同(或去掉括注后相同)优先;
|
||||
否则字对重合度不低于 0.8,或一方包含另一方且长度比不低于 0.8。「服务器」不会对上「服务器机柜」。"""
|
||||
n = norm(name)
|
||||
for c in caps:
|
||||
if norm(c.name) == n or PAREN.sub("", norm(c.name)) == PAREN.sub("", n):
|
||||
return c
|
||||
best, score = None, 0.0
|
||||
for c in caps:
|
||||
k = norm(c.name)
|
||||
x, y = _bigrams(k), _bigrams(n)
|
||||
dice = 2 * len(x & y) / (len(x) + len(y)) if x and y else 0.0
|
||||
contained = (k in n or n in k) and min(len(k), len(n)) / max(len(k), len(n)) >= 0.8
|
||||
s = max(dice, 0.8 if contained else 0.0)
|
||||
if s >= 0.8 and s > score:
|
||||
best, score = c, s
|
||||
return best
|
||||
|
||||
|
||||
def check_item_caps(facts: Facts, tables: list[PriceTable], hits: list[Hit], notes: list[str]) -> None:
|
||||
"""分项 / 单价上限(第三层限价,FR-M3-06):分项报价表里同名分项的单价(或分项总价)超过招标文件写的上限。"""
|
||||
if not facts.caps:
|
||||
return
|
||||
if not tables:
|
||||
notes.append(f"招标文件写了分项 / 单价上限 {len(facts.caps)} 条,投标文件里没认出分项报价表,没有比对")
|
||||
return
|
||||
matched = 0
|
||||
for t in tables:
|
||||
for r in t.rows:
|
||||
name = next((c.text for c in r.cells if not money(c.text, plain=True) and len(norm(c.text)) >= 2), "")
|
||||
cap = _match_cap(name, facts.caps) if name else None
|
||||
value = None if cap is None else r.unit_price if cap.per == "unit" else r.line
|
||||
if cap is None or value is None or r.ambiguous:
|
||||
continue
|
||||
matched += 1
|
||||
if value <= cap.cap:
|
||||
continue
|
||||
what = "单价" if cap.per == "unit" else "分项总价"
|
||||
hits.append(
|
||||
Hit(
|
||||
"PRICE.OVER_CEILING_ITEM",
|
||||
"price_form",
|
||||
"fatal",
|
||||
f"「{name[:20]}」的{what}超过上限",
|
||||
f"分项报价表里「{name[:30]}」的{what}是 {yuan(value)},招标文件写的{what}上限是 {yuan(cap.cap)}。"
|
||||
"超过分项或单价上限通常会导致投标无效,请核对。",
|
||||
[(t.doc, c) for c in r.cells if money(c.text, plain=True) or c.text == name],
|
||||
target(name),
|
||||
basis=f"cap:{cap.item}",
|
||||
item=cap.item,
|
||||
detail={"value": str(value), "cap": str(cap.cap), "per": cap.per},
|
||||
)
|
||||
)
|
||||
if matched < len(facts.caps):
|
||||
notes.append(f"分项 / 单价上限 {len(facts.caps)} 条,在分项报价表里对上名称的 {matched} 条,其余没有比对")
|
||||
|
||||
|
||||
LOWER_MARK = re.compile(r"小写|[¥¥]|报价|总价|合计|金额|价款")
|
||||
|
||||
|
||||
@@ -1065,6 +1089,7 @@ def run(facts: Facts, docs: list[BidDoc], tender_sentences: list[Sentence]) -> t
|
||||
hits: list[Hit] = []
|
||||
notes: list[str] = []
|
||||
check_prices(facts, docs, tables, totals, hits, notes)
|
||||
check_item_caps(facts, tables, hits, notes)
|
||||
check_quotes(docs, tender, tables, totals, hits)
|
||||
check_foreign(facts, docs, tender, hits, notes)
|
||||
check_blanks(docs, hits, notes)
|
||||
|
||||
Reference in New Issue
Block a user