execl问题修复
This commit is contained in:
@@ -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 验收仍待部署方完成。本任务不更新共享索引或集成快照。
|
||||||
@@ -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 现有结果';
|
||||||
|
|||||||
@@ -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])) {
|
||||||
|
|||||||
@@ -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 输出。必需字段完整性、宏/外链、全局超链接和外部公式等安全门槛保持失败关闭。
|
||||||
|
|
||||||
|
|||||||
7
archive/releases/2026-09-10/README.md
Normal file
7
archive/releases/2026-09-10/README.md
Normal 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)。
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 验收由部署方完成。
|
||||||
|
|
||||||
|
|||||||
@@ -6403,7 +6403,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.LTJTOrderAssistant = {
|
window.LTJTOrderAssistant = {
|
||||||
version: '0.5.172',
|
version: '0.5.173',
|
||||||
resolveNativeListSearchValues,
|
resolveNativeListSearchValues,
|
||||||
lookupKeywordMatchesText,
|
lookupKeywordMatchesText,
|
||||||
inspectLifecycleSearchCriteria: lifecycleSearchCriteria,
|
inspectLifecycleSearchCriteria: lifecycleSearchCriteria,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 数据行数不一致。');
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
10
dist/release-manifest.json
vendored
10
dist/release-manifest.json
vendored
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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'\)\)/);
|
||||||
|
|||||||
82
tools/passenger-tsv.test.mjs
Normal file
82
tools/passenger-tsv.test.mjs
Normal 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'));
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user