"""修改项目基本信息和分包(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} )