feat(check): 需人工核对(P2)和分项 / 单价上限(C7)

- 需人工核对:份数、密封、递交、CA 签章加密、解密、保证金这类文件里看不出来的要求,
  从正式清单按主题合并、带招标原文出处;manual-checks 接口按项目存勾选、写勾选人和时间,
  复查沿用;查标页加「需人工核对」一块,覆盖范围写明列了几条
- 「报价」类清单项:读标从采购需求的清单表规则读出分项 / 单价上限(checklist/price.py);
  查标交给规则 PRICE.OVER_CEILING_ITEM 比对分项报价表,不交给模型;规则集 2026.11-r2
- 版面工具(分页、分行、归栏)挪到 document/pipeline/layout.py,读标和查标共用
- 评测:price_defects.py 从规矩投标文件派生只带报价问题的 4 份(P01、P02、P03)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-04 17:31:32 +08:00
parent db1b1f6f9f
commit 81c58798d2
22 changed files with 888 additions and 84 deletions

View File

@@ -7,7 +7,8 @@
* 说明框在行内展开;处理后显示署名,可以撤销(U 键在第 6 步);
* - 每个问题带招标原文和投标原文两侧出处,「未找到」写明找过哪些分册;点出处在右侧抽屉里看原文;
* - 只有最近一次检查的问题可以处理,历史批次只读。
* TODO(第 6 步):键盘操作、批量处理「提示」、判断有误反馈、需人工核对、导出报告。
* - 需人工核对(FR-M3-04、P2):份数、密封、递交、CA 加密等文件里看不出来的要求,按项目勾选,写勾选人和时间;
* TODO(第 6 步):键盘操作、批量处理「提示」、判断有误反馈、导出报告。
*/
import { unwrap } from "@bid-doctor/api-client";
import { ChevronDown, ChevronRight, X } from "@lucide/vue";
@@ -21,7 +22,7 @@ import { api, errorTitle, isApiError } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { CHECK_STEPS, checkStepIndex, FINDING_ACTION, fmtDateTime, SEVERITY, SEVERITY_CLASS } from "@/lib/labels";
import { TERMINAL_JOB, useCheckRun, useFindings, useProject, type Citation, type Finding } from "@/lib/queries";
import { TERMINAL_JOB, useCheckRun, useFindings, useManualChecks, useProject, type Citation, type Finding, type ManualCheck } from "@/lib/queries";
import { toast } from "@/lib/toast";
const props = defineProps<{ projectId: string; runId: string }>();
@@ -116,6 +117,27 @@ function refresh() {
void qc.invalidateQueries({ queryKey: qk.projects });
}
/* ---------- 需人工核对 ---------- */
const manualQ = useManualChecks(() => props.projectId);
const manual = computed(() => manualQ.data.value?.items ?? []);
const manualDone = computed(() => manual.value.filter((m) => m.checked).length);
const manualBusy = ref<string | null>(null);
const projectReadOnly = computed(() => p.value?.status === "submitted" || p.value?.status === "archived");
async function toggleManual(m: ManualCheck, checked: boolean) {
manualBusy.value = m.check_key;
try {
await unwrap(
api.PUT("/projects/{project_id}/manual-checks", { params: { path: { project_id: props.projectId } }, body: { check_key: m.check_key, checked } }),
);
void qc.invalidateQueries({ queryKey: qk.manualChecks(props.projectId) });
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
manualBusy.value = null;
}
}
/* ---------- 原文抽屉 ---------- */
const viewer = ref<{ doc: { id: string; label: string }; target: ViewerTarget & { id: string } } | null>(null);
function openCitation(c: Citation) {
@@ -262,6 +284,30 @@ const errorText = computed(() => {
</template>
</template>
</section>
<section v-if="manual.length" class="card flush manual" aria-labelledby="h-manual">
<div class="card-h">
<h2 id="h-manual">需人工核对</h2>
<span class="cap">文件里看不出来,递交前请自己核对。勾选按项目保存,复查后还在。</span>
<span class="cap r">已核对 <span class="fig">{{ manualDone }}</span> / <span class="fig">{{ manual.length }}</span></span>
</div>
<article v-for="m in manual" :key="m.check_key" class="finding">
<div>
<label class="choice">
<input type="checkbox" class="ck" :checked="m.checked" :disabled="projectReadOnly || manualBusy === m.check_key" @change="toggleManual(m, ($event.target as HTMLInputElement).checked)" />
<span class="f-title">{{ m.label }}</span>
</label>
<p v-if="m.requirement_text" class="f-text">招标文件:{{ m.requirement_text }}<template v-if="(m.related_items?.length ?? 0) > 1">(同类要求共 <span class="fig">{{ m.related_items?.length }}</span> 条)</template></p>
<div v-if="m.checked" class="sign">
<span aria-hidden="true">✓</span><b>{{ m.checked_by?.name }}</b>
<span v-if="m.checked_at" class="fig">{{ fmtDateTime(m.checked_at) }}</span><span>已核对</span>
</div>
</div>
<aside>
<CitationBlock v-if="m.citation" :citation="m.citation" :active="viewer?.target.id === m.citation.id" @open="openCitation(m.citation)" />
</aside>
</article>
</section>
</template>
<div v-else-if="runQ.error.value" class="alert warn" role="alert"><span class="ico w" aria-hidden="true">!</span><p>{{ errorTitle(runQ.error.value) }}</p></div>
</div>
@@ -368,6 +414,21 @@ const errorText = computed(() => {
background: var(--c-n100);
color: var(--text-primary);
}
.manual {
margin-top: var(--sp-4);
}
.manual .card-h {
display: flex;
align-items: baseline;
gap: var(--sp-2);
}
.manual .card-h h2 {
margin: 0;
font-size: var(--fs-body);
}
.manual .card-h .r {
margin-left: auto;
}
.sign.ign {
color: var(--text-secondary);
}