diff --git a/.project-docs/30-worklog/tasks/20260923-wechat-receipt-template-01a0858b.md b/.project-docs/30-worklog/tasks/20260923-wechat-receipt-template-01a0858b.md new file mode 100644 index 0000000..574512e --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260923-wechat-receipt-template-01a0858b.md @@ -0,0 +1,31 @@ +# 微信三段式成功回执 + +## Scope / 授权 + +- 用户确认采用“📢 本次结果与团队信息 → 📌 当前安排 → 📋 系统记录”格式,日期采用 YYYY/MM/DD,时间采用 YYYY/MM/DD HH:mm。 +- 标题随操作变化,已安排项展示全部当前明细,未安排保持单行;名单/修改/文件展示最近记录,散拼子单分别列出。 +- 保持当前账号隔离、已验证记录、历史快照、附件与执行行为;不部署、重启、更新插件或真实外发。单线程实施。 + +## Worktree Gate + +- Branch: main;Worktree: /Users/andy/IdeaProjects/LWLT-AIBOT;Base: a7c9cc358a7a949da409e056dd7d22caf1c204ba。 +- 唯一 worktree;既有未跟踪 `.idea/` 和 `dist/ltjt-order-assistant-0.5.189.zip` 保留。 +- 源码无既有改动,Overlap: Clear;Feature 模式,仅更新本任务项目记忆。 + +## Progress + +- 完成:带团队进度快照的成功回执采用三段式;新订单字段顺序与用户示例一致,其他操作动态显示成功标题及本次结果。 +- 已安排项目逐条展开,未安排项目紧凑排列;名单、修改、文件显示最近记录;散拼子单继续分开,保留本次子单号和导入人数。 +- 日期按 YYYY/MM/DD 显示,记录时间使用北京时间。缺失的客户/产品实际名称仍区分为搜索条件,不推断缺失事实。 +- 只修改文本生成和对应文档/既有断言,不修改账本、迁移、账号隔离、ERP 执行、附件内容或投递行为。无进度快照的旧回执保留兼容格式。 +- 未部署或重启,未更新插件,未进行真实 ERP 写入或消息发送;应用更新后的控制面即可生效,无新增迁移。 + +## Validation / Promotion Candidates + +- `node --run check`、`node --run build`:通过。 +- `node --run test:control-plane`:293/293 通过。 +- `node --run test:legacy`:在源码等价的临时干净快照中 439/439 通过;测试使用临时本地 HTTP 监听,不连接真实 ERP。 +- `node --run check:repo`:原工作区 8/10,两个失败为既有 `.idea/`、`.DS_Store` 与 09/14 的历史 `.log` 残留;这些文件均保留。排除无关本地残留的源码快照 10/10 通过,含最终文档再次检查。 +- `git diff --check`:通过。文本断言已适配新格式;PostgreSQL smoke 工具只同步预期文案,本次未连接数据库执行。 +- 用纯文本渲染实际检查新订单、酒店安排和子单名单三种脱敏样例,见[验证与样例](../../../archive/evidence/2026-09-23/wechat-receipt-template-01a0858b/README.md)。未在真实微信外发验证。 +- Promotion Candidates:三段式回执与紧凑状态排版可在后续 Integration 中纳入 canonical 项目记忆;Feature 模式未改共享项目记忆。 diff --git a/agent设计规范/agentbus-reply-contract.md b/agent设计规范/agentbus-reply-contract.md index 364f8f4..b1186c6 100644 --- a/agent设计规范/agentbus-reply-contract.md +++ b/agent设计规范/agentbus-reply-contract.md @@ -56,14 +56,14 @@ | action | 用户回执 | 必须包含 | |---|---|---| -| `team_order_create` | 新订单成功 | 团号 | +| `team_order_create` | 新订单创建成功 | 团号 | | `team_order_batch_create` | 批量下单成功 | 全部团号,不只第一条 | | `shared_plan_create` | 散拼计划创建成功 | 全部母团团号 | | `shared_child_order_create` | 新子单成功 | 子单号;有母团号时可同时返回母团号 | | `shared_child_order_batch_create` | 批量新增子单成功 | 每个已确认子单号及对应母团号 | | `arrangement_guide` | 导游安排成功/清除成功 | 团号、导游或清除结果 | | `arrangement_vehicle` | 用车安排成功/清除成功 | 团号、用车日期、数量、车辆/项目 | -| `arrangement_hotel` | 客户酒店模板或清除成功 | 团号、入住/退房、晚数、房型/房数 | +| `arrangement_hotel` | 酒店安排成功/变更成功/清除成功 | 团号、入住/退房、晚数、房型/房数;无进度快照的历史回执兼容原双语模板 | | `arrangement_transport` | 大交通安排成功/清除成功 | 团号、交通日期、数量、项目 | | `arrangement_other` | 其他/备案安排成功/清除成功 | 团号、业务日期、项目/数量(有值才返回) | | `order_update_*` | 信息修改成功 | 团号/子单号、已更新业务字段和值 | @@ -76,7 +76,9 @@ ## 全业务团队进度与安排明细 -各业务成功回执在本次操作结果之后追加“本系统团队办理进度(当前账号)”。范围严格限定为同一组织、任务归属账号及完整团号的本系统成功记录,不读取 ERP 外部操作补全进度,不跨账号合并。团号无法确认或身份凭据冲突时只提示暂无法关联,不推断五类安排全部未安排。 +带有控制面团队进度快照的各业务成功回执统一为三个区块:`📢【本次业务结果】`、`📌【当前安排】`、`📋【系统记录】`。范围严格限定为同一组织、任务归属账号及完整团号的本系统成功记录,不读取 ERP 外部操作补全进度,不跨账号合并。团号无法确认或身份凭据冲突时只保留本次结果并提示暂无法关联,不推断五类安排全部未安排。 + +顶部标题随操作变化,例如“新订单创建成功”“酒店安排成功”“酒店变更成功”“名单导入成功”。团队信息按团号、出发日期、客户、产品、订单状态和下单/建计划时间排列,日期统一为 `YYYY/MM/DD`,记录时间按北京时间显示为 `YYYY/MM/DD HH:mm`。缺失字段不猜测;搜索条件仍标为客户/产品条件,不冒充实际选择名称。本次导入人数、修改字段、导出文件及具体子单号继续保留,不能仅展示全团历史而丢失本次结果。 - 独立团展示下单、最近名单导入人数与时间、最近修改、各类文件最近导出和取消/恢复状态。名单按最近成功导入统计,不累加重复导入,不输出游客姓名、证件、电话或名单原文。 - 散拼母团展示建计划、系统已记录子单数量和母团修改/文件;各子单分别展示新增、名单、修改、文件及状态。已记录子单不代表母团全部子单;母团恢复不代表子单已恢复。只有明确的子单—母团成功凭据或当前账号唯一历史关联才能归团。 @@ -88,11 +90,11 @@ 平台保存完整回执。AgentBus 最终文本超过现有 20,000 字符限制时,按行截取并明确提示“完整内容请在平台任务回执中查看”,不静默截断;阶段消息和附件交付规则不变。 -微信回执使用纯文本分段:本次结果标题与正文之间、办理进度与安排明细之间、各子单及各类安排之间留空行。多团号、多文件和多条安排逐条换行,安排字段及记录时间缩进显示;不依赖 Markdown 表格、HTML 或空格对齐列。排版不改变字段值、统计范围或附件内容。 +微信回执使用纯文本分段:三个区块之间、各子单及展开的安排之间留空行。未安排的五类状态紧凑排列,一类一行;已安排项逐条展示资源、项目、日期、数量等。系统记录无内容时为“名单:暂无导入记录”“修改:暂无修改记录”“文件:暂无导出记录”,有内容时展开最近记录。末尾提示“仅统计当前账号通过本系统完成的操作。”多团号、多文件和多条安排逐条换行,安排字段及记录时间缩进显示;不依赖 Markdown 表格、HTML 或空格对齐列。排版不改变字段值、统计范围或附件内容。 -## 酒店安排客户模板 +## 历史酒店安排客户模板兼容 -酒店新增成功且执行结果包含完整酒店字段时,使用以下格式。变量全部由真实回查结果填充;英文和中文日期使用同一组实际日期。 +没有团队进度快照的历史酒店新增回执,在执行结果包含完整酒店字段时仍兼容以下格式;带快照的当前回执使用统一三段式,保留入住、离店、晚数与房型/房数。变量全部由真实回查结果填充;英文和中文日期使用同一组实际日期。 ```text ①NEW BOOKING diff --git a/agent设计规范/business-adaptation-registry.md b/agent设计规范/business-adaptation-registry.md index eb14407..ee40101 100644 --- a/agent设计规范/business-adaptation-registry.md +++ b/agent设计规范/business-adaptation-registry.md @@ -36,7 +36,7 @@ ## 跨业务成功回执 -各业务成功回执追加当前账号、完整团号范围内的团队办理进度及五类当前有效安排明细;散拼子单分别记录,名单只展示最近成功导入人数,文件保留导出事实。该汇总只来源于本系统成功记录,强制删除任务不删除独立业务历史,旧回执保持生成时快照。具体规则见 [AgentBus 用户回复契约](agentbus-reply-contract.md#全业务团队进度与安排明细);实现位于 `control-plane/src/team-progress.ts`、`arrangement-ledger.ts` 与 `task-service.ts`,不改变 ERP 执行或用户指令。 +各业务成功回执按“📢 本次结果与团队信息 → 📌 当前安排 → 📋 系统记录”展示当前账号、完整团号范围内的团队办理进度及五类当前有效安排明细,日期采用 `YYYY/MM/DD`;散拼子单分别记录,名单只展示最近成功导入人数,文件保留导出事实。该汇总只来源于本系统成功记录,强制删除任务不删除独立业务历史,旧回执保持生成时业务快照。具体规则见 [AgentBus 用户回复契约](agentbus-reply-contract.md#全业务团队进度与安排明细);实现位于 `control-plane/src/team-progress.ts`、`arrangement-ledger.ts` 与 `task-service.ts`,不改变 ERP 执行或用户指令。 ## 列表检索与派发结果边界 diff --git a/archive/evidence/2026-09-23/wechat-receipt-template-01a0858b/README.md b/archive/evidence/2026-09-23/wechat-receipt-template-01a0858b/README.md new file mode 100644 index 0000000..9f62076 --- /dev/null +++ b/archive/evidence/2026-09-23/wechat-receipt-template-01a0858b/README.md @@ -0,0 +1,18 @@ +# 微信三段式回执验证(2026-09-23) + +本目录为本次实现的历史验证证据,不作为当前业务规则。当前契约见 [AgentBus 用户回复契约](../../../../agent设计规范/agentbus-reply-contract.md)。 + +## 脱敏渲染样例 + +样例由更新后的纯文本回执渲染器生成,使用合成业务数据,不包含真实名单或客户信息,未向微信发送。 + +- [新订单](receipt-new-order.txt):字段顺序、三个标题、紧凑未安排状态、日期与北京时间。 +- [酒店安排](receipt-hotel.txt):保留本次入住/离店/房型房数,并展开当前酒店安排明细。 +- [子单名单](receipt-child.txt):保留具体子单和本次人数,分别展示子单记录,不把母团状态冒充子单状态。 + +## 验证结论 + +- 类型检查、构建通过;控制面测试 293/293;旧功能回归 439/439。 +- 原工作区仓库治理检查 8/10,失败来自任务开始前已有的 IDE/macOS 文件及旧日志;未删除或移动这些文件。 +- 在保留相同源码、文档和当前发布物的临时快照中,排除上述本地残留后仓库治理检查 10/10;旧功能回归也在该快照运行。 +- 不涉及插件代码、数据库 Schema 或 ERP 行为,无新增迁移。未执行部署、重启、真实 ERP 写入、微信消息发送或数据库 smoke。 diff --git a/archive/evidence/2026-09-23/wechat-receipt-template-01a0858b/receipt-child.txt b/archive/evidence/2026-09-23/wechat-receipt-template-01a0858b/receipt-child.txt new file mode 100644 index 0000000..bca0fd0 --- /dev/null +++ b/archive/evidence/2026-09-23/wechat-receipt-template-01a0858b/receipt-child.txt @@ -0,0 +1,34 @@ +📢【名单导入成功】 + +团号:LW-SAMPLE-A +出发日期:2027/09/19 +客户:示例客户 +产品:示例订房产品 +订单状态:已创建 +建计划时间:2026/09/20 14:57 + +子单号:D123 +本次导入:20人 + +📌【当前安排】 +导游:未安排 +用车:未安排 +酒店:未安排 +大交通:未安排 +其他/备案:未安排 + +📋【系统记录】 +本系统记录子单:1个 +(不代表母团全部子单) + +母团修改:暂无修改记录 +母团文件:暂无导出记录 + +子单 D123:暂无本系统新增记录;状态:未记录 + D123 名单:已导入,最近一次 20 人 + 时间:2026/09/20 14:57 + + D123 修改:暂无修改记录 + D123 文件:暂无导出记录 + +仅统计当前账号通过本系统完成的操作。 diff --git a/archive/evidence/2026-09-23/wechat-receipt-template-01a0858b/receipt-hotel.txt b/archive/evidence/2026-09-23/wechat-receipt-template-01a0858b/receipt-hotel.txt new file mode 100644 index 0000000..fabe69f --- /dev/null +++ b/archive/evidence/2026-09-23/wechat-receipt-template-01a0858b/receipt-hotel.txt @@ -0,0 +1,34 @@ +📢【酒店安排成功】 + +团号:LW-SAMPLE-A +出发日期:2027/09/19 +客户:示例客户 +产品:示例订房产品 +订单状态:已创建 +下单时间:2026/09/20 14:57 + +入住:2027/09/19 +离店:2027/09/20 +晚数:1 晚 +房型/房数:标间 12 间 + +📌【当前安排】 +导游:未安排 +用车:未安排 + +酒店:已安排,共 1 条 + 1. 示例酒店 + 房型:TWN + 入住:2027/09/19 + 离店:2027/09/20 + 房数:12 间 + +大交通:未安排 +其他/备案:未安排 + +📋【系统记录】 +名单:暂无导入记录 +修改:暂无修改记录 +文件:暂无导出记录 + +仅统计当前账号通过本系统完成的操作。 diff --git a/archive/evidence/2026-09-23/wechat-receipt-template-01a0858b/receipt-new-order.txt b/archive/evidence/2026-09-23/wechat-receipt-template-01a0858b/receipt-new-order.txt new file mode 100644 index 0000000..5ce97f9 --- /dev/null +++ b/archive/evidence/2026-09-23/wechat-receipt-template-01a0858b/receipt-new-order.txt @@ -0,0 +1,22 @@ +📢【新订单创建成功】 + +团号:LW-SAMPLE-A +出发日期:2027/09/19 +客户:示例客户 +产品:示例订房产品 +订单状态:已创建 +下单时间:2026/09/20 14:57 + +📌【当前安排】 +导游:未安排 +用车:未安排 +酒店:未安排 +大交通:未安排 +其他/备案:未安排 + +📋【系统记录】 +名单:暂无导入记录 +修改:暂无修改记录 +文件:暂无导出记录 + +仅统计当前账号通过本系统完成的操作。 diff --git a/control-plane/src/reply-contract.ts b/control-plane/src/reply-contract.ts index 22a336e..b43dd83 100644 --- a/control-plane/src/reply-contract.ts +++ b/control-plane/src/reply-contract.ts @@ -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 { 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 } : {}) diff --git a/control-plane/src/team-progress.ts b/control-plane/src/team-progress.ts index f803295..4835354 100644 --- a/control-plane/src/team-progress.ts +++ b/control-plane/src/team-progress.ts @@ -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(); 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 { diff --git a/control-plane/test/team-progress.test.ts b/control-plane/test/team-progress.test.ts index 767e40d..5c0a829 100644 --- a/control-plane/test/team-progress.test.ts +++ b/control-plane/test/team-progress.test.ts @@ -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()=>{ diff --git a/tools/arrangement-ledger-postgres-smoke.mjs b/tools/arrangement-ledger-postgres-smoke.mjs index 40523ab..facdf4c 100644 --- a/tools/arrangement-ledger-postgres-smoke.mjs +++ b/tools/arrangement-ledger-postgres-smoke.mjs @@ -128,7 +128,7 @@ try { for (const count of [20,18]) { const item = await business(ownerA,'passenger_list_import',{passenger_list:{row_count:count,tsv:'PRIVATE ROSTER'}},childRefs); const outcome = await complete(ownerA,item); - assert.match(outcome.important_message.text,new RegExp('D300 名单:已导入,最近一次'+count+'人')); + assert.match(outcome.important_message.text,new RegExp('D300 名单:已导入,最近一次 '+count+' 人')); assert.doesNotMatch(outcome.important_message.text,/PRIVATE ROSTER/); if(count===18) await service.hardDeleteTask(ownerA,item.taskId); } @@ -139,7 +139,7 @@ try { const restored = await complete(ownerA,restore); assert.match(restored.important_message.text,/订单状态:已恢复/); assert.match(restored.important_message.text,/子单 D300.*已取消/); - assert.match(restored.important_message.text,/最近一次18人/,'latest roster survives deletion'); + assert.match(restored.important_message.text,/最近一次 18 人/,'latest roster survives deletion'); assert.match((await service.getTask(org,plan.taskId,ownerA)).important_message.text,/本系统记录子单:0个/,'old snapshot stays frozen'); assert.equal((await progress.history(pool,ownerB,'LW-PROGRESS')).length,0); assert.equal((await progress.history(pool,{...ownerA,organizationId:otherOrg},'LW-PROGRESS')).length,0);