feat(project): 项目基本信息修改和分包选择(FR-M2-03、04)
- 字段修改:人工值优先、撤销恢复 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:
@@ -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(额度流水、套餐开通、数据删除、运营后台)。
|
||||
|
||||
166
apps/web/src/components/FieldDialog.vue
Normal file
166
apps/web/src/components/FieldDialog.vue
Normal 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>
|
||||
@@ -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="如「提供营业执照副本复印件并加盖公章」" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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) };
|
||||
|
||||
@@ -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) } } })),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
369
apps/web/src/pages/LotsPage.vue
Normal file
369
apps/web/src/pages/LotsPage.vue
Normal 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>
|
||||
267
apps/web/src/pages/OverviewPage.vue
Normal file
267
apps/web/src/pages/OverviewPage.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 步) | — |
|
||||
| 通知与事件 | 事件流(含检查进度)、通知列表、反馈 | 标为已读 |
|
||||
|
||||
@@ -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
|
||||
|
||||
128
docs/architecture/project-info-plan.md
Normal file
128
docs/architecture/project-info-plan.md
Normal 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、一键改回不分包。
|
||||
@@ -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。
|
||||
|
||||
80
packages/api-client/src/schema.d.ts
vendored
80
packages/api-client/src/schema.d.ts
vendored
@@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
293
server/src/bid_doctor/project/edit.py
Normal file
293
server/src/bid_doctor/project/edit.py
Normal 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}
|
||||
)
|
||||
@@ -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"):
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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": {
|
||||
|
||||
160
server/tests/test_project_info.py
Normal file
160
server/tests/test_project_info.py
Normal 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]
|
||||
Reference in New Issue
Block a user