feat(project): 项目基本信息修改和分包选择(FR-M2-03、04)
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled

- 字段修改:人工值优先、撤销恢复 AI 值、按字段校验(金额可写「286万」,按元存)、
  冗余列同步、审计留痕、只读项目 409;关键日期显示改后的投标截止时间
- 分包:增删改、选包(至少一个)、一键「不分包」;删包时条目归为通用,删到一个不剩回到不分包
- 清单列表 lot_scope 默认只列通用 + 所投的包,响应带 hidden_count
- 检查记下当时用的项目依据(截止时间、限价、所投的包、法律体系):报告按它写,
  之后改了信息检查结果接口给 basis_changed,查标页提示重新检查
- 前端:概览标签页(项目要点、采购包、关键日期)、字段修改对话框、选包页和手工调整;
  解析完识别到多个包时进选包页;读标页清单范围和所属采购包;查标页写明查哪几个包
- 决策 PI1–PI7;方案和落地记录 docs/architecture/project-info-plan.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-04 23:46:07 +08:00
parent 08e3fe7eff
commit ed6a2a907d
29 changed files with 1834 additions and 43 deletions

View File

@@ -0,0 +1,293 @@
"""修改项目基本信息和分包(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}
)
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}
)