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

@@ -219,4 +219,5 @@ samples/
- 2026-11-04:查标第六轮:送给模型的「同一行」按单元格分行;修了「以合同签订时间为准」被当成中标后才交、业绩评分项被跳过的缺陷;重复条目按用户决定改为各自判断、只合并显示(C5 修订,`docs/decisions.md`);致命项重问按数据(开发集致命级证据校验失败 0 条)暂不做。评测集裁定 1 条。开发集召回 74%、致命级 25 / 32。见 `docs/architecture/check-eval.md` §9。
- 2026-11-04:查标第 3 步冻结(`a60d16e`),跑留出集一次:S03、S06、S09 各一份风格 B 规矩 + 风格 C 马虎(风格 C 由封存一侧的会话实现),96 个注入问题 Fable 复核通过 79,用户抽查 19 条全部同意。留出集召回 54%(开发集 74%)、致命级 56%(开发集 78%)、负偏离未说明 25%(开发集 92%)、规则问题 13 / 20;S03 的规矩投标文件因响应方式识别过度触发报了 1094 个提示,人工确认响应方式后降到 45 个。开发集分数有相当一部分是调出来的;要修的 5 处见 `docs/architecture/check-eval.md` §11。
- 2026-11-04:按用户决定(E2)先修留出集暴露的 5 处(响应方式识别、偏离栏按「是不是明确满足」判断、另报价 / 附加条件按结构判断、空白栏、异体字),规则集 2026.11-r3。开发集不退化(召回 75%、规矩投标文件问题数不变);留出集参考数字召回 54% → 69%、规矩投标文件问题 1134 → 82(已看过,正式成绩仍以冻结代码那次为准)。见 `docs/architecture/check-eval.md` §12。
- 下一步:跑国动真实投标文件(等用户提供,用修好的代码)。
- 2026-11-04:查标第 5 步检查报告:导出 → 后台用 Gotenberg 生成 A4 PDF(首页覆盖范围和免责声明、问题明细、需人工核对附录,页眉 logo、页脚免责一句和页码)→ 加密归档,可重复下载;前端加「报告」标签页(选项、导出、内嵌预览、历次下载)。补了项目状态流转(递交 / 撤销递交 / 归档 / 撤销归档,致命未处理也能递交,递交后只读,都留痕)。产品可以从上传招标文件一路走到导出报告、标记递交。见 `docs/architecture/m3-plan.md` 进度。
- 下一步:国动真实投标文件(用户晚些提供,到时先读标、请用户看清单,再查标一次);其余可做:第 4 步资质库和资质匹配、第 6 步前端补齐(键盘操作、批量处理、判断有误反馈)。

View File

@@ -2,19 +2,61 @@
/**
* 项目头(原型的 projHead):项目名、采购人等、投标截止、状态和就绪;下面是「读标 / 查标」标签页。
* 就绪只在「已检查」时显示(G7),只表示致命问题都已处理,不代表投标文件没有问题(READY_TIP)。
* 概览、报告页做到时在这里加标签页。
* 状态操作(FR-M6-02):递交 / 撤销递交 / 归档 / 撤销归档。致命问题没处理完也可以递交(红线 4),确认框里说明;
* 递交后项目只读,可以撤销,都留痕。
*/
import { computed } from "vue";
import { unwrap } from "@bid-doctor/api-client";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, ref } from "vue";
import { RouterLink } from "vue-router";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import { api, errorTitle } from "@/lib/api";
import { qk } from "@/lib/keys";
import { deadline, legalRegime, projectStatus, READY_TIP } from "@/lib/labels";
import type { ProjectDetail } from "@/lib/queries";
import { toast } from "@/lib/toast";
const props = defineProps<{ project?: ProjectDetail; tab: "checklist" | "check" }>();
const props = defineProps<{ project?: ProjectDetail; tab: "checklist" | "check" | "report" }>();
const qc = useQueryClient();
const p = computed(() => props.project);
const meta = computed(() =>
p.value ? [p.value.purchaser, p.value.legal_regime !== "unknown" ? legalRegime(p.value.legal_regime) : "", p.value.procurement_method, p.value.code].filter(Boolean).join(" · ") : "",
);
const due = computed(() => deadline(p.value?.deadline_at));
type Action = "submit" | "unsubmit" | "archive" | "unarchive";
const ACTIONS: Record<string, { action: Action; label: string }[]> = {
tracking: [{ action: "submit", label: "标记为已递交" }],
drafting: [{ action: "submit", label: "标记为已递交" }],
checked: [{ action: "submit", label: "标记为已递交" }],
submitted: [
{ action: "unsubmit", label: "撤销递交" },
{ action: "archive", label: "归档" },
],
archived: [{ action: "unarchive", label: "撤销归档" }],
};
const actions = computed(() => (p.value ? ACTIONS[p.value.status] ?? [] : []));
const confirmSubmit = ref(false);
const busy = ref(false);
async function run(action: Action) {
if (!p.value) return;
if (action === "submit" && !confirmSubmit.value) {
confirmSubmit.value = true;
return;
}
busy.value = true;
try {
await unwrap(api.POST("/projects/{project_id}/transitions", { params: { path: { project_id: p.value.id } }, body: { action } }));
confirmSubmit.value = false;
void qc.invalidateQueries({ queryKey: qk.project(p.value.id) });
void qc.invalidateQueries({ queryKey: qk.projects });
toast(action === "submit" ? "已标记为已递交,项目改为只读" : action === "archive" ? "已归档" : "已撤销");
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
busy.value = false;
}
}
const checkTo = computed(() =>
p.value?.latest_check_run_id
? { name: "results", params: { projectId: p.value.id, runId: p.value.latest_check_run_id } }
@@ -37,7 +79,13 @@ const checkTo = computed(() =>
<span v-else class="muted">—</span>
</dd>
</div>
<div><dt>状态</dt><dd><span class="tag">{{ projectStatus(p.status) }}</span></dd></div>
<div>
<dt>状态</dt>
<dd>
<span class="tag">{{ projectStatus(p.status) }}</span>
<button v-for="a in actions" :key="a.action" type="button" class="btn sm text" :disabled="busy" @click="run(a.action)">{{ a.label }}</button>
</dd>
</div>
<div v-if="p.readiness.ready !== null && p.readiness.ready !== undefined">
<dt>就绪</dt>
<dd>
@@ -50,6 +98,11 @@ const checkTo = computed(() =>
<nav class="tabs" aria-label="项目">
<RouterLink v-if="p" role="tab" :aria-selected="tab === 'checklist'" :to="{ name: 'checklist', params: { projectId: p.id } }">读标</RouterLink>
<RouterLink v-if="p" role="tab" :aria-selected="tab === 'check'" :to="checkTo">查标</RouterLink>
<RouterLink v-if="p" role="tab" :aria-selected="tab === 'report'" :to="{ name: 'report', params: { projectId: p.id } }">报告</RouterLink>
</nav>
</header>
<ConfirmDialog v-model:open="confirmSubmit" title="标记为已递交?" ok-text="标记为已递交" busy-text="正在标记…" :busy="busy" @ok="run('submit')">
<p>标记后项目改为只读,可以随时撤销。</p>
<p v-if="p && p.readiness.open_fatal_count">还有 <b class="fig">{{ p.readiness.open_fatal_count }}</b> 项致命问题没有处理。是否递交由你决定,系统不会阻止。</p>
</ConfirmDialog>
</template>

View File

@@ -26,8 +26,10 @@ const home = computed<RouteLocationRaw>(() => {
return { name: "results", params: { projectId: id, runId: p.value.latest_check_run_id } };
return { name: "checklist", params: { projectId: id } };
});
/** 「下一步」:骨架阶段只有上传、解析进度、清单三个去处,其余动作先落到清单页 */
const next = computed<RouteLocationRaw>(() => home.value);
/** 「下一步」:导出报告去报告页,其余同默认入口 */
const next = computed<RouteLocationRaw>(() =>
p.value.next_action === "export_report" ? { name: "report", params: { projectId: p.value.id } } : home.value,
);
</script>
<template>

View File

@@ -15,5 +15,6 @@ export const qk = {
checkRun: (id: string) => ["check-run", id] as const,
findings: (runId: string) => ["findings", runId] as const,
manualChecks: (projectId: string) => ["manual-checks", projectId] as const,
reports: (projectId: string) => ["reports", projectId] as const,
itemResults: (runId: string) => ["item-results", runId] as const,
};

View File

@@ -122,6 +122,7 @@ export function useCheckRun(id: MaybeRefOrGetter<string>) {
return useQuery({
queryKey: computed(() => qk.checkRun(toValue(id))),
queryFn: () => unwrap(api.GET("/check-runs/{run_id}", { params: { path: { run_id: toValue(id) } } })),
enabled: computed(() => !!toValue(id)),
refetchInterval: (query) => (query.state.data && TERMINAL_JOB.has(query.state.data.status) ? false : 5000),
});
}

View File

@@ -0,0 +1,239 @@
<script setup lang="ts">
/**
* 报告(原型 report.html,FR-M3-09):左边预览已生成的 PDF,右边导出选项和已导出的报告。
* - 报告对应最近一次检查;致命问题没处理完也可以导出(红线 4),只提示「报告会如实列出」;
* - 报告在后台生成,这里轮询任务状态;生成后不再改动,随项目归档,重新导出另存一份;
* - 预览就是生成出来的 PDF 本身(浏览器内嵌显示),和下载的文件一致。
*/
import { unwrap } from "@bid-doctor/api-client";
import { AlertCircle, Download, Eye } from "@lucide/vue";
import { useQuery, useQueryClient } from "@tanstack/vue-query";
import { computed, ref, watch } from "vue";
import { RouterLink } from "vue-router";
import ProjectHead from "@/components/ProjectHead.vue";
import { api, errorTitle } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { fmtDateTime } from "@/lib/labels";
import { useCheckRun, useProject } from "@/lib/queries";
import { toast } from "@/lib/toast";
const props = defineProps<{ projectId: string }>();
const qc = useQueryClient();
const project = useProject(() => props.projectId);
const p = computed(() => project.data.value);
const runId = computed(() => p.value?.latest_check_run_id ?? "");
const runQ = useCheckRun(runId);
const run = computed(() => (runId.value ? runQ.data.value : undefined));
useCrumbs(() => [{ label: "工作台", to: { name: "workbench" } }, { label: p.value?.name ?? "项目" }, { label: "报告" }]);
const reportsQ = useQuery({
queryKey: computed(() => qk.reports(props.projectId)),
queryFn: () => unwrap(api.GET("/projects/{project_id}/reports", { params: { path: { project_id: props.projectId } } })),
});
const reports = computed(() => reportsQ.data.value?.items ?? []);
const citations = ref(true);
const manualChecks = ref(true);
const exporting = ref(false);
const freshId = ref<string | null>(null);
const openFatal = computed(() => run.value?.counts?.open_fatal ?? 0);
async function exportReport() {
if (!runId.value || exporting.value) return;
exporting.value = true;
try {
let job = await unwrap(
api.POST("/check-runs/{run_id}/reports", {
params: { path: { run_id: runId.value } },
body: { citations: citations.value, manual_checks: manualChecks.value },
}),
);
for (let i = 0; i < 120 && (job.status === "queued" || job.status === "running"); i++) {
await new Promise((r) => setTimeout(r, 1000));
job = await unwrap(api.GET("/report-jobs/{job_id}", { params: { path: { job_id: job.id } } }));
}
if (job.status !== "succeeded" || !job.report_id) throw new Error(job.error?.message ?? "报告生成失败,请稍后重试");
freshId.value = job.report_id;
await qc.invalidateQueries({ queryKey: qk.reports(props.projectId) });
await preview(job.report_id);
toast("报告已生成");
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
exporting.value = false;
}
}
/* ---------- 预览和下载:短时签名地址 ---------- */
const previewId = ref<string | null>(null);
const previewUrl = ref<string | null>(null);
async function fileUrl(id: string) {
return (await unwrap(api.GET("/reports/{report_id}/file", { params: { path: { report_id: id } } }))).url;
}
async function preview(id: string) {
try {
previewUrl.value = await fileUrl(id);
previewId.value = id;
} catch (e) {
toast(errorTitle(e), { ok: false });
}
}
async function download(id: string) {
try {
window.open(await fileUrl(id), "_blank", "noopener");
} catch (e) {
toast(errorTitle(e), { ok: false });
}
}
watch(reports, (list) => {
if (!previewId.value && list[0]) void preview(list[0].id);
}, { immediate: true });
</script>
<template>
<ProjectHead :project="p" tab="report" />
<div class="content wide">
<div v-if="p && !p.latest_check_run_id" class="empty">
<b>还没有检查结果</b>
<p>报告对应最近一次检查。先上传投标文件并检查。</p>
<RouterLink class="btn primary" :to="{ name: 'bid-check', params: { projectId } }">去检查</RouterLink>
</div>
<div v-else class="rp">
<section class="preview card flush" aria-label="报告预览">
<iframe v-if="previewUrl" :key="previewUrl" :src="previewUrl" title="检查报告预览"></iframe>
<div v-else class="empty">
<b>还没有导出过报告</b>
<p>在右边选好内容后导出,生成的 PDF 会在这里显示。</p>
</div>
</section>
<aside class="rp-side">
<section class="card flush" aria-labelledby="h-exp">
<div class="card-h"><h2 id="h-exp">导出 PDF</h2></div>
<div class="card-b">
<p v-if="run" class="cap muted">第 <span class="fig">{{ run.run_no }}</span> 次检查 · <span class="fig">{{ fmtDateTime(run.finished_at ?? run.created_at) }}</span></p>
<div class="choices">
<label class="choice dis"><input type="checkbox" class="ck" checked disabled /><span>问题明细<small>全部级别,必选</small></span></label>
<label class="choice"><input v-model="manualChecks" type="checkbox" class="ck" /><span>附录:需人工核对</span></label>
<label class="choice"><input v-model="citations" type="checkbox" class="ck" /><span>每条附原文出处</span></label>
</div>
<p v-if="openFatal" class="hint-warn"><AlertCircle class="ic" :stroke-width="1.5" /><span>还有 <b><span class="fig">{{ openFatal }}</span> 项致命问题</b>待处理,报告会如实列出。</span></p>
</div>
<div class="card-f">
<button type="button" class="btn primary swap" :class="{ 'is-loading': exporting }" :aria-busy="exporting" :disabled="exporting || run?.status !== 'succeeded'" @click="exportReport">
<span class="idle"><Download class="ic" :stroke-width="1.5" />导出 PDF</span><span class="busy">正在生成…</span>
</button>
</div>
</section>
<section class="card flush" aria-labelledby="h-done">
<div class="card-h"><h2 id="h-done">已导出的报告</h2></div>
<div class="card-b">
<ul v-if="reports.length" class="exports">
<li v-for="r in reports" :key="r.id" :class="{ cur: r.id === previewId }">
<div>
<b>第 <span class="fig">{{ r.run_no }}</span> 次检查<span v-if="r.id === freshId" class="new">本次</span></b>
<small><span class="fig">{{ fmtDateTime(r.created_at) }}</span> · {{ r.generated_by.name }} · <span class="fig">{{ r.page_count }}</span> 页<template v-if="r.open_fatal_at_export"> · 导出时致命 <span class="fig">{{ r.open_fatal_at_export }}</span> 项待处理</template></small>
</div>
<button type="button" class="icon-btn sm" :aria-label="`预览第 ${r.run_no} 次检查的报告`" @click="preview(r.id)"><Eye class="ic" :stroke-width="1.5" /></button>
<button type="button" class="icon-btn sm" :aria-label="`下载第 ${r.run_no} 次检查的报告`" @click="download(r.id)"><Download class="ic" :stroke-width="1.5" /></button>
</li>
</ul>
<p v-else class="cap muted">还没有导出过。</p>
</div>
<div class="card-f cap muted">导出后不再改动,随项目归档;重新导出会另存一份。</div>
</section>
</aside>
</div>
</div>
</template>
<style scoped>
.rp {
display: grid;
grid-template-columns: minmax(0, 1fr) var(--aside-w);
gap: var(--gap-card);
align-items: start;
}
.preview {
min-height: 70vh;
overflow: hidden;
}
.preview iframe {
display: block;
width: 100%;
height: calc(100vh - 220px);
min-height: 70vh;
border: 0;
}
.preview .empty {
padding: var(--sp-8) var(--card-x);
}
.rp-side {
position: sticky;
top: calc(var(--topbar-h) + var(--sp-2));
}
.rp-side > .card + .card {
margin-top: var(--gap-card);
}
.choices {
display: grid;
gap: var(--sp-3);
margin-top: var(--sp-3);
}
.choice small {
display: block;
color: var(--text-secondary);
font-size: var(--fs-caption);
}
.hint-warn {
margin-top: var(--sp-4);
}
.card-f .btn.primary {
flex: 1;
}
.exports {
list-style: none;
margin: 0;
padding: 0;
}
.exports li {
display: flex;
gap: var(--sp-2);
align-items: center;
padding: var(--sp-3) 0;
border-top: var(--line) solid var(--border-subtle);
}
.exports li:first-child {
border-top: 0;
padding-top: 0;
}
.exports li > div {
flex: 1;
min-width: 0;
}
.exports b {
display: block;
font-weight: var(--fw-bold);
}
.exports small {
display: block;
font-size: var(--fs-caption);
line-height: var(--lh-caption);
color: var(--text-secondary);
}
.exports .new {
margin-left: var(--sp-2);
padding: 0 6px;
border-radius: var(--radius-tag);
background: var(--c-n100);
font-size: var(--fs-caption);
font-weight: var(--fw-regular);
}
.exports li.cur b {
text-decoration: underline;
text-underline-offset: 3px;
}
</style>

View File

@@ -42,6 +42,12 @@ export const router = createRouter({
component: () => import("@/pages/BidCheckPage.vue"),
props: true,
},
{
path: "projects/:projectId/report",
name: "report",
component: () => import("@/pages/ReportPage.vue"),
props: true,
},
{
path: "projects/:projectId/results/:runId",
name: "results",

View File

@@ -12,7 +12,9 @@
| 第 1 步 链路打通 | ✅ 2026-11-04 | 见下方「第 1 步落地记录」 |
| 第 2 步 规则 | 基本完成 | 响应方式(C12)、文件要在、扫描件兜底、规则检查 `rules.py`(报价形式、形式要件、响应表)、需人工核对(`manual.py`、`manual-checks` 接口)、「报价」类清单项和分项上限(C7)。还差分项和采购需求逐项对照(P13)、散文写法的单价上限。见 `check-eval.md` §4–§7 |
| 第 3 步 AI 响应匹配 | 进行中 | 整行上下文、提示词 v2、偏离栏直读、响应错位、复查复用、硬预算;送给模型的同一行按单元格、重复条目只合并显示(C5 修订);致命项重问按数据暂不做。见 `check-eval.md` §8–§9 |
| 第 4~6 步 | 未开始 | |
| 第 4 步 资质库 | 未开始 | |
| 第 5 步 检查报告 | ✅ 2026-11-04 | `report` 模块:导出 → worker 用 Gotenberg(Chromium)把 HTML 转成 A4 PDF → 按项目数据密钥加密存对象存储 → `report`(不可改,存哈希);`report_job` 记生成过程(迁移 0009),完成推 `report.ready`、发通知。首页有项目信息、投标文件版本、检查依据、规则版本、导出人、检查覆盖范围、汇总、免责声明;问题明细带两侧出处和处理人;附录是需人工核对的勾选结果;页眉 logo、页脚「本报告为辅助检查结果,仅供投标前自查参考」+ 页码;测试检查模板文字不出现「通过 / 合格 / 保证」。前端「报告」标签页:选项、导出、内嵌预览、历次报告下载。「证据索引」页没有单独做(出处已在问题明细里逐条列出)。同时补了项目状态流转(递交 / 撤销递交 / 归档 / 撤销归档,FR-M6-02):致命问题未处理也能递交,确认框说明;递交后只读,撤销归档回到归档前的状态,都写审计 |
| 第 6 步 前端补齐 | 部分 | 需人工核对、重复问题折叠、报告页已做;键盘操作、批量处理提示、判断有误反馈还没做 |
**第 1 步落地记录**(和本文设计不一致、或留到后面的,记在这里):

View File

@@ -1515,6 +1515,15 @@ paths:
operationId: generateReport
summary: 生成检查报告 PDF(后台生成,完成后推送事件)
description: 致命问题未处理也可以导出(红线 4)。每次生成都是一份新报告。
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
citations: { type: boolean, default: true, description: 问题明细带招标、投标两侧出处 }
manual_checks: { type: boolean, default: true, description: 附录「需人工核对」 }
responses:
'202':
description: 已排队
@@ -3039,7 +3048,7 @@ components:
id: { $ref: '#/components/schemas/Id' }
kind:
type: string
enum: [parse_succeeded, parse_failed, check_succeeded, check_failed, quota_80, quota_100,
enum: [parse_succeeded, parse_failed, check_succeeded, check_failed, report_succeeded, report_failed, quota_80, quota_100,
subscription_activated, subscription_expiring]
title: { type: string }
project_id:

View File

@@ -2191,7 +2191,7 @@ export interface components {
Notification: {
id: components["schemas"]["Id"];
/** @enum {string} */
kind: "parse_succeeded" | "parse_failed" | "check_succeeded" | "check_failed" | "quota_80" | "quota_100" | "subscription_activated" | "subscription_expiring";
kind: "parse_succeeded" | "parse_failed" | "check_succeeded" | "check_failed" | "report_succeeded" | "report_failed" | "quota_80" | "quota_100" | "subscription_activated" | "subscription_expiring";
title: string;
project_id?: components["schemas"]["Id"] | null;
/** @description 前端路由,如 /projects/<id>/results */
@@ -4319,7 +4319,22 @@ export interface operations {
};
cookie?: never;
};
requestBody?: never;
requestBody?: {
content: {
"application/json": {
/**
* @description 问题明细带招标、投标两侧出处
* @default true
*/
citations?: boolean;
/**
* @description 附录「需人工核对」
* @default true
*/
manual_checks?: boolean;
};
};
};
responses: {
/** @description 已排队 */
202: {

View File

@@ -0,0 +1,64 @@
"""检查报告(查标第 5 步,FR-M3-09):报告后台生成,生成过程记在 report_job;报告本身生成后不再改(report)。
- report_job:每次点「导出报告」一行,排队 → 生成 → 成功(指向 report)/ 失败;前端轮询兜底,事件 report.ready 推送;
- report 加 open_fatal_at_export(导出时还没处理的致命问题数,报告首页和报告列表都显示)和 options(带不带出处、附录);
- 通知加 report_succeeded / report_failed。
Revision ID: 0009
"""
from __future__ import annotations
from alembic import op
revision = "0009"
down_revision = "0008"
def upgrade() -> None:
op.get_bind().exec_driver_sql("""
ALTER TABLE report ADD COLUMN open_fatal_at_export int NOT NULL DEFAULT 0;
ALTER TABLE report ADD COLUMN options jsonb NOT NULL DEFAULT '{}';
CREATE TABLE report_job (
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
project_id uuid NOT NULL,
check_run_id uuid NOT NULL,
status text NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'succeeded', 'failed')),
options jsonb NOT NULL DEFAULT '{}',
report_id uuid,
error_code text,
requested_by uuid NOT NULL REFERENCES user_account (id),
created_at timestamptz NOT NULL DEFAULT now(),
finished_at timestamptz,
UNIQUE (tenant_id, id),
CHECK (status <> 'succeeded' OR report_id IS NOT NULL),
FOREIGN KEY (tenant_id, project_id) REFERENCES project (tenant_id, id) ON DELETE CASCADE,
FOREIGN KEY (tenant_id, check_run_id) REFERENCES check_run (tenant_id, id) ON DELETE CASCADE,
FOREIGN KEY (tenant_id, report_id) REFERENCES report (tenant_id, id) ON DELETE SET NULL (report_id)
);
CREATE INDEX ON report_job (tenant_id, project_id, created_at);
ALTER TABLE report_job ENABLE ROW LEVEL SECURITY;
ALTER TABLE report_job FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON report_job
USING (tenant_id = current_tenant()) WITH CHECK (tenant_id = current_tenant());
ALTER TABLE notification DROP CONSTRAINT notification_kind_check;
ALTER TABLE notification ADD CONSTRAINT notification_kind_check
CHECK (kind IN ('parse_succeeded', 'parse_failed', 'check_succeeded', 'check_failed', 'report_succeeded',
'report_failed', 'quota_80', 'quota_100', 'subscription_activated', 'subscription_expiring'));
""")
def downgrade() -> None:
op.get_bind().exec_driver_sql("""
DROP TABLE report_job;
ALTER TABLE report DROP COLUMN options;
ALTER TABLE report DROP COLUMN open_fatal_at_export;
ALTER TABLE notification DROP CONSTRAINT notification_kind_check;
ALTER TABLE notification ADD CONSTRAINT notification_kind_check
CHECK (kind IN ('parse_succeeded', 'parse_failed', 'check_succeeded', 'check_failed',
'quota_80', 'quota_100', 'subscription_activated', 'subscription_expiring'));
""")

View File

@@ -24,6 +24,7 @@ def create_app() -> FastAPI:
from bid_doctor.notification.api import router as notification_router
from bid_doctor.ops.api import router as ops_router
from bid_doctor.project.api import router as project_router
from bid_doctor.report.api import router as report_router
settings = get_settings()
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s %(message)s")
@@ -61,6 +62,7 @@ def create_app() -> FastAPI:
document_router,
checklist_router,
check_router,
report_router,
notification_router,
ops_router,
):

View File

@@ -59,3 +59,16 @@ async def events_for(session: AsyncSession, object_type: str, object_id: UUID) -
.order_by(AuditEvent.created_at.desc(), AuditEvent.id.desc())
)
return list(rows.scalars().all())
async def last_before(session: AsyncSession, action: str, object_id: UUID) -> dict[str, Any] | None:
"""某个对象最近一次某种操作之前的状态(例如撤销归档时回到归档前的项目状态)。"""
row = (
await session.execute(
select(AuditEvent.before)
.where(AuditEvent.action == action, AuditEvent.object_id == object_id)
.order_by(AuditEvent.id.desc())
.limit(1)
)
).scalar_one_or_none()
return row if isinstance(row, dict) else None

View File

@@ -33,6 +33,8 @@ CODES: dict[str, tuple[int, str]] = {
"checklist_empty": (409, "正式清单还没有条目"),
"no_parsed_tender": (409, "还没有解析成功的招标文件"),
"no_bid_documents": (409, "还没有上传投标文件"),
"invalid_transition": (409, "项目当前的状态不能做这个操作"),
"run_not_succeeded": (409, "这次检查没有成功完成,不能导出报告"),
"run_superseded": (409, "这是历史检查,只能查看"),
"finding_resolved_this_run": (409, "这个问题本次已解决,不需要处理"),
"precondition_failed": (412, "内容已被修改,请刷新后再改"),

View File

@@ -38,7 +38,14 @@ async def list_notifications(ctx: Ctx, unread: bool | None = None) -> dict[str,
.where(Notification.user_id == ctx.user_id, Notification.read_at.is_(None))
)
).scalar_one()
titles = {"parse_succeeded": "招标文件解析完成", "parse_failed": "招标文件解析失败,额度已退回"}
titles = {
"parse_succeeded": "招标文件解析完成",
"parse_failed": "招标文件解析失败,额度已退回",
"check_succeeded": "投标文件检查完成",
"check_failed": "投标文件检查没有完成,不扣额度",
"report_succeeded": "检查报告已生成",
"report_failed": "检查报告生成失败",
}
return {
"unread_count": n,
"next_cursor": None,

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)

View File

@@ -150,6 +150,49 @@ def mark_checked(p: Project, run_id: UUID, open_fatal: int) -> None:
p.latest_check_run_id, p.open_fatal_count = run_id, open_fatal
TRANSITIONS = { # 动作 → 允许从哪些状态做(FR-M6-02)
"submit": ("tracking", "drafting", "checked"),
"unsubmit": ("submitted",),
"archive": ("tracking", "drafting", "checked", "submitted"),
"unarchive": ("archived",),
}
def base_status(p: Project, parsed: bool) -> str:
"""不算手动状态时项目该在的状态:检查过 → 已检查;解析过招标文件 → 编制中;否则跟进中。"""
return "checked" if p.latest_check_run_id else "drafting" if parsed else "tracking"
async def transition(s: AsyncSession, ctx: TenantContext, project_id: UUID, action: str, parsed: bool) -> Project:
"""手动变更项目状态(FR-M6-02):递交后只读、可以撤销;归档随时可以撤销,撤销后回到归档前的状态。
致命问题没处理完也可以递交(红线 4),就绪状态由前端提示。每次变更都写审计(留痕)。"""
p = await get(s, ctx, project_id)
if p.status not in TRANSITIONS[action]:
raise AppError("invalid_transition")
before = p.status
if action == "submit":
p.status = "submitted"
elif action == "archive":
p.status = "archived"
elif action == "unsubmit":
p.status = base_status(p, parsed)
else:
prev = (await audit.last_before(s, "project.archive", p.id) or {}).get("status")
p.status = prev if prev in ("submitted", "checked", "drafting", "tracking") else base_status(p, parsed)
await audit.record(
s,
ctx,
f"project.{action}",
"project",
p.id,
project_id=p.id,
before={"status": before},
after={"status": p.status, "open_fatal_count": p.open_fatal_count},
)
await s.flush()
return p
def readiness(p: Project) -> dict[str, Any]:
"""ProjectReadiness:就绪状态只在「已检查」时有意义(G7);未就绪也不阻止导出和递交(红线 4)。"""
return {

View File

View File

@@ -0,0 +1,64 @@
from __future__ import annotations
from typing import Annotated, Any
from uuid import UUID
from fastapi import APIRouter, Body, Response
from pydantic import BaseModel
from bid_doctor.core.crypto import open_object, verify_download
from bid_doctor.core.db import TenantContext, tenant_tx
from bid_doctor.core.errors import AppError
from bid_doctor.core.keys import data_key
from bid_doctor.core.storage import storage
from bid_doctor.identity.deps import Ctx
from bid_doctor.report import service
router = APIRouter(tags=["reports"])
class ReportIn(BaseModel):
citations: bool = True # 问题明细带招标、投标两侧出处
manual_checks: bool = True # 附录:需人工核对
@router.post("/check-runs/{run_id}/reports", status_code=202)
async def generate_report(run_id: UUID, ctx: Ctx, body: Annotated[ReportIn | None, Body()] = None) -> dict[str, Any]:
opts = (body or ReportIn()).model_dump()
async with tenant_tx(ctx) as s:
return service.job_to_api(await service.start(s, ctx, run_id, opts))
@router.get("/report-jobs/{job_id}")
async def get_report_job(job_id: UUID, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
return service.job_to_api(await service.get_job(s, job_id))
@router.get("/projects/{project_id}/reports")
async def list_reports(project_id: UUID, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
return {"items": await service.list_reports(s, ctx, project_id)}
@router.get("/reports/{report_id}/file")
async def get_report_file_url(report_id: UUID, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
return await service.file_url(s, ctx, report_id)
@router.get("/report-files/{tenant_id}/{report_id}", include_in_schema=False)
async def download(tenant_id: UUID, report_id: UUID, exp: int, sig: str) -> Response:
"""签名下载(本地磁盘驱动用)。不带鉴权头,靠短时签名。"""
if not verify_download(f"{tenant_id}:{report_id}:report", exp, sig):
raise AppError("not_found")
ctx = TenantContext(tenant_id=tenant_id, user_id=UUID(int=0))
async with tenant_tx(ctx) as s:
r = await service.get_report(s, report_id)
dk = await data_key(s, ctx, r.project_id)
name = f"report-{r.created_at:%Y%m%d%H%M}.pdf"
return Response(
open_object(dk, storage().get(r.object_key), r.object_key),
media_type="application/pdf",
headers={"Cache-Control": "private, max-age=300", "Content-Disposition": f'inline; filename="{name}"'},
)

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from sqlalchemy import FetchedValue
from sqlalchemy.orm import Mapped, mapped_column
from bid_doctor.core.columns import PK, Bytes, DbTs, Json, Ts, UuidCol
from bid_doctor.core.db import Base
class Report(Base):
"""检查报告(FR-M3-09):生成后不再改(数据库触发器禁止 UPDATE),重新导出就是新的一行。"""
__tablename__ = "report"
id: Mapped[PK]
tenant_id: Mapped[UuidCol]
project_id: Mapped[UuidCol]
check_run_id: Mapped[UuidCol]
object_key: Mapped[str]
sha256: Mapped[Bytes]
page_count: Mapped[int]
template_version: Mapped[str]
open_fatal_at_export: Mapped[int]
options: Mapped[Json] = mapped_column(server_default=FetchedValue())
generated_by: Mapped[UuidCol]
created_at: Mapped[DbTs]
class ReportJob(Base):
"""一次「导出报告」:排队 → 生成 → 成功 / 失败(迁移 0009)。"""
__tablename__ = "report_job"
id: Mapped[PK]
tenant_id: Mapped[UuidCol]
project_id: Mapped[UuidCol]
check_run_id: Mapped[UuidCol]
status: Mapped[str] = mapped_column(server_default=FetchedValue())
options: Mapped[Json] = mapped_column(server_default=FetchedValue())
report_id: Mapped[UuidCol | None]
error_code: Mapped[str | None]
requested_by: Mapped[UuidCol]
created_at: Mapped[DbTs]
finished_at: Mapped[Ts | None]

View File

@@ -0,0 +1,113 @@
"""报告的后台生成(worker):整理数据 → HTML → Gotenberg(Chromium)转 PDF → 按项目数据密钥加密存对象存储 → 写 report。
- 生成后不再改:报告存哈希,重复导出就是新的一份;
- 失败不留半截文件,记 error_code(转换服务不可用 / 生成失败),通知用户;报告不扣额度。
"""
from __future__ import annotations
import io
import logging
from datetime import UTC, datetime
from uuid import UUID
import httpx
import pypdfium2 as pdfium
from sqlalchemy import select
from bid_doctor.core import events
from bid_doctor.core.config import get_settings
from bid_doctor.core.crypto import seal_object, sha256
from bid_doctor.core.db import TenantContext, tenant_tx
from bid_doctor.core.ids import uuid7
from bid_doctor.core.keys import data_key
from bid_doctor.core.storage import project_prefix, storage
from bid_doctor.notification.models import Notification
from bid_doctor.report import service, template
from bid_doctor.report.models import Report, ReportJob
log = logging.getLogger(__name__)
TIMEOUT = httpx.Timeout(120, connect=10)
A4 = {"paperWidth": "8.27", "paperHeight": "11.69"}
# 上下留白放页眉页脚;左右 20mm(设计规范 §7.17)
MARGINS = {"marginTop": "0.95", "marginBottom": "0.85", "marginLeft": "0.79", "marginRight": "0.79"}
async def to_pdf(html: str) -> bytes:
files = {
"index.html": ("index.html", html.encode(), "text/html"),
"header.html": ("header.html", template.header().encode(), "text/html"),
"footer.html": ("footer.html", template.footer().encode(), "text/html"),
}
async with httpx.AsyncClient(base_url=get_settings().gotenberg_url, timeout=TIMEOUT) as c:
r = await c.post(
"/forms/chromium/convert/html",
files=list(files.items()),
data={**A4, **MARGINS, "printBackground": "true", "preferCssPageSize": "false"},
)
r.raise_for_status()
return r.content
async def run(tenant_id: UUID, job_id: UUID) -> None:
ctx = TenantContext(tenant_id=tenant_id, user_id=UUID(int=0))
async with tenant_tx(ctx) as s:
job = (await s.execute(select(ReportJob).where(ReportJob.id == job_id))).scalar_one()
if job.status not in ("queued", "running"):
return # 重复派发
ctx = TenantContext(tenant_id=tenant_id, user_id=job.requested_by, actor_name="系统")
job.status = "running"
d = await service.data(s, ctx, job)
try:
pdf = await to_pdf(template.page(d))
pages = len(pdfium.PdfDocument(io.BytesIO(pdf)))
except httpx.HTTPError as e:
log.warning("report %s converter error: %s", job_id, e)
await _finish(ctx, job_id, None, "converter_unavailable")
return
except Exception:
log.exception("report %s crashed", job_id)
await _finish(ctx, job_id, None, "render_failed")
return
async with tenant_tx(ctx) as s:
job = (await s.execute(select(ReportJob).where(ReportJob.id == job_id))).scalar_one()
report_id = uuid7()
key = f"{project_prefix(tenant_id, job.project_id)}/reports/{report_id}.pdf"
storage().put(key, seal_object(await data_key(s, ctx, job.project_id), pdf, key))
s.add(
Report(
id=report_id,
tenant_id=tenant_id,
project_id=job.project_id,
check_run_id=job.check_run_id,
object_key=key,
sha256=sha256(pdf),
page_count=pages,
template_version=template.TEMPLATE_VERSION,
open_fatal_at_export=d["summary"]["fatal"]["pending"],
options=job.options or {},
generated_by=job.requested_by,
)
)
await s.flush()
await _finish(ctx, job_id, report_id, None)
async def _finish(ctx: TenantContext, job_id: UUID, report_id: UUID | None, error: str | None) -> None:
async with tenant_tx(ctx) as s:
job = (await s.execute(select(ReportJob).where(ReportJob.id == job_id))).scalar_one()
job.status = "succeeded" if report_id else "failed"
job.report_id, job.error_code, job.finished_at = report_id, error, datetime.now(UTC)
s.add(
Notification(
id=uuid7(),
tenant_id=ctx.tenant_id,
user_id=job.requested_by,
kind="report_succeeded" if report_id else "report_failed",
project_id=job.project_id,
payload={"job_id": str(job.id), **({"report_id": str(report_id)} if report_id else {})},
)
)
api = service.job_to_api(job)
project_id = job.project_id
await events.publish(ctx.tenant_id, "report.ready", project_id=str(project_id), job={"kind": "report_job", **api})

View File

@@ -0,0 +1,169 @@
"""检查报告的发起、查询和下载(FR-M3-09,api-design §4.1)。
- 只有成功完成的检查可以导出;致命问题没处理完也可以导出(红线 4),报告首页如实写明还有几项待处理;
- 「排队 → 入队」在同一个事务里(架构 §3.4);生成在 worker 里(runner.py),完成后推 report.ready;
- 报告随项目归档,可以重复下载;下载地址是短时签名,和投标文件下载同一套做法。
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from bid_doctor.audit import service as audit
from bid_doctor.check import service as checks
from bid_doctor.check.models import CheckRun
from bid_doctor.core import queue
from bid_doctor.core.config import get_settings
from bid_doctor.core.crypto import sign_download
from bid_doctor.core.db import TenantContext
from bid_doctor.core.errors import AppError
from bid_doctor.core.ids import uuid7
from bid_doctor.identity import service as identity
from bid_doctor.project import service as projects
from bid_doctor.report.models import Report, ReportJob
REPORT_TASK = "generate_report"
STATUSES = ("pending", "confirmed_ok", "modified", "ignored")
ERROR_MESSAGES = {
"render_failed": "报告生成失败,请稍后重试",
"converter_unavailable": "报告转换服务暂时不可用,请稍后重试",
}
async def start(s: AsyncSession, ctx: TenantContext, run_id: UUID, options: dict[str, bool]) -> ReportJob:
run = await checks.get_run(s, run_id)
await projects.get(s, ctx, run.project_id)
if run.status != "succeeded":
raise AppError("run_not_succeeded")
job = ReportJob(
id=uuid7(),
tenant_id=ctx.tenant_id,
project_id=run.project_id,
check_run_id=run.id,
options=options,
requested_by=ctx.user_id,
)
s.add(job)
await s.flush()
await queue.enqueue(s, REPORT_TASK, {"tenant_id": str(ctx.tenant_id), "job_id": str(job.id)})
await audit.record(
s, ctx, "report.export", "check_run", run.id, project_id=run.project_id, after={"job": str(job.id)}
)
return job
async def get_job(s: AsyncSession, job_id: UUID) -> ReportJob:
job = (await s.execute(select(ReportJob).where(ReportJob.id == job_id))).scalar_one_or_none()
if job is None:
raise AppError("not_found")
return job
def job_to_api(job: ReportJob) -> dict[str, Any]:
error = (
{"code": job.error_code, "message": ERROR_MESSAGES.get(job.error_code or "", job.error_code), "refunded": False}
if job.status == "failed" and job.error_code
else None
)
return {
"id": job.id,
"check_run_id": job.check_run_id,
"status": job.status,
"report_id": job.report_id,
"error": error,
}
async def list_reports(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> list[dict[str, Any]]:
await projects.get(s, ctx, project_id)
rows = list(
(
await s.execute(select(Report).where(Report.project_id == project_id).order_by(Report.created_at.desc()))
).scalars()
)
ids = [r.check_run_id for r in rows]
found = (await s.execute(select(CheckRun.id, CheckRun.run_no).where(CheckRun.id.in_(ids)))).all()
run_no = {row[0]: row[1] for row in found}
names = await identity.display_names(s, [r.generated_by for r in rows])
return [
{
"id": r.id,
"check_run_id": r.check_run_id,
"run_no": run_no.get(r.check_run_id, 0),
"page_count": r.page_count,
"sha256": r.sha256.hex(),
"template_version": r.template_version,
"open_fatal_at_export": r.open_fatal_at_export,
"generated_by": {"id": r.generated_by, "name": names.get(r.generated_by, "")},
"created_at": r.created_at,
}
for r in rows
]
async def file_url(s: AsyncSession, ctx: TenantContext, report_id: UUID) -> dict[str, Any]:
r = (await s.execute(select(Report).where(Report.id == report_id))).scalar_one_or_none()
if r is None:
raise AppError("not_found")
sig, exp = sign_download(f"{ctx.tenant_id}:{r.id}:report", get_settings().download_url_ttl_s)
url = f"{get_settings().public_base_url}/api/v1/report-files/{ctx.tenant_id}/{r.id}?exp={exp}&sig={sig}"
return {"url": url, "expires_at": datetime.fromtimestamp(exp, UTC), "sha256": r.sha256.hex()}
async def get_report(s: AsyncSession, report_id: UUID) -> Report:
r = (await s.execute(select(Report).where(Report.id == report_id))).scalar_one_or_none()
if r is None:
raise AppError("not_found")
return r
async def data(s: AsyncSession, ctx: TenantContext, job: ReportJob) -> dict[str, Any]:
"""报告要的数据:都从查标已有的接口函数取(检查批次、问题、需人工核对),和查标页看到的一致。"""
run = await checks.get_run(s, job.check_run_id)
p = await projects.get(s, ctx, run.project_id)
run_api = await checks.run_to_api(s, ctx, run)
every = await checks.findings(s, run.id, severity=None, status=None, presence=None)
items = [f for f in every if f["presence"] != "resolved"]
resolved = sum(1 for f in every if f["presence"] == "resolved")
summary = {
sev: {
"total": sum(f["severity"] == sev for f in items),
**{st: sum(f["severity"] == sev and f["status"] == st for f in items) for st in STATUSES},
}
for sev in ("fatal", "important", "hint")
}
lots = await projects.lot_labels(s, p.id)
names = await identity.display_names(s, [job.requested_by])
return {
"project": {
"name": p.name,
"code": p.code,
"purchaser": p.purchaser,
"legal_regime": run.legal_regime,
"procurement_method": p.procurement_method,
"deadline_at": p.deadline_at,
"lots": [f"采购包 {lots[x]}" for x in run.lot_ids if lots.get(x)],
},
"run": {
"run_no": run.run_no,
"created_at": run.created_at,
"finished_at": run.finished_at,
"rule_set_version": run.rule_set_version,
"checklist_version_no": run_api["checklist_version_no"],
"checklist_confirmed": run.checklist_confirmed,
"coverage": run.coverage,
"documents": [{"label": d["label"], "filename": d["original_filename"]} for d in run_api["documents"]],
},
"summary": summary,
"resolved_this_run": resolved,
"findings": items,
"manual": await checks.manual_checks(s, ctx, p.id),
"options": job.options or {},
"exported_by": names.get(job.requested_by, ""),
"exported_at": datetime.now(UTC),
}

View File

@@ -0,0 +1,243 @@
"""检查报告的 HTML(设计规范 §7.17,原型 report.html):纯函数,输入整理好的数据,输出正文、页眉、页脚三段 HTML,
由 Gotenberg 的 Chromium 转成 PDF(A4 竖版,页边距 20mm,页眉页脚每页重复、页码自动生成)。
- 首页:项目信息、投标文件版本、检查依据、检查时间、规则版本、导出人和时间、**检查覆盖范围**(查了 / 没查)、
汇总、免责声明;
- 问题明细:级别、问题、说明、出处(可选)、处理状态和处理人;
- 附录:需人工核对的条目和勾选结果(可选);
- logo 只在页眉,不放在结论、汇总数字或签名旁边(红线:logo 不能当「通过」章);
- 模板自己的文字不出现「通过」「合格」「保证」(测试检查);引用招标文件、投标文件的原文照录。
"""
from __future__ import annotations
import base64
from datetime import datetime
from functools import cache
from html import escape
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
TEMPLATE_VERSION = "2026.11-v1"
TITLE = "投标文件辅助检查报告"
FOOTER = "本报告为辅助检查结果,仅供投标前自查参考"
DISCLAIMER = (
"本报告由标博士比对招标文件和投标文件生成,经用户逐项处理后导出,供投标前自查使用。检查范围以首页「检查覆盖范围」为准;"
"结果可能有遗漏或误判,不代表资格审查、符合性审查或评标的结论。是否递交、如何修改,由投标人自行决定。"
)
SEVERITY = {"fatal": "致命", "important": "重要", "hint": "提示"}
STATUS = {"pending": "待处理", "confirmed_ok": "已确认无误", "modified": "已修改", "ignored": "已忽略"}
REGIME = {"gov_procurement": "政府采购", "tendering": "招标投标", "unknown": "未识别"}
LOCAL_TZ = ZoneInfo("Asia/Shanghai")
# 页面四周的留白交给 Gotenberg(页眉页脚放在上下留白里);正文只管内容
CSS = """
@page { size: A4; }
* { box-sizing: border-box; }
body { margin: 0; font-family: "Noto Sans CJK SC", "Source Han Sans SC", sans-serif; font-size: 10.5pt;
line-height: 1.6; color: #1D2733; }
h1, h2, h3 { font-family: "Noto Serif CJK SC", "Source Han Serif SC", serif; font-weight: 600; margin: 0; }
h1 { font-size: 18pt; line-height: 1.35; }
h2 { font-size: 13pt; margin: 16pt 0 6pt; break-after: avoid; }
.sub { color: #5C6670; margin: 4pt 0 12pt; }
.fig { font-family: "Barlow Semi Condensed", "Noto Sans CJK SC", sans-serif; font-variant-numeric: tabular-nums; }
dl { display: grid; grid-template-columns: 26mm 1fr; gap: 3pt 8pt; margin: 0 0 6pt; }
dt { color: #5C6670; }
dd { margin: 0; }
ul { margin: 2pt 0 6pt; padding-left: 1.2em; }
table { width: 100%; border-collapse: collapse; margin: 4pt 0 6pt; }
th, td { border-bottom: 0.5pt solid #D6D8DB; padding: 4pt 5pt; text-align: left; vertical-align: top; }
th { color: #5C6670; font-weight: 600; background: #F8F8F9; }
thead { display: table-header-group; }
tr { break-inside: avoid; }
td.num, th.num { text-align: right; }
.sev { display: inline-block; padding: 0 5pt; border-radius: 3pt; font-size: 9pt; font-weight: 700; line-height: 1.6;
white-space: nowrap; }
.sev.fatal { background: #C4122F; color: #fff; }
.sev.important { border: 1pt solid #A66300; color: #8A5200; }
.sev.hint { background: #F1F2F3; color: #5C6670; }
.cites { margin-top: 3pt; color: #5C6670; font-size: 9pt; }
.cites div { margin-top: 1pt; }
.who { display: block; color: #5C6670; font-size: 9pt; }
.note { display: block; font-size: 9pt; }
.todo { color: #C4122F; font-weight: 600; }
.cap { color: #5C6670; font-size: 9pt; }
.disc { margin-top: 12pt; padding: 8pt 10pt; background: #F8F8F9; border-radius: 4pt; font-size: 9.5pt;
break-inside: avoid; }
.disc b { display: block; margin-bottom: 2pt; }
.alert { margin: 6pt 0; padding: 6pt 10pt; border-left: 3pt solid #A66300; background: #FBF0DC; }
"""
@cache
def _logo() -> str:
svg = (Path(__file__).parent / "assets" / "logo-horizontal.svg").read_bytes()
return "data:image/svg+xml;base64," + base64.b64encode(svg).decode()
def when(at: datetime | str | None) -> str:
"""报告和留痕的时间写法:「2026-11-02 14:20」(设计规范 §9.3),北京时间。"""
if at is None:
return "—"
if isinstance(at, str):
at = datetime.fromisoformat(at)
return at.astimezone(LOCAL_TZ).strftime("%Y-%m-%d %H:%M")
def _e(v: Any) -> str:
return escape(str(v)) if v not in (None, "") else "—"
def _cite(c: dict[str, Any]) -> str:
quote = (c.get("quote") or "").strip()
quote = quote[:80] + "……" if len(quote) > 80 else quote
return f'{_e(c.get("document_label"))} · 第 <span class="fig">{c.get("page_no")}</span> 页' + (
f"「{escape(quote)}」" if quote else ""
)
def _status(f: dict[str, Any]) -> str:
st = f.get("status") or "pending"
if st == "pending":
return '<span class="todo">待处理</span>'
by = (f.get("resolved_by") or {}).get("name") or ""
note = f.get("resolution_note")
label = "原因" if st == "ignored" else "说明"
return (
f"{STATUS[st]}<span class='who'>{escape(by)} · <span class='fig'>{when(f.get('resolved_at'))}</span></span>"
+ (f"<span class='note'>{label}:{escape(note)}</span>" if note else "")
)
def cover(d: dict[str, Any]) -> str:
p, r = d["project"], d["run"]
docs = ";".join(f"{escape(x['label'])}({escape(x.get('filename') or '')})" for x in r["documents"]) or "—"
basis = f"清单 v{r['checklist_version_no']}" + (
"" if r["checklist_confirmed"] else "(未确认,以检查时的清单为准)"
)
cov = r.get("coverage") or {}
rows = []
for sev in ("fatal", "important", "hint"):
s = d["summary"][sev]
rows.append(
f"<tr><td><span class='sev {sev}'>{SEVERITY[sev]}</span></td>"
+ "".join(
f"<td class='num fig'>{s[k]}</td>" for k in ("total", "pending", "confirmed_ok", "modified", "ignored")
)
+ "</tr>"
)
open_fatal = d["summary"]["fatal"]["pending"]
regime = REGIME.get(p.get("legal_regime") or "unknown", "未识别")
method = f" · {escape(p['procurement_method'])}" if p.get("procurement_method") else ""
checked_at = f'<span class="fig">{when(r.get("finished_at") or r.get("created_at"))}</span>'
run_no = f'第 <span class="fig">{r["run_no"]}</span> 次检查'
alert = (
f'<div class="alert">导出时还有 <b class="fig">{open_fatal}</b> 项致命问题待处理,报告如实列出。</div>'
if open_fatal
else ""
)
return f"""
<h1>{_e(p["name"])}</h1>
<p class="sub">{TITLE}</p>
<dl>
<dt>项目编号</dt><dd class="fig">{_e(p.get("code"))}</dd>
<dt>采购人</dt><dd>{_e(p.get("purchaser"))}</dd>
<dt>法律体系</dt><dd>{regime}{method}</dd>
<dt>投标截止</dt><dd class="fig">{when(p.get("deadline_at"))}</dd>
<dt>所投采购包</dt><dd>{_e("、".join(p.get("lots") or []) or "整个项目")}</dd>
<dt>投标文件</dt><dd>{docs}</dd>
<dt>检查依据</dt><dd>{basis}</dd>
<dt>检查时间</dt><dd>{checked_at}({run_no})</dd>
<dt>规则版本</dt><dd class="fig">{_e(r.get("rule_set_version"))}</dd>
<dt>导出</dt><dd>{_e(d["exported_by"])} · <span class="fig">{when(d["exported_at"])}</span></dd>
</dl>
{alert}
<h2>检查覆盖范围</h2>
<p><b>查了:</b></p><ul>{"".join(f"<li>{escape(x)}</li>" for x in cov.get("checked") or [])}</ul>
<p><b>没查:</b></p><ul>{"".join(f"<li>{escape(x)}</li>" for x in cov.get("not_checked") or [])}</ul>
<h2>汇总</h2>
<table><thead><tr><th>级别</th><th class="num">问题数</th><th class="num">待处理</th><th class="num">已确认无误</th>
<th class="num">已修改</th><th class="num">已忽略</th></tr></thead><tbody>{"".join(rows)}</tbody></table>
<p class="cap">本次已解决 <span class="fig">{d["resolved_this_run"]}</span> 项(上次检查有、这次没有再发现)。</p>
<div class="disc"><b>免责声明</b>{DISCLAIMER}</div>
"""
def findings(d: dict[str, Any]) -> str:
rows = []
for f in d["findings"]:
cites = ""
if d["options"].get("citations", True):
parts = [f"<div>招标文件:{_cite(c)}</div>" for c in f.get("tender_citations") or []]
parts += [f"<div>投标文件:{_cite(c)}</div>" for c in f.get("bid_citations") or []]
cites = f"<div class='cites'>{''.join(parts)}</div>" if parts else ""
carried = "(沿用上次检查)" if f.get("presence") == "carried" else ""
dup = f"(同一要求在清单里出现 {len(f['duplicate_ids']) + 1} 处)" if f.get("duplicate_ids") else ""
rows.append(
f"<tr><td><span class='sev {f['severity']}'>{SEVERITY[f['severity']]}</span></td>"
f"<td><b>{escape(f['title'])}</b>{carried}{dup}<br>{escape(f['explanation'])}{cites}</td>"
f"<td>{_status(f)}</td></tr>"
)
body = (
"".join(rows)
or "<tr><td colspan='3'>本次检查范围内没有发现问题(不代表投标文件没有问题,没查的内容见首页)。</td></tr>"
)
return f"""
<h2>问题明细</h2>
<table><thead><tr><th style="width:15mm">级别</th><th>问题</th><th style="width:38mm">处理</th></tr></thead>
<tbody>{body}</tbody></table>
"""
def appendix(d: dict[str, Any]) -> str:
if not d["options"].get("manual_checks", True):
return ""
rows = []
for m in d["manual"]:
cite = _cite(m["citation"]) if m.get("citation") else "—"
state = (
f"已核对<span class='who'>{escape((m.get('checked_by') or {}).get('name') or '')} · "
f"<span class='fig'>{when(m.get('checked_at'))}</span></span>"
if m.get("checked")
else "未勾选"
)
rows.append(
f"<tr><td><b>{escape(m['label'])}</b><br><span class='cap'>{_e(m.get('requirement_text'))}</span></td>"
f"<td class='cites'>{cite}</td><td>{state}</td></tr>"
)
body = "".join(rows) or "<tr><td colspan='3'>清单里没有份数、密封、递交这类需人工核对的要求。</td></tr>"
return f"""
<h2>附录 · 需人工核对</h2>
<p class="cap">这些要求在投标文件里看不出来,需要投标人递交前自己核对。
「未勾选」表示导出时还没有人在系统里勾选核对。</p>
<table><thead><tr><th>要求</th><th style="width:48mm">出处</th><th style="width:30mm">核对</th></tr></thead>
<tbody>{body}</tbody></table>
"""
def page(d: dict[str, Any]) -> str:
return (
f'<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>{TITLE}</title><style>{CSS}</style>'
f"</head><body>{cover(d)}{findings(d)}{appendix(d)}</body></html>"
)
def _band(left: str, right: str, line: str) -> str:
"""页眉 / 页脚的一条:左右两端各一段。Chromium 渲染页眉页脚时 flex 不撑满宽度,用表格。"""
return f"""<!doctype html><html><head><meta charset="utf-8"><style>
html, body {{ margin: 0; padding: 0; width: 100%; -webkit-print-color-adjust: exact; }}
table {{ width: 100%; border-collapse: collapse; margin: 0 20mm; width: calc(100% - 40mm);
font-family: "Noto Sans CJK SC", sans-serif; font-size: 8pt; color: #5C6670; }}
td {{ padding: 0 0 3pt; {line} }}
td.r {{ text-align: right; }}
img {{ height: 18px; display: block; }}
</style></head><body><table><tr><td>{left}</td><td class="r">{right}</td></tr></table></body></html>"""
def header() -> str:
"""每页的页眉:左侧小号横版 logo(干净版),右侧报告名。logo 只在这里出现。"""
return _band(f'<img src="{_logo()}" alt="标博士">', TITLE, "border-bottom: 0.5pt solid #D6D8DB;")
def footer() -> str:
return _band(FOOTER, '<span class="pageNumber"></span> / <span class="totalPages"></span>', "")

View File

@@ -35,6 +35,13 @@ async def check_project(tenant_id: str, run_id: str) -> None:
await runner.run(UUID(tenant_id), UUID(run_id))
@app.task(name="generate_report", retry=False)
async def generate_report(tenant_id: str, job_id: str) -> None:
from bid_doctor.report import runner
await runner.run(UUID(tenant_id), UUID(job_id))
async def run_worker() -> None:
async with app.open_async():
await app.run_worker_async(concurrency=4, install_signal_handlers=True)

View File

@@ -0,0 +1,140 @@
"""检查报告(查标第 5 步,FR-M3-09):导出 → 后台生成 PDF → 列表 → 下载;致命问题未处理也能导出(红线 4);
模板文字不出现「通过 / 合格 / 保证」(设计规范 §7.17)。需要 Gotenberg。"""
from __future__ import annotations
import hashlib
import io
from datetime import UTC, datetime
from urllib.parse import urlsplit
from uuid import UUID
import pypdfium2 as pdfium
from bid_doctor.report import template
from tests.conftest import User
from tests.test_check_flow import checked_project
FORBIDDEN = ("通过", "合格", "保证")
async def export(u: User, run_id: str, **body: bool) -> dict[str, object]:
from bid_doctor.report import runner
r = await u.post(f"/check-runs/{run_id}/reports", json=body or None)
assert r.status_code == 202, r.text
job = r.json()
assert job["status"] == "queued" and job["report_id"] is None
await runner.run(UUID(u.tenant_id), UUID(job["id"]))
return (await u.get(f"/report-jobs/{job['id']}")).json()
def pdf_text(data: bytes) -> str:
pdf = pdfium.PdfDocument(io.BytesIO(data))
return "".join(pdf[i].get_textpage().get_text_range() for i in range(len(pdf)))
async def test_export_report_and_download(user: User) -> None:
project_id, _, run = await checked_project(user)
assert run["counts"]["open_fatal"] == 1
job = await export(user, run["id"])
assert job["status"] == "succeeded" and job["report_id"], job
(rep,) = (await user.get(f"/projects/{project_id}/reports")).json()["items"]
assert rep["id"] == job["report_id"] and rep["run_no"] == 1 and rep["page_count"] >= 2
assert rep["open_fatal_at_export"] == 1 and rep["template_version"] == template.TEMPLATE_VERSION
link = (await user.get(f"/reports/{rep['id']}/file")).json()
parts = urlsplit(link["url"])
got = await user.client.get(parts.path.removeprefix("/api/v1") + "?" + parts.query)
assert got.status_code == 200 and got.headers["content-type"] == "application/pdf"
assert hashlib.sha256(got.content).hexdigest() == rep["sha256"] == link["sha256"]
text = pdf_text(got.content)
for want in ("检查覆盖范围", "免责声明", "问题明细", template.FOOTER, "交换机支持硬件BFD"):
assert want in text.replace("\r", "").replace("\n", ""), want
# 重复导出就是新的一份,旧的还在(随项目归档)
await export(user, run["id"], citations=False, manual_checks=False)
assert len((await user.get(f"/projects/{project_id}/reports")).json()["items"]) == 2
notes = (await user.get("/notifications")).json()["items"]
assert notes[0]["kind"] == "report_succeeded"
async def test_cannot_export_unfinished_run_or_other_tenant(user: User, app_client: object) -> None:
from sqlalchemy import select
from bid_doctor.check.models import CheckRun
from bid_doctor.core.db import TenantContext, tenant_tx
project_id, _, run = await checked_project(user)
ctx = TenantContext(tenant_id=UUID(user.tenant_id), user_id=UUID(str(user.me["user"]["id"])))
async with tenant_tx(ctx) as s:
r = (await s.execute(select(CheckRun).where(CheckRun.id == UUID(run["id"])))).scalar_one()
r.status = "failed"
resp = await user.post(f"/check-runs/{run['id']}/reports")
assert resp.status_code == 409 and resp.json()["code"] == "run_not_succeeded"
del project_id, app_client
def test_template_text_has_no_conclusion_words() -> None:
"""模板自己的文字(标题、页脚、免责声明、表头、提示)不出现「通过 / 合格 / 保证」;原文引用不在此列。"""
data = {
"project": {
"name": "某市数据中心扩容项目",
"code": "ZC-1",
"purchaser": "某市大数据局",
"legal_regime": "gov_procurement",
"procurement_method": "公开招标",
"deadline_at": datetime(2026, 12, 1, 9, 30, tzinfo=UTC),
"lots": [],
},
"run": {
"run_no": 2,
"created_at": datetime.now(UTC),
"finished_at": datetime.now(UTC),
"rule_set_version": "r",
"checklist_version_no": 1,
"checklist_confirmed": False,
"coverage": {"checked": [], "not_checked": []},
"documents": [{"label": "技术分册", "filename": "a.pdf"}],
},
"summary": {
s: {"total": 0, "pending": 0, "confirmed_ok": 0, "modified": 0, "ignored": 0}
for s in ("fatal", "important", "hint")
},
"resolved_this_run": 0,
"findings": [],
"manual": [],
"options": {},
"exported_by": "张工",
"exported_at": datetime.now(UTC),
}
html = template.page(data) + template.header() + template.footer()
for w in FORBIDDEN:
assert w not in html, w
async def test_project_submit_archive_and_undo(user: User) -> None:
"""项目状态流(FR-M6-02):致命问题未处理也能递交(红线 4),递交后只读、可撤销;
归档后撤销回到归档前的状态;都留痕。"""
project_id, _, run = await checked_project(user)
assert run["counts"]["open_fatal"] == 1
r = await user.post(f"/projects/{project_id}/transitions", json={"action": "submit"})
assert r.status_code == 200 and r.json()["status"] == "submitted", r.text
# 只读:不能再发起检查、勾选需人工核对
blocked = await user.post(
f"/projects/{project_id}/checklist/items", json={"categories": ["schedule"], "title": "x"}
)
assert blocked.status_code == 409 and blocked.json()["code"] == "project_read_only"
# 报告照样能导出(随项目归档)
assert (await export(user, run["id"]))["status"] == "succeeded"
assert (await user.post(f"/projects/{project_id}/transitions", json={"action": "submit"})).status_code == 409
r = await user.post(f"/projects/{project_id}/transitions", json={"action": "archive"})
assert r.json()["status"] == "archived"
r = await user.post(f"/projects/{project_id}/transitions", json={"action": "unarchive"})
assert r.json()["status"] == "submitted" # 回到归档前
r = await user.post(f"/projects/{project_id}/transitions", json={"action": "unsubmit"})
assert r.json()["status"] == "checked"