execl问题修复

This commit is contained in:
andy
2026-09-10 17:30:27 +08:00
parent be7d765018
commit 07b23fce9e
18 changed files with 287 additions and 27 deletions

View File

@@ -0,0 +1,51 @@
# 名单 TSV 末尾空列修复
- Task ID: `20260910-roster-trailing-empty-columns-01a0858b`
- Mode: Feature
- Status: Complete (local implementation and validation)
- Authorization: 用户明确要求修复原始 XLS 名单解析失败;仅本地实现与验证。
## Concurrent Worktree Check
- Branch: `main`
- Worktree: `/Users/andy/IdeaProjects/LWLT-AIBOT`
- Base commit: `be7d765018a7c3e5cdfa4631d962327457cf33bd`
- Existing changes: 未跟踪 `.idea/`,保留且不涉及;忽略的 `.DS_Store` 同样保留。
- Other local worktrees: 无。
- Overlap result: Clear;没有与名单校验相关的既有改动。
## Scope and constraints
- 修复 TSV 校验/转换时整体 `trim()` 删除末尾空字段分隔符的问题。
- 相关执行端存在同源问题,范围包含服务端统一校验、插件派发和原生名单合并;按发布矩阵同步插件 `0.5.173`、平台最低版本、mapping、测试、版本化 ZIP 与清单。
- 保持 13 列、行数、证件类型、序号及领队联系人的现有契约。
- 使用合成数据补回归测试;用户原始 XLS 仅作本地只读复现,不纳入仓库或日志。
- 不部署、重启、操作 ERP 或发送外部通知;不修改数据库或 canonical 项目记忆。
## Findings
- 原始 XLS 经现有转换器和 normalizer 得到 19 条完整的 13 列记录。
- 最后一条备注为空,统一 operation 校验对整段 TSV 调用 `trim()` 后变为 12 列,复现生产错误。
- 插件派发校验及原生名单合并也使用整体 `trim()`;只修服务端会在后续插件阶段再次阻断。
## Verification
- 回归先行:新测试在旧代码上复现解析、派发和合并异常(8 项中 4 失败);实际 12/14 列仍需拒绝的测试也覆盖尾部分隔符不能被静默修正。
- 修复后定向 8/8 通过,覆盖两类名单、19 条记录、尾部一/两列为空、CRLF、覆盖确认、严格列数及领队匹配。
- 用户原始 XLS → 生产 converter/normalizer → Program Parser → 插件 dispatch validator → 本地空白游客位 merge:通过;19 条记录,输出每行均为 13 列,TSV 内容原样保留,原文件 SHA-256 未改变。合并使用本地模拟游客位,未接触真实 ERP。
- `node --run check`:PASS;`node --run build`:PASS。
- `node --run test:control-plane`:194/194 PASS。
- `node --run test:legacy`:290 项全部已验证通过(含本任务新增 3 项)。首次 287 通过;1 项发现 ZIP 多套一层目录,已修正为 manifest 位于 ZIP 根目录并通过完整 10 项 repository-hygiene 检查;另 2 项因沙箱禁止监听 127.0.0.1 失败,获本地测试权限后定向补跑 2/2 PASS。没有连接真实服务。
- `node --run check:repo`:10/10 PASS,验证副本 `/private/tmp/ltjt-roster-verify-uhqh_lyb` 使用当前 tracked 文件及本任务新文件,排除已有 `.idea/`、Finder 文件及秘密,复用依赖。原工作目录的 IDE/Finder 文件保持不动,仍构成 2 项无关 hygiene 问题。
- 新 ZIP 完整 entry set 和全部 20 个文件(包括 README)与源码逐字节一致;清单哈希、旧版归档原始哈希均通过。`git diff --check`:PASS。
- 最终本地复核:实现仅调整 TSV 换行/边界空行处理,未放宽 13 列规则、领队来源、占用游客位覆盖授权或原生保存门禁;其余源码改动为发布版本同步。未使用子 Agent,未声称独立 Agent 审核。
## Outcome and follow-ups
- 服务端统一校验和插件执行链路均已修复;插件为 `0.5.173`,旧版 `0.5.172` 原包与清单已移至日期归档。
- 部署范围:重建并部署包含 `LianSyn-platform/external-agent-client.mjs` 与平台最低版本更新的服务镜像,同时加载新版插件并刷新平台/ERP 页面。数据库、Parser/normalizer 输入契约及原始表格不需要修改。
- 部署、浏览器插件重载、真实 ERP 写入验收由用户后续执行;建议重新提交名单指令并重新上传原始 Excel,不自动重放本次失败任务。
## Promotion Candidates
- Target: canonical current state / 名单实现约束。集成时可登记插件 `0.5.173` 及服务端、插件 TSV 保留末尾空列的修复事实;证据为本任务回归及原始 XLS 只读链路验证。不增加业务能力或修改输入契约,真实 ERP 验收仍待部署方完成。本任务不更新共享索引或集成快照。

View File

@@ -100,7 +100,7 @@ const persistedExtensionResultVersions = new Map();
let taskCreateInProgress = false; let taskCreateInProgress = false;
const AUTO_HANDOFF_RETRY_MS = 30_000; const AUTO_HANDOFF_RETRY_MS = 30_000;
const REQUIRED_EXTENSION_VERSION = '0.5.172'; const REQUIRED_EXTENSION_VERSION = '0.5.173';
const MANUAL_HANDOFF_LABEL = '确认并提交到 ERP 插件'; const MANUAL_HANDOFF_LABEL = '确认并提交到 ERP 插件';
const RETRY_HANDOFF_LABEL = '继续提交到 ERP 插件'; const RETRY_HANDOFF_LABEL = '继续提交到 ERP 插件';
const RECONCILE_LABEL = '只读回查 ERP 现有结果'; const RECONCILE_LABEL = '只读回查 ERP 现有结果';

View File

@@ -1736,15 +1736,20 @@ function passengerCell(row, header) {
return ''; return '';
} }
function normalizePassengerTsvText(text) {
// Tabs represent columns, including empty final fields; trim only boundary newlines.
return String(text || '').replace(/\r\n?/g, '\n').replace(/^\n+|\n+$/g, '');
}
function buildPassengerTsv(value = {}) { function buildPassengerTsv(value = {}) {
if (typeof value.text === 'string' && value.text.trim()) return value.text.trim().replace(/\r\n?/g, '\n'); if (typeof value.text === 'string' && value.text.trim()) return normalizePassengerTsvText(value.text);
const rows = Array.isArray(value.rows) ? value.rows : []; const rows = Array.isArray(value.rows) ? value.rows : [];
if (!rows.length) return ''; if (!rows.length) return '';
return [PASSENGER_HEADERS.join('\t'), ...rows.map((row) => PASSENGER_HEADERS.map((header) => passengerCell(row, header)).join('\t'))].join('\n'); return [PASSENGER_HEADERS.join('\t'), ...rows.map((row) => PASSENGER_HEADERS.map((header) => passengerCell(row, header)).join('\t'))].join('\n');
} }
function validatePassengerLeaderContact(value, text, path, errors) { function validatePassengerLeaderContact(value, text, path, errors) {
const lines = String(text || '').trim().replace(/\r\n?/g, '\n').split('\n').filter((line) => line.length > 0); const lines = normalizePassengerTsvText(text).split('\n').filter((line) => line.length > 0);
const headers = String(lines[0] || '').split('\t').map((header) => header.trim()); const headers = String(lines[0] || '').split('\t').map((header) => header.trim());
if (headers.length !== PASSENGER_HEADERS.length if (headers.length !== PASSENGER_HEADERS.length
|| headers.some((header, index) => header !== PASSENGER_HEADERS[index])) return; || headers.some((header, index) => header !== PASSENGER_HEADERS[index])) return;
@@ -1987,7 +1992,7 @@ function validateAgentPassengerList(value, errors) {
errors.push(`${path}.text 必须包含本次提供的 13 列名单 TSV。`); errors.push(`${path}.text 必须包含本次提供的 13 列名单 TSV。`);
return; return;
} }
const lines = value.text.trim().replace(/\r\n?/g, '\n').split('\n').filter((line) => line.length > 0); const lines = normalizePassengerTsvText(value.text).split('\n').filter((line) => line.length > 0);
const header = String(lines[0] || '').split('\t'); const header = String(lines[0] || '').split('\t');
if (header.length !== PASSENGER_HEADERS.length if (header.length !== PASSENGER_HEADERS.length
|| header.some((name, index) => name.trim() !== PASSENGER_HEADERS[index])) { || header.some((name, index) => name.trim() !== PASSENGER_HEADERS[index])) {

View File

@@ -2,7 +2,7 @@
## 当前基线 ## 当前基线
- Chrome 插件:`0.5.172` - Chrome 插件:`0.5.173`
- Agent Prompt:`ltjt-agent-prompt-v1.8-independent-headcount-categories` - Agent Prompt:`ltjt-agent-prompt-v1.8-independent-headcount-categories`
- 生命周期契约:`ltjt-lifecycle-v2.9-roster-leader-contact-2026-08` - 生命周期契约:`ltjt-lifecycle-v2.9-roster-leader-contact-2026-08`
- 五个 Skill:`0.5.126`;运营 DOCX:`0.5.127` - 五个 Skill:`0.5.126`;运营 DOCX:`0.5.127`
@@ -13,6 +13,7 @@
- `0.5.168` 将 ERP 身份门禁从整页子串搜索收紧为唯一 `#Lable_UserName` 登录节点的规范化精确全等;缺失、重复、空白、子串或其他页面文字命中均不再让云电脑进入可执行状态,观察到的账号文本不离开扩展。 - `0.5.168` 将 ERP 身份门禁从整页子串搜索收紧为唯一 `#Lable_UserName` 登录节点的规范化精确全等;缺失、重复、空白、子串或其他页面文字命中均不再让云电脑进入可执行状态,观察到的账号文本不离开扩展。
- `0.5.170` 保留全部执行门禁,并新增 Program-only 散拼批量子单的“完整只读枚举后逐单执行、首错停止、不自动重试”门禁;ERP 写入不确定后的人工只读核验责任仍明确绑定到任务归属账号,管理员仍完全不进入任务数据面。 - `0.5.170` 保留全部执行门禁,并新增 Program-only 散拼批量子单的“完整只读枚举后逐单执行、首错停止、不自动重试”门禁;ERP 写入不确定后的人工只读核验责任仍明确绑定到任务归属账号,管理员仍完全不进入任务数据面。
- `0.5.172` 按维护方要求将酒店新增 `DoInfo_jiudian` 的固定字段门槛从 195 调整为 150,恢复原来的预检流程;`0.5.171` 的结构判定方案已撤回并归档。其他模式/业务原门槛及后续执行检查保留,低于 150 的页面仍会阻断。尚未部署或进行真实 ERP 写入验收;验证范围见[任务记录](../../../.project-docs/30-worklog/tasks/20260909-hotel-form-readiness-01a0858b.md)。 - `0.5.172` 按维护方要求将酒店新增 `DoInfo_jiudian` 的固定字段门槛从 195 调整为 150,恢复原来的预检流程;`0.5.171` 的结构判定方案已撤回并归档。其他模式/业务原门槛及后续执行检查保留,低于 150 的页面仍会阻断。尚未部署或进行真实 ERP 写入验收;验证范围见[任务记录](../../../.project-docs/30-worklog/tasks/20260909-hotel-form-readiness-01a0858b.md)。
- `0.5.173` 修复名单 TSV 末尾空列被整体 `trim()` 删除的问题,覆盖服务端统一 operation 校验、插件派发及原生名单合并。13 列完整性、序号、覆盖确认及领队约束继续执行;服务端与插件需同步更新,数据库无需变更。该版本的本地验证与真实 ERP 验收边界见[任务记录](../../../.project-docs/30-worklog/tasks/20260910-roster-trailing-empty-columns-01a0858b.md)。
- 运营输入/DOCX `0.5.127` 补齐第 19 项`散拼团多个新增子单`的可复制模板与示例;该入口仍固定为 Program-only,不进入 Agent Prompt 或 Skill。 - 运营输入/DOCX `0.5.127` 补齐第 19 项`散拼团多个新增子单`的可复制模板与示例;该入口仍固定为 Program-only,不进入 Agent Prompt 或 Skill。
- 名单工作簿 normalizer `v1.4.0`、Skill `0.5.126` 与业务输入 `0.5.127` 继续只选择 12 个必需源字段;身份证、年龄、源证件类型及其他普通额外列不参与行识别、字段校验或 canonical 输出。必需字段完整性、宏/外链、全局超链接和外部公式等安全门槛保持失败关闭。 - 名单工作簿 normalizer `v1.4.0`、Skill `0.5.126` 与业务输入 `0.5.127` 继续只选择 12 个必需源字段;身份证、年龄、源证件类型及其他普通额外列不参与行识别、字段校验或 canonical 输出。必需字段完整性、宏/外链、全局超链接和外部公式等安全门槛保持失败关闭。

View File

@@ -0,0 +1,7 @@
# 2026-09-10 发布归档
本目录仅供历史追溯,不定义当前业务规则或发布版本。当前交付以 [`../../../dist/release-manifest.json`](../../../dist/release-manifest.json) 为准。
- `ltjt-order-assistant-0.5.172.zip`:名单末尾空列修复前的插件原包,字节保持不变。
- `release-manifest-0.5.172-skills-0.5.126-docx-0.5.127.json`:对应原始发布清单,原路径及哈希作为历史记录保留。
- 替代版本 `0.5.173` 的本地修复与验证见[任务记录](../../../.project-docs/30-worklog/tasks/20260910-roster-trailing-empty-columns-01a0858b.md)。

View File

@@ -0,0 +1,69 @@
{
"manifest_version": 1,
"generated_on": "2026-09-09",
"baselines": {
"chrome_extension": "0.5.172",
"skills": "0.5.126",
"business_instruction_docx": "0.5.127",
"agent_prompt": "ltjt-agent-prompt-v1.8-independent-headcount-categories"
},
"artifacts": [
{
"kind": "chrome_extension",
"version": "0.5.172",
"path": "dist/ltjt-order-assistant-0.5.172.zip",
"source": "chrome-extension/ltjt-order-assistant",
"sha256": "5ded1ac1e5ed137f0997d1220ac3c76b66a7443f4fcda6c824e482f122d0c198"
},
{
"kind": "skill",
"name": "lwlt-arrangement",
"version": "0.5.126",
"path": "dist/lwlt-arrangement-0.5.126.skill",
"source": "agent设计规范/skills/lwlt-arrangement",
"sha256": "83770befe3a8e4048eacaeacb567c5139bf8a0b3a49f2b1513c26383422f2a9c"
},
{
"kind": "skill",
"name": "lwlt-confirmation",
"version": "0.5.126",
"path": "dist/lwlt-confirmation-0.5.126.skill",
"source": "agent设计规范/skills/lwlt-confirmation",
"sha256": "543507ed8b68c183d2df6722061b0e927ebb728de4bf254d15f83311c9264018"
},
{
"kind": "skill",
"name": "lwlt-lifecycle",
"version": "0.5.126",
"path": "dist/lwlt-lifecycle-0.5.126.skill",
"source": "agent设计规范/skills/lwlt-lifecycle",
"sha256": "fff3879d9a4143c34ae51e091b91411e55382ad3f5d77c99d6202a005b1ddbd9"
},
{
"kind": "skill",
"name": "lwlt-newbooking",
"version": "0.5.126",
"path": "dist/lwlt-newbooking-0.5.126.skill",
"source": "agent设计规范/skills/lwlt-newbooking",
"sha256": "5eb6a7bf5649a0fcee3fbee488a124e1b1d018a642a3a49ebb5439ccf20ccc2f"
},
{
"kind": "skill",
"name": "lwlt-updating",
"version": "0.5.126",
"path": "dist/lwlt-updating-0.5.126.skill",
"source": "agent设计规范/skills/lwlt-updating",
"sha256": "3bd665207649cbb8e0e5ebb012c916720201fe160eea62bccdf1c3a1f0275d1d"
},
{
"kind": "business_instruction_docx",
"version": "0.5.127",
"path": "dist/老挝联泰AI指令表-0.5.127.docx",
"source": "agent设计规范/templates/business-input-templates.md",
"builder": "tools/build_business_instruction_docx.py",
"sha256": "e1b8ae5a98d550ab0785692a01cf1c87b99e4edfaea39e4edbbab7341e585f29",
"source_sha256": "652af8ef645ae56af2dd9990f74b8078de230cb9b87acd13c289d1f4dae0738a",
"builder_sha256": "c162884210da22e3b78368749b66f1f177df5e9fc6155f4fd9954bd516d187f1"
}
]
}

View File

@@ -11,9 +11,13 @@ Chrome Manifest V3 扩展,在用户已登录的 LTJT ERP 页面内执行经过
## 当前版本 ## 当前版本
当前源码版本为 `0.5.172`。版本化 ZIP、文件哈希和 Skill/DOCX 基线见 [`../../dist/release-manifest.json`](../../dist/release-manifest.json)。旧版本实现流水已冻结在 [`../../archive/project-history/2026-08-16/chrome-extension-README.pre-governance.md`](../../archive/project-history/2026-08-16/chrome-extension-README.pre-governance.md)。 当前源码版本为 `0.5.173`。版本化 ZIP、文件哈希和 Skill/DOCX 基线见 [`../../dist/release-manifest.json`](../../dist/release-manifest.json)。旧版本实现流水已冻结在 [`../../archive/project-history/2026-08-16/chrome-extension-README.pre-governance.md`](../../archive/project-history/2026-08-16/chrome-extension-README.pre-governance.md)。
0.5.172 当前重点: 0.5.173 当前重点:
- 名单 TSV 在派发校验、领队提取和原生名单合并时保留末尾空列的制表符,只统一换行并移除首尾空行。最后一条电话或备注为空时仍按完整 13 列处理;真正少列、多列及领队不一致仍阻断。本次需同步部署服务端的统一 operation 校验修复,不涉及数据库变更。
继续保留 0.5.172 的酒店门槛修正:
- 按维护方要求,将酒店新增 `DoInfo_jiudian` 的最少可序列化字段数从 195 调整为 150,恢复原有页面预检流程。154 字段页面可以通过数量检查;关键字段、loading、稳定 1 秒、资源、空槽、财务状态、归属和保存后回查检查保持原样。酒店修改/清空仍为 150,其他业务阈值不变。低于 150 的页面仍会阻断;真实 ERP 验收由部署方完成。 - 按维护方要求,将酒店新增 `DoInfo_jiudian` 的最少可序列化字段数从 195 调整为 150,恢复原有页面预检流程。154 字段页面可以通过数量检查;关键字段、loading、稳定 1 秒、资源、空槽、财务状态、归属和保存后回查检查保持原样。酒店修改/清空仍为 150,其他业务阈值不变。低于 150 的页面仍会阻断;真实 ERP 验收由部署方完成。

View File

@@ -6403,7 +6403,7 @@
} }
window.LTJTOrderAssistant = { window.LTJTOrderAssistant = {
version: '0.5.172', version: '0.5.173',
resolveNativeListSearchValues, resolveNativeListSearchValues,
lookupKeywordMatchesText, lookupKeywordMatchesText,
inspectLifecycleSearchCriteria: lifecycleSearchCriteria, inspectLifecycleSearchCriteria: lifecycleSearchCriteria,

View File

@@ -2609,7 +2609,8 @@
} }
function buildPassengerTsv(value = {}) { function buildPassengerTsv(value = {}) {
if (typeof value.text === 'string' && value.text.trim()) return value.text.trim().replace(/\r\n?/g, '\n'); // Keep trailing tabs: they encode empty cells in the required 13-column roster.
if (typeof value.text === 'string' && value.text.trim()) return value.text.replace(/\r\n?/g, '\n').replace(/^\n+|\n+$/g, '');
const rows = Array.isArray(value.rows) ? value.rows : []; const rows = Array.isArray(value.rows) ? value.rows : [];
if (!rows.length) return ''; if (!rows.length) return '';
return [PASSENGER_HEADERS.join('\t'), ...rows.map((row) => PASSENGER_HEADERS.map((header) => passengerCell(row, header)).join('\t'))].join('\n'); return [PASSENGER_HEADERS.join('\t'), ...rows.map((row) => PASSENGER_HEADERS.map((header) => passengerCell(row, header)).join('\t'))].join('\n');

View File

@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "联泰下单助手", "name": "联泰下单助手",
"version": "0.5.172", "version": "0.5.173",
"description": "在已登录 LTJT ERP 页面内规划并受控执行联泰 ERP 业务操作。", "description": "在已登录 LTJT ERP 页面内规划并受控执行联泰 ERP 业务操作。",
"permissions": [ "permissions": [
"activeTab", "activeTab",

View File

@@ -457,9 +457,14 @@
return ''; return '';
} }
function normalizePassengerTsvText(text) {
// Tabs represent columns, including empty final fields; trim only boundary newlines.
return String(text || '').replace(/\r\n?/g, '\n').replace(/^\n+|\n+$/g, '');
}
function buildPassengerTsv(passengerList = {}) { function buildPassengerTsv(passengerList = {}) {
if (typeof passengerList.text === 'string' && passengerList.text.trim()) { if (typeof passengerList.text === 'string' && passengerList.text.trim()) {
return passengerList.text.trim().replace(/\r\n?/g, '\n'); return normalizePassengerTsvText(passengerList.text);
} }
const rows = Array.isArray(passengerList.rows) ? passengerList.rows : []; const rows = Array.isArray(passengerList.rows) ? passengerList.rows : [];
if (!rows.length) return ''; if (!rows.length) return '';
@@ -470,7 +475,7 @@
} }
function passengerLeaderFromTsv(text) { function passengerLeaderFromTsv(text) {
const lines = String(text || '').trim().replace(/\r\n?/g, '\n').split('\n').filter((line) => line.length > 0); const lines = normalizePassengerTsvText(text).split('\n').filter((line) => line.length > 0);
const headers = String(lines[0] || '').split('\t').map((header) => header.trim()); const headers = String(lines[0] || '').split('\t').map((header) => header.trim());
if (headers.length !== PASSENGER_HEADERS.length if (headers.length !== PASSENGER_HEADERS.length
|| headers.some((header, index) => header !== PASSENGER_HEADERS[index])) { || headers.some((header, index) => header !== PASSENGER_HEADERS[index])) {
@@ -1110,7 +1115,7 @@
blockers.push('data.passenger_list.text 必须包含本次提供的 13 列名单 TSV。'); blockers.push('data.passenger_list.text 必须包含本次提供的 13 列名单 TSV。');
return; return;
} }
const lines = value.text.trim().replace(/\r\n?/g, '\n').split('\n').filter((line) => line.length > 0); const lines = normalizePassengerTsvText(value.text).split('\n').filter((line) => line.length > 0);
const headers = String(lines[0] || '').split('\t'); const headers = String(lines[0] || '').split('\t');
if (headers.length !== PASSENGER_HEADERS.length || headers.some((header, index) => header.trim() !== PASSENGER_HEADERS[index])) blockers.push('名单 TSV 必须使用规定的 13 列表头和顺序。'); if (headers.length !== PASSENGER_HEADERS.length || headers.some((header, index) => header.trim() !== PASSENGER_HEADERS[index])) blockers.push('名单 TSV 必须使用规定的 13 列表头和顺序。');
if (lines.length - 1 !== value.row_count) blockers.push('data.passenger_list.row_count 与 TSV 数据行数不一致。'); if (lines.length - 1 !== value.row_count) blockers.push('data.passenger_list.row_count 与 TSV 数据行数不一致。');

View File

@@ -944,7 +944,7 @@
window.LTJTOrderAssistant = { window.LTJTOrderAssistant = {
...(window.LTJTOrderAssistant || {}), ...(window.LTJTOrderAssistant || {}),
version: '0.5.172', version: '0.5.173',
openTeamBatchForm, openTeamBatchForm,
pingTeamBatchFrame, pingTeamBatchFrame,
preflightTeamBatchNative, preflightTeamBatchNative,

View File

@@ -228,6 +228,40 @@ test('shared-child batch input compiles the inclusive cross-month range without
}); });
}); });
test('passenger TSV preserves 13 columns when the final passenger has empty optional fields', async () => {
for (const route of [routeCases[5], routeCases[6]]) {
for (const emptyPhone of [false, true]) {
const rows = Array.from({ length: 19 }, (_, i) => {
const cells = row.split('\t');
cells[0] = String(i + 1);
cells[12] = i === 0 ? '领队' : '';
if (emptyPhone && i === 18) cells[11] = '';
return cells.join('\t');
});
const text = `${headers}\n${rows.join('\n')}`;
for (const crlf of [false, true]) {
const input = `${route.input.replace(row, rows.join('\n'))}\n\n`;
const result = await parseProgramInput({ rawText: crlf ? input.replace(/\n/g, '\r\n') : input, receivedAt });
assert.equal(result.status, 'agent_parse_passed', JSON.stringify(result));
const roster = (result.operation as any).data.passenger_list;
assert.equal(roster.row_count, 19);
assert.equal(roster.text, text);
assert.deepEqual(roster.leader_contact, { sequence: 1, name: '测试游客', phone: '13800000001' });
}
}
}
});
test('passenger TSV rejects actual 12-column and 14-column data rows', async () => {
const cells = row.split('\t');
for (const malformed of [cells.slice(0, 12).join('\t'), `${cells.join('\t')}\t`]) {
const result = await parseProgramInput({ rawText: routeCases[5].input.replace(row, malformed), receivedAt });
assert.equal(result.status, 'agent_parse_needs_input');
assert.equal(result.error_code, 'program_invalid_value');
assert.match(String(result.reply), /13 列/);
}
});
test('passenger TSV promotes only an exact leader remark into structured ERP contact data', async () => { test('passenger TSV promotes only an exact leader remark into structured ERP contact data', async () => {
const leaderRow = '1\t测试领队\tLEADER TEST\t男\t1980-01-01\t湖南\t护照\tE90000001\t中国\t2025-01-01\t2035-01-01\t13800000001\t 领队 '; const leaderRow = '1\t测试领队\tLEADER TEST\t男\t1980-01-01\t湖南\t护照\tE90000001\t中国\t2025-01-01\t2035-01-01\t13800000001\t 领队 ';
const exact = await parseProgramInput({ const exact = await parseProgramInput({

View File

@@ -1,8 +1,8 @@
{ {
"manifest_version": 1, "manifest_version": 1,
"generated_on": "2026-09-09", "generated_on": "2026-09-10",
"baselines": { "baselines": {
"chrome_extension": "0.5.172", "chrome_extension": "0.5.173",
"skills": "0.5.126", "skills": "0.5.126",
"business_instruction_docx": "0.5.127", "business_instruction_docx": "0.5.127",
"agent_prompt": "ltjt-agent-prompt-v1.8-independent-headcount-categories" "agent_prompt": "ltjt-agent-prompt-v1.8-independent-headcount-categories"
@@ -10,10 +10,10 @@
"artifacts": [ "artifacts": [
{ {
"kind": "chrome_extension", "kind": "chrome_extension",
"version": "0.5.172", "version": "0.5.173",
"path": "dist/ltjt-order-assistant-0.5.172.zip", "path": "dist/ltjt-order-assistant-0.5.173.zip",
"source": "chrome-extension/ltjt-order-assistant", "source": "chrome-extension/ltjt-order-assistant",
"sha256": "5ded1ac1e5ed137f0997d1220ac3c76b66a7443f4fcda6c824e482f122d0c198" "sha256": "449ad5d7394904e897b9a6245eb3b01a80e956a353422536c33d0907356bea8d"
}, },
{ {
"kind": "skill", "kind": "skill",

View File

@@ -1,7 +1,7 @@
{ {
"contract_version": "ltjt-lifecycle-v2.9-roster-leader-contact-2026-08", "contract_version": "ltjt-lifecycle-v2.9-roster-leader-contact-2026-08",
"current_agent_parse_prompt_version": "ltjt-agent-prompt-v1.8-independent-headcount-categories", "current_agent_parse_prompt_version": "ltjt-agent-prompt-v1.8-independent-headcount-categories",
"current_extension_version": "0.5.172", "current_extension_version": "0.5.173",
"historical_test_marker": "TEST-202609", "historical_test_marker": "TEST-202609",
"scope": "/System/Business/", "scope": "/System/Business/",
"erp_session_keepalive": { "erp_session_keepalive": {
@@ -207,6 +207,7 @@
"full_replace requires confirmed=true and forces a native overwrite of every supplied sequence even when its current persisted projection is equal; the plugin must not downgrade confirmed user intent to no_changes", "full_replace requires confirmed=true and forces a native overwrite of every supplied sequence even when its current persisted projection is equal; the plugin must not downgrade confirmed user intent to no_changes",
"passenger_list.marker is exactly TEST-202609 only for an explicit historical test context", "passenger_list.marker is exactly TEST-202609 only for an explicit historical test context",
"every canonical row has implicit 证件类型 exactly 护照; any source 证件类型 column is ignored", "every canonical row has implicit 证件类型 exactly 护照; any source 证件类型 column is ignored",
"each TSV row retains exactly 13 columns, including empty final phone or remark cells; normalization removes only boundary newlines and never trims tab delimiters",
"exactly one workbook row within the first 100 rows maps to the required ERP passenger fields; the header may be row 1 or follow metadata rows, source order is arbitrary, approved source/ERP aliases map to one semantic field each, and ordinary non-import columns are ignored and excluded from canonical output; contiguous roster data is read below the detected header", "exactly one workbook row within the first 100 rows maps to the required ERP passenger fields; the header may be row 1 or follow metadata rows, source order is arbitrary, approved source/ERP aliases map to one semantic field each, and ordinary non-import columns are ignored and excluded from canonical output; contiguous roster data is read below the detected header",
"at most one row has trimmed 备注 exactly 领队; that row requires nonblank 姓名 and 电话 and must match passenger_list.leader_contact" "at most one row has trimmed 备注 exactly 领队; that row requires nonblank 姓名 and 电话 and must match passenger_list.leader_contact"
], ],

View File

@@ -1374,7 +1374,7 @@ test('passenger explicit server success is terminal without a post-save row requ
assert.doesNotMatch(passengerBranch, /passengerRequery|verifyLifecycleOperation/); assert.doesNotMatch(passengerBranch, /passengerRequery|verifyLifecycleOperation/);
assert.doesNotMatch(background, /attemptAutomaticPassengerReconciliation/); assert.doesNotMatch(background, /attemptAutomaticPassengerReconciliation/);
assert.match(background, /名单已取得 ERP 明确成功响应,按业务规则确认录入成功/); assert.match(background, /名单已取得 ERP 明确成功响应,按业务规则确认录入成功/);
assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.172'/); assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.173'/);
}); });
test('uncertain lifecycle writes can only converge through a read-only plugin requery', async () => { test('uncertain lifecycle writes can only converge through a read-only plugin requery', async () => {
@@ -1584,7 +1584,7 @@ test('lifecycle execution keeps the MV3 worker alive until the durable task sett
assert.match(background, /arrangement_resource_candidate_data_missing/); assert.match(background, /arrangement_resource_candidate_data_missing/);
}); });
test('extension 0.5.172 keeps reload dormant unless its durable execution state is fully idle', async () => { test('extension 0.5.173 keeps reload dormant unless its durable execution state is fully idle', async () => {
const [background, bridge] = await Promise.all([ const [background, bridge] = await Promise.all([
readFile(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8'), readFile(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8'),
readFile(new URL('../chrome-extension/ltjt-order-assistant/business-bridge.js', import.meta.url), 'utf8') readFile(new URL('../chrome-extension/ltjt-order-assistant/business-bridge.js', import.meta.url), 'utf8')
@@ -2023,7 +2023,7 @@ test('delete guard distinguishes independent, shared child, and shared parent ro
for (const operation of operations) assert.equal(plans.validateOperation(operation).ok, true, plans.validateOperation(operation).blockers.join('; ')); for (const operation of operations) assert.equal(plans.validateOperation(operation).ok, true, plans.validateOperation(operation).blockers.join('; '));
const mapping = JSON.parse(await readFile(new URL('../mappings/lifecycle.mapping.json', import.meta.url), 'utf8')); const mapping = JSON.parse(await readFile(new URL('../mappings/lifecycle.mapping.json', import.meta.url), 'utf8'));
assert.equal(mapping.current_extension_version, '0.5.172'); assert.equal(mapping.current_extension_version, '0.5.173');
assert.equal( assert.equal(
mapping.updates.order_update_independent.field_mapped_pending_live_validation['pax.child_no_bed'], mapping.updates.order_update_independent.field_mapped_pending_live_validation['pax.child_no_bed'],
'ertrenshu' 'ertrenshu'
@@ -2407,10 +2407,10 @@ test('schema and browser adapters contain the v2 safety fields and no confirm ov
assert.match(inpage, /`ys_danweiid\$\{index\}`, resolvedCustomerId/); assert.match(inpage, /`ys_danweiid\$\{index\}`, resolvedCustomerId/);
assert.doesNotMatch(inpage, /product_customer_source_region|sourceRegionCheck|source_reference/); assert.doesNotMatch(inpage, /product_customer_source_region|sourceRegionCheck|source_reference/);
assert.doesNotMatch(teamBatchInpage, /product_customer_source_region|sourceRegionCheck/); assert.doesNotMatch(teamBatchInpage, /product_customer_source_region|sourceRegionCheck/);
assert.equal(extensionManifest.version, '0.5.172'); assert.equal(extensionManifest.version, '0.5.173');
assert.match(inpage, /version: '0\.5\.172'/); assert.match(inpage, /version: '0\.5\.173'/);
assert.match(teamBatchInpage, /version: '0\.5\.172'/); assert.match(teamBatchInpage, /version: '0\.5\.173'/);
assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.172'/); assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.173'/);
assert.match(inpage, /function strictIsoDate\(value\)/); assert.match(inpage, /function strictIsoDate\(value\)/);
assert.match(inpage, /const startDate = strictIsoDate\(controlCanonicalValue\(form, 'riqi0'\)\)/); assert.match(inpage, /const startDate = strictIsoDate\(controlCanonicalValue\(form, 'riqi0'\)\)/);
assert.match(inpage, /const endDate = strictIsoDate\(controlCanonicalValue\(form, 'riqis0'\)\)/); assert.match(inpage, /const endDate = strictIsoDate\(controlCanonicalValue\(form, 'riqis0'\)\)/);

View File

@@ -0,0 +1,82 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import test from 'node:test';
import vm from 'node:vm';
import { validateProgramOperation } from '../LianSyn-platform/external-agent-client.mjs';
const require = createRequire(import.meta.url);
const plans = require('../chrome-extension/ltjt-order-assistant/operation-plans.js');
const headers = plans.PASSENGER_HEADERS.join('\t');
const row = (sequence, phone = '13800000001', remark = '') => [
sequence, `合成游客${sequence}`, 'SYNTHETIC GUEST', '男', '1990-01-01', '合成城市',
'护照', `TEST-PASS-${sequence}`, '合成城市', '2025-01-01', '2035-01-01', phone, remark
].join('\t');
const operation = (text, rowCount = 1, kind = 'independent_order') => ({
action: 'passenger_list_import', order_nature: 'formal', submit_mode: 'dry_run',
data: {
existing_refs: { kind, identifier: 'SYNTH-ORDER-001' },
passenger_list: { operation: 'import', row_count: rowCount, text }
}
});
test('passenger TSV keeps trailing empty columns through server and plugin dispatch', () => {
for (const kind of ['independent_order', 'shared_child_order']) {
for (const phone of ['13800000001', '']) {
const text = [headers, ...Array.from({ length: 19 }, (_, i) => row(i + 1, phone))].join('\n');
for (const input of [text, `\r\n${text.replace(/\n/g, '\r\n')}\r\n\r\n`]) {
for (const mode of ['import', 'full_replace']) {
const candidate = operation(input, 19, kind);
Object.assign(candidate.data.passenger_list, mode === 'full_replace'
? { operation: mode, confirmed: true } : { operation: mode });
assert.deepEqual(validateProgramOperation(candidate), []);
assert.deepEqual(plans.validateDispatchOperation(candidate).blockers, []);
assert.equal(plans.buildPassengerTsv(candidate.data.passenger_list), text);
assert.equal(candidate.data.passenger_list.text, input);
}
}
}
}
});
test('passenger TSV still rejects missing or surplus columns and keeps leader validation', () => {
for (const malformed of [row(1).slice(0, -1), `${row(1)}\t`]) {
const candidate = operation(`${headers}\n${malformed}`);
assert.match(validateProgramOperation(candidate).join('\n'), /完整 13 列/);
assert.match(plans.validateDispatchOperation(candidate).blockers.join('\n'), /13 列/);
}
const candidate = operation(`${headers}\n${row(1, '13800000001', '领队')}\n${row(2, '')}`, 2);
assert.match(validateProgramOperation(candidate).join('\n'), /leader_contact/);
assert.match(plans.validateDispatchOperation(candidate).blockers.join('\n'), /leader_contact/);
candidate.data.passenger_list.leader_contact = { sequence: 1, name: '合成游客1', phone: '13800000001' };
assert.deepEqual(validateProgramOperation(candidate), []);
assert.deepEqual(plans.validateDispatchOperation(candidate).blockers, []);
candidate.data.passenger_list.leader_contact.phone = '13800000002';
assert.match(validateProgramOperation(candidate).join('\n'), /完全一致/);
assert.match(plans.validateDispatchOperation(candidate).blockers.join('\n'), /完全一致/);
});
test('passenger merge accepts empty final fields without repairing malformed column counts', async () => {
const source = await readFile(new URL('../chrome-extension/ltjt-order-assistant/lifecycle-adapters.js', import.meta.url), 'utf8');
const sandbox = { window: { LTJTOrderAssistant: {} } };
vm.runInNewContext(source, sandbox);
const merge = sandbox.window.LTJTOrderAssistant.planPassengerMerge;
const blankRows = Array.from({ length: 19 }, (_, i) => Object.fromEntries(plans.PASSENGER_HEADERS.map(header => [
header, header === '序号' ? i + 1 : header === '证件类型' ? '护照' : ''
])));
for (const phone of ['13800000001', '']) {
const text = [headers, ...Array.from({ length: 19 }, (_, i) => row(i + 1, phone))].join('\n');
for (const input of [text, `\r\n${text.replace(/\n/g, '\r\n')}\r\n`]) {
const result = merge(blankRows, { operation: 'first_import', row_count: 19, text: input }, 19);
assert.deepEqual([...result.blockers], []);
assert.equal(result.merged_tsv, text);
assert.equal(result.input_rows[18]['电话'], phone);
assert.equal(result.input_rows[18]['备注'], '');
assert.equal(result.changed_sequences.length, 19);
}
}
for (const malformed of [row(1).slice(0, -1), `${row(1)}\t`]) {
const result = merge(blankRows, { operation: 'first_import', row_count: 1, text: `${headers}\n${malformed}` }, 19);
assert.ok(result.blockers.includes('passenger_input_row_columns_incomplete'));
}
});