feat(report): 检查报告 PDF 导出和归档;项目状态流转(递交 / 归档)
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled

- report 模块:导出 → worker 用 Gotenberg 把 HTML 转成 A4 PDF → 按项目数据密钥加密存储 → report
  (不可改、存哈希);report_job 记生成过程(迁移 0009),完成推 report.ready、发通知
- 报告首页:项目信息、投标文件版本、检查依据、规则版本、导出人、检查覆盖范围、汇总、免责声明;
  问题明细带两侧出处和处理人;附录为需人工核对的勾选结果;模板文字不出现「通过 / 合格 / 保证」
- 前端「报告」标签页:导出选项、内嵌预览、历次报告下载;工作台「导出检查报告」直达
- 项目状态流转 POST /projects/{id}/transitions:致命未处理也能递交(确认框说明),
  递交后只读,撤销归档回到归档前的状态,都写审计
- 通知补上检查、报告的标题

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-04 20:51:43 +08:00
parent 902a130c82
commit e7fb797925
26 changed files with 1276 additions and 13 deletions

View File

@@ -1,12 +1,14 @@
from __future__ import annotations
from typing import Any
from typing import Any, Literal
from uuid import UUID
from fastapi import APIRouter, Query
from pydantic import BaseModel, Field
from sqlalchemy import select
from bid_doctor.core.db import tenant_tx
from bid_doctor.document.models import SourceDocument
from bid_doctor.identity.deps import Ctx
from bid_doctor.project import service, workbench
@@ -17,6 +19,10 @@ class ProjectIn(BaseModel):
name: str | None = Field(default=None, max_length=200)
class TransitionIn(BaseModel):
action: Literal["submit", "unsubmit", "archive", "unarchive"]
@router.get("/projects")
async def list_projects(ctx: Ctx, limit: int = Query(default=20, ge=1, le=100)) -> dict[str, Any]:
# TODO(工作台筛选):status / deadline / q / 游标分页,按 openapi.yaml 补齐
@@ -35,3 +41,21 @@ async def create_project(body: ProjectIn, ctx: Ctx) -> dict[str, Any]:
async def get_project(project_id: UUID, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
return await workbench.detail(s, ctx, project_id)
@router.post("/projects/{project_id}/transitions")
async def transition_project(project_id: UUID, body: TransitionIn, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
parsed = (
await s.execute(
select(SourceDocument.id)
.where(
SourceDocument.project_id == project_id,
SourceDocument.role == "tender",
SourceDocument.text_layer_key.is_not(None),
)
.limit(1)
)
).scalar_one_or_none() is not None
await service.transition(s, ctx, project_id, body.action, parsed)
return await workbench.detail(s, ctx, project_id)