落地报价模板 AI 解析与对象存储,并增加回复超时系统设置页。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-09-18 17:42:46 +08:00
co-authored by Cursor
parent 982b3a2317
commit 527050bbf9
41 changed files with 4965 additions and 426 deletions
@@ -1,7 +1,6 @@
/**
* 报价模板预览用:合同样例事实 + 字段中文名。
* 与 contracts/quote-template-mapping-v1.json 的 samplePreviewFacts / systemFields 对齐。
* 仅用于管理端「用户端预览」弹窗回填一览;金额不由模型发明。
* 报价模板预览:Excel 样式网格(上传原文),辅以映射摘要。
* 主数据来自 /previewSheet;mappingJson 仅用于未映射必填提示。
*/
/** 系统字段 code → 中文标签 */
@@ -30,178 +29,82 @@ export const FIELD_LABELS: Record<string, string> = {
total_amount: "报价合计",
};
/** 弹窗优先展示的字段顺序(接近原型) */
export const PREVIEW_FIELD_ORDER = [
"customer_name",
"container_type_qty",
"pol",
"pod",
"origin",
"destination",
"sales_name",
"commodity",
"fcl_or_lcl",
"trade_terms",
"contact_name",
"contact_phone",
"quantity",
"pieces",
"weight_kg",
"volume_cbm",
"cargo_ready_date",
"currency",
] as const;
/** 与主账 previewSample 同源的合同样例事实 */
export const SAMPLE_PREVIEW_FACTS: Record<string, any> = {
customer_name: "ABC Trading Co.",
contact_name: "张三",
contact_phone: "13800000000",
quote_no: "QT-202607020001",
quote_date: "2026-07-02",
valid_until: "2026-07-05",
currency: "USD",
sales_name: "张敏",
pol: "CNSHA",
pod: "USLAX",
origin: "深圳",
destination: "洛杉矶",
commodity: "通用货物",
quantity: "2",
container_type_qty: "40HQ × 2",
fcl_or_lcl: "FCL",
trade_terms: "FOB",
cargo_ready_date: "2026-07-10",
pieces: "10",
weight_kg: "1200",
volume_cbm: "8.5",
total_amount: "3680.00",
fee_rows: [
{ name: "O/F 海运费", amount: "2900", remark: "" },
{ name: "THC + DOC + SEAL", amount: "780", remark: "" },
],
export type SheetPreviewCell = {
addr: string;
text: string;
yellow: boolean;
mappedCode?: string;
mappedLabel?: string;
};
export type PreviewFeeRow = { name: string; amount: string; remark?: string };
export type PreviewFieldRow = {
code: string;
label: string;
value: string;
cell: string;
export type SheetPreviewRow = {
rowNo: number;
cells: SheetPreviewCell[];
};
export type PreviewViewModel = {
loading: boolean;
error: string;
quoteNo: string;
quoteDate: string;
currency: string;
totalAmount: string;
validUntil: string;
/** 航线摘要:有 pol/pod 或 origin/destination 时拼出 */
routeText: string;
fields: PreviewFieldRow[];
fees: PreviewFeeRow[];
sheet: string;
fileName: string;
colHeaders: string[];
rows: SheetPreviewRow[];
mappedCount: number;
yellowCount: number;
unmappedLabels: string[];
unmappedRequired: string[];
};
function parseMapping(raw: unknown): { fields: Record<string, string>; unmapped: string[] } {
if (!raw) return { fields: {}, unmapped: [] };
let obj: any = raw;
if (typeof raw === "string") {
function parseUnmapped(mappingJson: unknown): string[] {
if (!mappingJson) return [];
let obj: any = mappingJson;
if (typeof mappingJson === "string") {
try {
obj = JSON.parse(raw);
obj = JSON.parse(mappingJson);
} catch {
return { fields: {}, unmapped: [] };
return [];
}
}
const fields: Record<string, string> = {};
const src = obj?.fields || {};
if (src && typeof src === "object") {
for (const [k, v] of Object.entries(src)) {
if (k === "fee_rows") continue;
const cell = String(v ?? "").trim();
if (cell) fields[k] = cell;
}
}
const unmapped = Array.isArray(obj?.unmappedRequired)
? obj.unmappedRequired.map((x: any) => String(x))
: [];
return { fields, unmapped };
return Array.isArray(obj?.unmappedRequired) ? obj.unmappedRequired.map((x: any) => String(x)) : [];
}
/**
* 根据 mappingJson 与样例事实组装弹窗视图。
* 只展示「已映射」字段的样例值;费用行在有 fee_rows 映射或样例费用时展示。
*/
export function buildPreviewViewModel(mappingJson: unknown, pointCountHint = 0): PreviewViewModel {
const { fields: mapped, unmapped } = parseMapping(mappingJson);
const facts = SAMPLE_PREVIEW_FACTS;
const fieldRows: PreviewFieldRow[] = [];
const ordered = [
...PREVIEW_FIELD_ORDER.filter((c) => mapped[c]),
...Object.keys(mapped).filter((c) => !(PREVIEW_FIELD_ORDER as readonly string[]).includes(c)),
];
// 航线合并展示,避免 grid 里重复 pol/pod
const hasRoute = Boolean(mapped.pol || mapped.pod || mapped.origin || mapped.destination);
let routeText = "";
if (hasRoute) {
if (mapped.pol || mapped.pod) {
routeText = `${facts.pol || "—"} → ${facts.pod || "—"}`;
} else {
routeText = `${facts.origin || "—"} → ${facts.destination || "—"}`;
}
}
for (const code of ordered) {
if (code === "pol" || code === "pod" || code === "origin" || code === "destination") {
continue;
}
const rawVal = facts[code];
if (rawVal == null || rawVal === "") continue;
fieldRows.push({
code,
label: FIELD_LABELS[code] || code,
value: String(rawVal),
cell: mapped[code],
});
}
if (routeText) {
fieldRows.splice(Math.min(2, fieldRows.length), 0, {
code: "route",
label: "航线",
value: routeText,
cell: [mapped.pol, mapped.pod, mapped.origin, mapped.destination].filter(Boolean).join(" / "),
});
}
const fees: PreviewFeeRow[] = Array.isArray(facts.fee_rows)
? facts.fee_rows.map((r: any) => ({
name: String(r.name ?? ""),
amount: String(r.amount ?? ""),
remark: String(r.remark ?? ""),
}))
/** 把主账 previewSheet 结果转成弹窗视图 */
export function buildSheetPreviewViewModel(sheetRes: any, mappingJson?: unknown): PreviewViewModel {
const colHeaders = Array.isArray(sheetRes?.colHeaders)
? sheetRes.colHeaders.map((x: any) => String(x))
: [];
const currency = String(facts.currency || "USD");
const total = String(facts.total_amount || "");
const rowsRaw = Array.isArray(sheetRes?.rows) ? sheetRes.rows : [];
const rows: SheetPreviewRow[] = rowsRaw.map((r: any) => ({
rowNo: Number(r.rowNo || 0),
cells: Array.isArray(r.cells)
? r.cells.map((c: any) => ({
addr: String(c.addr || ""),
text: String(c.text || ""),
yellow: Boolean(c.yellow),
mappedCode: c.mappedCode ? String(c.mappedCode) : undefined,
mappedLabel: c.mappedLabel ? String(c.mappedLabel) : undefined,
}))
: [],
}));
let yellowCount = 0;
let mappedHit = 0;
for (const row of rows) {
for (const c of row.cells) {
if (c.yellow) yellowCount += 1;
if (c.mappedCode) mappedHit += 1;
}
}
const unmapped = parseUnmapped(mappingJson);
return {
loading: false,
error: "",
quoteNo: String(facts.quote_no || ""),
quoteDate: String(facts.quote_date || ""),
currency,
totalAmount: total,
validUntil: String(facts.valid_until || ""),
routeText,
fields: fieldRows,
fees,
mappedCount: Object.keys(mapped).length || pointCountHint,
error: String(sheetRes?.error || ""),
sheet: String(sheetRes?.sheet || ""),
fileName: String(sheetRes?.fileName || ""),
colHeaders,
rows,
mappedCount: Number(sheetRes?.mappedCount || mappedHit || 0),
yellowCount,
unmappedLabels: unmapped.map((c) => FIELD_LABELS[c] || c),
unmappedRequired: unmapped,
};
}
@@ -210,15 +113,18 @@ export function emptyPreviewViewModel(): PreviewViewModel {
return {
loading: false,
error: "",
quoteNo: "",
quoteDate: "",
currency: "USD",
totalAmount: "",
validUntil: "",
routeText: "",
fields: [],
fees: [],
sheet: "",
fileName: "",
colHeaders: [],
rows: [],
mappedCount: 0,
yellowCount: 0,
unmappedLabels: [],
unmappedRequired: [],
};
}
/** @deprecated 兼容旧名:请用 buildSheetPreviewViewModel */
export function buildPreviewViewModel(_mappingJson: unknown, _pointCountHint = 0): PreviewViewModel {
return emptyPreviewViewModel();
}