Files
bid-doctor/server/src/bid_doctor/project/service.py
YANG JIANKUAN e499de7486 feat: 查标第 1 步——上传投标分册 → 检查 → 逐条处理 → 复查沿用
后端
- 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>
2026-11-04 11:48:10 +08:00

160 lines
6.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""项目、项目成员、分包(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,
}