Files
bid-doctor/server/src/bid_doctor/project/extracted.py
YANG JIANKUAN 6e0d4c1dd6
Some checks are pending
ci / server (push) Waiting to run
ci / web (push) Waiting to run
ci / contract (push) Waiting to run
feat(addendum): 补遗追加(FR-M2-10)
- 招标文件解析后可以追加补遗 / 澄清:单独解析、扣 1 次;新条目标「补遗 N」进正式清单,清单回到草稿(AD1)
- 同一种日期、时间不同的旧条目标「已被补遗 N 更新」,逐条确认更新或保留两条(AD2、AD3)
- 项目字段的新值在概览「采用 / 不采用」:采用时以补遗为准、清掉人工值;采用截止时间时日期条目一起确认;
  没确认前查标用旧值,检查预览提示(AD4)
- 补遗不改项目名称、编号、采购人、代理机构、法律体系(更正公告标题会被抽成项目名称);时间比较按北京时间补时区
- 接口:fields/{key}/pending、checklist-items/{id}/supersession;清单项 origin_document、superseded_by;
  关键日期 replaces;解析结果 dates_superseded、fields_pending;检查预览 pending_updates
- 前端:补遗模式的上传页、解析页结果、概览确认、读标页来源筛选和日期确认、检查预览提示
- 决策 AD1–AD6;方案和落地记录 docs/architecture/addendum-plan.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 00:27:21 +08:00

124 lines
5.4 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.

"""把解析出的项目基本信息和分包写回项目(FR-M2-03、04)。
- AI 值和人工值分开存:用户改过的字段不覆盖;项目名称只在还是「未命名项目」时自动填。
- 补遗带来的新值先放进待确认(FR-M2-10),用户确认后才生效;首份招标文件直接生效。
- 分包:解析出包时替换掉建项目时的默认包;已经有包的项目不动(要改由用户在选包页处理)。
"""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from typing import Any
from uuid import UUID
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from bid_doctor.checklist.extract import Extracted, ProjectInfo, amount_yuan
from bid_doctor.core.db import TenantContext
from bid_doctor.core.ids import uuid7
from bid_doctor.document import citations
from bid_doctor.document.models import SourceDocument
from bid_doctor.project import service as projects
from bid_doctor.project.models import Lot, Project, ProjectField
MONEY = ("budget_total", "ceiling_total")
COLUMNS = ("name", "code", "purchaser", "agency", "procurement_method", "legal_regime")
# 更正公告不改项目是谁:补遗、澄清里抽到的这几项(常把公告标题当成项目名称)不进待确认,免得一片噪音(FR-M2-10)
IDENTITY = ("name", "code", "purchaser", "agency", "legal_regime")
async def apply(
s: AsyncSession,
ctx: TenantContext,
doc: SourceDocument,
info: ProjectInfo,
regime_by_rule: str,
items: list[Extracted],
) -> dict[str, UUID]:
"""写入字段、分包和投标截止时间。返回 {包号: 分包编号},给清单项挂所属包用。"""
p = await projects.get(s, ctx, doc.project_id)
values: dict[str, tuple[Any, Any]] = {}
for key, f in info.fields.items():
value = amount_yuan(f.value, f.unit) if key in MONEY else f.value
if key == "legal_regime" and value not in ("gov_procurement", "tendering"):
continue
values[key] = (value, f.sentence)
if "legal_regime" not in values and regime_by_rule != "unknown":
values["legal_regime"] = (regime_by_rule, None)
deadline = _deadline(items)
if deadline:
values["deadline_at"] = deadline
for key, (value, sentence) in values.items():
if doc.role in ("addendum", "clarification") and key in IDENTITY:
continue
cite = None
if sentence is not None:
(c,) = await citations.from_sentences(s, ctx, doc, [sentence], "ai", ["id", "quote", "basis"])
cite = c.id
await _set_field(s, ctx, p, doc, key, value, cite)
return await _lots(s, ctx, p, info)
def _deadline(items: list[Extracted]) -> tuple[str, Any] | None:
for e in items:
sched = e.spec.get("schedule") or {}
if e.zone == "formal" and sched.get("date_kind") == "bid_deadline" and sched.get("at"):
return sched["at"], e.sentences[0]
return None
async def _set_field(
s: AsyncSession, ctx: TenantContext, p: Project, doc: SourceDocument, key: str, value: Any, cite: UUID | None
) -> None:
f = (
await s.execute(select(ProjectField).where(ProjectField.project_id == p.id, ProjectField.field_key == key))
).scalar_one_or_none()
if f is None:
f = ProjectField(tenant_id=ctx.tenant_id, project_id=p.id, field_key=key)
s.add(f)
if doc.role in ("addendum", "clarification") and f.ai_value is not None and f.ai_value != value:
f.pending_value, f.pending_citation_id = value, cite # 补遗的新值:等用户确认(FR-M2-10)
else:
f.ai_value, f.ai_citation_id, f.ai_document_id = value, cite, doc.id
await s.flush()
if f.human_value is not None or f.pending_value is not None:
return
if key == "name" and p.name != projects.UNNAMED:
return # 用户建项目时填了名称:以用户的为准
if key in COLUMNS:
setattr(p, key, value)
elif key == "deadline_at":
p.deadline_at = datetime.fromisoformat(value)
async def _lots(s: AsyncSession, ctx: TenantContext, p: Project, info: ProjectInfo) -> dict[str, UUID]:
existing = (await s.execute(select(Lot).where(Lot.project_id == p.id).order_by(Lot.sort_order))).scalars().all()
if info.lots and all(lot.is_default for lot in existing):
await s.execute(delete(Lot).where(Lot.project_id == p.id, Lot.is_default))
for i, lot in enumerate(info.lots):
budget, ceiling = lot.get("budget") or {}, lot.get("ceiling") or {}
b, c = (
amount_yuan(budget.get("value"), budget.get("unit")),
amount_yuan(ceiling.get("value"), ceiling.get("unit")),
)
s.add(
Lot(
id=uuid7(),
tenant_id=ctx.tenant_id,
project_id=p.id,
lot_no=str(lot["lot_no"])[:40],
name=lot.get("name"),
origin="ai",
sort_order=i,
edited_fields=[],
budget_amount=Decimal(b) if b else None,
ceiling_amount=Decimal(c) if c else None,
ai_values={k: v for k, v in lot.items() if k != "sentence"},
)
)
await s.flush()
existing = (await s.execute(select(Lot).where(Lot.project_id == p.id))).scalars().all()
return {lot.lot_no: lot.id for lot in existing}