后端 - check 模块:检查预览、发起、进度、取消、记录;六个阶段(准备投标文件、响应匹配、规则、证据校验、 状态沿用、汇总);问题列表、处理、撤销、批量处理(只限提示级)。 - 每条清单项的检查结论 check_item_result(C9,迁移 0005):「已响应」也带投标原文出处; 没有校验通过的证据不能判已响应(C3)。级别从清单项算,不让模型定;说明不写整改建议。 - 响应匹配先只有第三层:内存 BM25 检索候选句 + check.match v1 提示词,检索不到不调模型, 漏答记 missing_answer,模型临时错误只影响那一批。 - 计费:免费版不记流水;专业版 24 小时内复查免扣(记一行 0);expected_charge 不一致时拒绝不扣点; 失败、取消退回;投标文件读不出文字按输入问题失败、不扣点。 - 投标分册:类型(volume_kind)和版本;电子投标平台加密格式在上传时拦下。 - 解析流水线前几个阶段拆到 document/pipeline/normalize.py,解析和检查共用。 - ops 模块:反馈接口;致命问题确认无误的说明自动进误报反馈(U4)。 - SK1:Valkey 客户端短超时、SSE 连接池上限。SK2:CI 加 Gotenberg、直接跑 init-roles.sh。 前端 - 上传投标文件并检查页、查标页(进度、覆盖范围、按级别筛选、逐条处理和行内说明、撤销、原文抽屉、 已处理、本次已解决);项目头抽成公共组件,加「查标」标签页;工作台「下一步」覆盖查标阶段。 契约、文档:openapi.yaml 加 item-results、volume_kind、coverage 字段;data-model、api-design §10、 架构 §8 / §13(SK14)、AGENTS.md 同步。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
160 lines
6.3 KiB
Python
160 lines
6.3 KiB
Python
"""项目、项目成员、分包(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,
|
||
}
|