feat(project): 项目基本信息修改和分包选择(FR-M2-03、04)
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled

- 字段修改:人工值优先、撤销恢复 AI 值、按字段校验(金额可写「286万」,按元存)、
  冗余列同步、审计留痕、只读项目 409;关键日期显示改后的投标截止时间
- 分包:增删改、选包(至少一个)、一键「不分包」;删包时条目归为通用,删到一个不剩回到不分包
- 清单列表 lot_scope 默认只列通用 + 所投的包,响应带 hidden_count
- 检查记下当时用的项目依据(截止时间、限价、所投的包、法律体系):报告按它写,
  之后改了信息检查结果接口给 basis_changed,查标页提示重新检查
- 前端:概览标签页(项目要点、采购包、关键日期)、字段修改对话框、选包页和手工调整;
  解析完识别到多个包时进选包页;读标页清单范围和所属采购包;查标页写明查哪几个包
- 决策 PI1–PI7;方案和落地记录 docs/architecture/project-info-plan.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-04 23:46:07 +08:00
parent 08e3fe7eff
commit ed6a2a907d
29 changed files with 1834 additions and 43 deletions

View File

@@ -221,4 +221,5 @@ samples/
- 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` 进度。
- 2026-11-04:查标第 4 步资质库和资质匹配(`docs/architecture/qualification-plan.md`,QL1–QL7 全部按建议确认)。资质库页可增删改、挂证书扫描件(企业级密钥加密),到期按进行中项目的投标截止日提醒;查标按证书类别词表(16 类,产品证书、人员证书不进匹配)核对有效期、等级、有没有覆盖,投标文字层按清单项定级,只有资质库记录的降一级、出处显示「依据:资质库记录」,规则集 2026.11-r4。开发集不退化;派生的资质情况表上 D12 3 / 3、D18 2 / 2、规矩的资质表 0 误报,但表和词表是对着同一批样本写的,泛化要等国动真实文件(`check-eval.md` §13)。
- 下一步:国动真实投标文件(用户晚些提供,到时先读标、请用户看清单,再查标一次;请用户按公司真实证书录资质库);其余可做:第 6 步前端补齐(键盘操作、批量处理、判断有误反馈、日期选择器的日历弹层)、M6 剩下的 P0(额度流水、套餐开通、数据删除、运营后台)。
- 2026-11-04:项目基本信息修改和分包选择(FR-M2-03、04,`docs/architecture/project-info-plan.md`,PI1–PI7 全部按建议确认)。新增「概览」标签页(项目要点可改、带出处、可恢复 AI 值;采购包;关键日期)和选包页(手工调整、一键「不分包」);清单默认只列通用 + 所投的包;检查记下当时用的截止时间、限价、所投的包、法律体系,报告按它写,之后改了信息查标页提示重新检查。样本里分包识别对的只有 S10(S06 把分项认成 5 个包,S01、S08 单包写了包号),改进分包抽取记进读标专项。
- 下一步:国动真实投标文件(用户晚些提供,到时先读标、请用户看清单,再查标一次;请用户按公司真实证书录资质库);其余可做:补遗追加(FR-M2-10,含补遗带来的新值确认)、第 6 步前端补齐(键盘操作、批量处理、判断有误反馈、日期选择器的日历弹层、概览页的检查记录和文件卡)、M6 剩下的 P0(额度流水、套餐开通、数据删除、运营后台)。

View File

@@ -0,0 +1,166 @@
<script setup lang="ts">
/**
* 修改项目基本信息的一个字段(FR-M2-03,project-info-plan §2)。按字段类型给输入控件;人工值优先,可以恢复 AI 值。
* 金额可写「286万」「2,860,000」,服务端换成元(PI7);投标截止时间按北京时间录入。修改留痕(审计)。
*/
import { unwrap, type Schemas } from "@bid-doctor/api-client";
import { X } from "@lucide/vue";
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from "reka-ui";
import { computed, ref, watch } from "vue";
import { api, errorTitle, isApiError } from "@/lib/api";
import { BOOL_FIELDS, FIELD_LABEL, fieldText, LEGAL_REGIME, MONEY_FIELDS, parseDay } from "@/lib/labels";
import type { ProjectField } from "@/lib/queries";
type Key = Schemas["ProjectFieldKey"];
const props = defineProps<{ projectId: string; fieldKey: Key | null; field: ProjectField | null }>();
const open = defineModel<boolean>("open", { required: true });
const emit = defineEmits<{ saved: [f: ProjectField, restored: boolean] }>();
const text = ref("");
const flag = ref<boolean | null>(null);
const day = ref("");
const time = ref("");
const err = ref("");
const busy = ref(false);
const key = computed(() => props.fieldKey);
const kind = computed(() =>
!key.value ? "text" : MONEY_FIELDS.has(key.value) ? "money" : BOOL_FIELDS.has(key.value) ? "bool" : key.value === "legal_regime" ? "regime" : key.value === "deadline_at" ? "time" : "text",
);
const canRestore = computed(() => props.field?.source === "human" && props.field.ai_value != null);
/** 时间按北京时间拆成日期和时分 */
function splitTime(iso: string): [string, string] {
const fmt = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false });
const parts = Object.fromEntries(fmt.formatToParts(new Date(iso)).map((x) => [x.type, x.value]));
return [`${parts.year}-${parts.month}-${parts.day}`, `${parts.hour === "24" ? "00" : parts.hour}:${parts.minute}`];
}
watch(open, (v) => {
if (!v) return;
const val = props.field?.value;
err.value = "";
text.value = kind.value === "money" && val != null ? String(val) : typeof val === "string" ? val : kind.value === "regime" ? "unknown" : "";
flag.value = typeof val === "boolean" ? val : null;
[day.value, time.value] = kind.value === "time" && typeof val === "string" ? splitTime(val) : ["", "09:30"];
});
function value(): string | boolean | null | undefined {
if (kind.value === "bool") return flag.value;
if (kind.value === "time") {
const d = parseDay(day.value);
const m = /^(\d{1,2})[::](\d{2})$/.exec(time.value.trim());
if (!d || !m || Number(m[1]) > 23 || Number(m[2]) > 59) {
err.value = "请填日期和时间,例如:2026-11-20 和 09:30";
return undefined;
}
return `${d}T${m[1]!.padStart(2, "0")}:${m[2]}:00+08:00`;
}
const t = text.value.trim();
if (key.value === "name" && !t) {
err.value = "项目名称不能为空";
return undefined;
}
return t || null;
}
async function save(restore = false) {
if (!key.value || busy.value) return;
const v = restore ? null : value();
if (v === undefined) return;
busy.value = true;
err.value = "";
try {
const f = await unwrap(
api.PUT("/projects/{project_id}/fields/{field_key}", { params: { path: { project_id: props.projectId, field_key: key.value } }, body: { value: v } }),
);
emit("saved", f, restore);
open.value = false;
} catch (e) {
const detail = isApiError(e, "validation_failed") ? (e.problem as { errors?: { message: string }[] }).errors?.[0]?.message : undefined;
err.value = detail ?? errorTitle(e);
} finally {
busy.value = false;
}
}
</script>
<template>
<DialogRoot v-model:open="open">
<DialogPortal>
<DialogOverlay class="scrim">
<DialogContent class="dialog" :aria-describedby="undefined">
<DialogTitle as="h2">修改{{ fieldKey ? FIELD_LABEL[fieldKey] : "" }}</DialogTitle>
<DialogClose class="icon-btn sm x" aria-label="关闭"><X class="ic" :stroke-width="1.5" /></DialogClose>
<form class="body" novalidate @submit.prevent="save()">
<div v-if="kind === 'text'" class="field fill">
<label for="fd-text">{{ fieldKey ? FIELD_LABEL[fieldKey] : "" }}</label>
<input id="fd-text" v-model="text" class="input" maxlength="500" autocomplete="off" />
</div>
<div v-else-if="kind === 'money'" class="field fill">
<label for="fd-money">{{ fieldKey ? FIELD_LABEL[fieldKey] : "" }}</label>
<input id="fd-money" v-model="text" class="input num" placeholder="例如:2,860,000 或 286万" autocomplete="off" />
<span class="msg">按招标文件原文填写,存成元;留空表示没写明</span>
</div>
<div v-else-if="kind === 'regime'" class="field fill">
<label for="fd-regime">法律体系</label>
<select id="fd-regime" v-model="text" class="input">
<option v-for="(label, v) in LEGAL_REGIME" :key="v" :value="v">{{ label }}</option>
</select>
<span class="msg">决定查标用「投标无效」还是「否决投标」的口径</span>
</div>
<div v-else-if="kind === 'bool'" class="field fill">
<label>{{ fieldKey ? FIELD_LABEL[fieldKey] : "" }}</label>
<div class="seg" role="group" :aria-label="fieldKey ? FIELD_LABEL[fieldKey] : ''">
<button type="button" :aria-pressed="flag === true" @click="flag = true">是</button>
<button type="button" :aria-pressed="flag === false" @click="flag = false">否</button>
<button type="button" :aria-pressed="flag === null" @click="flag = null">没写明</button>
</div>
</div>
<div v-else class="field fill">
<label for="fd-day">投标截止时间(北京时间)</label>
<div class="dt-row">
<input id="fd-day" v-model="day" class="input num" placeholder="例如:2026-11-20" autocomplete="off" @blur="day = parseDay(day) || day" />
<input v-model="time" class="input num" aria-label="时分" placeholder="09:30" autocomplete="off" />
</div>
<span class="msg">查标按它核对证书有效期和落款日期,改了以后重新检查才生效</span>
</div>
<p v-if="field && field.ai_value != null" class="cap muted ai">
AI 提取:{{ fieldText(field.key, field.ai_value) }}<template v-if="field.citation">({{ field.citation.document_label }} 第 <span class="fig">{{ field.citation.page_no }}</span> 页)</template>
</p>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<div class="foot">
<button v-if="canRestore" type="button" class="btn text left" :disabled="busy" @click="save(true)">恢复 AI 值</button>
<span v-else class="cap muted left">修改会留痕</span>
<DialogClose class="btn" type="button" :disabled="busy">取消</DialogClose>
<button type="submit" class="btn primary swap" :class="{ 'is-loading': busy }" :disabled="busy">
<span class="idle">保存</span><span class="busy">正在保存…</span>
</button>
</div>
</form>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</DialogRoot>
</template>
<style scoped>
.dt-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 96px;
gap: var(--sp-2);
}
.input.num {
font-family: var(--font-figures);
font-variant-numeric: tabular-nums;
font-size: var(--fs-figure-sm);
}
.input.num::placeholder {
font-family: var(--font-sans);
font-size: var(--fs-body);
}
.ai,
.hint-warn {
margin: var(--sp-4) 0 0;
}
</style>

View File

@@ -12,13 +12,14 @@ import { computed, ref, watch } from "vue";
import { errorTitle, isApiError } from "@/lib/api";
import type { ItemFields } from "@/lib/checklistOps";
import { category, CATEGORY_ORDER, IMPORTANCE_MARK, IMPORTANCE_NAME } from "@/lib/labels";
import type { ChecklistItem } from "@/lib/queries";
import type { ChecklistItem, Lot } from "@/lib/queries";
type Mode = "edit" | "accept" | "create";
type Cat = ChecklistItem["categories"][number];
type Imp = ChecklistItem["importance"];
const props = defineProps<{ mode: Mode; item?: ChecklistItem | null; defaultCategory?: Cat }>();
/** lots:项目的分包(不含默认包);有 2 个及以上时可以改条目归属哪个包(FR-M2-04) */
const props = defineProps<{ mode: Mode; item?: ChecklistItem | null; defaultCategory?: Cat; lots?: Lot[] }>();
const open = defineModel<boolean>("open", { required: true });
const emit = defineEmits<{
/** 提交:changes 只含改过的字段;ack = 人工确认、无出处 */
@@ -32,6 +33,7 @@ const cats = ref<Cat[]>([]);
const imp = ref<Imp>("normal");
const evidence = ref("");
const reqText = ref("");
const lotId = ref<string | null>(null);
const ack = ref(false);
const busy = ref(false);
const err = ref("");
@@ -46,6 +48,7 @@ watch(open, (v) => {
imp.value = i?.importance ?? "normal";
evidence.value = i?.evidence_required ?? "";
reqText.value = i?.requirement_text ?? "";
lotId.value = i?.lot_id ?? null;
ack.value = false;
err.value = titleErr.value = catErr.value = "";
});
@@ -75,6 +78,7 @@ function changes(): ItemFields {
if (!i || imp.value !== i.importance) out.importance = imp.value;
const ev = evidence.value.trim() || null;
if (ev !== (i?.evidence_required ?? null)) out.evidence_required = ev;
if ((props.lots?.length ?? 0) > 1 && lotId.value !== (i?.lot_id ?? null)) out.lot_id = lotId.value;
const rt = reqText.value.trim() || null;
if (reqEditable.value && rt !== (i?.requirement_text ?? null)) out.requirement_text = rt;
return out;
@@ -129,6 +133,13 @@ function submit() {
</div>
<span v-if="imp === 'substantive'" class="msg">★ 条款不满足就会被判无效,已同时归在「无效条款」</span>
</div>
<div v-if="(lots?.length ?? 0) > 1" class="field fill">
<label for="it-lot">所属采购包</label>
<select id="it-lot" v-model="lotId" class="input">
<option :value="null">各包通用</option>
<option v-for="l in lots" :key="l.id" :value="l.id">采购包 {{ l.lot_no }}<template v-if="l.name"> · {{ l.name }}</template></option>
</select>
</div>
<div class="field fill">
<label for="it-ev">证明材料 <small>(选填)</small></label>
<input id="it-ev" v-model="evidence" class="input" placeholder="如「提供营业执照副本复印件并加盖公章」" />

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
/**
* 项目头(原型的 projHead):项目名、采购人等、投标截止、状态和就绪;下面是「读标 / 查标」标签页。
* 项目头(原型的 projHead):项目名、采购人等、投标截止、状态和就绪;下面是「概览 / 读标 / 查标 / 报告」标签页。
* 就绪只在「已检查」时显示(G7),只表示致命问题都已处理,不代表投标文件没有问题(READY_TIP)。
* 状态操作(FR-M6-02):递交 / 撤销递交 / 归档 / 撤销归档。致命问题没处理完也可以递交(红线 4),确认框里说明;
* 递交后项目只读,可以撤销,都留痕。
@@ -16,7 +16,7 @@ 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" | "report" }>();
const props = defineProps<{ project?: ProjectDetail; tab: "overview" | "checklist" | "check" | "report" }>();
const qc = useQueryClient();
const p = computed(() => props.project);
const meta = computed(() =>
@@ -96,6 +96,7 @@ const checkTo = computed(() =>
</dl>
</div>
<nav class="tabs" aria-label="项目">
<RouterLink v-if="p" role="tab" :aria-selected="tab === 'overview'" :to="{ name: 'overview', params: { projectId: p.id } }">概览</RouterLink>
<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>

View File

@@ -18,4 +18,5 @@ export const qk = {
reports: (projectId: string) => ["reports", projectId] as const,
itemResults: (runId: string) => ["item-results", runId] as const,
qualifications: ["qualifications"] as const,
lots: (projectId: string) => ["lots", projectId] as const,
};

View File

@@ -196,6 +196,44 @@ export const QUALIFICATION_KIND: Dict<Schemas["QualificationKind"]> = {
};
export const qualificationKind = (s: string) => pick(QUALIFICATION_KIND, s);
/* ---------- 项目基本信息(FR-M2-03) ---------- */
export const FIELD_LABEL: Dict<Schemas["ProjectFieldKey"]> = {
name: "项目名称",
code: "项目编号",
purchaser: "采购人 / 招标人",
agency: "代理机构",
procurement_method: "采购方式",
legal_regime: "法律体系",
budget_total: "预算金额",
ceiling_total: "最高限价",
eval_method: "评标方法",
accept_consortium: "接受联合体",
accept_imported: "接受进口产品",
sme_only: "专门面向中小企业",
deadline_at: "投标截止时间",
};
export const FIELD_ORDER = Object.keys(FIELD_LABEL) as Schemas["ProjectFieldKey"][];
export const MONEY_FIELDS: ReadonlySet<string> = new Set(["budget_total", "ceiling_total"]);
export const BOOL_FIELDS: ReadonlySet<string> = new Set(["accept_consortium", "accept_imported", "sme_only"]);
/** 金额(元,字符串)显示成「286.00 万元」;不到一万的写元。数字来自招标文件原文,不是报价建议(红线 6) */
export function fmtYuan(v: string | null | undefined): string {
if (v == null || v === "") return "";
const n = Number(v);
if (!Number.isFinite(n)) return v;
return n >= 10000 ? `${(n / 10000).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} 万元` : `${n.toLocaleString("zh-CN")} 元`;
}
/** 字段值的显示文字;没有值时为空串 */
export function fieldText(key: string, v: unknown): string {
if (v == null || v === "") return "";
if (MONEY_FIELDS.has(key)) return fmtYuan(String(v));
if (BOOL_FIELDS.has(key)) return v ? "是" : "否";
if (key === "legal_regime") return legalRegime(String(v));
if (key === "deadline_at") return fmtDateTimeFull(String(v));
return String(v);
}
/* ---------- 时间 ---------- */
const pad = (n: number) => String(n).padStart(2, "0");
export const fmtDate = (d: Date) => `${d.getMonth() + 1}月${d.getDate()}日`;
@@ -228,6 +266,21 @@ export function parseDay(text: string): string | null {
if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== mo - 1 || dt.getUTCDate() !== d) return null;
return `${y}-${pad(mo)}-${pad(d)}`;
}
/** 完整的日期时间:「2026年11月20日 09:30」(北京时间) */
export function fmtDateTimeFull(iso: string): string {
const d = new Date(iso);
const parts = new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).formatToParts(d);
const get = (t: string) => parts.find((x) => x.type === t)?.value ?? "";
return `${get("year")}年${get("month")}月${get("day")}日 ${get("hour")}:${get("minute")}`;
}
export const fmtDuration = (ms: number) => {
const s = Math.max(0, Math.round(ms / 1000));
return { min: Math.floor(s / 60), sec: pad(s % 60) };

View File

@@ -27,6 +27,8 @@ export type Finding = Schemas["Finding"];
export type CheckItemResult = Schemas["CheckItemResult"];
export type ManualCheck = Schemas["ManualCheck"];
export type Qualification = Schemas["Qualification"];
export type Lot = Schemas["Lot"];
export type ProjectField = Schemas["ProjectField"];
export const fetchMe = () => unwrap(api.GET("/me"));
@@ -87,13 +89,16 @@ export function useChecklist(projectId: MaybeRefOrGetter<string>) {
});
}
export function useChecklistItems(projectId: MaybeRefOrGetter<string>) {
/** 清单项。scope:bidding = 通用 + 所投的包(默认,FR-M2-04),all = 全部;hidden_count 是没列出的条数 */
export function useChecklistItems(projectId: MaybeRefOrGetter<string>, scope: MaybeRefOrGetter<"bidding" | "all"> = "bidding") {
return useQuery({
queryKey: computed(() => qk.checklistItems(toValue(projectId))),
queryKey: computed(() => [...qk.checklistItems(toValue(projectId)), toValue(scope)] as const),
queryFn: () =>
unwrap(api.GET("/projects/{project_id}/checklist/items", { params: { path: { project_id: toValue(projectId) } } })) as Promise<{
items: ChecklistItem[];
}>,
unwrap(
api.GET("/projects/{project_id}/checklist/items", {
params: { path: { project_id: toValue(projectId) }, query: { lot_scope: toValue(scope) } },
}),
) as Promise<{ items: ChecklistItem[]; hidden_count: number }>,
});
}
@@ -147,3 +152,11 @@ export function useFindings(runId: MaybeRefOrGetter<string>, enabled: MaybeRefOr
export function useQualifications() {
return useQuery({ queryKey: qk.qualifications, queryFn: () => unwrap(api.GET("/qualifications")) });
}
/** 分包(含默认包;界面上不显示默认包) */
export function useLots(projectId: MaybeRefOrGetter<string>) {
return useQuery({
queryKey: computed(() => qk.lots(toValue(projectId))),
queryFn: () => unwrap(api.GET("/projects/{project_id}/lots", { params: { path: { project_id: toValue(projectId) } } })),
});
}

View File

@@ -209,6 +209,10 @@ async function start() {
</div>
</fieldset>
<p v-if="project.data.value?.bidding_lots?.length" class="cap muted lots-line">
本次查:通用条目 + {{ project.data.value.bidding_lots.map((n) => `采购包 ${n}`).join("、") }}
<RouterLink class="lnk" :to="{ name: 'lots', params: { projectId } }">更改所投的包</RouterLink>
</p>
<div v-if="startErr" class="alert warn" role="alert"><span class="ico w" aria-hidden="true">!</span><p>{{ startErr }}</p></div>
</div>

View File

@@ -25,14 +25,18 @@ import { groupCitations, type CitationGroup } from "@/lib/citations";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { category, CATEGORY_ORDER, fmtDateTime, MARKER, projectStatus } from "@/lib/labels";
import { useChecklist, useChecklistItems, useProject, type ChecklistItem, type Citation, type MarkingRule } from "@/lib/queries";
import { useChecklist, useChecklistItems, useLots, useProject, type ChecklistItem, type Citation, type MarkingRule } from "@/lib/queries";
import { toast } from "@/lib/toast";
const props = defineProps<{ projectId: string }>();
const project = useProject(() => props.projectId);
const checklist = useChecklist(() => props.projectId);
const itemsQ = useChecklistItems(() => props.projectId);
const lotScope = ref<"bidding" | "all">("bidding"); // 默认只列通用 + 所投的包(FR-M2-04)
const itemsQ = useChecklistItems(() => props.projectId, lotScope);
const lotsQ = useLots(() => props.projectId);
const realLots = computed(() => (lotsQ.data.value?.items ?? []).filter((l) => !l.is_default));
const biddingNos = computed(() => realLots.value.filter((l) => l.is_bidding).map((l) => `采购包 ${l.lot_no}`));
const docsQ = useQuery({
queryKey: computed(() => ["project-documents", props.projectId] as const),
queryFn: () => unwrap(api.GET("/projects/{project_id}/documents", { params: { path: { project_id: props.projectId } } })),
@@ -290,6 +294,20 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
</div>
</div>
<div v-if="realLots.length > 1" class="lot-scope" aria-label="清单范围">
<span class="cap muted">采购包</span>
<template v-if="lotScope === 'bidding'">
<span>通用 + {{ biddingNos.join("、") }}</span>
<span v-if="itemsQ.data.value?.hidden_count" class="cap muted">· 不投的包隐藏 <span class="fig">{{ itemsQ.data.value.hidden_count }}</span> 条</span>
<button v-if="itemsQ.data.value?.hidden_count" type="button" class="lnk cap" @click="lotScope = 'all'">显示全部</button>
</template>
<template v-else>
<span>全部采购包的条目</span>
<button type="button" class="lnk cap" @click="lotScope = 'bidding'">只看所投的包</button>
</template>
<RouterLink v-if="!readOnly" class="lnk cap" :to="{ name: 'lots', params: { projectId } }">更改所投的包</RouterLink>
</div>
<div v-if="cl?.response_modes?.length" class="modes" aria-label="招标文件的响应方式">
<span class="cap muted">响应方式</span>
<div v-for="m in cl.response_modes" :key="m.category" class="mode">
@@ -455,7 +473,7 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
</div>
</div>
<ItemDialog v-model:open="dlg.open" :mode="dlg.mode" :item="dlg.item" :default-category="cat !== 'all' && cat !== 'pending' && cat !== 'deleted' ? cat : undefined" @submit="onSubmit" @pick="onDialogPick" />
<ItemDialog v-model:open="dlg.open" :mode="dlg.mode" :item="dlg.item" :lots="realLots" :default-category="cat !== 'all' && cat !== 'pending' && cat !== 'deleted' ? cat : undefined" @submit="onSubmit" @pick="onDialogPick" />
<HistoryDialog v-model:open="hist.open" :item="hist.item" />
<ConfirmDialog v-model:open="confirmOpen" :title="`确认清单 v${nextVersion}?`" ok-text="确认清单" busy-text="正在确认…" :busy="confirming" @ok="doConfirm">
<p>确认后,查标以这一版清单为依据。之后再改清单会回到草稿,历史版本保留。</p>
@@ -467,6 +485,13 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
</template>
<style scoped>
.lot-scope {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
align-items: baseline;
margin-bottom: var(--sp-3);
}
/* 响应方式(C12):清单栏下面一行,每类一个选择框 */
.modes {
display: flex;

View File

@@ -0,0 +1,369 @@
<script setup lang="ts">
/**
* 选择要投的包(原型 lots.html,FR-M2-04):解析完成后识别到 2 个及以上的包时进入;概览和读标页「更改所投的包」也回到这里。
* - 清单只保留通用条目和所选包的条目,以后可以改;至少选一个包;
* - 识别得不对时手工调整(增删包、改包号 / 名称 / 预算 / 限价),或者一键「这个项目不分包」(PI2);
* 删包时原属这个包的条目归为通用(PI3);只有 1 个包时按不分包处理(PI4);
* - 限价是招标文件里的原文数字,不是系统给出的报价建议(红线 6)。
*/
import { unwrap } from "@bid-doctor/api-client";
import { Info, Plus, Trash2, X } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from "reka-ui";
import { computed, ref, watch } from "vue";
import { useRouter } from "vue-router";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import ProjectHead from "@/components/ProjectHead.vue";
import { api, errorTitle, isApiError } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { fmtYuan } from "@/lib/labels";
import { useChecklistItems, useLots, useProject, type Lot } from "@/lib/queries";
import { toast } from "@/lib/toast";
const props = defineProps<{ projectId: string }>();
const router = useRouter();
const qc = useQueryClient();
const project = useProject(() => props.projectId);
const lotsQ = useLots(() => props.projectId);
const itemsQ = useChecklistItems(() => props.projectId, "all");
const p = computed(() => project.data.value);
useCrumbs(() => [{ label: "工作台", to: { name: "workbench" } }, { label: p.value?.name ?? "项目", to: { name: "overview", params: { projectId: props.projectId } } }, { label: "选择所投的包" }]);
const readOnly = computed(() => p.value?.status === "submitted" || p.value?.status === "archived");
const lots = computed(() => (lotsQ.data.value?.items ?? []).filter((l) => !l.is_default));
const multi = computed(() => lots.value.length > 1);
/** 正式清单里各包的条目数;通用 = 没有归属的包 */
const counts = computed(() => {
const out = new Map<string | null, number>();
for (const i of itemsQ.data.value?.items ?? []) {
if (i.zone !== "formal") continue;
const k = i.lot_id ?? null;
out.set(k, (out.get(k) ?? 0) + 1);
}
return out;
});
const common = computed(() => counts.value.get(null) ?? 0);
const chosen = ref<Set<string>>(new Set());
watch(
lots,
(ls) => {
chosen.value = new Set(ls.filter((l) => l.is_bidding).map((l) => l.id));
},
{ immediate: true },
);
function toggle(id: string, on: boolean) {
const s = new Set(chosen.value);
if (on) s.add(id);
else s.delete(id);
chosen.value = s;
}
const shown = computed(() => common.value + lots.value.filter((l) => chosen.value.has(l.id)).reduce((a, l) => a + (counts.value.get(l.id) ?? 0), 0));
const hidden = computed(() => lots.value.filter((l) => !chosen.value.has(l.id)).reduce((a, l) => a + (counts.value.get(l.id) ?? 0), 0));
function refresh() {
void qc.invalidateQueries({ queryKey: qk.lots(props.projectId) });
void qc.invalidateQueries({ queryKey: qk.project(props.projectId) });
void qc.invalidateQueries({ queryKey: qk.projects });
void qc.invalidateQueries({ queryKey: qk.checklistItems(props.projectId) });
}
const saving = ref(false);
async function saveChoice() {
if (!multi.value) {
await router.push({ name: "checklist", params: { projectId: props.projectId } });
return;
}
if (!chosen.value.size || saving.value) return;
saving.value = true;
try {
await unwrap(api.PUT("/projects/{project_id}/bidding-lots", { params: { path: { project_id: props.projectId } }, body: { lot_ids: [...chosen.value] } }));
refresh();
toast(`已选择 ${lots.value.filter((l) => chosen.value.has(l.id)).map((l) => `采购包 ${l.lot_no}`).join("、")}`);
await router.push({ name: "checklist", params: { projectId: props.projectId } });
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
saving.value = false;
}
}
/* ---------- 「这个项目不分包」 ---------- */
const clearOpen = ref(false);
const clearing = ref(false);
async function clearLots() {
clearing.value = true;
try {
await unwrap(api.POST("/projects/{project_id}/lots/clear", { params: { path: { project_id: props.projectId } } }));
clearOpen.value = false;
refresh();
toast("已改为不分包,条目都归为通用");
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
clearing.value = false;
}
}
/* ---------- 手工调整 ---------- */
interface Row {
id: string | null;
lot_no: string;
name: string;
budget: string;
ceiling: string;
removed: boolean;
}
const fixOpen = ref(false);
const fixRows = ref<Row[]>([]);
const fixErr = ref("");
const fixBusy = ref(false);
const yuanText = (v: string | null | undefined) => (v ? String(Number(v)) : "");
function openFix() {
fixRows.value = lots.value.map((l) => ({ id: l.id, lot_no: l.lot_no, name: l.name ?? "", budget: yuanText(l.budget_amount), ceiling: yuanText(l.ceiling_amount), removed: false }));
if (!fixRows.value.length) addRow();
fixErr.value = "";
fixOpen.value = true;
}
function addRow() {
fixRows.value.push({ id: null, lot_no: String(fixRows.value.filter((r) => !r.removed).length + 1), name: "", budget: "", ceiling: "", removed: false });
}
const removedItems = computed(() => fixRows.value.filter((r) => r.removed && r.id).reduce((a, r) => a + (counts.value.get(r.id) ?? 0), 0));
async function saveFix() {
const live = fixRows.value.filter((r) => !r.removed);
const nos = live.map((r) => r.lot_no.trim());
if (nos.some((n) => !n)) return (fixErr.value = "包号不能为空");
if (new Set(nos).size !== nos.length) return (fixErr.value = "包号不能重复");
fixBusy.value = true;
fixErr.value = "";
const before = new Map(lots.value.map((l) => [l.id, l]));
try {
// 先删再改再加:改包号时不会和要删的包撞号
for (const r of fixRows.value.filter((x) => x.removed && x.id)) {
await unwrap(api.DELETE("/lots/{lot_id}", { params: { path: { lot_id: r.id! } } }));
}
for (const r of live.filter((x) => x.id)) {
const old = before.get(r.id!) as Lot;
const body: Record<string, string | null> = {};
if (r.lot_no.trim() !== old.lot_no) body.lot_no = r.lot_no.trim();
if (r.name.trim() !== (old.name ?? "")) body.name = r.name.trim();
if (r.budget.trim() !== yuanText(old.budget_amount)) body.budget_amount = r.budget.trim() || null;
if (r.ceiling.trim() !== yuanText(old.ceiling_amount)) body.ceiling_amount = r.ceiling.trim() || null;
if (Object.keys(body).length) await unwrap(api.PATCH("/lots/{lot_id}", { params: { path: { lot_id: r.id! } }, body }));
}
for (const r of live.filter((x) => !x.id)) {
await unwrap(
api.POST("/projects/{project_id}/lots", {
params: { path: { project_id: props.projectId } },
body: { lot_no: r.lot_no.trim(), name: r.name.trim() || undefined, budget_amount: r.budget.trim() || null, ceiling_amount: r.ceiling.trim() || null },
}),
);
}
fixOpen.value = false;
toast("已保存分包");
} catch (e) {
const detail = isApiError(e, "validation_failed") ? (e.problem as { errors?: { message: string }[] }).errors?.[0]?.message : undefined;
fixErr.value = detail ?? errorTitle(e);
} finally {
fixBusy.value = false;
refresh();
}
}
</script>
<template>
<ProjectHead :project="p" tab="overview" />
<div class="content lots-wrap">
<div class="page-title">
<div>
<h1>选择要投的包</h1>
<p v-if="multi">识别到 <span class="fig">{{ lots.length }}</span> 个采购包。清单只保留通用条目和所选包的条目,以后可以改。</p>
<p v-else-if="lots.length === 1">只识别到 1 个采购包,按不分包处理:清单里的条目都适用,这个包的限价照用。</p>
<p v-else>这个项目不分包,清单里的条目都适用。招标文件分了包而没有识别出来时,可以手工添加。</p>
</div>
</div>
<section class="card flush" aria-labelledby="h-lots">
<div class="card-h">
<h2 id="h-lots">采购包</h2>
<span v-if="!readOnly" class="r acts">
<button type="button" class="lnk cap" @click="openFix">{{ lots.length ? "识别得不对?手工调整" : "手工添加采购包" }}</button>
<button v-if="lots.length" type="button" class="lnk cap" @click="clearOpen = true">这个项目不分包</button>
</span>
</div>
<div class="card-b">
<div v-if="lots.length" class="grid" role="group" aria-labelledby="h-lots">
<label v-for="l in lots" :key="l.id" class="choice-card" :class="{ single: !multi }">
<input v-if="multi" type="checkbox" class="ck" :checked="chosen.has(l.id)" :disabled="readOnly" @change="toggle(l.id, ($event.target as HTMLInputElement).checked)" />
<span class="lot-main">
<b>采购包 {{ l.lot_no }}<template v-if="l.name"> · {{ l.name }}</template></b>
<small>专属条目 <span class="fig">{{ counts.get(l.id) ?? 0 }}</span> 条 · {{ l.origin === "human" ? "人工添加" : l.edited_fields.length ? "AI 识别,已修改" : "AI 识别" }}</small>
</span>
<span class="money">
<small>最高限价</small>
<template v-if="l.ceiling_amount">{{ fmtYuan(l.ceiling_amount) }}</template>
<span v-else class="muted">未识别</span>
</span>
</label>
</div>
<div class="common">
<Info class="ic" :stroke-width="1.5" />
<p>通用条目 <span class="fig">{{ common }}</span> 条(资格条件、无效情形、递交要求等)所有包都适用,始终保留。限价是招标文件里的原文数字,不是报价建议。</p>
</div>
</div>
<div class="card-f">
<span v-if="multi" class="sum" :class="{ warn: !chosen.size }">
<template v-if="chosen.size">清单 <span class="fig">{{ shown }}</span> 条:通用 + {{ lots.filter((l) => chosen.has(l.id)).map((l) => `采购包 ${l.lot_no}`).join("、") }}<template v-if="hidden"> · 隐藏 <span class="fig">{{ hidden }}</span> 条</template></template>
<template v-else><span class="ico w" aria-hidden="true">!</span> 至少选一个包</template>
</span>
<div class="r">
<button type="button" class="btn primary swap" :class="{ 'is-loading': saving }" :disabled="saving || (multi && !chosen.size) || (multi && readOnly)" @click="saveChoice">
<span class="idle">查看清单</span><span class="busy">正在保存…</span>
</button>
</div>
</div>
</section>
</div>
<ConfirmDialog v-model:open="clearOpen" title="改为不分包?" ok-text="改为不分包" busy-text="正在保存…" :busy="clearing" @ok="clearLots">
<p>删掉所有采购包,原属各包的条目都归为通用,检查时全部要查。把分项、标段说明误识别成包时用这个。修改会留痕。</p>
</ConfirmDialog>
<DialogRoot v-model:open="fixOpen">
<DialogPortal>
<DialogOverlay class="scrim">
<DialogContent class="dialog wide" :aria-describedby="undefined">
<DialogTitle as="h2">手工调整分包</DialogTitle>
<DialogClose class="icon-btn sm x" aria-label="关闭"><X class="ic" :stroke-width="1.5" /></DialogClose>
<form class="body" novalidate @submit.prevent="saveFix">
<p class="muted lead">可以增加、删除采购包,改包号、名称、预算和限价。条目归属哪个包,在读标页逐条改。金额可写「286万」或「2,860,000」。</p>
<table class="data">
<thead><tr><th>包号</th><th>名称</th><th>预算(元)</th><th>最高限价(元)</th><th><span class="vh">删除</span></th></tr></thead>
<tbody>
<tr v-for="(r, i) in fixRows" v-show="!r.removed" :key="r.id ?? `new-${i}`">
<td class="no"><input v-model="r.lot_no" class="input" maxlength="40" :aria-label="`第 ${i + 1} 行包号`" /></td>
<td><input v-model="r.name" class="input" maxlength="200" :aria-label="`第 ${i + 1} 行名称`" /></td>
<td><input v-model="r.budget" class="input num" :aria-label="`第 ${i + 1} 行预算`" /></td>
<td><input v-model="r.ceiling" class="input num" :aria-label="`第 ${i + 1} 行最高限价`" /></td>
<td><button type="button" class="icon-btn sm" :aria-label="`删除第 ${i + 1} 行`" @click="r.removed = true"><Trash2 class="ic" :stroke-width="1.5" /></button></td>
</tr>
</tbody>
</table>
<button type="button" class="btn sm add" @click="addRow"><Plus class="ic" :stroke-width="1.5" />增加一个包</button>
<p v-if="removedItems" class="hint-warn"><span class="ico w" aria-hidden="true">!</span>删掉的包里有 <span class="fig">{{ removedItems }}</span> 条清单项,保存后归为通用。</p>
<p v-if="!fixRows.some((r) => !r.removed)" class="cap muted">一个包都不留,项目就改为不分包。</p>
<p v-if="fixErr" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ fixErr }}</p>
<div class="foot">
<span class="cap muted left">修改会留痕</span>
<DialogClose class="btn" type="button" :disabled="fixBusy">取消</DialogClose>
<button type="submit" class="btn primary swap" :class="{ 'is-loading': fixBusy }" :disabled="fixBusy">
<span class="idle">保存</span><span class="busy">正在保存…</span>
</button>
</div>
</form>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</DialogRoot>
</template>
<style scoped>
.lots-wrap {
max-width: 880px;
}
.card-h .r {
margin-left: auto;
}
.acts {
display: flex;
gap: var(--sp-4);
}
.grid {
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
/* 选择卡(原型 app.css 的 .choice-card) */
.choice-card {
display: flex;
gap: var(--sp-3);
align-items: flex-start;
padding: var(--sp-3) var(--sp-4);
border: var(--line) solid var(--border-control);
border-radius: var(--radius-ctl);
background: var(--bg-surface);
cursor: pointer;
}
.choice-card:hover {
background: var(--c-n50);
}
.choice-card:has(:checked) {
border-color: var(--c-ink);
box-shadow: inset 0 0 0 1px var(--c-ink);
}
.choice-card.single {
cursor: default;
}
.choice-card .ck {
margin-top: 3px;
}
.choice-card .lot-main {
flex: 1;
min-width: 0;
}
.choice-card b {
display: block;
}
.choice-card small {
display: block;
color: var(--text-secondary);
font-size: var(--fs-caption);
line-height: var(--lh-caption);
}
.money {
text-align: right;
white-space: nowrap;
}
.common {
display: flex;
gap: var(--sp-2);
align-items: flex-start;
margin-top: var(--sp-4);
color: var(--text-secondary);
}
.common p {
margin: 0;
}
.card-f {
display: flex;
align-items: center;
gap: var(--sp-4);
padding: var(--sp-3) var(--sp-5);
border-top: var(--line) solid var(--border-subtle);
}
.card-f .r {
margin-left: auto;
}
.sum.warn {
color: var(--warn-text);
}
.lead {
margin-top: 0;
}
.dialog td .input {
width: 100%;
}
.dialog td.no {
width: 88px;
}
.input.num {
font-family: var(--font-figures);
font-variant-numeric: tabular-nums;
}
.add,
.hint-warn {
margin-top: var(--sp-3);
}
</style>

View File

@@ -0,0 +1,267 @@
<script setup lang="ts">
/**
* 项目概览(原型 project.html,PI1):这一步只做三张卡——项目要点(基本信息字段,带出处,可改、可恢复 AI 值)、
* 采购包(所投的包和限价)、关键日期(时间线和倒计时,FR-M2-09)。检查记录、文件、AI 摘要卡留到第 6 步。
* 查标按这里的值核对截止时间、限价、法律体系;改了以后重新检查才生效(PI5)。项目已递交、已归档时只读。
*/
import type { Schemas } from "@bid-doctor/api-client";
import { Pencil, X } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, ref } from "vue";
import { RouterLink } from "vue-router";
import FieldDialog from "@/components/FieldDialog.vue";
import PdfViewer, { type ViewerTarget } from "@/components/PdfViewer.vue";
import ProjectHead from "@/components/ProjectHead.vue";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { deadline, FIELD_LABEL, FIELD_ORDER, fieldText, fmtDateTimeFull, fmtYuan } from "@/lib/labels";
import { useLots, useProject, type Citation, type ProjectField } from "@/lib/queries";
import { toast } from "@/lib/toast";
const props = defineProps<{ projectId: string }>();
const qc = useQueryClient();
const project = useProject(() => props.projectId);
const lotsQ = useLots(() => props.projectId);
const p = computed(() => project.data.value);
useCrumbs(() => [{ label: "工作台", to: { name: "workbench" } }, { label: p.value?.name ?? "项目" }, { label: "概览" }]);
const readOnly = computed(() => p.value?.status === "submitted" || p.value?.status === "archived");
const byKey = computed(() => new Map((p.value?.fields ?? []).map((f) => [f.key, f])));
/** 字段的当前值;没有字段记录的(建项目时填的名称、改过的截止时间等)取项目本身的值 */
const COLUMN: Partial<Record<string, keyof NonNullable<typeof p.value>>> = {
name: "name",
code: "code",
purchaser: "purchaser",
agency: "agency",
procurement_method: "procurement_method",
deadline_at: "deadline_at",
};
const rows = computed(() =>
FIELD_ORDER.map((key) => {
const f = byKey.value.get(key) ?? null;
const col = COLUMN[key];
const value = f && f.value != null ? f.value : col && p.value ? (p.value[col] as unknown) : null;
return { key, f, value };
}),
);
const realLots = computed(() => (lotsQ.data.value?.items ?? []).filter((l) => !l.is_default));
const dates = computed(() => (p.value?.key_dates ?? []).map((d) => ({ ...d, due: deadline(d.at) })));
const dlg = ref<{ open: boolean; key: Schemas["ProjectFieldKey"] | null; field: ProjectField | null }>({ open: false, key: null, field: null });
function edit(key: Schemas["ProjectFieldKey"], f: ProjectField | null) {
dlg.value = { open: true, key, field: f };
}
function onSaved(f: ProjectField, restored: boolean) {
void qc.invalidateQueries({ queryKey: qk.project(props.projectId) });
void qc.invalidateQueries({ queryKey: qk.projects });
toast(restored ? `已恢复${FIELD_LABEL[f.key]}的 AI 值` : `已修改${FIELD_LABEL[f.key]}`);
}
const viewer = ref<{ doc: { id: string; label: string }; target: ViewerTarget & { id: string } } | null>(null);
function openCitation(c: Citation) {
viewer.value = { doc: { id: c.document_id, label: c.document_label }, target: { id: c.id, page: c.page_no, quads: c.quads, sha: c.normalized_sha256 } };
}
</script>
<template>
<ProjectHead :project="p" tab="overview" />
<div class="content ov">
<section class="card flush" aria-labelledby="h-facts">
<div class="card-h">
<h2 id="h-facts">项目要点</h2>
<span class="cap muted r">查标按这里的值核对截止时间、限价和法律体系</span>
</div>
<dl class="facts-list">
<div v-for="{ key, f, value } in rows" :key="key" class="frow">
<dt>{{ FIELD_LABEL[key] }}</dt>
<dd>
<span v-if="value != null && value !== ''" :class="{ fig: key === 'deadline_at' }">{{ fieldText(key, value) }}</span>
<span v-else class="dash">没写明</span>
<span v-if="f?.source === 'human'" class="flag box">已修改</span>
<button v-if="f?.citation" type="button" class="cite-src inline" @click="openCitation(f.citation)">
第 <span class="fig">{{ f.citation.page_no }}</span> 页
</button>
<span v-if="f?.pending" class="cap warn-text">补遗带来了新值:{{ fieldText(key, f.pending.value) }}(下一步补遗确认)</span>
</dd>
<button v-if="!readOnly" type="button" class="icon-btn sm" :aria-label="`修改${FIELD_LABEL[key]}`" @click="edit(key, f ?? { key, value: value as never, source: 'none' })">
<Pencil class="ic" :stroke-width="1.5" />
</button>
</div>
</dl>
</section>
<aside class="stack">
<section class="card flush" aria-labelledby="h-lots">
<div class="card-h">
<h3 id="h-lots">采购包</h3>
<RouterLink v-if="!readOnly" class="lnk cap r" :to="{ name: 'lots', params: { projectId } }">{{ realLots.length > 1 ? "更改所投的包" : "调整分包" }}</RouterLink>
</div>
<div class="card-b">
<p v-if="!realLots.length" class="cap muted none">不分包:清单里的条目都适用</p>
<ul v-else class="lots">
<li v-for="l in realLots" :key="l.id" :class="{ off: realLots.length > 1 && !l.is_bidding }">
<b>采购包 {{ l.lot_no }}</b>
<span v-if="realLots.length > 1" class="tag">{{ l.is_bidding ? "投" : "不投" }}</span>
<span v-if="l.name" class="name">{{ l.name }}</span>
<span class="cap muted">
<template v-if="l.budget_amount">预算 {{ fmtYuan(l.budget_amount) }}</template>
<template v-if="l.budget_amount && l.ceiling_amount"> · </template>
<template v-if="l.ceiling_amount">最高限价 {{ fmtYuan(l.ceiling_amount) }}</template>
<template v-if="l.edited_fields.length"> · 已修改</template>
</span>
</li>
</ul>
<p v-if="realLots.length" class="cap muted note">限价是招标文件里的原文数字,不是报价建议。</p>
</div>
</section>
<section class="card flush" aria-labelledby="h-dates">
<div class="card-h"><h3 id="h-dates">关键日期</h3></div>
<div class="card-b">
<p v-if="!dates.length" class="cap muted none">招标文件解析后,这里列出投标截止、开标等日期</p>
<ol v-else class="timeline">
<li v-for="(d, i) in dates" :key="i">
<span class="fig when">{{ fmtDateTimeFull(d.at) }}</span>
<b>{{ d.label }}</b>
<span v-if="d.edited" class="flag box">已修改</span>
<span v-if="d.due && d.due.cls !== 'due-past'" class="cap" :class="d.due.cls || 'muted'">{{ d.due.main }}</span>
</li>
</ol>
</div>
</section>
</aside>
</div>
<FieldDialog v-model:open="dlg.open" :project-id="projectId" :field-key="dlg.key" :field="dlg.field" @saved="onSaved" />
<aside v-if="viewer" class="drawer" aria-label="原文">
<button type="button" class="icon-btn sm close" aria-label="关闭原文" @click="viewer = null"><X class="ic" :stroke-width="1.5" /></button>
<PdfViewer :document-id="viewer.doc.id" :document-label="viewer.doc.label" :target="viewer.target" />
</aside>
</template>
<style scoped>
/* 主列 + 右侧信息栏(--aside-w),同工作台 */
.ov {
display: grid;
grid-template-columns: minmax(0, 1fr) var(--aside-w);
gap: var(--gap-card);
align-items: start;
}
.stack {
display: flex;
flex-direction: column;
gap: var(--gap-card);
}
.card-h .r {
margin-left: auto;
}
.facts-list {
margin: 0;
}
.frow {
display: grid;
grid-template-columns: 140px minmax(0, 1fr) auto;
gap: var(--sp-4);
align-items: center;
padding: var(--sp-3) var(--sp-5);
border-top: var(--line) solid var(--border-subtle);
}
.frow dt {
color: var(--text-secondary);
}
.frow dd {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
align-items: baseline;
margin: 0;
}
.frow .icon-btn {
color: var(--text-secondary);
}
.frow:hover .icon-btn {
color: var(--text-primary);
}
.cite-src.inline {
display: inline;
padding: 0;
border: 0;
background: none;
font: inherit;
font-size: var(--fs-caption);
color: var(--ai);
cursor: pointer;
}
.cite-src.inline:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
.warn-text {
color: var(--warn-text);
}
.none,
.note {
margin: 0;
}
.note {
margin-top: var(--sp-3);
}
.lots,
.timeline {
margin: 0;
padding: 0;
list-style: none;
}
.lots li {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1) var(--sp-2);
align-items: baseline;
padding: var(--sp-2) 0;
}
.lots li + li {
border-top: var(--line) solid var(--border-subtle);
}
.lots li.off {
color: var(--text-secondary);
}
.lots .name {
flex-basis: 100%;
}
.lots .cap {
flex-basis: 100%;
}
.timeline li {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1) var(--sp-2);
align-items: baseline;
padding: var(--sp-2) 0;
}
.timeline .when {
flex-basis: 100%;
font-size: var(--fs-figure-sm);
}
.drawer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
z-index: 40;
display: flex;
width: min(640px, 50vw);
background: var(--bg-surface);
box-shadow: var(--shadow-overlay);
}
.drawer > :deep(*:last-child) {
flex: 1;
min-width: 0;
}
.drawer .close {
position: absolute;
top: var(--sp-3);
left: calc(-1 * var(--sp-8));
background: var(--bg-surface);
box-shadow: var(--shadow-overlay);
}
</style>

View File

@@ -190,7 +190,9 @@ const result = computed(() => j.value?.result ?? null);
<span class="r"><RouterLink class="btn sm" :to="{ name: 'workbench' }">回工作台,完成后通知我</RouterLink></span>
</template>
<template v-else-if="status === 'succeeded'">
<RouterLink class="btn primary" :to="{ name: 'checklist', params: { projectId } }">查看检查清单</RouterLink>
<!-- 识别到 2 个及以上的包时先选包(FR-M2-04);只有 1 个包按不分包处理(PI4) -->
<RouterLink v-if="(result?.lots_found ?? 0) > 1" class="btn primary" :to="{ name: 'lots', params: { projectId } }">选择要投的包</RouterLink>
<RouterLink v-else class="btn primary" :to="{ name: 'checklist', params: { projectId } }">查看检查清单</RouterLink>
<span class="r"><span class="cost">已结算 <span class="fig">1</span> 次解析 · {{ j.finished_at ? fmtDateTime(j.finished_at) : "" }}</span></span>
</template>
<template v-else>

View File

@@ -226,6 +226,12 @@ const errorText = computed(() => {
</div>
</div>
<div v-if="!run.is_latest" class="alert warn" role="note"><span class="ico w" aria-hidden="true">!</span><p>这是历史检查,只能查看。</p></div>
<!-- PI5:检查之后改了截止时间、限价、所投的包或法律体系;不自动重新检查(可能扣点,由用户决定) -->
<div v-else-if="run.basis_changed" class="alert warn" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p>项目信息(截止时间、限价、所投的包或法律体系)在这次检查之后改过,这次的结果按检查时的值,重新检查后生效。</p>
<RouterLink class="btn sm" :to="{ name: 'bid-check', params: { projectId } }">重新检查</RouterLink>
</div>
<section v-if="run.coverage" class="card coverage" aria-labelledby="h-cov">
<h2 id="h-cov" class="vh">检查覆盖范围</h2>

View File

@@ -31,6 +31,18 @@ export const router = createRouter({
component: () => import("@/pages/ParsePage.vue"),
props: true,
},
{
path: "projects/:projectId",
name: "overview",
component: () => import("@/pages/OverviewPage.vue"),
props: true,
},
{
path: "projects/:projectId/lots",
name: "lots",
component: () => import("@/pages/LotsPage.vue"),
props: true,
},
{
path: "projects/:projectId/checklist",
name: "checklist",

View File

@@ -188,9 +188,9 @@
|---|---|---|
| 登录与账号 | 发验证码、注册、登录(含挤下线确认)、退出、`GET /me` | 修改显示名、在线会话列表和下线、注销账号 |
| 额度与套餐 | `GET /plans`、`GET /quota` | 额度流水、开通申请 |
| 项目 | 列表(不含筛选和分页)、新建、详情(含解析出的基本信息字段和关键日期) | 基本信息字段修改与补遗确认、状态变更、删除、分包的增删改和选包 |
| 项目 | 列表(不含筛选和分页)、新建、详情(含解析出的基本信息字段和关键日期)、状态变更、基本信息字段修改、分包的增删改、「不分包」和选包 | 补遗确认、删除 |
| 上传与文档 | 分片上传全套、挂到项目、文档列表和详情、逐页摘要、一页的句子、签名下载、发起解析、解析进度、取消、人工建出处 | 删除文档 |
| 清单 | 清单概况(含确认人、删除数、按类别计数)、清单项列表(按区域、类别筛选,可含已删除)、清单项详情(ETag)、修改(If-Match 选填)、采纳、删除恢复、人工新增、替换出处、修改记录、确认清单、版本 | 补遗更新确认(等补遗解析);列表的 `lot_scope`(等选包) |
| 清单 | 清单概况(含确认人、删除数、按类别计数)、清单项列表(按区域、类别、所投的包筛选,可含已删除)、清单项详情(ETag)、修改(If-Match 选填)、采纳、删除恢复、人工新增、替换出处、修改记录、确认清单、版本 | 补遗更新确认(等补遗解析) |
| 检查 | 检查预览、发起、进度、取消、检查记录、问题列表(含本次已解决)、每条清单项的检查结论(C9,2026-11-04 新增)、处理、撤销、批量处理(只限提示级);投标分册类型和版本、电子投标平台加密格式识别 | 需人工核对(第 2 步) |
| 报告、资质 | 报告:导出、生成进度、历次报告、签名下载(第 5 步);资质库:增删改、按类型 / 关键词筛选、附件(上传后挂上、签名下载、删除)、`late_for` 到期提醒(第 4 步) | — |
| 通知与事件 | 事件流(含检查进度)、通知列表、反馈 | 标为已读 |

View File

@@ -573,6 +573,28 @@ paths:
items: { $ref: '#/components/schemas/Lot' }
'409': { $ref: '#/components/responses/ProjectReadOnly' }
/projects/{project_id}/lots/clear:
parameters:
- $ref: '#/components/parameters/ProjectId'
post:
tags: [projects]
operationId: clearLots
summary: 这个项目不分包(PI2)
description: 删掉所有包,清单项都归为各包通用,建回默认包。用于把分项误识别成包等情况。
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
required: [items]
properties:
items:
type: array
items: { $ref: '#/components/schemas/Lot' }
'409': { $ref: '#/components/responses/ProjectReadOnly' }
/lots/{lot_id}:
parameters:
- name: lot_id
@@ -599,12 +621,11 @@ paths:
delete:
tags: [projects]
operationId: deleteLot
summary: 删除分包(原属这个包的清单项变为各包通用)
summary: 删除分包(原属这个包的清单项变为各包通用,PI3)
description: 删掉最后一个包时项目回到「不分包」(自动建回默认包,PI2)。
responses:
'204': { description: 已删除 }
'409':
description: '`last_lot`:至少保留一个包;或 `project_read_only`'
content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }
'409': { $ref: '#/components/responses/ProjectReadOnly' }
# ------------------------------------------------------------------ uploads
/uploads:
@@ -965,11 +986,12 @@ paths:
application/json:
schema:
type: object
required: [items]
required: [items, hidden_count]
properties:
items:
type: array
items: { $ref: '#/components/schemas/ChecklistItem' }
hidden_count: { type: integer, description: '`lot_scope = bidding` 时不在所投包里、没列出的条数' }
post:
tags: [checklist]
operationId: createChecklistItem
@@ -2229,9 +2251,15 @@ components:
type: object
required: [item_id, label, at]
properties:
item_id: { $ref: '#/components/schemas/Id' }
item_id:
oneOf:
- $ref: '#/components/schemas/Id'
- type: 'null'
description: 来自哪条清单项;人工改过的投标截止时间没有对应条目,为 null
label: { type: string, examples: [投标截止] }
kind: { type: string, enum: [question_deadline, bid_deadline, opening, deposit_deadline, validity, other] }
at: { $ref: '#/components/schemas/Timestamp' }
edited: { type: boolean, description: 人工改过(投标截止时间按项目字段的人工值显示) }
superseded_pending: { type: boolean, description: 补遗带来了新值、等用户确认 }
latest_check_run_id:
oneOf:
@@ -2265,6 +2293,10 @@ components:
oneOf:
- $ref: '#/components/schemas/UserRef'
- type: 'null'
updated_at:
oneOf:
- $ref: '#/components/schemas/Timestamp'
- type: 'null'
pending:
type: [object, 'null']
description: 补遗带来的新值(FR-M2-10)
@@ -2306,6 +2338,9 @@ components:
edited_fields:
type: array
items: { type: string }
ai_values:
type: [object, 'null']
description: AI 识别的原值(包号、名称、预算、限价),人工修改不覆盖
# ---------- uploads / documents
Upload:
@@ -2782,6 +2817,9 @@ components:
documents:
type: array
items: { $ref: '#/components/schemas/Document' }
basis_changed:
type: boolean
description: 检查之后改过项目依据(截止时间、限价、所投的包、法律体系),查标页提示「重新检查后生效」(PI5)
coverage:
type: [object, 'null']
description: |
@@ -2823,6 +2861,12 @@ components:
value: { $ref: '#/components/schemas/LegalRegime' }
rules_verified: { type: boolean, description: 这套法律体系的规则是否经过样本验证(招标投标体系暂无样本) }
rule_set_version: { type: string }
qualification:
type: object
description: 资质匹配的计数(qualification-plan §3.4):资质库条数、核对的资质要求、按资质库判断的、没认出类别的、排除的
basis:
type: object
description: 检查时用的项目依据(PI5):deadline_at、ceiling_total、legal_regime 各为 {value, source},lots 是所投各包 {lot_no, ceiling_amount, source}。报告按它写
prompt_versions: { type: object }
tokens:
type: object

View File

@@ -0,0 +1,128 @@
# 项目基本信息修改和分包选择实现方案 v0.1(2026-11-04)
> 范围:FR-M2-03 项目基本信息「用户可以修改,修改留痕」,FR-M2-04 分包识别与选择(G3)。
> 依据:`03-functional-requirements.md`、`02-user-journeys.md` J1 第 4 步、决策 G3 / DM7、`data-model.md` §5.3、`openapi.yaml`(`/projects/{id}/fields`、`/lots`、`/bidding-lots`)、原型 `project.html` `lots.html`。
> 状态:PI1–PI7 已全部按建议确认(2026-11-04,`docs/decisions.md`)。
## 0. 结论速览
- **为什么现在做**:查标的几条规则直接用项目信息。投标截止日决定证书过期(第 4 步刚做的资质匹配)和落款日期;所投的包决定查哪些清单项、和哪个限价比;法律体系决定用「投标无效」还是「否决投标」。这些都是模型抽的,现在抽错了用户改不了,查标结果就跟着错。国动是真实文件,不能指望抽得全对。
- **表和接口契约都已经有了**:`project_field` 分开存 AI 值和人工值,`lot` 有 `ai_values` 和 `edited_fields`;`openapi.yaml` 定义了字段修改、分包增删改和选包的接口。查标已经按「人工值优先」读字段、按 `is_bidding` 读所投的包。缺的是这些接口的实现和前端页面。
- **分包识别经常不对**(§1),所以「手工调整」和「这个项目不分包」都要一步就能做到,不能藏得太深。
- **检查要记下当时用的项目依据**:现在报告首页读的是导出时的项目信息,检查用的是检查时的值,中间改过就对不上(§5)。
## 1. 现状
**后端**:解析时 `project/extracted.py` 把 13 个字段和分包写进项目。用户改过的字段不覆盖,补遗的新值放进待确认;第一次识别到分包时替换掉建项目时的默认包。查标读字段时人工值优先,读所投的包用 `bidding_lot_ids`(不含默认包;没有分包时为空,表示整个项目)。清单项有 `lot_id`(空 = 各包通用),清单项修改接口已经能改 `lot_id`。
**没做的**:字段修改接口、分包增删改和选包接口、清单列表的 `lot_scope` 筛选;前端没有概览页、没有选包页,字段只在解析页显示了「识别到 N 个采购包」。
**分包识别的实际情况**(S01–S10 读标结果 `evals/out/runs/fix3/*/info.json`,对照 `07-sample-findings.md` 的人工档案):
| 招标文件 | 档案 | 识别结果 | 问题 |
|---|---|---|---|
| S10 | 3 个采购包,各有限价 | 3 个包,名称、限价都对 | — |
| S06 | 不分包(有分项限价) | 5 个「包」 | 把分项当成了包 |
| S01、S08 | 不分包 | 各 1 个包(「01」「1」),带限价 | 单包项目写了包号 |
| 其余 6 份 | 不分包 | 没有包 | — |
结论:只识别到 1 个包时按不分包处理(PI4);S06 这种错误要靠用户一键改成「不分包」,原属这些包的条目归为通用(PI2、PI3)。改进分包抽取属于读标专项优化(`extract-status.md`),这次不做,记进已知问题。
## 2. 基本信息修改(FR-M2-03)
### 2.1 接口
按契约实现 `PUT /projects/{id}/fields/{field_key}`,`value = null` 表示撤销人工值、恢复 AI 值。
- **校验按字段**:
| 字段 | 值 | 校验 |
|---|---|---|
| 名称、编号、采购人、代理机构、采购方式、评标方法 | 文本 | 去首尾空白;名称不能为空,其余空串等于撤销 |
| 法律体系 | `gov_procurement` / `tendering` / `unknown` | 枚举 |
| 预算、总价限价 | 金额字符串(元,两位小数) | 非负;界面可以写「286 万」「2,860,000」,提交前换成元(PI7) |
| 联合体、进口产品、专门面向中小企业 | 布尔 | 也可以撤销成「没写明」 |
| 投标截止时间 | 带时区的时间 | 界面按北京时间录入到分钟 |
- 写 `human_value / human_set_by / human_set_at`;`project` 表上冗余的几列(名称、编号、采购人、代理机构、采购方式、法律体系、截止时间)同步更新,撤销时回到 AI 值。
- 每次修改写审计 `project_field.set`(前后值),字段的修改记录从审计里读,和清单项同一做法。
- 项目已递交或已归档时返回 409 `project_read_only`。`If-Match` 选填,和清单项修改一致(MVP 只有一个人用,先不强制)。
- 响应里的 `updated_by` 填人工修改人。
- **关键日期跟着改**:概览的关键日期来自「日期与递交」类清单项;投标截止时间被人工改过时,时间线上的「投标截止」显示改后的值并标「已修改」,不然两处对不上。
### 2.2 不在这一步做的
补遗带来的待确认值(`POST .../fields/{key}/pending`)属于 FR-M2-10 补遗追加,下一步一起做;这次只在字段上显示「补遗 N 带来了新值」,不能确认。
## 3. 分包(FR-M2-04)
### 3.1 接口
按契约实现分包列表、新增、修改、删除和选包。补充几条规则:
- **默认包**:没分包的项目有一个默认包(`is_default`),界面上不显示,所有条目都归它(`lot_id` 为空)。手工新增第一个包时删掉默认包;删掉最后一个真实的包时自动建回默认包,即回到「不分包」,**不报 `last_lot`**(PI2,改契约)。
- **「这个项目不分包」**:新增 `POST /projects/{id}/lots/clear`,一次删掉所有包、建回默认包,条目都归为通用(PI2)。S06 那样识别错的项目一步改好。
- **删除一个包**:原属这个包的清单项归为各包通用(契约原文,外键 `ON DELETE SET NULL`);确认框写明「有 N 条清单项会归为通用」(PI3)。
- **修改**:包号(项目内不能重复)、名称、预算、限价;AI 识别的包改过的字段记进 `edited_fields`,AI 原值留在 `ai_values`。
- **选包**:`PUT /projects/{id}/bidding-lots`,至少选一个。没选过时所有包都算投(表的默认值),选包页会提示先选。
- 增删改、选包都写审计(`lot.create / update / delete / clear`、`project.bidding_lots`);只读项目返回 409。
### 3.2 清单按包筛选
- 清单列表加 `lot_scope`:`bidding`(默认,通用 + 所投的包)/ `all`。响应里带被隐藏的条数,清单页顶部写「通用 + 采购包 1 · 隐藏 23 条」,可以切到全部。
- 清单项对话框加「所属采购包」下拉(通用 / 各包),改归属走现有的清单项修改接口,留修改记录。
- 换了所投的包,清单的确认状态不变:清单内容没变,变的只是查哪些(PI6)。查标本来就按检查时的所投的包筛选,不在所投包里的条目写进覆盖范围。
## 4. 前端
- **概览标签页**(原型 `project.html`):这一步只做三张卡(PI1):
- **项目要点**:13 个字段,每个带出处(点开看原文);人工改过的标「已修改」,可以恢复 AI 值;点「修改」打开对话框,按字段类型给输入控件。
- **采购包**:包号、名称、预算、限价、是否投;「更改所投的包」去选包页。不分包的项目这张卡写「不分包」。
- **关键日期**:时间线和倒计时(FR-M2-09)。
- 原型里的检查记录、文件、AI 摘要卡留到第 6 步。
- **选包页**(原型 `lots.html`):
- 解析完成后,识别到 2 个及以上的包时,解析页的「下一步」进选包页,否则直接进清单(PI4)。
- 页面内容:每个包一张选择卡(包号、名称、预算、限价带出处、各包的条目数)、通用条目数、「识别得不对?手工调整」、「这个项目不分包」。
- 手工调整对话框:增删包、改包号、名称、预算、限价。
- **读标页**:顶部显示「通用 + 采购包 1」和隐藏条数,带「更改所投的包」链接;清单项对话框加所属包。
- **查标页**:检查预览里写明这次查哪几个包。检查结果页在项目信息于检查之后改过时提示「项目信息在本次检查后改过,重新检查后生效」(PI5)。
- 金额输入和显示:界面写元或万元都行,显示「286.00 万元」;限价旁边注明「招标文件原文」(红线 6:不是报价建议)。
## 5. 检查记下当时的项目依据(PI5)
`check_run` 已经存了 `lot_ids`。再在覆盖范围里存一份 `basis`:投标截止时间、总价限价、所投各包的包号和限价、法律体系,各带来源(AI / 人工)。
- **报告首页**改为读 `basis`。用户在检查后改了截止日再导出报告时,报告写的是检查时用的值,和问题明细一致。
- **查标结果页**比较 `basis` 和项目当前的值,不一样就出上面那条提示。
- 不加迁移,`coverage` 是 jsonb。
## 6. 测试
- 集成测试:字段修改、撤销、校验、只读、审计、冗余列同步;分包增删改、默认包的建回、「不分包」、删包时条目归通用、选包至少一个;清单 `lot_scope`;检查用了改后的截止日和所投的包,`basis` 记下的是检查时的值。
- 前端在浏览器里走一遍:概览改截止日 → 查标页提示重新检查;选包页手工调整、一键不分包、选包后清单隐藏条数。
- 不需要真实模型:分包用接口直接建,字段用测试样例解析出来的。
## 7. 工作量和顺序
1. 后端字段修改、分包接口、`lot_scope`、`basis`,以及测试。约半天。
2. 前端概览页(三张卡和修改对话框)。约半天。
3. 前端选包页、读标页和查标页的联动。约半天。
## 8. 待确认事项
| 编号 | 事项 | 建议 |
|---|---|---|
| PI1 | 字段在哪里改 | **做「概览」标签页**,这一步只做项目要点、采购包、关键日期三张卡;检查记录、文件、AI 摘要卡留到第 6 步。另一种做法是只在项目头放一个「项目信息」对话框,省事,但以后还得做概览页 |
| PI2 | 删掉最后一个包 | **回到「不分包」**(自动建回默认包),不报 `last_lot`;另给「这个项目不分包」一键操作。S06 那种把分项识别成 5 个包的,一步改好 |
| PI3 | 删包时原属这个包的条目 | **归为各包通用**(契约原文),确认框写明条数。被误识别的包里的条目多半本来就是通用的;归通用宁可多查,不会漏查 |
| PI4 | 只识别到 1 个包 | **按不分包处理**:跳过选包页,这个包的限价照用(作为采购包限价)。S01、S08 都是这种情况 |
| PI5 | 检查之后改了项目信息 | **检查记下当时的依据**,报告按检查时的值写;查标页提示「重新检查后生效」。不自动重新检查(重新检查可能扣点,红线 9 要求用户自己决定) |
| PI6 | 换了所投的包,清单还算不算已确认 | **算**:清单内容没变;查标按检查时的所投的包筛选,覆盖范围里写明不在所投包里的条数 |
| PI7 | 金额怎么录 | **按元存**,界面可以写「286 万」「2,860,000」,提交前换成元;显示用万元 |
## 9. 落地记录(2026-11-04)
- 后端:`project/edit.py`(字段修改、分包增删改、「不分包」、选包,都写审计);`project/service.basis` 是检查时的项目依据,查标存进覆盖范围、检查结果接口给 `basis_changed`,报告首页按它写截止时间;清单列表 `lot_scope` 默认只列通用 + 所投的包,响应带 `hidden_count`。
- 名称没有 AI 值时(建项目时手填的),撤销人工值保持现在的名称;界面只在有 AI 值时给「恢复 AI 值」。
- 前端:概览页三张卡(项目要点的值没有字段记录时取项目本身的值,比如建项目时填的名称)、字段修改对话框、选包页和手工调整对话框;解析完识别到 2 个及以上的包时「下一步」进选包页;读标页显示清单范围和隐藏条数,可以「显示全部」;清单项对话框加「所属采购包」;查标页写明本次查哪几个包;检查结果页在 `basis_changed` 时提示重新检查。
- 浏览器里走过:改截止时间(项目头、要点、关键日期一起变)、写「286万」的限价、手工加两个包并写限价、只投包 1、一键改回不分包。

View File

@@ -4,6 +4,22 @@
---
## PI1–PI7 · 2026-11-04 · 项目基本信息修改和分包选择:全部按建议
依据 `docs/architecture/project-info-plan.md` §8。
| # | 决策 | 理由 |
|---|---|---|
| PI1 | 做「概览」标签页,这一步只做项目要点、采购包、关键日期三张卡;检查记录、文件、AI 摘要卡留到第 6 步 | 原型就是在概览页改;只做对话框以后还得重做 |
| PI2 | 删掉最后一个包时回到「不分包」(自动建回默认包),不报 `last_lot`;另加「这个项目不分包」一键操作 | S06 把 5 个分项识别成 5 个包,要能一步改好 |
| PI3 | 删包时原属这个包的清单项归为各包通用,确认框写明条数 | 误识别的包里的条目多半本来就通用;宁可多查,不漏查 |
| PI4 | 只识别到 1 个包时按不分包处理:跳过选包页,这个包的限价照用 | S01、S08 是单包项目写了包号 |
| PI5 | 检查记下当时用的项目依据(截止时间、限价、所投的包、法律体系),报告按检查时的值写;之后改了信息,查标页提示「重新检查后生效」,不自动重新检查 | 报告首页和问题明细要一致;重新检查可能扣点,由用户决定(红线 9) |
| PI6 | 换了所投的包,清单的确认状态不变 | 清单内容没变,变的只是查哪些 |
| PI7 | 金额按元存,界面可以写「286 万」「2,860,000」,显示用万元 | 招标文件两种写法都常见 |
- **影响**:`openapi.yaml` 删包不再返回 `last_lot`,新增 `POST /projects/{id}/lots/clear`,清单列表加 `lot_scope`;检查覆盖范围加 `basis`;不加迁移。
## QL1–QL7 · 2026-11-04 · 资质库和资质匹配:全部按建议
依据 `docs/architecture/qualification-plan.md` §7。

View File

@@ -360,6 +360,28 @@ export interface paths {
patch?: never;
trace?: never;
};
"/projects/{project_id}/lots/clear": {
parameters: {
query?: never;
header?: never;
path: {
project_id: components["parameters"]["ProjectId"];
};
cookie?: never;
};
get?: never;
put?: never;
/**
* 这个项目不分包(PI2)
* @description 删掉所有包,清单项都归为各包通用,建回默认包。用于把分项误识别成包等情况。
*/
post: operations["clearLots"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/lots/{lot_id}": {
parameters: {
query?: never;
@@ -372,7 +394,10 @@ export interface paths {
get?: never;
put?: never;
post?: never;
/** 删除分包(原属这个包的清单项变为各包通用) */
/**
* 删除分包(原属这个包的清单项变为各包通用,PI3)
* @description 删掉最后一个包时项目回到「不分包」(自动建回默认包,PI2)。
*/
delete: operations["deleteLot"];
options?: never;
head?: never;
@@ -1601,9 +1626,14 @@ export interface components {
fields: components["schemas"]["ProjectField"][];
/** @description 关键日期时间线(FR-M2-09),来自「日期与递交」类清单项 */
key_dates: {
item_id: components["schemas"]["Id"];
/** @description 来自哪条清单项;人工改过的投标截止时间没有对应条目,为 null */
item_id: components["schemas"]["Id"] | null;
label: string;
/** @enum {string} */
kind?: "question_deadline" | "bid_deadline" | "opening" | "deposit_deadline" | "validity" | "other";
at: components["schemas"]["Timestamp"];
/** @description 人工改过(投标截止时间按项目字段的人工值显示) */
edited?: boolean;
/** @description 补遗带来了新值、等用户确认 */
superseded_pending?: boolean;
}[];
@@ -1626,6 +1656,7 @@ export interface components {
ai_value?: components["schemas"]["FieldValue"];
citation?: components["schemas"]["Citation"] | null;
updated_by?: components["schemas"]["UserRef"] | null;
updated_at?: components["schemas"]["Timestamp"] | null;
/** @description 补遗带来的新值(FR-M2-10) */
pending?: {
value?: components["schemas"]["FieldValue"];
@@ -1650,6 +1681,8 @@ export interface components {
is_bidding: boolean;
origin: components["schemas"]["Origin"];
edited_fields: string[];
/** @description AI 识别的原值(包号、名称、预算、限价),人工修改不覆盖 */
ai_values?: Record<string, never> | null;
};
Upload: {
id: components["schemas"]["Id"];
@@ -1999,6 +2032,8 @@ export interface components {
rule_set_version: string;
legal_regime?: components["schemas"]["LegalRegime"];
documents?: components["schemas"]["Document"][];
/** @description 检查之后改过项目依据(截止时间、限价、所投的包、法律体系),查标页提示「重新检查后生效」(PI5) */
basis_changed?: boolean;
/** @description 查了什么、没查什么(报告首页、查标页顶部,FR-M3-09、m3-plan §2.5)。认不出、查不了、没查的都写在这里,不静默跳过。
* */
coverage?: {
@@ -2028,6 +2063,10 @@ export interface components {
rules_verified?: boolean;
};
rule_set_version?: string;
/** @description 资质匹配的计数(qualification-plan §3.4):资质库条数、核对的资质要求、按资质库判断的、没认出类别的、排除的 */
qualification?: Record<string, never>;
/** @description 检查时用的项目依据(PI5):deadline_at、ceiling_total、legal_regime 各为 {value, source},lots 是所投各包 {lot_no, ceiling_amount, source}。报告按它写 */
basis?: Record<string, never>;
prompt_versions?: Record<string, never>;
tokens?: {
in?: number;
@@ -3006,6 +3045,31 @@ export interface operations {
409: components["responses"]["ProjectReadOnly"];
};
};
clearLots: {
parameters: {
query?: never;
header?: never;
path: {
project_id: components["parameters"]["ProjectId"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
items: components["schemas"]["Lot"][];
};
};
};
409: components["responses"]["ProjectReadOnly"];
};
};
deleteLot: {
parameters: {
query?: never;
@@ -3024,15 +3088,7 @@ export interface operations {
};
content?: never;
};
/** @description `last_lot`:至少保留一个包;或 `project_read_only` */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["Problem"];
};
};
409: components["responses"]["ProjectReadOnly"];
};
};
updateLot: {
@@ -3586,6 +3642,8 @@ export interface operations {
content: {
"application/json": {
items: components["schemas"]["ChecklistItem"][];
/** @description `lot_scope = bidding` 时不在所投包里、没列出的条数 */
hidden_count: number;
};
};
};

View File

@@ -173,6 +173,7 @@ async def _stages(ctx: TenantContext, run_id: UUID) -> None:
facts = await _facts(s, ctx, r, snapshot)
prior = await _prior(s, r)
library = await qualifications.records(s)
basis = await projects.basis(s, r.project_id) # 检查时用的项目依据(PI5)
# 招标文件按类别的响应方式(C12):用清单里存的(人工改过的为准);解析早于这项功能的项目临时识别
modes = response_mode.from_stored(stored_modes) if stored_modes else response_mode.detect(tender_sents)
bidding = set(r.lot_ids)
@@ -244,7 +245,7 @@ async def _stages(ctx: TenantContext, run_id: UUID) -> None:
r.counts = await _counts(s, run_id)
r.coverage = _coverage(
r, pages_info, items, results, pending, other_lot, stats, tokens, skipped, modes, rule_cov, qual_cov
)
) | {"basis": basis}
await _publish(ctx, r, "running", "summarize", PROGRESS["summarize"])

View File

@@ -212,6 +212,12 @@ async def list_runs(s: AsyncSession, project_id: UUID) -> list[CheckRun]:
return list(rows.scalars())
async def _basis_changed(s: AsyncSession, run: CheckRun) -> bool:
"""检查之后改过项目依据(截止时间、限价、所投的包、法律体系):查标页提示重新检查后生效(PI5)。"""
saved = (run.coverage or {}).get("basis")
return saved is not None and saved != await projects.basis(s, run.project_id)
async def run_to_api(s: AsyncSession, ctx: TenantContext, run: CheckRun) -> dict[str, Any]:
p = await projects.get(s, ctx, run.project_id)
doc_ids = list(
@@ -243,6 +249,8 @@ async def run_to_api(s: AsyncSession, ctx: TenantContext, run: CheckRun) -> dict
"legal_regime": run.legal_regime,
"documents": [await documents.doc_to_api(s, d) for d in docs],
"coverage": run.coverage,
# 检查之后改过项目依据(截止时间、限价、所投的包、法律体系):查标页提示重新检查后生效(PI5)
"basis_changed": await _basis_changed(s, run),
"counts": run.counts,
"created_by": {"id": run.created_by, "name": names.get(run.created_by, "")},
"created_at": run.created_at,

View File

@@ -63,11 +63,16 @@ async def list_items(
zone: Literal["pending", "formal"] | None = None,
category: Category | None = None,
include_deleted: bool = False,
lot_scope: Literal["bidding", "all"] = "bidding",
) -> dict[str, Any]:
# TODO(分包):lot_scope(默认只列通用 + 所投的包),选包做好之后加
async with tenant_tx(ctx) as s:
await projects.get(s, ctx, project_id)
return {"items": await service.items(s, project_id, zone, category, include_deleted)}
rows = await service.items(s, project_id, zone, category, include_deleted)
if lot_scope == "all":
return {"items": rows, "hidden_count": 0}
bidding = set(await projects.bidding_lot_ids(s, project_id)) # 通用 + 所投的包(FR-M2-04);不分包时为空
shown = [r for r in rows if not bidding or r["lot_id"] is None or r["lot_id"] in bidding]
return {"items": shown, "hidden_count": len(rows) - len(shown)}
@router.post("/projects/{project_id}/checklist/items", status_code=201)

View File

@@ -409,6 +409,7 @@ async def key_dates(s: AsyncSession, project_id: UUID) -> list[dict[str, Any]]:
{
"item_id": r.id,
"label": labels.get(sched.get("date_kind", "other"), "其他日期"),
"kind": sched.get("date_kind", "other"),
"at": sched["at"],
"superseded_pending": r.superseded_by_id is not None,
}

View File

@@ -1,16 +1,16 @@
from __future__ import annotations
from typing import Any, Literal
from typing import Annotated, Any, Literal
from uuid import UUID
from fastapi import APIRouter, Query
from fastapi import APIRouter, Header, 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
from bid_doctor.project import edit, service, workbench
router = APIRouter(tags=["projects"])
@@ -23,6 +23,38 @@ class TransitionIn(BaseModel):
action: Literal["submit", "unsubmit", "archive", "unarchive"]
FieldKey = Literal[
"name",
"code",
"purchaser",
"agency",
"procurement_method",
"legal_regime",
"budget_total",
"ceiling_total",
"eval_method",
"accept_consortium",
"accept_imported",
"sme_only",
"deadline_at",
]
class FieldIn(BaseModel):
value: str | bool | None
class LotIn(BaseModel):
lot_no: str | None = Field(default=None, max_length=40)
name: str | None = Field(default=None, max_length=200)
budget_amount: str | None = None
ceiling_amount: str | None = None
class BiddingIn(BaseModel):
lot_ids: list[UUID] = Field(min_length=1)
@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 补齐
@@ -59,3 +91,59 @@ async def transition_project(project_id: UUID, body: TransitionIn, ctx: Ctx) ->
).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)
@router.put("/projects/{project_id}/fields/{field_key}")
async def set_project_field(
project_id: UUID,
field_key: FieldKey,
body: FieldIn,
ctx: Ctx,
if_match: Annotated[str | None, Header()] = None, # 选填,和清单项修改一致(MVP 只有一个人用,先不强制)
) -> dict[str, Any]:
del if_match
async with tenant_tx(ctx) as s:
await edit.set_field(s, ctx, project_id, field_key, body.value)
return next(
(f for f in await service.fields(s, project_id) if f["key"] == field_key),
{"key": field_key, "value": None, "source": "none", "ai_value": None, "citation": None, "updated_by": None},
)
@router.get("/projects/{project_id}/lots")
async def list_lots(project_id: UUID, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
await service.get(s, ctx, project_id)
return {"items": [edit.lot_to_api(x) for x in await edit.lots(s, project_id)]}
@router.post("/projects/{project_id}/lots", status_code=201)
async def create_lot(project_id: UUID, body: LotIn, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
return edit.lot_to_api(await edit.create_lot(s, ctx, project_id, body.model_dump(exclude_unset=True)))
@router.post("/projects/{project_id}/lots/clear")
async def clear_lots(project_id: UUID, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
await edit.clear_lots(s, ctx, project_id)
return {"items": [edit.lot_to_api(x) for x in await edit.lots(s, project_id)]}
@router.put("/projects/{project_id}/bidding-lots")
async def set_bidding_lots(project_id: UUID, body: BiddingIn, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
await edit.set_bidding(s, ctx, project_id, body.lot_ids)
return {"items": [edit.lot_to_api(x) for x in await edit.lots(s, project_id)]}
@router.patch("/lots/{lot_id}")
async def update_lot(lot_id: UUID, body: LotIn, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
return edit.lot_to_api(await edit.update_lot(s, ctx, lot_id, body.model_dump(exclude_unset=True)))
@router.delete("/lots/{lot_id}", status_code=204)
async def delete_lot(lot_id: UUID, ctx: Ctx) -> None:
async with tenant_tx(ctx) as s:
await edit.delete_lot(s, ctx, lot_id)

View File

@@ -0,0 +1,293 @@
"""修改项目基本信息和分包(FR-M2-03、04,project-info-plan §2、§3)。
- 字段:人工值和 AI 值分开存,人工值优先;`value = null` 撤销人工值、恢复 AI 值;project 表上冗余的几列同步;
- 分包:没分包的项目有一个默认包(界面不显示);手工加第一个包时删掉默认包,删掉最后一个包时建回默认包(PI2);
删包时原属这个包的清单项归为通用(外键 ON DELETE SET NULL,PI3);
- 每次修改都写审计(全程留痕,红线 1);项目已递交、已归档时只读。
"""
from __future__ import annotations
import re
from datetime import datetime
from decimal import Decimal, InvalidOperation
from typing import Any
from uuid import UUID
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from bid_doctor.audit import service as audit
from bid_doctor.core.authz import Permission, require
from bid_doctor.core.db import TenantContext
from bid_doctor.core.errors import AppError
from bid_doctor.core.ids import uuid7
from bid_doctor.project import service
from bid_doctor.project.models import Lot, Project, ProjectField
COLUMNS = ("name", "code", "purchaser", "agency", "procurement_method", "legal_regime") # project 表上冗余的列
TEXT = ("name", "code", "purchaser", "agency", "procurement_method", "eval_method")
MONEY = ("budget_total", "ceiling_total")
BOOL = ("accept_consortium", "accept_imported", "sme_only")
REGIMES = ("gov_procurement", "tendering", "unknown")
DEFAULT_LOT_NO = "默认"
LOT_FIELDS = ("lot_no", "name", "budget_amount", "ceiling_amount")
def _invalid(field: str, message: str) -> AppError:
return AppError("validation_failed", errors=[{"field": field, "code": "invalid", "message": message}])
def money(value: Any, field: str = "value") -> str | None:
"""金额 → 元(字符串,两位小数)。接受「2,860,000」「286万」「286 万元」(PI7)。
数字是用户自己填的(招标文件原文),不是系统给的。"""
if value in (None, ""):
return None
text = re.sub(r"[\s,,]", "", str(value))
wan = bool(re.search(r"万(元)?$", text))
text = re.sub(r"万?元?$", "", text)
try:
n = Decimal(text)
except InvalidOperation as e:
raise _invalid(field, "金额写法不对,例如:2,860,000 或 286万") from e
if n < 0 or not n.is_finite():
raise _invalid(field, "金额不能是负数")
return f"{n * 10000 if wan else n:.2f}"
def field_value(key: str, value: Any) -> Any:
"""按字段校验、规整人工值;返回 None 表示撤销人工值。"""
if value is None:
return None
if key in TEXT:
if not isinstance(value, str):
raise _invalid(key, "应为文本")
text = value.strip()
if key == "name" and not text:
raise _invalid(key, "项目名称不能为空")
return text[:500] or None
if key == "legal_regime":
if value not in REGIMES:
raise _invalid(key, "法律体系只能是政府采购、招标投标或待识别")
return value
if key in MONEY:
return money(value, key)
if key in BOOL:
if not isinstance(value, bool):
raise _invalid(key, "应为是或否")
return value
if key == "deadline_at":
try:
at = datetime.fromisoformat(str(value))
except ValueError as e:
raise _invalid(key, "时间写法不对,例如:2026-11-20T09:30:00+08:00") from e
if at.tzinfo is None:
raise _invalid(key, "时间要带时区")
return at.isoformat()
raise _invalid(key, "不能修改这个字段")
def _sync(p: Project, key: str, value: Any) -> None:
"""project 表上冗余的当前值(工作台排序、筛选用)。"""
if key in COLUMNS:
setattr(p, key, value if value is not None or key != "legal_regime" else "unknown")
elif key == "deadline_at":
p.deadline_at = datetime.fromisoformat(value) if value else None
async def set_field(s: AsyncSession, ctx: TenantContext, project_id: UUID, key: str, value: Any) -> None:
require(ctx, Permission.PROJECT_EDIT)
p = await service.get(s, ctx, project_id)
service.ensure_editable(p)
human = field_value(key, value)
f = (
await s.execute(select(ProjectField).where(ProjectField.project_id == p.id, ProjectField.field_key == key))
).scalar_one_or_none()
if f is None:
if human is None:
return
f = ProjectField(tenant_id=ctx.tenant_id, project_id=p.id, field_key=key)
s.add(f)
before = {"value": f.human_value if f.human_value is not None else f.ai_value, "source": _source(f)}
f.human_value = human
f.human_set_by, f.human_set_at = (ctx.user_id, func.now()) if human is not None else (None, None)
current = human if human is not None else f.ai_value
if key == "name" and current is None:
current = p.name # 名称没有 AI 值时撤销人工值,保持现在的名称
_sync(p, key, current)
await s.flush()
after = {"value": current, "source": "human" if human is not None else _source(f)}
await audit.record(
s, ctx, "project_field.set", "project", p.id, project_id=p.id, before={key: before}, after={key: after}
)
def _source(f: ProjectField) -> str:
return "human" if f.human_value is not None else "ai" if f.ai_value is not None else "none"
# ---------------------------------------------------------------- 分包
def lot_to_api(lot: Lot) -> dict[str, Any]:
return {
"id": lot.id,
"lot_no": lot.lot_no,
"name": lot.name,
"budget_amount": f"{lot.budget_amount:.2f}" if lot.budget_amount is not None else None,
"ceiling_amount": f"{lot.ceiling_amount:.2f}" if lot.ceiling_amount is not None else None,
"is_default": lot.is_default,
"is_bidding": lot.is_bidding,
"origin": lot.origin,
"edited_fields": lot.edited_fields,
"ai_values": lot.ai_values,
}
async def lots(s: AsyncSession, project_id: UUID) -> list[Lot]:
return list(
(await s.execute(select(Lot).where(Lot.project_id == project_id).order_by(Lot.sort_order, Lot.lot_no)))
.scalars()
.all()
)
async def _editable(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> Project:
require(ctx, Permission.PROJECT_EDIT)
p = await service.get(s, ctx, project_id)
service.ensure_editable(p)
return p
def _lot_values(values: dict[str, Any]) -> dict[str, Any]:
out: dict[str, Any] = {}
if "lot_no" in values:
no = (values["lot_no"] or "").strip()
if not no:
raise _invalid("lot_no", "包号不能为空")
out["lot_no"] = no[:40]
if "name" in values:
out["name"] = (values["name"] or "").strip()[:200] or None
for k in ("budget_amount", "ceiling_amount"):
if k in values:
v = money(values[k], k)
out[k] = Decimal(v) if v is not None else None
return out
async def _unique_no(s: AsyncSession, project_id: UUID, lot_no: str, exclude: UUID | None = None) -> None:
q = select(Lot.id).where(Lot.project_id == project_id, Lot.lot_no == lot_no)
if exclude:
q = q.where(Lot.id != exclude)
if (await s.execute(q)).first():
raise _invalid("lot_no", f"已经有「{lot_no}」这个包")
def _audit_lot(lot: Lot) -> dict[str, Any]:
return {k: str(v) if isinstance(v, Decimal) else v for k, v in ((f, getattr(lot, f)) for f in LOT_FIELDS)}
async def create_lot(s: AsyncSession, ctx: TenantContext, project_id: UUID, values: dict[str, Any]) -> Lot:
p = await _editable(s, ctx, project_id)
data = _lot_values(values)
if "lot_no" not in data:
raise _invalid("lot_no", "包号不能为空")
await s.execute(delete(Lot).where(Lot.project_id == p.id, Lot.is_default)) # 手工分包:默认包不要了
await _unique_no(s, p.id, data["lot_no"])
order = (
await s.execute(select(func.coalesce(func.max(Lot.sort_order), -1)).where(Lot.project_id == p.id))
).scalar_one()
lot = Lot(id=uuid7(), tenant_id=ctx.tenant_id, project_id=p.id, origin="human", sort_order=order + 1, **data)
lot.edited_fields = []
s.add(lot)
await s.flush()
await audit.record(s, ctx, "lot.create", "lot", lot.id, project_id=p.id, after=_audit_lot(lot))
await s.refresh(lot)
return lot
async def get_lot(s: AsyncSession, lot_id: UUID) -> Lot:
lot = (await s.execute(select(Lot).where(Lot.id == lot_id))).scalar_one_or_none()
if lot is None or lot.is_default:
raise AppError("not_found")
return lot
async def update_lot(s: AsyncSession, ctx: TenantContext, lot_id: UUID, values: dict[str, Any]) -> Lot:
lot = await get_lot(s, lot_id)
await _editable(s, ctx, lot.project_id)
data = _lot_values(values)
if "lot_no" in data:
await _unique_no(s, lot.project_id, data["lot_no"], exclude=lot.id)
before = _audit_lot(lot)
changed = [k for k, v in data.items() if getattr(lot, k) != v]
for k in changed:
setattr(lot, k, data[k])
if lot.origin == "ai":
lot.edited_fields = sorted({*lot.edited_fields, *changed})
await s.flush()
if changed:
await audit.record(
s, ctx, "lot.update", "lot", lot.id, project_id=lot.project_id, before=before, after=_audit_lot(lot)
)
await s.refresh(lot)
return lot
async def _ensure_default(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> None:
"""一个包都不剩时建回默认包:项目回到「不分包」(PI2)。"""
if not (await s.execute(select(Lot.id).where(Lot.project_id == project_id).limit(1))).first():
s.add(
Lot(
id=uuid7(),
tenant_id=ctx.tenant_id,
project_id=project_id,
lot_no=DEFAULT_LOT_NO,
is_default=True,
origin="human",
edited_fields=[],
)
)
await s.flush()
async def delete_lot(s: AsyncSession, ctx: TenantContext, lot_id: UUID) -> None:
"""删包:原属这个包的清单项由外键改为各包通用(PI3)。"""
lot = await get_lot(s, lot_id)
await _editable(s, ctx, lot.project_id)
before = _audit_lot(lot)
await s.delete(lot)
await s.flush()
await _ensure_default(s, ctx, lot.project_id)
await audit.record(s, ctx, "lot.delete", "lot", lot.id, project_id=lot.project_id, before=before)
async def clear_lots(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> None:
"""「这个项目不分包」:删掉所有包,清单项都归为通用,建回默认包(PI2)。"""
p = await _editable(s, ctx, project_id)
existing = [lot for lot in await lots(s, p.id) if not lot.is_default]
if not existing:
return
await s.execute(delete(Lot).where(Lot.project_id == p.id))
await s.flush()
await _ensure_default(s, ctx, p.id)
await audit.record(
s, ctx, "lot.clear", "project", p.id, project_id=p.id, before={"lots": [_audit_lot(x) for x in existing]}
)
async def set_bidding(s: AsyncSession, ctx: TenantContext, project_id: UUID, lot_ids: list[UUID]) -> None:
p = await _editable(s, ctx, project_id)
real = {lot.id: lot for lot in await lots(s, p.id) if not lot.is_default}
chosen = set(lot_ids)
if not chosen or not chosen <= set(real):
raise _invalid("lot_ids", "至少选一个本项目的采购包")
before = sorted(lot.lot_no for lot in real.values() if lot.is_bidding)
await s.execute(
update(Lot).where(Lot.project_id == p.id, Lot.is_default.is_(False)).values(is_bidding=Lot.id.in_(chosen))
)
await s.flush()
after = sorted(real[i].lot_no for i in chosen)
await audit.record(
s, ctx, "project.bidding_lots", "project", p.id, project_id=p.id, before={"lots": before}, after={"lots": after}
)

View File

@@ -15,6 +15,7 @@ from bid_doctor.core.db import TenantContext
from bid_doctor.core.errors import AppError
from bid_doctor.core.ids import uuid7
from bid_doctor.document import citations
from bid_doctor.identity import service as identity
from bid_doctor.project.models import Lot, Project, ProjectField, ProjectMember
UNNAMED = "未命名项目"
@@ -107,6 +108,7 @@ async def fields(s: AsyncSession, project_id: UUID) -> list[dict[str, Any]]:
"""项目基本信息字段(FR-M2-03):当前值、来源(AI / 人工)、AI 原值、出处、补遗带来的待确认值。"""
rows = (await s.execute(select(ProjectField).where(ProjectField.project_id == project_id))).scalars().all()
cites = await citations.to_api_many(s, [c for r in rows for c in (r.ai_citation_id, r.pending_citation_id) if c])
names = await identity.display_names(s, [r.human_set_by for r in rows if r.human_set_by])
out = []
for r in sorted(rows, key=lambda r: FIELD_ORDER.index(r.field_key)):
human = r.human_value is not None
@@ -117,7 +119,10 @@ async def fields(s: AsyncSession, project_id: UUID) -> list[dict[str, Any]]:
"source": "human" if human else "ai" if r.ai_value is not None else "none",
"ai_value": r.ai_value,
"citation": cites.get(r.ai_citation_id) if r.ai_citation_id else None,
"updated_by": None,
"updated_by": {"id": r.human_set_by, "name": names.get(r.human_set_by, "")}
if human and r.human_set_by
else None,
"updated_at": r.human_set_at if human else None,
"pending": {
"value": r.pending_value,
"citation": cites.get(r.pending_citation_id) if r.pending_citation_id else None,
@@ -143,6 +148,42 @@ async def bidding_lot_ids(s: AsyncSession, project_id: UUID) -> list[UUID]:
return list(rows.scalars())
async def basis(s: AsyncSession, project_id: UUID) -> dict[str, Any]:
"""查标用到的项目依据(PI5):投标截止时间、总价限价、法律体系、所投的包和各包限价,各带来源。
检查时存进覆盖范围;报告按它写,查标页拿它和当前的值比,改过就提示重新检查。"""
p = (await s.execute(select(Project).where(Project.id == project_id))).scalar_one()
rows = {
f.field_key: f
for f in (await s.execute(select(ProjectField).where(ProjectField.project_id == project_id))).scalars()
}
def field(key: str) -> dict[str, Any]:
f = rows.get(key)
human = f is not None and f.human_value is not None
return {"value": (f.human_value if human else f.ai_value) if f else None, "source": "human" if human else "ai"}
lots = (
await s.execute(
select(Lot)
.where(Lot.project_id == project_id, Lot.is_bidding, Lot.is_default.is_(False))
.order_by(Lot.sort_order)
)
).scalars()
return {
"deadline_at": {**field("deadline_at"), "value": p.deadline_at.isoformat() if p.deadline_at else None},
"ceiling_total": field("ceiling_total"),
"legal_regime": {**field("legal_regime"), "value": p.legal_regime},
"lots": [
{
"lot_no": x.lot_no,
"ceiling_amount": f"{x.ceiling_amount:.2f}" if x.ceiling_amount is not None else None,
"source": "human" if x.origin == "human" or "ceiling_amount" in x.edited_fields else "ai",
}
for x in lots
],
}
def mark_checked(p: Project, run_id: UUID, open_fatal: int) -> None:
"""完成过至少一次检查:跟进中 / 编制中 → 已检查(FR-M6-02);就绪标记随最近一次检查更新(data-model §5.6)。"""
if p.status in ("tracking", "drafting"):

View File

@@ -29,18 +29,29 @@ async def detail(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> dict[
docs = (await documents.summaries(s, [p.id]))[p.id]
pending = (await checklist.pending_counts(s, [p.id])).get(p.id, 0)
state = (await checklist.statuses(s, [p.id])).get(p.id)
fields = await projects.fields(s, p.id)
return {
**_row(p, lots, docs, pending, state),
"legal_regime": p.legal_regime,
"agency": p.agency,
"procurement_method": p.procurement_method,
"fields": await projects.fields(s, p.id),
"key_dates": await checklist.key_dates(s, p.id),
"fields": fields,
"key_dates": _key_dates(await checklist.key_dates(s, p.id), fields),
"created_by": {"id": p.created_by, "name": ctx.actor_name},
"created_at": p.created_at,
}
def _key_dates(dates: list[dict[str, Any]], fields: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""投标截止时间人工改过时,时间线上的「投标截止」用改后的值,标「已修改」(project-info-plan §2.1)。"""
f = next((x for x in fields if x["key"] == "deadline_at" and x["source"] == "human"), None)
if f is None:
return dates
rest = [d for d in dates if d.get("kind") != "bid_deadline"]
edited = {"item_id": None, "label": "投标截止", "kind": "bid_deadline", "at": f["value"], "edited": True}
return sorted([*rest, edited], key=lambda d: str(d["at"]))
def _row(
p: Project, lots: list[str], docs: dict[str, Any], pending: int, checklist_status: str | None
) -> dict[str, Any]:

View File

@@ -122,6 +122,11 @@ async def get_report(s: AsyncSession, report_id: UUID) -> Report:
return r
def _deadline(run: CheckRun) -> datetime | None:
at = ((run.coverage or {}).get("basis") or {}).get("deadline_at", {}).get("value")
return datetime.fromisoformat(at) if at else None
async def data(s: AsyncSession, ctx: TenantContext, job: ReportJob) -> dict[str, Any]:
"""报告要的数据:都从查标已有的接口函数取(检查批次、问题、需人工核对),和查标页看到的一致。"""
run = await checks.get_run(s, job.check_run_id)
@@ -146,7 +151,8 @@ async def data(s: AsyncSession, ctx: TenantContext, job: ReportJob) -> dict[str,
"purchaser": p.purchaser,
"legal_regime": run.legal_regime,
"procurement_method": p.procurement_method,
"deadline_at": p.deadline_at,
# 报告按检查时用的截止时间写(PI5),和问题明细一致;早于这项功能的检查没有记,用项目当前的值
"deadline_at": _deadline(run) or p.deadline_at,
"lots": [f"采购包 {lots[x]}" for x in run.lot_ids if lots.get(x)],
},
"run": {

View File

@@ -0,0 +1,160 @@
"""项目基本信息修改和分包(FR-M2-03、04,project-info-plan):人工值优先、撤销恢复 AI 值、校验、只读、留痕;
分包增删改、默认包建回、「不分包」、删包时条目归通用、选包;清单按所投的包筛选;检查记下当时的项目依据(PI5)。"""
from __future__ import annotations
from typing import Any
from uuid import UUID
from sqlalchemy import select
from tests.conftest import User, project_with_tender, run_parse
from tests.test_check_flow import run_check, upload_bid
async def audit_actions(u: User) -> list[str]:
from bid_doctor.audit.models import AuditEvent
from bid_doctor.core.db import TenantContext, tenant_tx
ctx = TenantContext(tenant_id=UUID(u.tenant_id), user_id=UUID(int=0))
async with tenant_tx(ctx) as s:
return list((await s.execute(select(AuditEvent.action).order_by(AuditEvent.created_at))).scalars())
def field(detail: dict[str, Any], key: str) -> dict[str, Any] | None:
return next((f for f in detail["fields"] if f["key"] == key), None)
async def test_fields_human_value_reset_and_validation(user: User) -> None:
project_id, tender_id = await project_with_tender(user)
assert (await run_parse(user, tender_id))["status"] == "succeeded"
before = (await user.get(f"/projects/{project_id}")).json()
r = await user.put(f"/projects/{project_id}/fields/deadline_at", json={"value": "2026-12-01T09:30:00+08:00"})
assert r.status_code == 200, r.text
f = r.json()
assert f["source"] == "human" and f["value"] == "2026-12-01T09:30:00+08:00" and f["updated_by"]["name"] == "张工"
detail = (await user.get(f"/projects/{project_id}")).json()
assert detail["deadline_at"].startswith("2026-12-01T01:30") # project 表上冗余的列跟着改
(bid,) = [d for d in detail["key_dates"] if d["label"] == "投标截止"]
assert bid["edited"] and bid["item_id"] is None and bid["at"] == "2026-12-01T09:30:00+08:00"
r = await user.put(f"/projects/{project_id}/fields/ceiling_total", json={"value": "286万"})
assert r.json()["value"] == "2860000.00" # PI7:按元存
r = await user.put(f"/projects/{project_id}/fields/accept_consortium", json={"value": False})
assert r.json() == {**r.json(), "value": False, "source": "human"}
r = await user.put(f"/projects/{project_id}/fields/name", json={"value": " 某市政务云扩容项目 "})
assert (await user.get(f"/projects/{project_id}")).json()["name"] == "某市政务云扩容项目"
for key, value in (
("name", " "),
("ceiling_total", "-1"),
("ceiling_total", "两百万"),
("deadline_at", "明天"),
("legal_regime", "civil"),
("accept_imported", "是"),
):
r = await user.put(f"/projects/{project_id}/fields/{key}", json={"value": value})
assert r.status_code == 422, (key, value, r.text)
# 撤销人工值:有 AI 值的恢复 AI 值,冗余的列也恢复;名称没有 AI 值时保持现在的名称
await user.put(f"/projects/{project_id}/fields/legal_regime", json={"value": "gov_procurement"})
assert (await user.get(f"/projects/{project_id}")).json()["legal_regime"] == "gov_procurement"
r = await user.put(f"/projects/{project_id}/fields/legal_regime", json={"value": None})
assert r.json()["source"] == "ai" and r.json()["value"] == "tendering"
assert (await user.get(f"/projects/{project_id}")).json()["legal_regime"] == "tendering"
r = await user.put(f"/projects/{project_id}/fields/name", json={"value": None})
assert r.json()["source"] == "none"
assert (await user.get(f"/projects/{project_id}")).json()["name"] == "某市政务云扩容项目"
r = await user.put(f"/projects/{project_id}/fields/deadline_at", json={"value": None})
after = (await user.get(f"/projects/{project_id}")).json()
assert after["deadline_at"] == before["deadline_at"]
assert not any(d.get("edited") for d in after["key_dates"])
assert (await audit_actions(user)).count("project_field.set") == 8
# 已递交的项目只读
assert (await user.post(f"/projects/{project_id}/transitions", json={"action": "submit"})).status_code == 200
r = await user.put(f"/projects/{project_id}/fields/code", json={"value": "X-1"})
assert r.status_code == 409 and r.json()["code"] == "project_read_only"
async def test_lots_default_clear_and_checklist_scope(user: User) -> None:
project_id, tender_id = await project_with_tender(user)
await run_parse(user, tender_id)
(default,) = (await user.get(f"/projects/{project_id}/lots")).json()["items"]
assert default["is_default"]
r = await user.post(
f"/projects/{project_id}/lots", json={"lot_no": "1", "name": "核心网络", "ceiling_amount": "120万"}
)
assert r.status_code == 201, r.text
lot1 = r.json()
assert lot1["ceiling_amount"] == "1200000.00" and lot1["origin"] == "human"
lot2 = (await user.post(f"/projects/{project_id}/lots", json={"lot_no": "2", "name": "机房配套"})).json()
lots = (await user.get(f"/projects/{project_id}/lots")).json()["items"]
assert [x["lot_no"] for x in lots] == ["1", "2"] # 手工分包:默认包不要了
assert (await user.post(f"/projects/{project_id}/lots", json={"lot_no": "1"})).status_code == 422
assert (await user.patch(f"/lots/{lot2['id']}", json={"lot_no": "1"})).status_code == 422
r = await user.patch(f"/lots/{lot2['id']}", json={"budget_amount": "80,000"})
assert r.json()["budget_amount"] == "80000.00"
# 条目归属:包 2 的条目不在所投的包里时不列出
created = []
for title, lot in (("包1:交换机须支持硬件BFD", lot1["id"]), ("包2:精密空调制冷量不低于40kW", lot2["id"])):
r = await user.post(
f"/projects/{project_id}/checklist/items",
json={"categories": ["tech_param"], "title": title, "lot_id": lot},
)
assert r.status_code == 201, r.text
created.append(r.json()["id"])
everything = (await user.get(f"/projects/{project_id}/checklist/items", params={"lot_scope": "all"})).json()
assert (await user.put(f"/projects/{project_id}/bidding-lots", json={"lot_ids": []})).status_code == 422
assert (
await user.put(f"/projects/{project_id}/bidding-lots", json={"lot_ids": [default["id"]]})
).status_code == 422
r = await user.put(f"/projects/{project_id}/bidding-lots", json={"lot_ids": [lot1["id"]]})
assert [x["is_bidding"] for x in r.json()["items"]] == [True, False]
shown = (await user.get(f"/projects/{project_id}/checklist/items")).json()
assert shown["hidden_count"] == 1 and len(shown["items"]) == len(everything["items"]) - 1
assert created[1] not in {i["id"] for i in shown["items"]}
assert (await user.get(f"/projects/{project_id}")).json()["bidding_lots"] == ["1"]
# 删包:原属这个包的条目归为通用(PI3)
assert (await user.delete(f"/lots/{lot2['id']}")).status_code == 204
item = (await user.get(f"/checklist-items/{created[1]}")).json()
assert item["lot_id"] is None
# 删掉最后一个包:回到不分包(PI2),条目都列出
assert (await user.delete(f"/lots/{lot1['id']}")).status_code == 204
(back,) = (await user.get(f"/projects/{project_id}/lots")).json()["items"]
assert back["is_default"]
assert (await user.get(f"/projects/{project_id}/checklist/items")).json()["hidden_count"] == 0
# 一键「不分包」
for no in ("A", "B", "C"):
await user.post(f"/projects/{project_id}/lots", json={"lot_no": no})
r = await user.post(f"/projects/{project_id}/lots/clear")
assert [x["is_default"] for x in r.json()["items"]] == [True]
actions = await audit_actions(user)
assert {"lot.create", "lot.update", "lot.delete", "lot.clear", "project.bidding_lots"} <= set(actions)
async def test_check_records_basis_and_flags_changes(user: User) -> None:
project_id, tender_id = await project_with_tender(user)
await run_parse(user, tender_id)
await user.put(f"/projects/{project_id}/fields/deadline_at", json={"value": "2026-12-01T09:30:00+08:00"})
bid = await upload_bid(user, project_id, "bid-mini-v1.pdf")
run = await run_check(user, project_id, [bid["id"]])
basis = run["coverage"]["basis"]
assert basis["deadline_at"] == {"value": "2026-12-01T01:30:00+00:00", "source": "human"}
assert basis["lots"] == [] and not run["basis_changed"]
await user.put(f"/projects/{project_id}/fields/deadline_at", json={"value": "2026-12-08T09:30:00+08:00"})
assert (await user.get(f"/check-runs/{run['id']}")).json()["basis_changed"]
# 报告按检查时的截止时间写
from bid_doctor.report.service import _deadline # pyright: ignore[reportPrivateUsage]
class Run:
coverage = run["coverage"]
assert _deadline(Run()).isoformat() == "2026-12-01T01:30:00+00:00" # type: ignore[arg-type]