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>
This commit is contained in:
@@ -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),
|
||||
}
|
||||
|
||||
141
server/src/bid_doctor/checklist/addendum.py
Normal file
141
server/src/bid_doctor/checklist/addendum.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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, "文件还没有上传完"),
|
||||
|
||||
@@ -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:")},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"]))
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user