feat(project): 项目基本信息修改和分包选择(FR-M2-03、04)
- 字段修改:人工值优先、撤销恢复 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:
293
server/src/bid_doctor/project/edit.py
Normal file
293
server/src/bid_doctor/project/edit.py
Normal 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}
|
||||
)
|
||||
Reference in New Issue
Block a user