- 招标文件解析后可以追加补遗 / 澄清:单独解析、扣 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>
324 lines
13 KiB
Python
324 lines
13 KiB
Python
"""修改项目基本信息和分包(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}
|
||
)
|