feat(report): 检查报告 PDF 导出和归档;项目状态流转(递交 / 归档)
- 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:
@@ -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 步前端补齐(键盘操作、批量处理、判断有误反馈)。
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
239
apps/web/src/pages/ReportPage.vue
Normal file
239
apps/web/src/pages/ReportPage.vue
Normal 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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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 步落地记录**(和本文设计不一致、或留到后面的,记在这里):
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
19
packages/api-client/src/schema.d.ts
vendored
19
packages/api-client/src/schema.d.ts
vendored
@@ -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: {
|
||||
|
||||
64
server/migrations/versions/0009_report_job.py
Normal file
64
server/migrations/versions/0009_report_job.py
Normal 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'));
|
||||
""")
|
||||
@@ -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,
|
||||
):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, "内容已被修改,请刷新后再改"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
0
server/src/bid_doctor/report/__init__.py
Normal file
0
server/src/bid_doctor/report/__init__.py
Normal file
64
server/src/bid_doctor/report/api.py
Normal file
64
server/src/bid_doctor/report/api.py
Normal 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}"'},
|
||||
)
|
||||
1
server/src/bid_doctor/report/assets/logo-horizontal.svg
Normal file
1
server/src/bid_doctor/report/assets/logo-horizontal.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 41 KiB |
42
server/src/bid_doctor/report/models.py
Normal file
42
server/src/bid_doctor/report/models.py
Normal 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]
|
||||
113
server/src/bid_doctor/report/runner.py
Normal file
113
server/src/bid_doctor/report/runner.py
Normal 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})
|
||||
169
server/src/bid_doctor/report/service.py
Normal file
169
server/src/bid_doctor/report/service.py
Normal 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),
|
||||
}
|
||||
243
server/src/bid_doctor/report/template.py
Normal file
243
server/src/bid_doctor/report/template.py
Normal 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>', "")
|
||||
@@ -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)
|
||||
|
||||
140
server/tests/test_report_flow.py
Normal file
140
server/tests/test_report_flow.py
Normal 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"
|
||||
Reference in New Issue
Block a user