回执再次优化

This commit is contained in:
andy
2026-09-23 12:02:56 +08:00
parent a7c9cc358a
commit 4cfbefffe8
11 changed files with 230 additions and 45 deletions

View File

@@ -1,6 +1,6 @@
import { normalizeTaskArtifactFileName } from './artifact-name.js';
import { arrangementSummaryText, isArrangementAction } from './arrangement-ledger.js';
import { teamProgressText } from './team-progress.js';
import { receiptDate, teamProgressText } from './team-progress.js';
export const AGENTBUS_REPLY_CONTRACT_VERSION = 'agentbus-user-reply-v1';
@@ -575,15 +575,42 @@ export function buildCustomerSuccessReceipt(
): Record<string, unknown> {
const reply = buildCustomerSuccessReply(baseReceipt, resultValue, operationValue);
if (!reply) return baseReceipt;
const action = operationAction(operationValue, object(resultValue), resultContext(object(resultValue)));
const summaryText = teamProgressText(baseReceipt.team_progress)
|| (isArrangementAction(action) ? arrangementSummaryText(baseReceipt.arrangement_summary) : '');
const result = object(resultValue);
const action = operationAction(operationValue, result, resultContext(result));
const refs = refsFrom(operationValue, result);
const identity = identifierLine(action, baseReceipt, result, refs);
const groups = object(baseReceipt.team_progress).groups;
const keepIdentity = !Array.isArray(groups) || !groups.length || refs.kind === 'shared_child_order'
|| ['shared_child_order_create', 'shared_child_order_batch_create'].includes(action);
let title = action === 'team_order_create' ? '新订单创建成功' : reply.text.split('\n')[0];
const bodyStart = reply.text.indexOf('\n');
let resultText = bodyStart < 0 ? '' : reply.text.slice(bodyStart + 1).trim();
if (!keepIdentity && identity.text && resultText.startsWith(identity.text)) resultText = resultText.slice(identity.text.length).trim();
if (reply.fields.mode === 'update') title = title.replace('安排成功', '变更成功');
// Current snapshot receipts use the same layout for hotels too. Preserve the
// bilingual renderer as the fallback for older receipts without a snapshot.
if (reply.profile === 'hotel-arrangement-lwlt-v1') {
title = '酒店安排成功';
resultText = [keepIdentity ? identity.text : '',
`入住:${receiptDate(reply.fields.in_date)}`, `离店:${receiptDate(reply.fields.out_date)}`,
`晚数:${reply.fields.nights} 晚`, `房型/房数:${reply.fields.room_type} ${reply.fields.room_count} 间`
].filter(Boolean).join('\n');
} else {
// Only reformat date fields generated by this renderer, not names, remarks,
// filenames or identifiers that might themselves contain date-like text.
resultText = resultText.split('\n').map(line => line.replace(
/^((?:入住日期|退房日期|出发日期|离店日期|日期):)(\d{4}-\d{1,2}-\d{1,2})(?: 至 (\d{4}-\d{1,2}-\d{1,2}))?$/u,
(_match, label: string, start: string, end: string | undefined) => `${label}${receiptDate(start)}${end ? ` 至 ${receiptDate(end)}` : ''}`
)).join('\n');
}
const progressText = teamProgressText(baseReceipt.team_progress, { title, resultText });
const legacySummary = !progressText && isArrangementAction(action) ? arrangementSummaryText(baseReceipt.arrangement_summary) : '';
return {
...baseReceipt,
reply: {
contract: reply.contract,
profile: reply.profile,
text: summaryText ? `${reply.text}\n\n${summaryText}` : reply.text,
text: progressText || (legacySummary ? `${reply.text}\n\n${legacySummary}` : reply.text),
fields: reply.fields
},
...(reply.attachments.length ? { reply_attachments: reply.attachments } : {})

View File

@@ -141,63 +141,89 @@ function ordered(events: ProgressEvent[]): ProgressEvent[] {
|| a.execution_id.localeCompare(b.execution_id));
}
function latest(events: ProgressEvent[], match: (e: ProgressEvent) => boolean): ProgressEvent | undefined { return events.filter(match).at(-1); }
export function receiptDate(value: unknown): string {
const source = txt(value), match = source.match(/^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})$/);
return match ? `${match[1]}/${match[2].padStart(2, '0')}/${match[3].padStart(2, '0')}` : source;
}
function displayTime(value: string): string {
return new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit' }).format(new Date(value));
hour: '2-digit', minute: '2-digit' }).format(new Date(value)).replaceAll('-', '/');
}
function arrangementLine(action: string, details: Json): string {
const hotel = action === 'arrangement_hotel';
const fields = [txt(details.resource_name), details.item ? `${hotel ? '房型' : '项目'}:${txt(details.item)}` : '',
details.start_date ? `${hotel ? '入住日期' : '开始日期'}:${txt(details.start_date)}` : details.date ? `日期:${txt(details.date)}` : '',
details.start_date ? `${hotel ? '离店日期' : '结束日期'}:${txt(details.end_date) || '未记录'}` : '',
details.quantity === undefined ? '' : `${hotel ? '房数' : '数量'}:${txt(details.quantity)}${hotel ? '间' : ''}`,
details.start_date ? `${hotel ? '入住' : '开始日期'}:${receiptDate(details.start_date)}` : details.date ? `日期:${receiptDate(details.date)}` : '',
details.start_date ? `${hotel ? '离店' : '结束日期'}:${receiptDate(details.end_date) || '未记录'}` : '',
details.quantity === undefined ? '' : `${hotel ? '房数' : '数量'}:${txt(details.quantity)}${hotel ? ' 间' : ''}`,
details.phone ? `${action === 'arrangement_vehicle' ? '司机电话' : '电话'}:${txt(details.phone)}` : '',
details.vehicle_number ? `车号:${txt(details.vehicle_number)}` : '', details.grade ? `等级:${txt(details.grade)}` : '',
details.filing_number ? `备案号:${txt(details.filing_number)}` : '', details.filing_date ? `备案日期:${txt(details.filing_date)}` : '',
details.filing_number ? `备案号:${txt(details.filing_number)}` : '', details.filing_date ? `备案日期:${receiptDate(details.filing_date)}` : '',
details.filing_entry_port ? `入境口岸:${txt(details.filing_entry_port)}` : '', details.filing_exit_port ? `出境口岸:${txt(details.filing_exit_port)}` : '',
details.remark ? `备注:${txt(details.remark)}` : ''].filter(Boolean);
if (!details.resource_name) fields.unshift('资源名称未记录');
return fields.length > 1 || details.resource_name ? fields.join('\n ') : '已安排,明细未记录';
}
export function teamProgressText(value: unknown): string {
export function teamProgressText(value: unknown, presentation: { title?: string; resultText?: string } = {}): string {
const snap = value as ProgressSnapshot;
if (!snap || snap.version !== 1 || snap.scope !== 'current_account' || !Array.isArray(snap.groups)) return '';
const lines = ['本系统团队办理进度(当前账号)'];
const lines: string[] = presentation.title ? [`📢【${presentation.title}】`] : [];
if (presentation.resultText && snap.groups.length !== 1) lines.push('', presentation.resultText);
for (const entry of snap.groups) {
const events = ordered(entry.events), team = events.filter(e => e.subject_kind === 'team');
lines.push(`\n团号:${entry.group_number}`);
const facts = Object.assign({}, ...team.map(e => e.details)) as Json;
for (const [key, label] of [['customer', '客户'], ['product', '产品'], ['departure_date', '出发日期']] as const)
if (txt(facts.departure_date)) lines.push(`出发日期:${receiptDate(facts.departure_date)}`);
for (const [key, label] of [['customer', '客户'], ['product', '产品']] as const)
if (txt(facts[key])) lines.push(`${label}:${txt(facts[key])}`);
if (!facts.customer && facts.customer_condition) lines.push(`客户条件:${txt(facts.customer_condition)}`);
if (!facts.product && facts.product_condition) lines.push(`产品条件:${txt(facts.product_condition)}`);
const status = latest(team, e => e.action === 'order_cancel' || e.action === 'order_restore'
|| ['team_order_create','team_order_batch_create','shared_plan_create'].includes(e.action));
lines.push('', '【办理进度】', `订单状态:${status ? txt(status.details.order_status) || '已创建' : '暂无本系统状态记录'}`);
lines.push(`订单状态:${status ? txt(status.details.order_status) || '已创建' : '未记录'}`);
const subjects = [...new Set(events.filter(e => e.subject_kind === 'child').map(e => e.subject_number))].sort();
const shared = facts.team_kind === 'shared_plan' || subjects.length > 0;
const creation = latest(team, e => ['team_order_create','team_order_batch_create','shared_plan_create'].includes(e.action));
lines.push(`${shared ? '建计划' : '下单'}:${creation ? `已完成\n 时间:${displayTime(creation.effective_at)}` : '暂无本系统成功记录'}`);
lines.push(`${shared ? '建计划' : '下单'}时间:${creation ? displayTime(creation.effective_at) : '未记录'}`);
if (presentation.resultText && snap.groups.length === 1) lines.push('', presentation.resultText);
lines.push('', '📌【当前安排】');
const actions = Object.entries(ARRANGEMENT_LABELS);
for (let index = 0; index < actions.length; index++) {
const [action, label] = actions[index];
const items = entry.arrangements.filter(a => a.action === action);
const previousHasDetails = index > 0 && entry.arrangements.some(a => a.action === actions[index - 1][0]);
if (index > 0 && (items.length || previousHasDetails)) lines.push('');
lines.push(`${label}:${items.length ? `已安排,共 ${items.length} 条` : '未安排'}`);
items.forEach((a,i) => {
if (i > 0) lines.push('');
lines.push(` ${i+1}. ${arrangementLine(action, a.details)}`);
});
}
lines.push('', '📋【系统记录】');
const subjectProgress = (subset: ProgressEvent[], label: string, parent = false): void => {
const roster = latest(subset, e => e.action === 'passenger_list_import');
if (!parent) lines.push('', `${label}名单:${roster ? `已导入,最近一次${roster.details.roster_count === undefined ? '人数未记录' : `${roster.details.roster_count}人`}\n 时间:${displayTime(roster.effective_at)}` : '暂无本系统导入记录'}`);
if (!parent) lines.push(`${label}名单:${roster ? `已导入,最近一次${roster.details.roster_count === undefined ? '人数未记录' : ` ${roster.details.roster_count} 人`}\n 时间:${displayTime(roster.effective_at)}` : '暂无导入记录'}`);
const update = latest(subset, e => e.action.startsWith('order_update_'));
lines.push('', `${label}最近修改:${update ? `\n ${list(update.details.updates).map(u => `${txt(u.label)}${u.mode === 'append' ? '追加' : '改为'}${txt(u.value)}`).join('\n ') || '修改成功,明细未记录'}\n 时间:${displayTime(update.effective_at)}` : '暂无本系统修改记录'}`);
if (roster || update) lines.push('');
lines.push(`${label}修改:${update ? `\n ${list(update.details.updates).map(u => `${txt(u.label)}${u.mode === 'append' ? '追加' : '改为'}${u.label === '出发日期' ? receiptDate(u.value) : txt(u.value)}`).join('\n ') || '修改成功,明细未记录'}\n 时间:${displayTime(update.effective_at)}` : '暂无修改记录'}`);
const exports = new Map<string, ProgressEvent>();
for (const e of subset) if (e.action === 'confirmation_export') for (const f of strings(e.details.files)) exports.set(f, e);
const unknownExport = latest(subset, e => e.action === 'confirmation_export' && !strings(e.details.files).length);
if (!exports.size && !unknownExport) lines.push('', `${label}文件:暂无本系统导出记录`);
if (unknownExport) lines.push('', `${label}文件:已导出,文件类型未记录\n 时间:${displayTime(unknownExport.effective_at)}`);
if (update || exports.size || unknownExport) lines.push('');
if (!exports.size && !unknownExport) lines.push(`${label}文件:暂无导出记录`);
else lines.push(`${label}文件:`);
if (unknownExport) lines.push(` 已导出,文件类型未记录\n 时间:${displayTime(unknownExport.effective_at)}`);
for (const [file, e] of exports) {
const changed = events.some(change => change.action !== 'confirmation_export' && change.effective_at > e.effective_at
&& (change.subject_kind === 'team' || subset.includes(change) || (file === 'visitor-list' && shared)));
lines.push('', `${label}${parent && file === 'visitor-list' ? '整团游客信息' : FILES[file] || '团队文件'}:已导出\n 时间:${displayTime(e.effective_at)}${changed ? '\n 之后有业务变更,可能需要重新导出' : ''}`);
lines.push(` ${parent && file === 'visitor-list' ? '整团游客信息' : FILES[file] || '团队文件'}:已导出\n 时间:${displayTime(e.effective_at)}${changed ? '\n 之后有业务变更,可能需要重新导出' : ''}`);
}
};
if (!shared) subjectProgress(team, '');
else {
lines.push('', `本系统记录子单:${subjects.length}个`, '(不代表母团全部子单)');
lines.push(`本系统记录子单:${subjects.length}个`, '(不代表母团全部子单)', '');
// Parent modifications and whole-group exports belong to the parent.
subjectProgress(team, '母团', true);
for (const child of subjects) {
@@ -208,19 +234,10 @@ export function teamProgressText(value: unknown): string {
subjectProgress(subset, ` ${child} `);
}
}
lines.push('', '【安排情况与明细】');
for (const [action, label] of Object.entries(ARRANGEMENT_LABELS)) {
const items = entry.arrangements.filter(a => a.action === action);
lines.push('', `${label}:${items.length ? `已安排,共${items.length}条` : '未安排'}`);
items.forEach((a,i) => {
if (i > 0) lines.push('');
lines.push(` ${i+1}. ${arrangementLine(action, a.details)}`);
});
}
}
if (!snap.groups.length || snap.unlinked_subjects.length) lines.push(`\n${snap.unlinked_subjects.length ? `对象:${snap.unlinked_subjects.join('、')}。` : ''}暂无法关联团号,未推断安排状态。`);
lines.push('\n仅统计当前账号通过本系统成功完成的操作。', '未记录不代表 ERP 中没有。', '已安排表示至少一条,不代表全行程已安排齐全。');
return lines.join('\n');
lines.push('\n仅统计当前账号通过本系统完成的操作。');
return lines.join('\n').trim();
}
export class TeamProgressLedger {

View File

@@ -76,9 +76,9 @@ test('native child-create receipts carry the verified parent; missing parents re
test('roster counts use the latest import per subject and never add repeated uploads',()=>{
const events=[ev('passenger_list_import',{details:{roster_count:20}}),ev('passenger_list_import',{time:'2026-09-20T02:00:00Z',details:{roster_count:18}})];
const text=teamProgressText(snapshot(events));assert.match(text,/最近一次18人/);assert.doesNotMatch(text,/38人|20人/);
const text=teamProgressText(snapshot(events));assert.match(text,/最近一次 18 人/);assert.doesNotMatch(text,/38人|20人/);
const late={...events[0],recorded_at:'2026-09-21T00:00:00Z'};
assert.match(teamProgressText(snapshot([events[1],late])),/最近一次18人/);
assert.match(teamProgressText(snapshot([events[1],late])),/最近一次 18 人/);
});
test('shared-plan summaries separate each child and never claim every roster is complete',()=>{
@@ -106,7 +106,7 @@ test('all current arrangement rows render supported details; unknown legacy deta
{action:'arrangement_hotel',details:{}},
{action:'arrangement_transport',details:{resource_name:'票务甲',item:'C91',date:'2026-10-01',quantity:20}},
{action:'arrangement_other',details:{resource_name:'备案项目',filing_number:'ABC',filing_entry_port:'口岸甲',filing_exit_port:'口岸乙',remark:'备注'}}];
const text=teamProgressText(s);for(const term of ['酒店:已安排,共2条','酒店甲','明细未记录','车牌甲','司机电话','备案号:ABC','C91'])assert.ok(text.includes(term),term);
const text=teamProgressText(s);for(const term of ['酒店:已安排,共 2 条','酒店甲','明细未记录','车牌甲','司机电话','备案号:ABC','C91'])assert.ok(text.includes(term),term);
});
test('exports are latest per file type and labelled stale after later changes without deleting records',()=>{
@@ -122,7 +122,7 @@ test('receipts stay frozen and rebuilding never duplicates the progress block',(
const i=input('order_update_independent'),base={success:true,group_number:'LW-TEST-A',team_progress:snapshot([ev('team_order_create')])};
const first=buildCustomerSuccessReceipt(base,i.result,i.operation);
assert.equal(replyTextFromReceipt(buildCustomerSuccessReceipt(first,i.result,i.operation)),replyTextFromReceipt(first));
assert.equal(replyTextFromReceipt(first).match(/本系统团队办理进度/g)?.length,1);
assert.equal(replyTextFromReceipt(first).match(/📌【当前安排】/g)?.length,1);
});
test('encrypted ledger isolates accounts and organizations, deduplicates, and survives task deletion',async()=>{