回执再次优化

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

@@ -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 模式未改共享项目记忆。

View File

@@ -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

View File

@@ -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 执行或用户指令。
## 列表检索与派发结果边界

View File

@@ -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。

View File

@@ -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 文件:暂无导出记录
仅统计当前账号通过本系统完成的操作。

View File

@@ -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 间
大交通:未安排
其他/备案:未安排
📋【系统记录】
名单:暂无导入记录
修改:暂无修改记录
文件:暂无导出记录
仅统计当前账号通过本系统完成的操作。

View File

@@ -0,0 +1,22 @@
📢【新订单创建成功】
团号:LW-SAMPLE-A
出发日期:2027/09/19
客户:示例客户
产品:示例订房产品
订单状态:已创建
下单时间:2026/09/20 14:57
📌【当前安排】
导游:未安排
用车:未安排
酒店:未安排
大交通:未安排
其他/备案:未安排
📋【系统记录】
名单:暂无导入记录
修改:暂无修改记录
文件:暂无导出记录
仅统计当前账号通过本系统完成的操作。

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()=>{

View File

@@ -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);