机器人显示名与站点标题可配;多段联运列表投影;协同群监听对账;工单附件落存;模板与工单后台补强

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-19 09:41:59 +08:00
co-authored by Cursor
parent 277cdc1c28
commit 2b528758b4
119 changed files with 6760 additions and 3516 deletions
@@ -27,6 +27,7 @@ const LAND_LABEL: Record<string, string> = {
/**
* JSON 字符串 / 对象都解开成扁平键值。坏 JSON 当空。
* 协同快照里的车型等也要能展示,调用方自己决定合并顺序。
* segments_json 若已是数组/对象,序列化成字符串留给 parseSegments,禁止直接丢掉。
*/
export function parseFactsMap(raw: unknown): Record<string, string> {
if (raw == null) return {};
@@ -43,13 +44,97 @@ export function parseFactsMap(raw: unknown): Record<string, string> {
for (const key of Object.keys(obj)) {
const val = obj[key];
if (val == null) continue;
if (typeof val === "object") continue;
if (typeof val === "object") {
// 多段 segments_json 常被反序列成数组;保留才能在详情里画出段1/段2。
if (
(key === "segments_json" || key === "segmentsJson") &&
(Array.isArray(val) || typeof val === "object")
) {
try {
out[key] = JSON.stringify(val);
} catch {
/* ignore */
}
}
continue;
}
const text = String(val).trim();
if (text) out[key] = text;
}
return out;
}
/** 列表子工单行:一段的运输方式码 + 展示文案。 */
export type MultiSegmentView = {
index: number;
/** AIR / SEA / LAND */
mode: string;
modeWord: string;
/** 子工单左侧详情:海运 · 上海 → 汉堡 · … */
detailLine: string;
/** 旧列表兼容:段1陆运 北京 → 上海 茶叶 */
summaryLine: string;
};
/**
* 多段各段一行摘要(段1陆运 北京 → 上海)。无段返回空数组。
*/
export function multiSegmentSummaryLines(ticket: any): string[] {
return multiSegmentViews(ticket).map((seg) => seg.summaryLine);
}
/**
* 多段子工单视图:列表父行下灰底子行用。
* 详情串尽量贴近原型「方式 · 路线 · 货量条款」;缺字段就跳过,不编造。
*/
export function multiSegmentViews(ticket: any): MultiSegmentView[] {
const facts = mergeInquiryFacts(ticket);
const segs = parseSegments(facts);
return segs.map((seg) => {
const sf = seg.facts;
const o = pick(sf, "起运港", "起运地", "始发站");
const d = pick(sf, "目的港", "目的地", "目的站");
const g = pick(sf, "品名", "货物品名");
const route = o || d ? `${o || "-"} → ${d || "-"}` : "";
const bits: string[] = [seg.modeWord];
if (route) bits.push(route);
const extras = [
pick(sf, "运输条款", "贸易条款"),
pick(sf, "整柜或拼柜"),
pick(sf, "箱型箱量"),
pick(sf, "包装方式", "包装类型"),
pick(sf, "运输类型"),
pick(sf, "客户"),
volumeWeightText(sf),
pick(sf, "车型/数量", "车型"),
readyDateText(sf),
g ? `品名 ${g}` : "",
].filter(Boolean);
bits.push(...extras);
return {
index: seg.index,
mode: seg.mode,
modeWord: seg.modeWord,
detailLine: bits.join(" · "),
summaryLine: [`段${seg.index}${seg.modeWord}`, route, g].filter(Boolean).join(" "),
};
});
}
function volumeWeightText(sf: Record<string, string>): string {
const vol = pick(sf, "体积");
const w = pick(sf, "毛重", "重量");
if (vol && w) return `${vol} / ${w}`;
if (vol) return vol;
if (w) return w;
return "";
}
function readyDateText(sf: Record<string, string>): string {
const d = pick(sf, "货好时间", "cargo_ready_date");
return d ? `货好 ${d}` : "";
}
/** 询价事实优先,协同只补空键(中港整车车型常在协同里)。 */
export function mergeInquiryFacts(ticket: any): Record<string, string> {
const inquiry = parseFactsMap(ticket?.factsJson ?? ticket?.facts);
@@ -69,6 +154,40 @@ function pick(facts: Record<string, string>, ...keys: string[]): string {
return "";
}
const MODE_WORD: Record<string, string> = { AIR: "空运", SEA: "海运", LAND: "陆运" };
/** 从 facts.segments_json 解出各段;坏 JSON 当没有段。 */
function parseSegments(facts: Record<string, string>): Array<{
index: number;
mode: string;
modeWord: string;
facts: Record<string, string>;
}> {
const raw = (facts.segments_json || facts.segmentsJson || "").trim();
if (!raw) return [];
try {
const arr = JSON.parse(raw);
if (!Array.isArray(arr)) return [];
return arr
.filter((x) => x && typeof x === "object")
.map((seg: any, i: number) => {
const mode = String(seg.mode || "").toUpperCase();
const nested =
seg.facts && typeof seg.facts === "object" && !Array.isArray(seg.facts)
? parseFactsMap(seg.facts)
: {};
return {
index: Number(seg.index) > 0 ? Number(seg.index) : i + 1,
mode,
modeWord: MODE_WORD[mode] || mode || "段",
facts: nested,
};
});
} catch {
return [];
}
}
function dash(v: unknown): string {
const s = (v ?? "").toString().trim();
return s ? s : "-";
@@ -183,8 +302,34 @@ export function buildDemandFields(ticket: any): DemandField[] {
push(fields, "增值服务", added);
}
push(fields, "报价日期", quoteDate);
} else if (line === "MULTI") {
// 多段:按 segments_json 逐段展示,并带整票工单号。
push(fields, "工单号", columnText(ticket?.workOrderNo) || columnText(ticket?.work_order_no));
const segs = parseSegments(facts);
if (segs.length) {
for (const seg of segs) {
const sf = seg.facts;
const o = pick(sf, "起运港", "起运地", "始发站");
const d = pick(sf, "目的港", "目的地", "目的站");
const g = pick(sf, "品名", "货物品名");
push(
fields,
`段${seg.index}${seg.modeWord}`,
[o || d ? `${o || "-"} → ${d || "-"}` : "", g, pick(sf, "件数"), pick(sf, "毛重", "重量"), pick(sf, "体积")]
.filter(Boolean)
.join(" · "),
);
}
} else {
push(fields, "起运 / 目的", origin || dest ? `${origin || "-"} → ${dest || "-"}` : "");
push(fields, "品名", goods);
}
push(fields, "件数", pick(facts, "件数") || columnText(ticket?.pieces));
push(fields, "重量(KG)", pick(facts, "毛重", "重量") || columnText(ticket?.weightKg));
push(fields, "体积(CBM)", pick(facts, "体积") || columnText(ticket?.volumeCbm));
push(fields, "报价日期", quoteDate);
} else {
// 多段或未识别:至少把路线和货量列出来,避免整块空白。
// 未识别:至少把路线和货量列出来,避免整块空白。
push(fields, "起运 / 目的", origin || dest ? `${origin || "-"} → ${dest || "-"}` : "");
push(fields, "品名", goods);
push(fields, "件数", pick(facts, "件数") || columnText(ticket?.pieces));