feat(addendum): 补遗追加(FR-M2-10)
Some checks are pending
ci / server (push) Waiting to run
ci / web (push) Waiting to run
ci / contract (push) Waiting to run

- 招标文件解析后可以追加补遗 / 澄清:单独解析、扣 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>
This commit is contained in:
YANG JIANKUAN
2026-11-05 00:27:21 +08:00
parent ed6a2a907d
commit 6e0d4c1dd6
27 changed files with 752 additions and 45 deletions

View File

@@ -116,6 +116,9 @@ async def preview(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> dict
"waive_until": mode.waive_until,
"checklist_confirmed": states.get(project_id) == "confirmed",
"pending_item_count": (await checklist.pending_counts(s, [project_id])).get(project_id, 0),
# 补遗带来的新值、日期更新还没确认:查标用旧值(AD4),预览里提示,不阻止检查
"pending_updates": await projects.pending_field_count(s, project_id)
+ await checklist.superseded_pending(s, project_id),
"bid_documents": [await documents.doc_to_api(s, d) for d in bids],
"lot_ids": await projects.bidding_lot_ids(s, project_id),
}

View File

@@ -0,0 +1,141 @@
"""补遗追加(FR-M2-10,addendum-plan §2、§3):日期类条目的「已被补遗 N 更新」和确认。
- 只关联日期类(AD2):补遗里有具体时间的「日期与递交」条目,在更早的文件里找同一种日期、还在正式清单里的条目,
时间不同的把旧条目的 superseded_by_id 指向新条目,等用户确认;时间相同的是复述,不关联;
- 确认:采用 → 旧条目软删除(可以恢复),新条目保留;保留两条 → 解除关联,由用户自己处理;
- 参数等条目的「原为……现为……」逐条对照是变更判断,P1,这里不做。
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
from zoneinfo import ZoneInfo
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from bid_doctor.audit import service as audit
from bid_doctor.checklist import edit, service
from bid_doctor.checklist.models import ChecklistItem
from bid_doctor.core.db import TenantContext
from bid_doctor.core.errors import AppError
from bid_doctor.document.models import SourceDocument
DATE_KINDS = (
"bid_deadline",
"opening",
"question_deadline",
"deposit_deadline",
"validity",
) # 「其他日期」对不上,不关联
TENDER_ROLES = ("tender", "addendum", "clarification")
LOCAL_TZ = ZoneInfo("Asia/Shanghai")
def _when(item: ChecklistItem) -> tuple[str | None, str | None]:
sched = (item.spec or {}).get("schedule") or {}
return sched.get("date_kind"), sched.get("at")
def _same_time(a: str, b: str) -> bool:
"""同一时刻(不带时区的按北京时间算,模型有时给有时不给)。"""
try:
x, y = datetime.fromisoformat(a), datetime.fromisoformat(b)
except ValueError:
return a == b
return x.replace(tzinfo=x.tzinfo or LOCAL_TZ) == y.replace(tzinfo=y.tzinfo or LOCAL_TZ)
async def link_dates(s: AsyncSession, doc: SourceDocument) -> int:
"""补遗并入清单后:把更早文件里同一种日期、时间不同的旧条目标成「已被这份补遗更新」。返回关联了几条。"""
if doc.role not in ("addendum", "clarification"):
return 0
earlier = {
d.id
for d in (
await s.execute(
select(SourceDocument).where(
SourceDocument.project_id == doc.project_id,
SourceDocument.role.in_(TENDER_ROLES),
SourceDocument.id != doc.id,
SourceDocument.created_at < doc.created_at,
)
)
).scalars()
}
live = (
(
await s.execute(
select(ChecklistItem).where(
ChecklistItem.project_id == doc.project_id,
ChecklistItem.zone == "formal",
ChecklistItem.deleted_at.is_(None),
ChecklistItem.categories.contains(["schedule"]),
)
)
)
.scalars()
.all()
)
new = [i for i in live if i.origin_document_id == doc.id and _when(i)[0] in DATE_KINDS and _when(i)[1]]
old = [i for i in live if i.origin_document_id in earlier and i.superseded_by_id is None]
n = 0
for item in new:
kind, at = _when(item)
for o in old:
o_kind, o_at = _when(o)
if o_kind == kind and o_at and at and not _same_time(o_at, at):
o.superseded_by_id = item.id
n += 1
await s.flush()
return n
async def resolve(s: AsyncSession, ctx: TenantContext, item_id: UUID, accept: bool) -> dict[str, Any]:
"""对旧条目确认「已被补遗 N 更新」:采用时旧条目软删除,保留两条时解除关联(契约 resolveSupersession)。"""
item = await service.load(s, item_id)
await edit.editable(s, ctx, item.project_id) # 只读项目 409
if item.superseded_by_id is None or item.deleted_at is not None:
raise AppError("not_superseded")
new_id = item.superseded_by_id
if accept:
item.deleted_at, item.deleted_by = datetime.now(UTC), ctx.user_id
await edit.reopen(s, item.project_id)
else:
item.superseded_by_id = None
await audit.record(
s,
ctx,
"checklist_item.resolve_supersession",
"checklist_item",
item.id,
project_id=item.project_id,
after={"accept": accept, "superseded_by": str(new_id)},
)
await s.flush()
return await service.item(s, item.id)
async def accept_kind(s: AsyncSession, ctx: TenantContext, project_id: UUID, kind: str) -> int:
"""采用补遗的投标截止时间时,清单里同一种日期的更新一起确认(AD3)。返回确认了几条。"""
rows = (
(
await s.execute(
select(ChecklistItem).where(
ChecklistItem.project_id == project_id,
ChecklistItem.superseded_by_id.is_not(None),
ChecklistItem.deleted_at.is_(None),
)
)
)
.scalars()
.all()
)
n = 0
for r in rows:
if _when(r)[0] == kind:
await resolve(s, ctx, r.id, True)
n += 1
return n

View File

@@ -6,7 +6,7 @@ from uuid import UUID
from fastapi import APIRouter, Header, Response
from pydantic import BaseModel, ConfigDict, Field
from bid_doctor.checklist import edit, response_mode, service
from bid_doctor.checklist import addendum, edit, response_mode, service
from bid_doctor.core.db import tenant_tx
from bid_doctor.identity.deps import Ctx
from bid_doctor.project import service as projects
@@ -75,6 +75,17 @@ async def list_items(
return {"items": shown, "hidden_count": len(rows) - len(shown)}
class SupersessionIn(BaseModel):
accept: bool
@router.post("/checklist-items/{item_id}/supersession")
async def resolve_supersession(item_id: UUID, body: SupersessionIn, ctx: Ctx) -> dict[str, Any]:
"""对旧条目确认「已被补遗 N 更新」:采用时旧条目软删除,保留两条时解除关联(FR-M2-10)。"""
async with tenant_tx(ctx) as s:
return await addendum.resolve(s, ctx, item_id, body.accept)
@router.post("/projects/{project_id}/checklist/items", status_code=201)
async def create_item(project_id: UUID, body: ItemCreate, ctx: Ctx) -> dict[str, Any]:
values = body.changes()

View File

@@ -41,11 +41,11 @@ def _json(v: Any) -> Any:
return str(v) if isinstance(v, UUID) else v
async def _editable(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> None:
async def editable(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> None:
projects.ensure_editable(await projects.get(s, ctx, project_id))
async def _reopen(s: AsyncSession, project_id: UUID) -> None:
async def reopen(s: AsyncSession, project_id: UUID) -> None:
"""正式清单有变化:已确认的清单回到草稿(FR-M2-08)。"""
cl = (await s.execute(select(Checklist).where(Checklist.project_id == project_id))).scalar_one()
if cl.status == "confirmed":
@@ -170,7 +170,7 @@ async def update(
s: AsyncSession, ctx: TenantContext, item_id: UUID, changes: dict[str, Any], if_match: str | None
) -> dict[str, Any]:
item = await service.load(s, item_id)
await _editable(s, ctx, item.project_id)
await editable(s, ctx, item.project_id)
if if_match and if_match.removeprefix("W/") != service.etag(item):
raise AppError("precondition_failed")
if item.deleted_at is not None:
@@ -189,7 +189,7 @@ async def update(
after=after,
)
if item.zone == "formal":
await _reopen(s, item.project_id)
await reopen(s, item.project_id)
return await _done(s, item)
@@ -203,7 +203,7 @@ async def accept(
) -> dict[str, Any]:
"""从待确认区采纳(FR-M2-07):要么有出处,要么声明「人工确认、无出处」(红线 2);可以同时修改字段。"""
item = await service.load(s, item_id)
await _editable(s, ctx, item.project_id)
await editable(s, ctx, item.project_id)
if item.zone != "pending" or item.deleted_at is not None:
raise AppError("item_not_pending")
before, after = _apply(item, await _check(s, item.project_id, changes))
@@ -231,13 +231,13 @@ async def accept(
before=before,
after=after,
)
await _reopen(s, item.project_id)
await reopen(s, item.project_id)
return await _done(s, item)
async def set_citations(s: AsyncSession, ctx: TenantContext, item_id: UUID, citation_ids: list[UUID]) -> dict[str, Any]:
item = await service.load(s, item_id)
await _editable(s, ctx, item.project_id)
await editable(s, ctx, item.project_id)
if not citation_ids and item.zone == "formal" and item.origin != "human" and item.no_citation_ack_by is None:
raise AppError("citation_required") # 正式清单里的 AI 条目不能没有出处(红线 2)
old = await _set_citations(s, item, citation_ids)
@@ -255,14 +255,14 @@ async def set_citations(s: AsyncSession, ctx: TenantContext, item_id: UUID, cita
after={"citation_ids": [str(c) for c in dict.fromkeys(citation_ids)]},
)
if item.zone == "formal":
await _reopen(s, item.project_id)
await reopen(s, item.project_id)
return await _done(s, item)
async def remove(s: AsyncSession, ctx: TenantContext, item_id: UUID) -> None:
"""软删除;待确认区的条目删除就是「丢弃」。删除的条目可以恢复,重新解析也不会再冒出来。"""
item = await service.load(s, item_id)
await _editable(s, ctx, item.project_id)
await editable(s, ctx, item.project_id)
if item.deleted_at is not None:
return
item.deleted_at, item.deleted_by = datetime.now(UTC), ctx.user_id
@@ -271,12 +271,12 @@ async def remove(s: AsyncSession, ctx: TenantContext, item_id: UUID) -> None:
s, ctx, action, "checklist_item", item.id, project_id=item.project_id, before={"zone": item.zone}
)
if item.zone == "formal":
await _reopen(s, item.project_id)
await reopen(s, item.project_id)
async def restore(s: AsyncSession, ctx: TenantContext, item_id: UUID) -> dict[str, Any]:
item = await service.load(s, item_id)
await _editable(s, ctx, item.project_id)
await editable(s, ctx, item.project_id)
if item.deleted_at is not None:
item.deleted_at, item.deleted_by = None, None
await audit.record(
@@ -289,7 +289,7 @@ async def restore(s: AsyncSession, ctx: TenantContext, item_id: UUID) -> dict[st
after={"zone": item.zone},
)
if item.zone == "formal":
await _reopen(s, item.project_id)
await reopen(s, item.project_id)
return await _done(s, item)
@@ -297,7 +297,7 @@ async def create(
s: AsyncSession, ctx: TenantContext, project_id: UUID, values: dict[str, Any], citation_ids: list[UUID]
) -> dict[str, Any]:
"""人工新增(标注「人工添加」,可以没有出处),直接进正式清单。"""
await _editable(s, ctx, project_id)
await editable(s, ctx, project_id)
cl = await service.get_or_create(s, ctx, project_id)
v = await _check(s, project_id, {"importance": "normal", "spec": {}, **values})
item = ChecklistItem(
@@ -327,7 +327,7 @@ async def create(
project_id=project_id,
after={**{k: _json(x) for k, x in v.items()}, "citation_ids": [str(c) for c in citation_ids]},
)
await _reopen(s, project_id)
await reopen(s, project_id)
return await _done(s, item)
@@ -446,7 +446,7 @@ async def version_numbers(s: AsyncSession, version_ids: list[UUID]) -> dict[UUID
async def confirm(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> dict[str, Any]:
"""确认清单:把正式清单里未删除的条目存成不可变版本;内容和上一版一样就复用上一版(data-model §5)。
待确认区的条目不进版本,也不参与检查。"""
await _editable(s, ctx, project_id)
await editable(s, ctx, project_id)
cl = await service.get_or_create(s, ctx, project_id)
rows = await service.items(s, project_id, zone="formal")
if not rows:

View File

@@ -140,6 +140,19 @@ async def merge(
return stats
async def superseded_pending(s: AsyncSession, project_id: UUID) -> int:
"""「已被补遗 N 更新」还没确认的条数(FR-M2-10)。"""
return (
await s.execute(
select(func.count()).where(
ChecklistItem.project_id == project_id,
ChecklistItem.superseded_by_id.is_not(None),
ChecklistItem.deleted_at.is_(None),
)
)
).scalar_one()
async def overview(s: AsyncSession, project_id: UUID) -> dict[str, Any]:
cl = (await s.execute(select(Checklist).where(Checklist.project_id == project_id))).scalar_one_or_none()
zone_or_deleted = case((ChecklistItem.deleted_at.is_not(None), "deleted"), else_=ChecklistItem.zone)
@@ -234,6 +247,7 @@ async def overview(s: AsyncSession, project_id: UUID) -> dict[str, Any]:
"formal": counts.get("formal", 0),
"pending": counts.get("pending", 0),
"deleted": counts.get("deleted", 0),
"superseded_pending": await superseded_pending(s, project_id),
"by_category": by_category,
},
"marking_rules": [
@@ -284,7 +298,24 @@ async def items(
by_item.setdefault(item_id, []).append(citations.to_api(c, citations.document_label(role, seq)))
names = await identity.display_names(s, [r.accepted_by for r in rows if r.accepted_by])
lots = await projects.lot_labels(s, project_id)
return [_item(r, by_item.get(r.id, []), names, lots) for r in rows]
# 来源文件(补遗 N)和「已被补遗 N 更新」的新条目来自哪份补遗(FR-M2-10)
newer = dict(
(
await s.execute(
select(ChecklistItem.id, ChecklistItem.origin_document_id).where(
ChecklistItem.id.in_([r.superseded_by_id for r in rows if r.superseded_by_id])
)
)
)
.tuples()
.all()
)
doc_ids = {r.origin_document_id for r in rows if r.origin_document_id} | {d for d in newer.values() if d}
docs = {
d.id: citations.document_label(d.role, d.seq_no)
for d in (await s.execute(select(SourceDocument).where(SourceDocument.id.in_(doc_ids)))).scalars()
}
return [_item(r, by_item.get(r.id, []), names, lots, docs, newer) for r in rows]
async def item(s: AsyncSession, item_id: UUID) -> dict[str, Any]:
@@ -310,8 +341,21 @@ ORIGIN_LABEL = {"ai": "AI", "rule": "规则", "human": "人工添加"}
def _item(
r: ChecklistItem, cites: list[dict[str, Any]], names: dict[UUID, str], lots: dict[UUID, str | None]
r: ChecklistItem,
cites: list[dict[str, Any]],
names: dict[UUID, str],
lots: dict[UUID, str | None],
docs: dict[UUID, str],
newer: dict[UUID, UUID | None],
) -> dict[str, Any]:
source = docs.get(r.origin_document_id) if r.origin_document_id else None
from_addendum = source is not None and source != "招标文件" and r.origin != "human"
by = r.superseded_by_id
superseded = (
{"item_id": by, "document_label": docs.get(newer.get(by) or by, ""), "confirmed": r.deleted_at is not None}
if by
else None
)
return {
"id": r.id,
"categories": r.categories,
@@ -326,7 +370,8 @@ def _item(
"evidence_required": r.evidence_required,
"spec": r.spec,
"origin": r.origin,
"origin_label": ORIGIN_LABEL[r.origin],
"origin_label": source if from_addendum else ORIGIN_LABEL[r.origin],
"origin_document": {"id": r.origin_document_id, "label": source} if source else None,
"zone": r.zone,
"pending_reason": r.pending_reason,
"no_citation_ack": r.no_citation_ack_by is not None,
@@ -334,7 +379,7 @@ def _item(
"ai_values": r.ai_values,
"edited_fields": r.edited_fields,
"confidence": float(r.confidence) if r.confidence is not None else None,
"superseded_by": None,
"superseded_by": superseded,
"accepted_by": {"id": r.accepted_by, "name": names.get(r.accepted_by, "")} if r.accepted_by else None,
"deleted_at": r.deleted_at,
"updated_at": r.updated_at,
@@ -401,6 +446,29 @@ async def key_dates(s: AsyncSession, project_id: UUID) -> list[dict[str, Any]]:
.scalars()
.all()
)
# 已确认的补遗更新:新条目注「已按补遗 N 更新 · 原为……」(原型 project.html 的关键日期卡)
replaced = {
o.superseded_by_id: (o.spec or {}).get("schedule", {}).get("at")
for o in (
await s.execute(
select(ChecklistItem).where(
ChecklistItem.project_id == project_id,
ChecklistItem.superseded_by_id.in_([r.id for r in rows]),
ChecklistItem.deleted_at.is_not(None),
)
)
).scalars()
}
docs = {
d.id: citations.document_label(d.role, d.seq_no)
for d in (
await s.execute(
select(SourceDocument).where(
SourceDocument.id.in_({r.origin_document_id for r in rows if r.origin_document_id})
)
)
).scalars()
}
out = []
for r in rows:
sched = (r.spec or {}).get("schedule") or {}
@@ -412,6 +480,9 @@ async def key_dates(s: AsyncSession, project_id: UUID) -> list[dict[str, Any]]:
"kind": sched.get("date_kind", "other"),
"at": sched["at"],
"superseded_pending": r.superseded_by_id is not None,
"replaces": {"at": replaced[r.id], "document_label": docs.get(r.origin_document_id, "")}
if r.id in replaced and r.origin_document_id
else None,
}
)
return sorted(out, key=lambda d: d["at"])

View File

@@ -26,6 +26,8 @@ CODES: dict[str, tuple[int, str]] = {
"job_already_running": (409, "任务正在进行"),
"job_finished": (409, "任务已经结束"),
"project_read_only": (409, "项目已递交或已归档,要先撤销才能修改"),
"not_superseded": (409, "这一条没有待确认的补遗更新"),
"no_pending_value": (409, "这个字段没有待确认的补遗新值"),
"document_role_not_parsable": (409, "这类文件不需要单独解析"),
"document_not_ready": (409, "文件还在处理"),
"upload_incomplete": (409, "文件还没有上传完"),

View File

@@ -18,7 +18,7 @@ from sqlalchemy import select
from bid_doctor.ai.providers.base import TransientError
from bid_doctor.billing import service as billing
from bid_doctor.checklist import extract, response_mode
from bid_doctor.checklist import addendum, extract, response_mode
from bid_doctor.checklist import service as checklist
from bid_doctor.core import events
from bid_doctor.core.db import TenantContext, tenant_tx
@@ -140,8 +140,13 @@ async def _stages(ctx: TenantContext, job_id: UUID) -> dict[str, Any]:
stats = await checklist.merge(s, ctx, d, job_id, items, notes, lots)
cl = await checklist.get_or_create(s, ctx, d.project_id)
await response_mode.save(s, ctx, cl, d, body) # 招标文件的响应方式(C12),清单页显示、可改
# 补遗:同一种日期、时间不同的旧条目标「已被补遗 N 更新」;项目字段的新值已在 apply 里放进待确认(FR-M2-10)
superseded = await addendum.link_dates(s, d)
fields_pending = await projects.pending_field_count(s, d.project_id) if d.role != "tender" else 0
return {
**stats,
"dates_superseded": superseded,
"fields_pending": fields_pending,
"lots_found": len([k for k in lots if k != "默认"]),
# 质量统计(评测和排查用):没通过校验的原因码条数、对半重跑次数、拆到底仍不完整的分块、项目信息各字段的结论
"verify_failures": {k.removeprefix("fail:"): n for k, n in run_stats.items() if k.startswith("fail:")},

View File

@@ -7,6 +7,7 @@ from fastapi import APIRouter, Header, Query
from pydantic import BaseModel, Field
from sqlalchemy import select
from bid_doctor.checklist import addendum
from bid_doctor.core.db import tenant_tx
from bid_doctor.document.models import SourceDocument
from bid_doctor.identity.deps import Ctx
@@ -110,6 +111,20 @@ async def set_project_field(
)
class PendingIn(BaseModel):
accept: bool
@router.post("/projects/{project_id}/fields/{field_key}/pending")
async def resolve_pending_field(project_id: UUID, field_key: FieldKey, body: PendingIn, ctx: Ctx) -> dict[str, Any]:
"""确认或驳回补遗带来的新值;采用投标截止时间时,清单里「投标截止」日期条目的更新一起确认(AD3)。"""
async with tenant_tx(ctx) as s:
await edit.resolve_pending(s, ctx, project_id, field_key, body.accept)
if body.accept and field_key == "deadline_at":
await addendum.accept_kind(s, ctx, project_id, "bid_deadline")
return next(f for f in await service.fields(s, project_id) if f["key"] == field_key)
@router.get("/projects/{project_id}/lots")
async def list_lots(project_id: UUID, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:

View File

@@ -122,6 +122,36 @@ async def set_field(s: AsyncSession, ctx: TenantContext, project_id: UUID, key:
)
async def resolve_pending(s: AsyncSession, ctx: TenantContext, project_id: UUID, key: str, accept: bool) -> None:
"""补遗带来的新值(FR-M2-10):采用 → 变成 AI 值、出处换成补遗那一句、清掉人工值(以补遗为准,AD3);
不采用 → 丢掉待确认值,保持原值。都写审计。"""
require(ctx, Permission.PROJECT_EDIT)
p = await service.get(s, ctx, project_id)
service.ensure_editable(p)
f = (
await s.execute(select(ProjectField).where(ProjectField.project_id == p.id, ProjectField.field_key == key))
).scalar_one_or_none()
if f is None or f.pending_value is None:
raise AppError("no_pending_value")
before = {"value": f.human_value if f.human_value is not None else f.ai_value, "pending": f.pending_value}
if accept:
f.ai_value, f.ai_citation_id = f.pending_value, f.pending_citation_id
f.human_value, f.human_set_by, f.human_set_at = None, None, None
_sync(p, key, f.ai_value)
f.pending_value, f.pending_citation_id = None, None
await s.flush()
await audit.record(
s,
ctx,
"project_field.resolve_pending",
"project",
p.id,
project_id=p.id,
before={key: before},
after={key: {"accept": accept, "value": f.ai_value if accept else before["value"]}},
)
def _source(f: ProjectField) -> str:
return "human" if f.human_value is not None else "ai" if f.ai_value is not None else "none"

View File

@@ -25,6 +25,8 @@ 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(
@@ -49,6 +51,8 @@ async def apply(
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"])

View File

@@ -125,6 +125,9 @@ async def fields(s: AsyncSession, project_id: UUID) -> list[dict[str, Any]]:
"updated_at": r.human_set_at if human else None,
"pending": {
"value": r.pending_value,
"document_label": (cites.get(r.pending_citation_id) or {}).get("document_label", "补遗")
if r.pending_citation_id
else "补遗",
"citation": cites.get(r.pending_citation_id) if r.pending_citation_id else None,
}
if r.pending_value is not None
@@ -134,6 +137,19 @@ async def fields(s: AsyncSession, project_id: UUID) -> list[dict[str, Any]]:
return out
async def pending_field_count(s: AsyncSession, project_id: UUID) -> int:
"""补遗带来的新值还没确认的字段数(FR-M2-10)。"""
return (
await s.execute(
select(func.count()).where(
ProjectField.project_id == project_id,
ProjectField.pending_value.is_not(None),
func.jsonb_typeof(ProjectField.pending_value) != "null", # ORM 写 None 存的是 JSON null
)
)
).scalar_one()
async def lot_labels(s: AsyncSession, project_id: UUID) -> dict[UUID, str | None]:
"""项目的分包编号(给清单项展示「采购包 N」、校验 lot_id 用);默认包没有编号,表示各包通用。"""
rows = await s.execute(select(Lot.id, Lot.lot_no, Lot.is_default).where(Lot.project_id == project_id))

View File

@@ -48,7 +48,17 @@ def _key_dates(dates: list[dict[str, Any]], fields: list[dict[str, Any]]) -> lis
if f is None:
return dates
rest = [d for d in dates if d.get("kind") != "bid_deadline"]
edited = {"item_id": None, "label": "投标截止", "kind": "bid_deadline", "at": f["value"], "edited": True}
pending = any(d.get("superseded_pending") for d in dates if d.get("kind") == "bid_deadline") or bool(
f.get("pending")
)
edited = {
"item_id": None,
"label": "投标截止",
"kind": "bid_deadline",
"at": f["value"],
"edited": True,
"superseded_pending": pending,
}
return sorted([*rest, edited], key=lambda d: str(d["at"]))

BIN
server/tests/fixtures/addendum-mini.pdf vendored Normal file

Binary file not shown.

View File

@@ -6,7 +6,8 @@
- bid-mini-v2.pdf:补上了交换机硬件 BFD → 复查时这个问题「本次已解决」;
- bid-mini-forms.pdf:v2 的内容 + 格式文件里的规则问题(大写和小写金额不一致、授权书没填投标人名称、
报价附加条件、另外给了一个优惠报价),措辞故意和评测集的开发词库不同;
- bid-mini-quals.pdf:v2 的内容 + 企业资质情况表(证书、编号、有效期,全部虚构),资质匹配的端到端测试用。
- bid-mini-quals.pdf:v2 的内容 + 企业资质情况表(证书、编号、有效期,全部虚构),资质匹配的端到端测试用;
- addendum-mini.pdf:tender-full.pdf 的更正公告(补遗,FR-M2-10):递交截止时间推后一周、新增一条 ★ 参数。
"""
from __future__ import annotations
@@ -62,8 +63,18 @@ QUALS = [
"</table>",
]
ADDENDUM = [
"<h1>关于某市智慧政务系统集成项目的更正公告(测试样例,内容虚构)</h1>",
"<p>各潜在供应商:本项目采购文件更正如下,其他内容不变。</p>",
"<p>一、响应文件递交截止时间更正为:2026年12月8日9时30分(北京时间)。</p>",
"<p>二、★新增技术要求:存储系统可用容量不低于100TB,并提供产品彩页作为证明材料。</p>",
"<p>本更正公告是采购文件的组成部分,与采购文件具有同等效力。</p>",
"</body></html>",
]
if __name__ == "__main__":
render("bid-mini-v1.pdf", [*COMMON, *TAIL])
render("bid-mini-v2.pdf", [*COMMON, BFD, *TAIL])
render("bid-mini-forms.pdf", [*COMMON, BFD, *FORMS, *TAIL])
render("bid-mini-quals.pdf", [*COMMON, BFD, *QUALS, *TAIL])
render("addendum-mini.pdf", ADDENDUM)

View File

@@ -0,0 +1,109 @@
"""补遗追加(FR-M2-10,addendum-plan):追加 → 解析扣 1 次 → 新条目标「补遗 1」、清单回到草稿、
截止时间进待确认、日期条目标「已被补遗 1 更新」;采用 / 不采用;采用截止时间时日期条目一起确认;检查预览提示。"""
from __future__ import annotations
from typing import Any
from tests.conftest import FIXTURES, User, balance, project_with_tender, run_parse, upload
async def add_addendum(u: User, project_id: str) -> tuple[str, dict[str, Any]]:
done = await upload(u, "关于某市智慧政务系统集成项目的更正公告.pdf", (FIXTURES / "addendum-mini.pdf").read_bytes())
assert done.status_code == 200, done.text
r = await u.post(f"/projects/{project_id}/documents", json={"upload_id": done.json()["id"], "role": "addendum"})
assert r.status_code == 201, r.text
doc = r.json()
assert doc["label"] == "补遗 1"
job = await run_parse(u, doc["id"])
assert job["status"] == "succeeded", job
return doc["id"], job
def deadline_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [i for i in items if ((i.get("spec") or {}).get("schedule") or {}).get("date_kind") == "bid_deadline"]
async def parsed_project(u: User) -> str:
project_id, tender_id = await project_with_tender(u, "tender-full.pdf")
assert (await run_parse(u, tender_id))["status"] == "succeeded"
return project_id
async def test_addendum_merge_and_accept(user: User) -> None:
project_id = await parsed_project(user)
before = (await user.get(f"/projects/{project_id}")).json()
assert before["deadline_at"].startswith("2026-12-01T01:30")
await user.put(f"/projects/{project_id}/fields/deadline_at", json={"value": "2026-12-02T09:30:00+08:00"})
assert (await user.post(f"/projects/{project_id}/checklist/confirm")).status_code == 200
quota = await balance(user)
_, job = await add_addendum(user, project_id)
result = job["result"]
assert result["dates_superseded"] == 1 and result["fields_pending"] >= 1 and result["items_added"] >= 2
assert await balance(user) == quota - 1 # 补遗单独扣 1 次解析
overview = (await user.get(f"/projects/{project_id}/checklist")).json()
assert overview["status"] == "draft" and overview["counts"]["superseded_pending"] == 1 # 并入后回到草稿(AD1)
items = (await user.get(f"/projects/{project_id}/checklist/items")).json()["items"]
new_items = [i for i in items if i["origin_label"] == "补遗 1"]
assert any("存储系统" in i["title"] for i in new_items) # 新条目直接进正式清单
assert all(i["origin_document"]["label"] == "补遗 1" for i in new_items)
old, new = sorted(deadline_items(items), key=lambda i: i["origin_label"] != "补遗 1")[::-1]
assert old["superseded_by"] == {"item_id": new["id"], "document_label": "补遗 1", "confirmed": False}
assert new["superseded_by"] is None
# 没确认之前项目用旧值(AD4),概览和检查预览都能看到待确认
detail = (await user.get(f"/projects/{project_id}")).json()
assert detail["deadline_at"].startswith("2026-12-02T01:30")
(f,) = [x for x in detail["fields"] if x["key"] == "deadline_at"]
assert f["source"] == "human" and f["pending"]["value"].startswith("2026-12-08T09:30")
assert f["pending"]["document_label"] == "补遗 1"
assert any(d["superseded_pending"] for d in detail["key_dates"])
preview = (await user.get(f"/projects/{project_id}/check-preview")).json()
assert preview["pending_updates"] >= 2
# 采用截止时间:清掉人工值、以补遗为准;清单里「投标截止」条目的更新一起确认(AD3)
r = await user.post(f"/projects/{project_id}/fields/deadline_at/pending", json={"accept": True})
assert r.status_code == 200, r.text
assert r.json()["source"] == "ai" and r.json()["pending"] is None and r.json()["value"].startswith("2026-12-08")
detail = (await user.get(f"/projects/{project_id}")).json()
assert detail["deadline_at"].startswith("2026-12-08T01:30")
(bid,) = [d for d in detail["key_dates"] if d["label"] == "投标截止"]
assert bid["replaces"]["document_label"] == "补遗 1" and bid["replaces"]["at"].startswith("2026-12-01")
old_now = (await user.get(f"/checklist-items/{old['id']}")).json()
assert old_now["deleted_at"] and old_now["superseded_by"]["confirmed"]
assert (await user.get(f"/projects/{project_id}/checklist")).json()["counts"]["superseded_pending"] == 0
r = await user.post(f"/projects/{project_id}/fields/deadline_at/pending", json={"accept": True})
assert r.status_code == 409 and r.json()["code"] == "no_pending_value"
async def test_addendum_reject_keeps_both(user: User) -> None:
project_id = await parsed_project(user)
await add_addendum(user, project_id)
items = (await user.get(f"/projects/{project_id}/checklist/items")).json()["items"]
(old,) = [i for i in deadline_items(items) if i["superseded_by"]]
r = await user.post(f"/checklist-items/{old['id']}/supersession", json={"accept": False})
assert r.status_code == 200 and r.json()["superseded_by"] is None and r.json()["deleted_at"] is None
assert len(deadline_items((await user.get(f"/projects/{project_id}/checklist/items")).json()["items"])) == 2
r = await user.post(f"/checklist-items/{old['id']}/supersession", json={"accept": True})
assert r.status_code == 409 and r.json()["code"] == "not_superseded"
r = await user.post(f"/projects/{project_id}/fields/deadline_at/pending", json={"accept": False})
assert r.json()["pending"] is None and r.json()["value"].startswith("2026-12-01")
assert (await user.get(f"/projects/{project_id}")).json()["deadline_at"].startswith("2026-12-01T01:30")
assert (await user.get(f"/projects/{project_id}/check-preview")).json()["pending_updates"] == 0
# 已递交的项目只读
assert (await user.post(f"/projects/{project_id}/transitions", json={"action": "submit"})).status_code == 200
r = await user.post(f"/checklist-items/{old['id']}/supersession", json={"accept": True})
assert r.status_code == 409 and r.json()["code"] == "project_read_only"
def test_same_time_ignores_missing_timezone() -> None:
"""补遗只是复述原时间时不关联;模型给的时间有时带时区、有时不带,按北京时间比。"""
from bid_doctor.checklist.addendum import _same_time # pyright: ignore[reportPrivateUsage]
assert _same_time("2026-12-01T09:30", "2026-12-01T09:30:00+08:00")
assert not _same_time("2026-12-01T09:30", "2026-12-08T09:30:00+08:00")