Files
bid-doctor/server/src/bid_doctor/project/edit.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

324 lines
13 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,project-info-plan §2、§3)。
- 字段:人工值和 AI 值分开存,人工值优先;`value = null` 撤销人工值、恢复 AI 值;project 表上冗余的几列同步;
- 分包:没分包的项目有一个默认包(界面不显示);手工加第一个包时删掉默认包,删掉最后一个包时建回默认包(PI2);
删包时原属这个包的清单项归为通用(外键 ON DELETE SET NULL,PI3);
- 每次修改都写审计(全程留痕,红线 1);项目已递交、已归档时只读。
"""
from __future__ import annotations
import re
from datetime import datetime
from decimal import Decimal, InvalidOperation
from typing import Any
from uuid import UUID
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from bid_doctor.audit import service as audit
from bid_doctor.core.authz import Permission, require
from bid_doctor.core.db import TenantContext
from bid_doctor.core.errors import AppError
from bid_doctor.core.ids import uuid7
from bid_doctor.project import service
from bid_doctor.project.models import Lot, Project, ProjectField
COLUMNS = ("name", "code", "purchaser", "agency", "procurement_method", "legal_regime") # project 表上冗余的列
TEXT = ("name", "code", "purchaser", "agency", "procurement_method", "eval_method")
MONEY = ("budget_total", "ceiling_total")
BOOL = ("accept_consortium", "accept_imported", "sme_only")
REGIMES = ("gov_procurement", "tendering", "unknown")
DEFAULT_LOT_NO = "默认"
LOT_FIELDS = ("lot_no", "name", "budget_amount", "ceiling_amount")
def _invalid(field: str, message: str) -> AppError:
return AppError("validation_failed", errors=[{"field": field, "code": "invalid", "message": message}])
def money(value: Any, field: str = "value") -> str | None:
"""金额 → 元(字符串,两位小数)。接受「2,860,000」「286万」「286 万元」(PI7)。
数字是用户自己填的(招标文件原文),不是系统给的。"""
if value in (None, ""):
return None
text = re.sub(r"[\s,,]", "", str(value))
wan = bool(re.search(r"万(元)?$", text))
text = re.sub(r"万?元?$", "", text)
try:
n = Decimal(text)
except InvalidOperation as e:
raise _invalid(field, "金额写法不对,例如:2,860,000 或 286万") from e
if n < 0 or not n.is_finite():
raise _invalid(field, "金额不能是负数")
return f"{n * 10000 if wan else n:.2f}"
def field_value(key: str, value: Any) -> Any:
"""按字段校验、规整人工值;返回 None 表示撤销人工值。"""
if value is None:
return None
if key in TEXT:
if not isinstance(value, str):
raise _invalid(key, "应为文本")
text = value.strip()
if key == "name" and not text:
raise _invalid(key, "项目名称不能为空")
return text[:500] or None
if key == "legal_regime":
if value not in REGIMES:
raise _invalid(key, "法律体系只能是政府采购、招标投标或待识别")
return value
if key in MONEY:
return money(value, key)
if key in BOOL:
if not isinstance(value, bool):
raise _invalid(key, "应为是或否")
return value
if key == "deadline_at":
try:
at = datetime.fromisoformat(str(value))
except ValueError as e:
raise _invalid(key, "时间写法不对,例如:2026-11-20T09:30:00+08:00") from e
if at.tzinfo is None:
raise _invalid(key, "时间要带时区")
return at.isoformat()
raise _invalid(key, "不能修改这个字段")
def _sync(p: Project, key: str, value: Any) -> None:
"""project 表上冗余的当前值(工作台排序、筛选用)。"""
if key in COLUMNS:
setattr(p, key, value if value is not None or key != "legal_regime" else "unknown")
elif key == "deadline_at":
p.deadline_at = datetime.fromisoformat(value) if value else None
async def set_field(s: AsyncSession, ctx: TenantContext, project_id: UUID, key: str, value: Any) -> None:
require(ctx, Permission.PROJECT_EDIT)
p = await service.get(s, ctx, project_id)
service.ensure_editable(p)
human = field_value(key, value)
f = (
await s.execute(select(ProjectField).where(ProjectField.project_id == p.id, ProjectField.field_key == key))
).scalar_one_or_none()
if f is None:
if human is None:
return
f = ProjectField(tenant_id=ctx.tenant_id, project_id=p.id, field_key=key)
s.add(f)
before = {"value": f.human_value if f.human_value is not None else f.ai_value, "source": _source(f)}
f.human_value = human
f.human_set_by, f.human_set_at = (ctx.user_id, func.now()) if human is not None else (None, None)
current = human if human is not None else f.ai_value
if key == "name" and current is None:
current = p.name # 名称没有 AI 值时撤销人工值,保持现在的名称
_sync(p, key, current)
await s.flush()
after = {"value": current, "source": "human" if human is not None else _source(f)}
await audit.record(
s, ctx, "project_field.set", "project", p.id, project_id=p.id, before={key: before}, after={key: after}
)
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"
# ---------------------------------------------------------------- 分包
def lot_to_api(lot: Lot) -> dict[str, Any]:
return {
"id": lot.id,
"lot_no": lot.lot_no,
"name": lot.name,
"budget_amount": f"{lot.budget_amount:.2f}" if lot.budget_amount is not None else None,
"ceiling_amount": f"{lot.ceiling_amount:.2f}" if lot.ceiling_amount is not None else None,
"is_default": lot.is_default,
"is_bidding": lot.is_bidding,
"origin": lot.origin,
"edited_fields": lot.edited_fields,
"ai_values": lot.ai_values,
}
async def lots(s: AsyncSession, project_id: UUID) -> list[Lot]:
return list(
(await s.execute(select(Lot).where(Lot.project_id == project_id).order_by(Lot.sort_order, Lot.lot_no)))
.scalars()
.all()
)
async def _editable(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> Project:
require(ctx, Permission.PROJECT_EDIT)
p = await service.get(s, ctx, project_id)
service.ensure_editable(p)
return p
def _lot_values(values: dict[str, Any]) -> dict[str, Any]:
out: dict[str, Any] = {}
if "lot_no" in values:
no = (values["lot_no"] or "").strip()
if not no:
raise _invalid("lot_no", "包号不能为空")
out["lot_no"] = no[:40]
if "name" in values:
out["name"] = (values["name"] or "").strip()[:200] or None
for k in ("budget_amount", "ceiling_amount"):
if k in values:
v = money(values[k], k)
out[k] = Decimal(v) if v is not None else None
return out
async def _unique_no(s: AsyncSession, project_id: UUID, lot_no: str, exclude: UUID | None = None) -> None:
q = select(Lot.id).where(Lot.project_id == project_id, Lot.lot_no == lot_no)
if exclude:
q = q.where(Lot.id != exclude)
if (await s.execute(q)).first():
raise _invalid("lot_no", f"已经有「{lot_no}」这个包")
def _audit_lot(lot: Lot) -> dict[str, Any]:
return {k: str(v) if isinstance(v, Decimal) else v for k, v in ((f, getattr(lot, f)) for f in LOT_FIELDS)}
async def create_lot(s: AsyncSession, ctx: TenantContext, project_id: UUID, values: dict[str, Any]) -> Lot:
p = await _editable(s, ctx, project_id)
data = _lot_values(values)
if "lot_no" not in data:
raise _invalid("lot_no", "包号不能为空")
await s.execute(delete(Lot).where(Lot.project_id == p.id, Lot.is_default)) # 手工分包:默认包不要了
await _unique_no(s, p.id, data["lot_no"])
order = (
await s.execute(select(func.coalesce(func.max(Lot.sort_order), -1)).where(Lot.project_id == p.id))
).scalar_one()
lot = Lot(id=uuid7(), tenant_id=ctx.tenant_id, project_id=p.id, origin="human", sort_order=order + 1, **data)
lot.edited_fields = []
s.add(lot)
await s.flush()
await audit.record(s, ctx, "lot.create", "lot", lot.id, project_id=p.id, after=_audit_lot(lot))
await s.refresh(lot)
return lot
async def get_lot(s: AsyncSession, lot_id: UUID) -> Lot:
lot = (await s.execute(select(Lot).where(Lot.id == lot_id))).scalar_one_or_none()
if lot is None or lot.is_default:
raise AppError("not_found")
return lot
async def update_lot(s: AsyncSession, ctx: TenantContext, lot_id: UUID, values: dict[str, Any]) -> Lot:
lot = await get_lot(s, lot_id)
await _editable(s, ctx, lot.project_id)
data = _lot_values(values)
if "lot_no" in data:
await _unique_no(s, lot.project_id, data["lot_no"], exclude=lot.id)
before = _audit_lot(lot)
changed = [k for k, v in data.items() if getattr(lot, k) != v]
for k in changed:
setattr(lot, k, data[k])
if lot.origin == "ai":
lot.edited_fields = sorted({*lot.edited_fields, *changed})
await s.flush()
if changed:
await audit.record(
s, ctx, "lot.update", "lot", lot.id, project_id=lot.project_id, before=before, after=_audit_lot(lot)
)
await s.refresh(lot)
return lot
async def _ensure_default(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> None:
"""一个包都不剩时建回默认包:项目回到「不分包」(PI2)。"""
if not (await s.execute(select(Lot.id).where(Lot.project_id == project_id).limit(1))).first():
s.add(
Lot(
id=uuid7(),
tenant_id=ctx.tenant_id,
project_id=project_id,
lot_no=DEFAULT_LOT_NO,
is_default=True,
origin="human",
edited_fields=[],
)
)
await s.flush()
async def delete_lot(s: AsyncSession, ctx: TenantContext, lot_id: UUID) -> None:
"""删包:原属这个包的清单项由外键改为各包通用(PI3)。"""
lot = await get_lot(s, lot_id)
await _editable(s, ctx, lot.project_id)
before = _audit_lot(lot)
await s.delete(lot)
await s.flush()
await _ensure_default(s, ctx, lot.project_id)
await audit.record(s, ctx, "lot.delete", "lot", lot.id, project_id=lot.project_id, before=before)
async def clear_lots(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> None:
"""「这个项目不分包」:删掉所有包,清单项都归为通用,建回默认包(PI2)。"""
p = await _editable(s, ctx, project_id)
existing = [lot for lot in await lots(s, p.id) if not lot.is_default]
if not existing:
return
await s.execute(delete(Lot).where(Lot.project_id == p.id))
await s.flush()
await _ensure_default(s, ctx, p.id)
await audit.record(
s, ctx, "lot.clear", "project", p.id, project_id=p.id, before={"lots": [_audit_lot(x) for x in existing]}
)
async def set_bidding(s: AsyncSession, ctx: TenantContext, project_id: UUID, lot_ids: list[UUID]) -> None:
p = await _editable(s, ctx, project_id)
real = {lot.id: lot for lot in await lots(s, p.id) if not lot.is_default}
chosen = set(lot_ids)
if not chosen or not chosen <= set(real):
raise _invalid("lot_ids", "至少选一个本项目的采购包")
before = sorted(lot.lot_no for lot in real.values() if lot.is_bidding)
await s.execute(
update(Lot).where(Lot.project_id == p.id, Lot.is_default.is_(False)).values(is_bidding=Lot.id.in_(chosen))
)
await s.flush()
after = sorted(real[i].lot_no for i in chosen)
await audit.record(
s, ctx, "project.bidding_lots", "project", p.id, project_id=p.id, before={"lots": before}, after={"lots": after}
)