"""项目、项目成员、分包(FR-M6-01、02)。""" from __future__ import annotations from typing import Any from uuid import UUID from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from bid_doctor.audit import service as audit from bid_doctor.billing import service as billing 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.document import citations from bid_doctor.project.models import Lot, Project, ProjectField, ProjectMember UNNAMED = "未命名项目" async def create(s: AsyncSession, ctx: TenantContext, name: str | None) -> Project: require(ctx, Permission.PROJECT_CREATE) _, plan = await billing.current_plan(s, ctx) limit = plan.limits.get("max_projects") if limit is not None: n = (await s.execute(select(func.count()).select_from(Project))).scalar_one() if n >= limit: raise AppError("plan_limit_exceeded", f"{plan.name}最多 {limit} 个项目") p = Project(id=uuid7(), tenant_id=ctx.tenant_id, name=(name or "").strip() or UNNAMED, created_by=ctx.user_id) s.add(p) await s.flush() s.add( ProjectMember(tenant_id=ctx.tenant_id, project_id=p.id, user_id=ctx.user_id, role="lead", added_by=ctx.user_id) ) # 没有分包时也有一个默认包(04 §2);解析出分包后替换 s.add(Lot(id=uuid7(), tenant_id=ctx.tenant_id, project_id=p.id, lot_no="默认", is_default=True, origin="ai")) await audit.record(s, ctx, "project.create", "project", p.id, project_id=p.id, after={"name": p.name}) await s.flush() await s.refresh(p) return p async def get(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> Project: """按可见范围取项目(AR12):MVP 只有租户这一层,交给行级安全策略;以后在这里加项目成员条件。""" del ctx p = (await s.execute(select(Project).where(Project.id == project_id))).scalar_one_or_none() if p is None: raise AppError("not_found") return p def ensure_editable(p: Project) -> None: if p.status in ("submitted", "archived"): raise AppError("project_read_only") async def mark_drafting(s: AsyncSession, p: Project) -> None: """首份招标文件解析成功:跟进中 → 编制中(04 §3.1)。""" if p.status == "tracking": p.status = "drafting" async def list_projects(s: AsyncSession, ctx: TenantContext, limit: int) -> list[tuple[Project, list[str]]]: """只读本模块的表;工作台要的其他模块信息由 workbench 组合(模块之间不直接读写对方的表,架构 §3.2)。""" del ctx projects = ( ( await s.execute( select(Project).order_by(Project.deadline_at.asc().nulls_last(), Project.created_at.desc()).limit(limit) ) ) .scalars() .all() ) lots: dict[Any, list[str]] = {} for pid, lot_no in ( await s.execute( select(Lot.project_id, Lot.lot_no) .where(Lot.project_id.in_([p.id for p in projects]), Lot.is_bidding, Lot.is_default.is_(False)) .order_by(Lot.sort_order) ) ).tuples(): lots.setdefault(pid, []).append(lot_no) return [(p, lots.get(p.id, [])) for p in projects] FIELD_ORDER = ( "name", "code", "purchaser", "agency", "procurement_method", "legal_regime", "budget_total", "ceiling_total", "eval_method", "accept_consortium", "accept_imported", "sme_only", "deadline_at", ) async def fields(s: AsyncSession, project_id: UUID) -> list[dict[str, Any]]: """项目基本信息字段(FR-M2-03):当前值、来源(AI / 人工)、AI 原值、出处、补遗带来的待确认值。""" rows = (await s.execute(select(ProjectField).where(ProjectField.project_id == project_id))).scalars().all() cites = await citations.to_api_many(s, [c for r in rows for c in (r.ai_citation_id, r.pending_citation_id) if c]) out = [] for r in sorted(rows, key=lambda r: FIELD_ORDER.index(r.field_key)): human = r.human_value is not None out.append( { "key": r.field_key, "value": r.human_value if human else r.ai_value, "source": "human" if human else "ai" if r.ai_value is not None else "none", "ai_value": r.ai_value, "citation": cites.get(r.ai_citation_id) if r.ai_citation_id else None, "updated_by": None, "pending": { "value": r.pending_value, "citation": cites.get(r.pending_citation_id) if r.pending_citation_id else None, } if r.pending_value is not None else None, } ) return out async def lot_labels(s: AsyncSession, project_id: UUID) -> dict[UUID, str | None]: """项目的分包编号(给清单项展示「采购包 N」、校验 lot_id 用);默认包没有编号,表示各包通用。""" rows = await s.execute(select(Lot.id, Lot.lot_no, Lot.is_default).where(Lot.project_id == project_id)) return {i: None if default else no for i, no, default in rows.tuples()} async def bidding_lot_ids(s: AsyncSession, project_id: UUID) -> list[UUID]: """所投的包(不含默认包);没有分包时为空,表示整个项目。""" rows = await s.execute( select(Lot.id).where(Lot.project_id == project_id, Lot.is_bidding, Lot.is_default.is_(False)) ) return list(rows.scalars()) def mark_checked(p: Project, run_id: UUID, open_fatal: int) -> None: """完成过至少一次检查:跟进中 / 编制中 → 已检查(FR-M6-02);就绪标记随最近一次检查更新(data-model §5.6)。""" if p.status in ("tracking", "drafting"): p.status = "checked" p.latest_check_run_id, p.open_fatal_count = run_id, open_fatal def readiness(p: Project) -> dict[str, Any]: """ProjectReadiness:就绪状态只在「已检查」时有意义(G7);未就绪也不阻止导出和递交(红线 4)。""" return { "status": p.status, "open_fatal_count": p.open_fatal_count, "ready": None if p.status != "checked" else p.open_fatal_count == 0, }