feat(addendum): 补遗追加(FR-M2-10)
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled

- 招标文件解析后可以追加补遗 / 澄清:单独解析、扣 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

@@ -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"