修复变更、增加筛选条件

This commit is contained in:
andy committed 2026-09-11 21:22:14 +08:00
1 parent 712ec51d68
commit e06afc8c33
137 files changed
+35872 -275

No files matched your search

@@ -11,9 +11,18 @@ Chrome Manifest V3 扩展,在用户已登录的 LTJT ERP 页面内执行经过
## 当前版本
当前源码版本为 `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.177`。版本化 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 当前重点:
0.5.177 当前重点:
- 程序模式“安排大交通 / 安排大交通变更”新增选填车票说明(别名:车票说明搜索)。与结算单位搜索共同筛选同一条原生候选,填写后仅接受唯一结果;省略沿用首条匹配行为。完整说明与价格等字段仍由 ERP 原生选择联动。原有“车票/控位说明”兼容字段不变。需同步更新服务端,无新增迁移。
- 程序模式“安排用车 / 安排用车变更”新增选填用车类型(别名:车型)。以车型关键词过滤结算单位搜索候选,只接受唯一结果;不匹配或多匹配停止,保留原有省略行为。变更日期仅定位原安排,类型只筛选新车辆。需同步更新服务端,数据库仍为迁移 024,无新增迁移。
- 新增 Program-only“安排导游变更 / 安排用车变更 / 安排大交通变更 / 安排其他/备案变更”。日期仅定位原安排,资源和明确填写的数量、备注及备案号/口岸作为新值;保留原日期、ID、未填写字段和其他行。需同步部署服务端、执行迁移 `024_arrangement_update_routes` 并由管理员授予四项变更权限;没有自动授予权限。已完成本地回归,真实 ERP 验收待部署方执行。
- 程序模式支持“安排酒店变更”:按原入住及离店日期唯一定位已有酒店行,使用 ERP 原生酒店选择联动更换酒店和房型,并修改间数;保留记录 ID、原日期、状态、备注及其他行。仅允许未安排或未确认、无付款及审核的记录;多条日期匹配时停止。已完成本地回归,真实 ERP 验收由部署方进行;需同步升级服务端。
继续保留 0.5.173 的名单修复:
- 名单 TSV 在派发校验、领队提取和原生名单合并时保留末尾空列的制表符,只统一换行并移除首尾空行。最后一条电话或备注为空时仍按完整 13 列处理;真正少列、多列及领队不一致仍阻断。本次需同步部署服务端的统一 operation 校验修复,不涉及数据库变更。
@@ -2387,6 +2387,17 @@ function erpResolutionFailure(operation, report = {}) {
function lifecycleRouteFailure(operation, report = {}) {
const blockers = Array.isArray(report.blockers) ? report.blockers.map(String) : [];
const targetCount = blockers.find((item) => /^arrangement_update_target_candidate_count:\d+$/.test(item));
if (targetCount) {
const count = Number(targetCount.split(':').at(-1));
const label = { arrangement_hotel: '入住及离店日期均匹配的酒店', arrangement_vehicle: '用车日期范围匹配的车辆', arrangement_transport: '交通日期匹配的大交通', arrangement_other: '业务日期匹配的其他/备案', arrangement_guide: '当前团队的导游' }[operation?.action] || '原';
return {
errorCode: 'arrangement_update_target_not_unique',
message: count === 0
? `ERP 中没有找到${label}安排,请核对原安排;未写入 ERP。`
: `ERP 中找到 ${count} 条${label}安排,无法唯一确定可变更的记录,请核对原安排;未写入 ERP。`
};
}
const noopBlocker = blockers.find((item) => item.startsWith('lifecycle_transition_target_already_current:'));
if (noopBlocker) {
const currentStatus = noopBlocker.slice('lifecycle_transition_target_already_current:'.length).trim();
+142 -25
View File
@@ -2939,16 +2939,28 @@
.map((row) => row.split('◆').map((cell) => String(cell || '').trim()));
}
function uniqueLookupCandidate(rows, ref, { guide = false, hotel = false, transport = false, other = false, item = '', searchColumns = [] } = {}) {
function candidateDescriptionMatches(item, query) {
const text = normalizedLookupSearchValue(String(item || '').normalize('NFKC'));
const keyword = normalizedLookupSearchValue(String(query || '').normalize('NFKC'));
if (!keyword) return false;
// Numeric fragments must not match larger numbers, e.g. 15 vs 115 or D88 vs D888.
for (let at = text.indexOf(keyword); at >= 0; at = text.indexOf(keyword, at + 1)) {
if (/\d/.test(keyword[0]) && /\d/.test(text[at - 1] || '')) continue;
if (/\d/.test(keyword.at(-1)) && /\d/.test(text[at + keyword.length] || '')) continue;
return true;
}
return false;
}
function uniqueLookupCandidate(rows, ref, { guide = false, hotel = false, transport = false, other = false, item = '', vehicleType, ticketDescription, searchColumns = [] } = {}) {
const combinedCandidateLookup = hotel || transport || other;
const queries = hotel
? hotelLookupQueries(ref)
: combinedCandidateLookup
? [...new Set([ref?.name, ref?.keyword].map(compactHotelLookupValue).filter(Boolean))]
: [...new Set([ref?.name, ref?.keyword].map(normalizedLookupSearchValue).filter(Boolean))];
// Hotel room type and transport ticket/control description are ERP-derived
// values, never parser-side filters. The selected dropdown row supplies
// them after the settlement-unit candidate is chosen.
// Legacy item remains ERP-derived. Optional type/description keywords are
// separate filters; the chosen native row still supplies the complete item.
const expectedItem = combinedCandidateLookup ? '' : normalizedLookupSearchValue(item);
const candidates = rows.map((row) => ({
row,
@@ -2956,7 +2968,10 @@
name: String(guide ? row[2] : row[1] || '').trim(),
item: String(guide ? '' : row[2] || '').trim(),
raw: Array.isArray(row) ? row.join('◆') : String(row || '')
})).filter((candidate) => candidate.id && candidate.name && (!expectedItem || normalizedLookupSearchValue(candidate.item) === expectedItem));
})).filter((candidate) => candidate.id && candidate.name
&& (!expectedItem || normalizedLookupSearchValue(candidate.item) === expectedItem)
&& (vehicleType === undefined || candidateDescriptionMatches(candidate.item, vehicleType))
&& (ticketDescription === undefined || candidateDescriptionMatches(candidate.item, ticketDescription)));
const searchable = (candidate) => {
const cells = combinedCandidateLookup
// Hotel and transport SelectBox options are rendered as combined
@@ -2995,10 +3010,10 @@
: contains.length
? contains
: tokenized;
const firstMatchDefaulted = combinedCandidateLookup && matches.length > 1;
const firstMatchDefaulted = combinedCandidateLookup && ticketDescription === undefined && matches.length > 1;
return {
// The native dropdown is ordered; for hotel and transport, use its
// first matching row when one settlement unit has multiple candidates.
// Preserve ordered legacy selection only when no explicit description
// filter is supplied. An explicit filter always requires a unique row.
candidate: matches.length === 1 || firstMatchDefaulted ? matches[0] : null,
match_count: matches.length,
candidate_count: candidates.length,
@@ -3047,7 +3062,8 @@
|| form?.querySelector?.('#daoyou0, [name="daoyou0"]');
const currentValue = controlCanonicalValue(form, 'daoguan');
const rows = selectBoxRows(field);
const guideRef = operation?.data?.arrangement?.resource || {};
const guideArrangement = operation?.data?.arrangement || {};
const guideRef = guideArrangement.changes?.resource || guideArrangement.resource || {};
const guideResolution = uniqueLookupCandidate(selectBoxRows(lookup), guideRef, { guide: true });
const linkedCandidate = guideResolution.candidate?.row || [];
const linkedValue = String(linkedCandidate[5] || '').trim();
@@ -3066,7 +3082,7 @@
value_redacted: true
};
}
if (currentValue && normalizedLookupValue(currentValue) !== normalizedLookupValue(linkedValue)) {
if (guideArrangement.mode !== 'update' && currentValue && normalizedLookupValue(currentValue) !== normalizedLookupValue(linkedValue)) {
return {
value: currentValue,
source: 'form_linked_value_mismatch',
@@ -3126,20 +3142,105 @@
const blockers = [];
let evidence = null;
if (hotel && arrangement.mode === 'update') {
const rowId = controlCanonicalValue(form, 'id0');
const resourceId = controlCanonicalValue(form, 'danweiid0');
const resourceName = controlCanonicalValue(form, 'danwei0');
const roomType = controlCanonicalValue(form, 'fangxing0');
const startDate = strictIsoDate(controlCanonicalValue(form, 'riqi0'));
const endDate = strictIsoDate(controlCanonicalValue(form, 'riqis0'));
const roomCount = Number(controlCanonicalValue(form, 'jianshu0'));
const status = controlCanonicalValue(form, 'zt0');
const remark = controlCanonicalValue(form, 'beizhu0');
if (!rowId || !resourceId || !resourceName || !roomType) blockers.push('arrangement_update_current_row_reference_incomplete');
if (!hotel && arrangement.mode === 'update') {
let slotIndex = 0;
const selection = arrangement.selection || {};
if (guide) {
const existing = Array.from(form.elements || []).filter((control) => /^daoyouid\d+$/.test(control.name || control.id || '') && String(control.value || '').trim() && String(control.value).trim() !== '0');
if (existing.length !== 1 || (existing[0].name || existing[0].id) !== 'daoyouid0') return { ok: false, operation: resolved, blockers: [`arrangement_update_target_candidate_count:${existing.length}`], evidence: { value_redacted: true } };
} else {
const indices = [...new Set(Array.from(form.elements || []).map((control) => /^id(\d+)$/.exec(control.name || control.id || '')?.[1]).filter(Boolean).map(Number))];
const matching = indices.filter((index) => {
const id = controlCanonicalValue(form, `id${index}`);
return id && id !== '0' && (action === 'arrangement_vehicle'
? strictIsoDate(controlCanonicalValue(form, `riqi${index}`)) === selection.start_date && strictIsoDate(controlCanonicalValue(form, `riqis${index}`)) === selection.end_date
: strictIsoDate(controlCanonicalValue(form, `riqi${index}`)) === selection.date);
});
if (matching.length !== 1) return { ok: false, operation: resolved, blockers: [`arrangement_update_target_candidate_count:${matching.length}`], evidence: { value_redacted: true } };
slotIndex = matching[0];
}
const current = (name) => controlCanonicalValue(form, `${name}${slotIndex}`);
const resourceId = current(guide ? 'daoyouid' : 'danweiid');
const resourceName = current(guide ? 'daoyou' : 'danwei');
const rowId = guide ? '' : current('id');
if (!resourceId || resourceId === '0' || !resourceName || (!guide && (!rowId || rowId === '0'))) blockers.push('arrangement_update_current_row_reference_incomplete');
if (!guide && Array.from(form.elements || []).filter((control) => /^id\d+$/.test(control.name || control.id || '') && String(control.value).trim() === rowId).length !== 1) blockers.push('arrangement_update_row_id_not_unique');
arrangement.target = { slot_index: slotIndex, ...(!guide ? { row_id: rowId } : {}) };
arrangement[guide ? 'resource' : 'supplier'] = { name: resourceName, id: resourceId, resolved: true };
arrangement.remark = current('beizhu');
arrangement.status = current('zt') || ((guide || other) ? '未确认' : '');
arrangement.side_effect_policy = 'no_external';
if (!guide) {
arrangement.item = current(action === 'arrangement_vehicle' ? 'chehao' : 'shuoming');
if (action === 'arrangement_vehicle') {
arrangement.start_date = strictIsoDate(current('riqi'));
arrangement.end_date = strictIsoDate(current('riqis'));
} else arrangement.date = strictIsoDate(current('riqi'));
const quantityControl = form.elements?.namedItem?.(`shuliang${slotIndex}`) || (action === 'arrangement_vehicle' ? form.elements?.namedItem?.(`tianshu${slotIndex}`) : null);
if (!quantityControl) blockers.push('arrangement_update_quantity_field_missing');
else {
const quantity = Number(quantityControl.value);
if (!Number.isInteger(quantity) || quantity < (other ? 0 : 1)) blockers.push('arrangement_update_current_quantity_invalid');
arrangement.quantity = quantity;
}
if (!arrangement.item) blockers.push('arrangement_update_current_item_missing');
}
if (other) {
const filing = {};
for (const [key, name] of Object.entries({ number: 'beianhao', date: 'beianri', entry_port: 'rujingkou', exit_port: 'chujingkou' })) {
const value = controlCanonicalValue(form, name);
if (value) filing[key] = key === 'date' ? strictIsoDate(value) : value;
}
if (selection.filing_date && selection.filing_date !== filing.date) blockers.push('arrangement_update_original_filing_date_mismatch');
arrangement.filing = filing;
}
const lookup = form.elements?.namedItem?.(`${guide ? 'daoyou' : 'danwei'}${slotIndex}`);
const selected = uniqueLookupCandidate(selectBoxRows(lookup), arrangement.changes?.[guide ? 'resource' : 'supplier'], {
guide, transport, other, vehicleType: action === 'arrangement_vehicle' ? arrangement.vehicle_type : undefined,
ticketDescription: action === 'arrangement_transport' ? arrangement.ticket_description : undefined,
searchColumns: action === 'arrangement_vehicle' ? [1, 2, 3] : []
});
if (!selected.candidate) blockers.push(`arrangement_resource_candidate_count:${selected.match_count}`);
else {
const ref = arrangement.changes[guide ? 'resource' : 'supplier'];
arrangement.changes = { ...arrangement.changes, [guide ? 'resource' : 'supplier']: { ...ref, id: selected.candidate.id, name: selected.candidate.name, resolved: true }, ...(!guide ? { item: selected.candidate.item } : {}) };
if (!guide && !selected.candidate.item) blockers.push('arrangement_update_new_item_missing');
}
if (guide) {
const coordinator = guideCoordinatorResolution(form, operation, form.ownerDocument);
if (!coordinator.value || coordinator.mismatch) blockers.push('arrangement_guide_linked_coordinator_unresolved');
}
delete arrangement.selection;
evidence = { mode: 'update', slot_index: slotIndex, current_row_count: 1, value_redacted: true };
} else if (hotel && arrangement.mode === 'update') {
const selection = arrangement.selection;
let slotIndex = 0;
if (selection) {
const indices = [...new Set(Array.from(form.elements || []).map((control) => /^id(\d+)$/.exec(control.name || control.id || '')?.[1]).filter(Boolean).map(Number))];
const matching = indices.filter((index) => {
const id = controlCanonicalValue(form, `id${index}`);
return id && id !== '0'
&& strictIsoDate(controlCanonicalValue(form, `riqi${index}`)) === selection.start_date
&& strictIsoDate(controlCanonicalValue(form, `riqis${index}`)) === selection.end_date;
});
if (matching.length !== 1) return { ok: false, operation: resolved, blockers: [`arrangement_update_target_candidate_count:${matching.length}`], evidence: { match_count: matching.length, value_redacted: true } };
slotIndex = matching[0];
}
const current = (name) => controlCanonicalValue(form, `${name}${slotIndex}`);
const rowId = current('id');
const resourceId = current('danweiid');
const resourceName = current('danwei');
const roomType = current('fangxing');
const startDate = strictIsoDate(current('riqi'));
const endDate = strictIsoDate(current('riqis'));
const roomCount = Number(current('jianshu'));
const status = current('zt');
const remark = current('beizhu');
if (!rowId || rowId === '0' || !resourceId || resourceId === '0' || !resourceName || !roomType) blockers.push('arrangement_update_current_row_reference_incomplete');
if (Array.from(form.elements || []).filter((control) => /^id\d+$/.test(control.name || control.id || '') && String(control.value).trim() === rowId).length !== 1) blockers.push('arrangement_update_row_id_not_unique');
if (!startDate || !endDate || !Number.isInteger(roomCount) || roomCount < 1) blockers.push('arrangement_update_current_business_snapshot_incomplete');
if (!status) blockers.push('arrangement_update_current_status_missing');
arrangement.target = { slot_index: 0, row_id: rowId };
arrangement.target = { slot_index: slotIndex, row_id: rowId };
arrangement.resource = { name: resourceName, keyword: resourceName, id: resourceId, resolved: true };
arrangement.room_type = roomType;
arrangement.start_date = startDate;
@@ -3148,7 +3249,19 @@
arrangement.status = status;
arrangement.remark = remark;
arrangement.side_effect_policy = 'no_external';
evidence = { mode: 'update', current_row_count: rowId ? 1 : 0, status, value_redacted: true };
delete arrangement.selection;
if (arrangement.changes?.resource) {
const lookup = form.elements?.namedItem?.(`danwei${slotIndex}`)
|| form.querySelector?.(`[name="danwei${slotIndex}"]`);
const selected = uniqueLookupCandidate(selectBoxRows(lookup), arrangement.changes.resource, { hotel: true });
if (!selected.candidate) blockers.push(`arrangement_resource_candidate_count:${selected.match_count}`);
else arrangement.changes = {
...arrangement.changes,
resource: { ...arrangement.changes.resource, name: selected.candidate.name, id: selected.candidate.id, resolved: true },
room_type: selected.candidate.item
};
}
evidence = { mode: 'update', slot_index: slotIndex, current_row_count: rowId ? 1 : 0, status, value_redacted: true };
} else {
const lookup = form?.elements?.namedItem?.(guide ? 'daoyou0' : 'danwei0')
|| form?.querySelector?.(`#${guide ? 'daoyou0' : 'danwei0'}, [name="${guide ? 'daoyou0' : 'danwei0'}"]`);
@@ -3166,6 +3279,8 @@
const selected = uniqueLookupCandidate(rows, ref, {
guide,
item: requestedItem,
vehicleType: action === 'arrangement_vehicle' ? arrangement.vehicle_type : undefined,
ticketDescription: action === 'arrangement_transport' ? arrangement.ticket_description : undefined,
hotel,
transport,
other,
@@ -3294,7 +3409,7 @@
});
}
const arrangement = operation.data?.arrangement || {};
const lookupRequired = !(operation.action === 'arrangement_hotel' && arrangement.mode === 'update');
const lookupRequired = !(operation.action === 'arrangement_hotel' && arrangement.mode === 'update' && !arrangement.changes?.resource);
const lookupReadiness = lookupRequired
? await waitForCondition(() => {
const readiness = arrangementLookupReadiness(formDocument.querySelector('#InfoForm1, form[name="InfoForm1"]'), operation.action, formDocument, operation);
@@ -6403,7 +6518,7 @@
}
window.LTJTOrderAssistant = {
version: '0.5.173',
version: '0.5.177',
resolveNativeListSearchValues,
lookupKeywordMatchesText,
inspectLifecycleSearchCriteria: lifecycleSearchCriteria,
@@ -6439,6 +6554,8 @@
inspectArrangementLookupReadiness(form, action, scopeDocument = null, operation = {}) {
return arrangementLookupReadiness(form, action, scopeDocument, operation);
},
matchesVehicleType: candidateDescriptionMatches,
matchesTicketDescription: candidateDescriptionMatches,
inspectArrangementLookupCandidate(rows, ref, options = {}) {
return uniqueLookupCandidate(rows, ref, options);
},
@@ -29,11 +29,11 @@
'arrangement_other'
]);
const ARRANGEMENT_UPDATE_KEYS = Object.freeze({
arrangement_guide: new Set(),
arrangement_vehicle: new Set(),
arrangement_hotel: new Set(['end_date', 'room_count']),
arrangement_transport: new Set(),
arrangement_other: new Set()
arrangement_guide: new Set(["resource", "remark"]),
arrangement_vehicle: new Set(["supplier", "item", "quantity", "remark"]),
arrangement_hotel: new Set(['end_date', 'room_count', 'resource', 'room_type', 'remark']),
arrangement_transport: new Set(["supplier", "item", "quantity", "remark"]),
arrangement_other: new Set(["supplier", "item", "quantity", "remark", "filing"])
});
const HOTEL_ARRANGEMENT_STATUSES = new Set(['未安排', '未确认']);
const UPDATE_FIELDS = Object.freeze({
@@ -331,7 +331,7 @@
function arrangementFinalState(arrangement = {}) {
if (arrangement.mode !== 'update' || !isObject(arrangement.changes)) return arrangement;
const finalState = { ...arrangement, ...arrangement.changes };
const finalState = { ...arrangement, ...arrangement.changes, ...(arrangement.changes.filing ? { filing: { ...arrangement.filing, ...arrangement.changes.filing } } : {}) };
delete finalState.changes;
return finalState;
}
@@ -353,20 +353,34 @@
if (ARRANGEMENT_ACTIONS.has(action)) {
const arrangement = data.arrangement || {};
const finalArrangement = arrangementFinalState(arrangement);
if (arrangement.vehicle_type !== undefined && (action !== 'arrangement_vehicle'
|| !['create', 'update'].includes(arrangement.mode)
|| typeof arrangement.vehicle_type !== 'string' || !arrangement.vehicle_type.trim()
|| !assistant.matchesVehicleType?.(finalArrangement.item, arrangement.vehicle_type))) blockers.push('arrangement_vehicle_type_mismatch');
if (arrangement.ticket_description !== undefined && (action !== 'arrangement_transport'
|| !['create', 'update'].includes(arrangement.mode)
|| typeof arrangement.ticket_description !== 'string' || !arrangement.ticket_description.trim()
|| !assistant.matchesTicketDescription?.(finalArrangement.item, arrangement.ticket_description))) blockers.push('arrangement_ticket_description_mismatch');
if (!['create', 'update', 'clear'].includes(arrangement.mode)) blockers.push('arrangement_mode_must_be_create_update_or_clear');
if (arrangement.mode === 'create' && arrangement.target !== undefined) blockers.push('arrangement_create_target_forbidden');
if (arrangement.mode === 'update' || arrangement.mode === 'clear') {
if (!isObject(arrangement.target) || arrangement.target.slot_index !== 0) blockers.push(`arrangement_${arrangement.mode}_target_slot_must_be_zero`);
if (!isObject(arrangement.target) || (action !== 'arrangement_guide' && arrangement.mode === 'update'
? !Number.isInteger(arrangement.target.slot_index) || arrangement.target.slot_index < 0
: arrangement.target.slot_index !== 0)) blockers.push(`arrangement_${arrangement.mode}_target_slot_invalid`);
if (action !== 'arrangement_guide' && (typeof arrangement.target?.row_id !== 'string' || !arrangement.target.row_id.trim())) blockers.push(`arrangement_${arrangement.mode}_row_id_missing`);
}
if (arrangement.mode === 'update') {
const changes = arrangement.changes;
const allowed = ARRANGEMENT_UPDATE_KEYS[action] || new Set();
if (action !== 'arrangement_hotel') blockers.push('arrangement_update_action_not_live_validated');
if (!isObject(changes) || Object.keys(changes).length === 0) blockers.push('arrangement_update_changes_missing');
for (const key of Object.keys(changes || {})) {
if (!allowed.has(key)) blockers.push(`arrangement_update_field_not_live_validated:${key}`);
}
if (changes?.room_type !== undefined && !changes.resource) blockers.push('arrangement_update_field_not_live_validated:room_type');
if (changes?.resource && (changes.resource.resolved !== true || !nativeRef(changes.resource) || (action === 'arrangement_hotel' && !changes.room_type))) blockers.push('arrangement_update_new_resource_unresolved');
if (changes?.supplier && (changes.supplier.resolved !== true || !nativeRef(changes.supplier) || !changes.item)) blockers.push('arrangement_update_new_supplier_unresolved');
if (changes?.item !== undefined && !changes.supplier) blockers.push('arrangement_update_item_requires_native_supplier');
if (changes?.filing && (!isObject(changes.filing) || Object.keys(changes.filing).some((key) => !['number', 'entry_port', 'exit_port'].includes(key)))) blockers.push('arrangement_update_filing_fields_invalid');
if (action === 'arrangement_hotel'
? !HOTEL_ARRANGEMENT_STATUSES.has(arrangement.status)
: arrangement.status !== '未确认') blockers.push(action === 'arrangement_hotel'
@@ -402,7 +416,7 @@
if (['arrangement_vehicle', 'arrangement_transport'].includes(action)
&& (!Number.isInteger(finalArrangement.quantity) || finalArrangement.quantity <= 0)) blockers.push('arrangement_quantity_must_be_positive');
if (action === 'arrangement_other' && finalArrangement.quantity !== undefined
&& (!Number.isInteger(finalArrangement.quantity) || finalArrangement.quantity <= 0)) blockers.push('arrangement_quantity_must_be_positive');
&& (!Number.isInteger(finalArrangement.quantity) || (finalArrangement.quantity <= 0 && !(arrangement.mode === 'update' && arrangement.changes?.quantity === undefined && finalArrangement.quantity === 0)))) blockers.push('arrangement_quantity_must_be_positive');
if (finalArrangement.filing?.date !== undefined
&& (!validDate(finalArrangement.filing.date) || (testMode && !finalArrangement.filing.date.startsWith('2026-09-')))) blockers.push(testMode ? 'arrangement_filing_date_outside_2026_09' : 'arrangement_filing_date_invalid');
}
@@ -552,6 +566,20 @@
return { control: null, name: '' };
}
function arrangementFieldName(operation, name) {
const arrangement = operation.data?.arrangement;
if (!ARRANGEMENT_ACTIONS.has(operation.action) || arrangement?.mode !== 'update') return name;
return name.replace(/^([a-zA-Z_]+)0$/, (_, prefix) => `${prefix}${arrangement.target?.slot_index ?? 0}`);
}
function getArrangementControl(form, operation, name) {
return getControl(form, arrangementFieldName(operation, name));
}
function firstArrangementControl(form, operation, names) {
return firstControl(form, names.map((name) => arrangementFieldName(operation, name)));
}
function findForm(contract, targetDocument = document) {
if (!contract?.form) return null;
if (contract.form === 'DaoruText') return targetDocument.querySelector('#DaoruText, textarea[name="DaoruText"]');
@@ -759,26 +787,26 @@
arrangement_other: 'shuoming0'
}[action];
const quantityMatch = action === 'arrangement_hotel'
? { name: 'jianshu0', control: getControl(form, 'jianshu0') }
: action === 'arrangement_guide' ? { name: '', control: null } : firstControl(form, ['shuliang0', 'tianshu0']);
? { name: 'jianshu0', control: getArrangementControl(form, operation, 'jianshu0') }
: action === 'arrangement_guide' ? { name: '', control: null } : firstArrangementControl(form, operation, ['shuliang0', 'tianshu0']);
return {
slot_index: 0,
row_id: action === 'arrangement_guide' ? '' : controlValue(getControl(form, 'id0')),
resource_id: controlValue(getControl(form, resourceField)),
slot_index: operation.data?.arrangement?.target?.slot_index ?? 0,
row_id: action === 'arrangement_guide' ? '' : controlValue(getArrangementControl(form, operation, 'id0')),
resource_id: controlValue(getArrangementControl(form, operation, resourceField)),
expected_resource_id: nativeRef(ref),
item_field: itemField || '',
item_value: itemField ? controlValue(getControl(form, itemField)) : '',
quantity_field: quantityMatch.name,
item_field: itemField ? arrangementFieldName(operation, itemField) : '',
item_value: itemField ? controlValue(getArrangementControl(form, operation, itemField)) : '',
quantity_field: arrangementFieldName(operation, quantityMatch.name),
quantity_value: quantityMatch.control ? controlValue(quantityMatch.control) : '',
status_values: controlComparableValues(getControl(form, 'zt0')),
marker_present: normalize(controlValue(getControl(form, 'beizhu0'))).includes(TEST_MARKER),
payment_clear: action === 'arrangement_guide' || emptySlotValue(controlValue(getControl(form, 'yf0'))),
status_values: controlComparableValues(getArrangementControl(form, operation, 'zt0')),
marker_present: normalize(controlValue(getArrangementControl(form, operation, 'beizhu0'))).includes(TEST_MARKER),
payment_clear: action === 'arrangement_guide' || emptySlotValue(controlValue(getArrangementControl(form, operation, 'yf0'))),
audit_clear: action === 'arrangement_guide' || ['sh0', 'shenhe0'].every((name) => {
const control = getControl(form, name);
const control = getArrangementControl(form, operation, name);
return !control || emptySlotValue(controlValue(control));
}),
realized_clear: ['ap_dy', 'ap_qt'].every((name) => {
const control = getControl(form, name);
const control = getArrangementControl(form, operation, name);
return !control || emptySlotValue(controlValue(control));
}),
value_redacted: true
@@ -1031,6 +1059,16 @@
const mode = arrangement.mode;
const testMode = isObject(contextFor(operation));
const required = action === 'arrangement_guide' ? ['daoyou0', 'daoyouid0', 'daoguan'] : ['id0', 'danwei0', 'danweiid0', 'yf0'];
if (action === 'arrangement_hotel' && mode === 'update') required.push('riqi0', 'riqis0', 'fangxing0', 'jianshu0', 'zt0', 'beizhu0', 'sh0');
if (mode === 'update' && action !== 'arrangement_hotel') {
if (action === 'arrangement_guide') required.push('dianhua0', 'dengji0', 'beizhu0', 'ap_dy');
else {
required.push('riqi0', 'beizhu0', 'sh0', action === 'arrangement_vehicle' ? 'chehao0' : 'shuoming0');
if (action === 'arrangement_vehicle') required.push('riqis0', 'zt0');
if (action === 'arrangement_transport') required.push('zt0');
if (action === 'arrangement_other') required.push('beianhao', 'beianri', 'rujingkou', 'chujingkou', 'ap_qt');
}
}
const itemField = {
arrangement_vehicle: 'chehao0',
arrangement_hotel: 'fangxing0',
@@ -1041,13 +1079,13 @@
? ['daoyou0', 'daoyouid0', 'beizhu0']
: ['id0', 'danwei0', 'danweiid0', itemField, 'beizhu0'].filter(Boolean);
const financialFields = action === 'arrangement_guide' ? [] : ['yf0', 'sh0', 'shenhe0'];
const missingFields = required.filter((name) => !getControl(form, name));
const missingFields = required.filter((name) => !getArrangementControl(form, operation, name));
const blockingFields = mode === 'create' ? occupiedFields.filter((name) => {
const control = getControl(form, name);
const control = getArrangementControl(form, operation, name);
return control && !emptySlotValue(controlValue(control));
}) : [];
const financialBlockers = financialFields.filter((name) => {
const control = getControl(form, name);
const control = getArrangementControl(form, operation, name);
return control && !emptySlotValue(controlValue(control));
});
const filingMismatches = [];
@@ -1060,7 +1098,7 @@
};
for (const [key, name] of Object.entries(filingFields)) {
if (arrangement.filing[key] === undefined) continue;
const existing = controlValue(getControl(form, name));
const existing = controlValue(getArrangementControl(form, operation, name));
const valuesMatch = key === 'date'
? dateYyyyMD(existing) === dateYyyyMD(arrangement.filing[key])
: normalize(existing) === normalize(arrangement.filing[key]);
@@ -1074,31 +1112,38 @@
if ((mode === 'clear' || mode === 'update') && missingFields.length === 0) {
const ref = action === 'arrangement_guide' || action === 'arrangement_hotel' ? arrangement.resource : arrangement.supplier;
const resourceField = action === 'arrangement_guide' ? 'daoyouid0' : 'danweiid0';
if (controlValue(getControl(form, resourceField)) !== nativeRef(ref)) targetMismatches.push(resourceField);
if (action !== 'arrangement_guide' && controlValue(getControl(form, 'id0')) !== String(arrangement.target?.row_id || '')) targetMismatches.push('id0');
if (controlValue(getArrangementControl(form, operation, resourceField)) !== nativeRef(ref)) targetMismatches.push(resourceField);
if (action !== 'arrangement_guide' && controlValue(getArrangementControl(form, operation, 'id0')) !== String(arrangement.target?.row_id || '')) targetMismatches.push('id0');
if (itemField) {
const expectedItem = action === 'arrangement_hotel' ? arrangement.room_type : arrangement.item;
if (!controlMatches(getControl(form, itemField), expectedItem)) targetMismatches.push(itemField);
if (!controlMatches(getArrangementControl(form, operation, itemField), expectedItem)) targetMismatches.push(itemField);
}
if (action === 'arrangement_hotel') {
if (!dateControlMatches(getControl(form, 'riqi0'), arrangement.start_date)) targetMismatches.push('riqi0');
if (!dateControlMatches(getControl(form, 'riqis0'), arrangement.end_date)) targetMismatches.push('riqis0');
if (!controlMatches(getControl(form, 'jianshu0'), arrangement.room_count)) targetMismatches.push('jianshu0');
if (!dateControlMatches(getArrangementControl(form, operation, 'riqi0'), arrangement.start_date)) targetMismatches.push('riqi0');
if (!dateControlMatches(getArrangementControl(form, operation, 'riqis0'), arrangement.end_date)) targetMismatches.push('riqis0');
if (!controlMatches(getArrangementControl(form, operation, 'jianshu0'), arrangement.room_count)) targetMismatches.push('jianshu0');
} else if (action === 'arrangement_vehicle') {
if (!dateControlMatches(getControl(form, 'riqi0'), arrangement.start_date)) targetMismatches.push('riqi0');
if (!dateControlMatches(getControl(form, 'riqis0'), arrangement.end_date)) targetMismatches.push('riqis0');
const quantity = firstControl(form, ['shuliang0', 'tianshu0']);
if (!quantity.control || !controlMatches(quantity.control, arrangement.quantity)) targetMismatches.push(quantity.name || 'shuliang0|tianshu0');
if (!dateControlMatches(getArrangementControl(form, operation, 'riqi0'), arrangement.start_date)) targetMismatches.push('riqi0');
if (!dateControlMatches(getArrangementControl(form, operation, 'riqis0'), arrangement.end_date)) targetMismatches.push('riqis0');
const quantity = firstArrangementControl(form, operation, ['shuliang0', 'tianshu0']);
if (!quantity.control || !(action === 'arrangement_other' && arrangement.quantity === 0 ? emptySlotValue(controlValue(quantity.control)) : controlMatches(quantity.control, arrangement.quantity))) targetMismatches.push(quantity.name || 'shuliang0|tianshu0');
} else if (action !== 'arrangement_guide') {
if (!dateControlMatches(getControl(form, 'riqi0'), arrangement.date)) targetMismatches.push('riqi0');
const quantity = firstControl(form, ['shuliang0', 'tianshu0']);
if (!quantity.control || !controlMatches(quantity.control, arrangement.quantity)) targetMismatches.push(quantity.name || 'shuliang0|tianshu0');
if (!dateControlMatches(getArrangementControl(form, operation, 'riqi0'), arrangement.date)) targetMismatches.push('riqi0');
const quantity = firstArrangementControl(form, operation, ['shuliang0', 'tianshu0']);
if (!quantity.control || !(action === 'arrangement_other' && arrangement.quantity === 0 ? emptySlotValue(controlValue(quantity.control)) : controlMatches(quantity.control, arrangement.quantity))) targetMismatches.push(quantity.name || 'shuliang0|tianshu0');
}
if (getControl(form, 'zt0') && !controlMatches(getControl(form, 'zt0'), arrangement.status)) targetMismatches.push('zt0');
if (normalize(controlValue(getControl(form, 'beizhu0'))) !== normalize(arrangement.remark)
if (getArrangementControl(form, operation, 'zt0') && !controlMatches(getArrangementControl(form, operation, 'zt0'), arrangement.status)) targetMismatches.push('zt0');
if (normalize(controlValue(getArrangementControl(form, operation, 'beizhu0'))) !== normalize(arrangement.remark)
|| (testMode && !normalize(arrangement.remark).includes(TEST_MARKER))) targetMismatches.push('beizhu0');
const realizedField = action === 'arrangement_guide' ? 'ap_dy' : action === 'arrangement_other' ? 'ap_qt' : '';
if (realizedField && getControl(form, realizedField) && !emptySlotValue(controlValue(getControl(form, realizedField)))) targetMismatches.push(realizedField);
if (realizedField && getArrangementControl(form, operation, realizedField) && !emptySlotValue(controlValue(getArrangementControl(form, operation, realizedField)))) targetMismatches.push(realizedField);
}
if (mode === 'update' && action === 'arrangement_other') {
for (const [key, name] of Object.entries({ number: 'beianhao', date: 'beianri', entry_port: 'rujingkou', exit_port: 'chujingkou' })) {
const expected = arrangement.filing?.[key] || '';
const control = getControl(form, name);
if (!(key === 'date' && expected ? dateControlMatches(control, expected) : controlMatchesIncludingEmpty(control, expected))) filingMismatches.push(name);
}
}
const blockers = [
...missingFields.map((name) => `arrangement_slot_guard_field_missing:${name}`),
@@ -1111,7 +1156,7 @@
ready: blockers.length === 0,
empty: mode === 'create' && blockers.length === 0,
mode,
slot_index: 0,
slot_index: operation.data?.arrangement?.target?.slot_index ?? 0,
target: arrangementSlotSnapshot(form, operation),
missing_fields: missingFields,
occupied_fields: blockingFields,
@@ -1188,20 +1233,20 @@
}
function arrangementResourceProof(form, operation) {
const arrangement = operation.data?.arrangement || {};
const arrangement = arrangementFinalState(operation.data?.arrangement || {});
const guide = operation.action === 'arrangement_guide';
const ref = guide || operation.action === 'arrangement_hotel' ? arrangement.resource : arrangement.supplier;
const expectedId = nativeRef(ref);
const expectedName = normalize(ref?.name);
const expectedItem = guide ? '' : normalize(operation.action === 'arrangement_hotel' ? arrangement.room_type : arrangement.item);
const lookup = getControl(form, guide ? 'daoyou0' : 'danwei0');
const lookup = getArrangementControl(form, operation, guide ? 'daoyou0' : 'danwei0');
const raw = String(lookup?.getAttribute?.('data') || '');
const rows = raw.split('◇').map((row) => row.trim()).filter(Boolean).map((row) => row.split('◆').map(normalize));
const matches = rows.filter((row) => {
if (guide) return row[1] === expectedId && row[2] === expectedName;
return row[0] === expectedId && row[1] === expectedName && row[2] === expectedItem;
});
const coordinator = guide ? getControl(form, 'daoguan') : null;
const coordinator = guide ? getArrangementControl(form, operation, 'daoguan') : null;
const linkedGuide = guide && matches.length === 1 ? matches[0] : [];
const expectedCoordinator = guide ? normalize(linkedGuide[5]) : '';
const coordinatorRaw = String(coordinator?.getAttribute?.('data') || '');
@@ -1214,11 +1259,29 @@
if (!lookup) blockers.push('arrangement_resource_lookup_missing');
if (!raw) blockers.push('arrangement_resource_candidate_data_missing');
if (raw && matches.length !== 1) blockers.push(`arrangement_resource_candidate_count:${matches.length}`);
if (operation.action === 'arrangement_vehicle' && arrangement.vehicle_type !== undefined
&& !assistant.matchesVehicleType?.(arrangement.item, arrangement.vehicle_type)) blockers.push('arrangement_vehicle_type_mismatch');
if (operation.action === 'arrangement_transport' && arrangement.ticket_description !== undefined
&& !assistant.matchesTicketDescription?.(arrangement.item, arrangement.ticket_description)) blockers.push('arrangement_ticket_description_mismatch');
const filteredVehicle = operation.action === 'arrangement_vehicle' && arrangement.vehicle_type !== undefined;
const filteredTransport = operation.action === 'arrangement_transport' && arrangement.ticket_description !== undefined;
if (filteredVehicle || filteredTransport) {
const filtered = assistant.inspectArrangementLookupCandidate?.(selectBoxRows(lookup), ref, {
transport: filteredTransport,
vehicleType: filteredVehicle ? arrangement.vehicle_type : undefined,
ticketDescription: filteredTransport ? arrangement.ticket_description : undefined,
searchColumns: filteredVehicle ? [1, 2, 3] : []
});
if (!filtered?.candidate || filtered.match_count !== 1
|| normalize(filtered.candidate.id) !== expectedId || normalize(filtered.candidate.item) !== expectedItem) {
blockers.push(`${operation.action}_filtered_candidate_changed:${filtered?.match_count || 0}`);
}
}
if (guide && !expectedCoordinator) blockers.push('arrangement_guide_coordinator_missing');
if (guide && !coordinator) blockers.push('arrangement_guide_coordinator_field_missing');
if (guide && !coordinatorRaw) blockers.push('arrangement_guide_coordinator_candidate_data_missing');
if (guide && coordinatorRaw && coordinatorMatches.length !== 1) blockers.push(`arrangement_guide_coordinator_candidate_count:${coordinatorMatches.length}`);
if (guide && currentCoordinator && currentCoordinator !== expectedCoordinator) blockers.push('arrangement_guide_coordinator_current_value_mismatch');
if (guide && arrangement.mode !== 'update' && currentCoordinator && currentCoordinator !== expectedCoordinator) blockers.push('arrangement_guide_coordinator_current_value_mismatch');
return {
matched: blockers.length === 0,
lookup_field: guide ? 'daoyou0' : 'danwei0',
@@ -1672,7 +1735,7 @@
'id0', 'yf0', 'sh0', 'shenhe0', 'daoyou0', 'daoyouid0', 'dianhua0', 'dengji0', 'daoguan', 'danwei0', 'danweiid0', 'chehao0',
'fangxing0', 'shuoming0', 'riqi0', 'riqis0', 'shuliang0', 'tianshu0', 'jianshu0',
'zt0', 'beizhu0', 'beianhao', 'beianri', 'rujingkou', 'chujingkou', 'ap_dy', 'ap_qt'
];
].map((name) => arrangementFieldName(operation, name));
}
if (operation.action.startsWith('order_update_')) {
const fields = Object.values(UPDATE_FIELDS[operation.action] || {});
@@ -1949,8 +2012,8 @@
.map((row) => row.split('◆').map((cell) => String(cell || '').trim()));
}
function applyNativeHotelSelection(form, resource, changes, blockers) {
const control = getControl(form, 'danwei0');
function applyNativeHotelSelection(form, resource, changes, blockers, operation = {}) {
const control = getArrangementControl(form, operation, 'danwei0');
if (!control) {
blockers.push('arrangement_hotel_native_selectbox_missing');
return null;
@@ -2004,7 +2067,7 @@
blockers.push('arrangement_hotel_native_selectbox_setval_missing');
return null;
}
const linkedFields = ['danwei0', 'danweiid0', 'fangxing0', 'diqu0', 'dianhua0', 'bizhong0', 'danjia0', 'fangshi0'];
const linkedFields = ['danwei0', 'danweiid0', 'fangxing0', 'diqu0', 'dianhua0', 'bizhong0', 'danjia0', 'fangshi0'].map((name) => arrangementFieldName(operation, name));
const before = Object.fromEntries(linkedFields.map((name) => [name, controlValue(getControl(form, name))]));
try {
// SetValToObj is the same ERP-native mapping used by a real hotel-row
@@ -2018,11 +2081,11 @@
}
const after = Object.fromEntries(linkedFields.map((name) => [name, controlValue(getControl(form, name))]));
changes.push({
field: 'danwei0',
field: control.name,
target: 'arrangement_hotel_native_selectbox',
operation: 'select_first_match',
before: before.danwei0,
after: after.danwei0,
before: before[control.name],
after: after[control.name],
match_count: selected.match_count,
selection: selected.selection,
linked_fields_changed: linkedFields.filter((name) => before[name] !== after[name]),
@@ -2031,11 +2094,10 @@
return selected;
}
function applyNativeSettlementSelection(form, supplier, changes, blockers, action = 'arrangement_transport') {
const prefix = action === 'arrangement_other' ? 'arrangement_other' : 'arrangement_transport';
// The concrete targets remain arrangement_transport_native_selectbox or
// arrangement_other_native_selectbox so result evidence stays action-specific.
const control = getControl(form, 'danwei0');
function applyNativeSettlementSelection(form, supplier, changes, blockers, action = 'arrangement_transport', operation = {}) {
const prefix = action;
// Native selection evidence stays action-specific for vehicle, transport and other.
const control = getArrangementControl(form, operation, 'danwei0');
if (!control) {
blockers.push(`${prefix}_native_selectbox_missing`);
return null;
@@ -2051,13 +2113,23 @@
}
const lookup = assistant.inspectArrangementLookupCandidate(rows, supplier, {
transport: action === 'arrangement_transport',
other: action === 'arrangement_other'
other: action === 'arrangement_other',
vehicleType: action === 'arrangement_vehicle' ? operation.data?.arrangement?.vehicle_type : undefined,
ticketDescription: action === 'arrangement_transport' ? operation.data?.arrangement?.ticket_description : undefined,
searchColumns: action === 'arrangement_vehicle' ? [1, 2, 3] : []
});
if (!lookup?.candidate) {
blockers.push(`${prefix}_native_candidate_count:${lookup?.match_count || 0}`);
return null;
}
const expectedId = nativeRef(supplier);
const explicitlyFiltered = (action === 'arrangement_vehicle' && operation.data?.arrangement?.vehicle_type !== undefined)
|| (action === 'arrangement_transport' && operation.data?.arrangement?.ticket_description !== undefined);
if (explicitlyFiltered && (lookup.match_count !== 1 || String(lookup.candidate.id || '').trim() !== expectedId
|| normalize(lookup.candidate.item) !== normalize(arrangementFinalState(operation.data.arrangement).item))) {
blockers.push(`${action}_filtered_candidate_changed`);
return null;
}
let selected = lookup;
if (expectedId && String(selected.candidate.id || '').trim() !== expectedId) {
// Route preparation resolves the first native row. Reuse its hidden id
@@ -2091,7 +2163,7 @@
blockers.push(`${prefix}_native_selectbox_setval_missing`);
return null;
}
const linkedFields = ['danwei0', 'danweiid0', 'shuoming0', 'bizhong0', 'danjia0', 'fangshi0'];
const linkedFields = ['danwei0', 'danweiid0', action === 'arrangement_vehicle' ? 'chehao0' : 'shuoming0', 'bizhong0', 'danjia0', 'fangshi0'].map((name) => arrangementFieldName(operation, name));
const before = Object.fromEntries(linkedFields.map((name) => [name, controlValue(getControl(form, name))]));
try {
// The same native mapping used by a real settlement-unit row click fills
@@ -2105,11 +2177,11 @@
}
const after = Object.fromEntries(linkedFields.map((name) => [name, controlValue(getControl(form, name))]));
changes.push({
field: 'danwei0',
field: control.name,
target: `${prefix}_native_selectbox`,
operation: 'select_first_match',
before: before.danwei0,
after: after.danwei0,
before: before[control.name],
after: after[control.name],
match_count: selected.match_count,
selection: selected.selection,
linked_fields_changed: linkedFields.filter((name) => before[name] !== after[name]),
@@ -2118,8 +2190,8 @@
return selected;
}
function applyNativeTransportSelection(form, supplier, changes, blockers) {
return applyNativeSettlementSelection(form, supplier, changes, blockers, 'arrangement_transport');
function applyNativeTransportSelection(form, supplier, changes, blockers, operation) {
return applyNativeSettlementSelection(form, supplier, changes, blockers, 'arrangement_transport', operation);
}
function applyNativeOtherSelection(form, supplier, changes, blockers) {
@@ -2227,39 +2299,73 @@
return { changes, blockers, arrangementSlot: slot, clearedTarget: slot.target };
}
async function applyGuideResource(form, ref, changes, blockers) {
const refId = nativeRef(ref);
const lookup = getControl(form, 'daoyou0');
const candidateRows = String(lookup?.getAttribute?.('data') || '').split('◇')
.map((row) => row.trim()).filter(Boolean).map((row) => row.split('◆').map(normalize));
const linkedCandidates = candidateRows.filter((row) => row[1] === refId && row[2] === normalize(ref?.name));
if (linkedCandidates.length !== 1) blockers.push(`arrangement_guide_linked_candidate_count:${linkedCandidates.length}`);
const linkedCandidate = linkedCandidates[0] || [];
const coordinator = normalize(linkedCandidate[5]);
if (!coordinator) blockers.push('arrangement_guide_linked_coordinator_missing');
if (blockers.length) return;
applyExact(form, 'daoyou0', 'set', ref?.name, changes, blockers);
applyExact(form, 'daoyouid0', 'set', refId, changes, blockers);
applyExact(form, 'dianhua0', 'set', linkedCandidate[3] || '', changes, blockers);
applyExact(form, 'dengji0', 'set', linkedCandidate[4] || '', changes, blockers);
applyExact(form, 'daoguan', 'set', coordinator, changes, blockers);
if (!await waitForStable(form, ['daoyou0', 'daoyouid0', 'dianhua0', 'dengji0', 'daoguan'])) blockers.push('linked_fields_not_stable');
requireControlValue(form, 'daoyouid0', refId, blockers);
requireControlValue(form, 'daoguan', coordinator, blockers);
}
async function applyArrangementUpdate(form, operation, slot) {
const action = operation.action;
const arrangement = operation.data?.arrangement || {};
const updates = arrangement.changes || {};
const changes = [];
const blockers = [];
const otherRows = Array.from(form.elements || []).filter((control) => {
const match = /^([a-zA-Z_]+)(\d+)$/.exec(control.name);
return match && Number(match[2]) !== arrangement.target?.slot_index;
}).map((control) => ({ control, value: controlValue(control) }));
const annotate = (beforeCount, target) => {
for (let index = beforeCount; index < changes.length; index += 1) changes[index].target = `arrangement.${target}`;
};
const applyField = (target, field, value, options = {}) => {
const { date = false, ...applyOptions } = options;
const control = getControl(form, field);
const control = getArrangementControl(form, operation, field);
const alreadyCurrent = control && (date
? dateControlMatches(control, value)
: controlMatches(control, value));
if (alreadyCurrent) return;
const beforeCount = changes.length;
applyExact(form, field, 'set', value, changes, blockers, applyOptions);
applyExact(form, arrangementFieldName(operation, field), 'set', value, changes, blockers, applyOptions);
annotate(beforeCount, target);
};
const applyQuantity = (target, names, value) => {
const current = firstControl(form, names);
const current = firstArrangementControl(form, operation, names);
if (current.control && controlMatches(current.control, value)) return;
const beforeCount = changes.length;
applyFirst(form, names, 'set', value, changes, blockers);
applyFirst(form, names.map((name) => arrangementFieldName(operation, name)), 'set', value, changes, blockers);
annotate(beforeCount, target);
};
if (Object.hasOwn(updates, 'item')) {
if (Object.hasOwn(updates, 'item') && !updates.supplier) {
const field = action === 'arrangement_vehicle' ? 'chehao0' : 'shuoming0';
applyField('item', field, updates.item);
}
if (Object.hasOwn(updates, 'room_type')) applyField('room_type', 'fangxing0', updates.room_type);
if (updates.resource) {
if (action === 'arrangement_guide') await applyGuideResource(form, updates.resource, changes, blockers);
else applyNativeHotelSelection(form, updates.resource, changes, blockers, operation);
if (blockers.length) return { changes, blockers, arrangementSlot: slot };
}
if (updates.supplier) {
applyNativeSettlementSelection(form, updates.supplier, changes, blockers, action, operation);
if (blockers.length) return { changes, blockers, arrangementSlot: slot };
}
if (Object.hasOwn(updates, 'room_type') && !updates.resource) applyField('room_type', 'fangxing0', updates.room_type);
if (Object.hasOwn(updates, 'date')) {
applyField('date', 'riqi0', updates.date, { date: true });
applyField('date', 'riqis0', updates.date, { date: true, optional: true });
@@ -2268,72 +2374,91 @@
if (Object.hasOwn(updates, 'end_date')) applyField('end_date', 'riqis0', updates.end_date, { date: true });
const linkageFields = [
...((updates.resource || updates.supplier) ? ['danwei0', 'danweiid0', 'daoyou0', 'daoyouid0', 'daoguan', 'bizhong0', 'danjia0', 'fangshi0'] : []),
Object.hasOwn(updates, 'item') ? (action === 'arrangement_vehicle' ? 'chehao0' : 'shuoming0') : '',
Object.hasOwn(updates, 'room_type') ? 'fangxing0' : '',
Object.hasOwn(updates, 'date') || Object.hasOwn(updates, 'start_date') ? 'riqi0' : '',
Object.hasOwn(updates, 'date') || Object.hasOwn(updates, 'end_date') ? 'riqis0' : ''
].filter(Boolean);
].filter(Boolean).map((name) => arrangementFieldName(operation, name));
if (linkageFields.length && !await waitForStable(form, linkageFields)) blockers.push('arrangement_update_linked_fields_not_stable');
if (Object.hasOwn(updates, 'quantity')) {
const names = action === 'arrangement_vehicle' ? ['shuliang0', 'tianshu0'] : ['shuliang0'];
applyQuantity('quantity', names, updates.quantity);
}
if (action === 'arrangement_other' && !Object.hasOwn(updates, 'quantity') && arrangement.quantity !== undefined) {
const quantity = firstArrangementControl(form, operation, ['shuliang0']);
const unchanged = quantity.control && (arrangement.quantity === 0 ? emptySlotValue(controlValue(quantity.control)) : controlMatches(quantity.control, arrangement.quantity));
if (!unchanged) applyQuantity('quantity', ['shuliang0'], arrangement.quantity);
}
if (Object.hasOwn(updates, 'room_count')) applyField('room_count', 'jianshu0', updates.room_count);
if (updates.filing) {
for (const [key, name] of Object.entries({ number: 'beianhao', entry_port: 'rujingkou', exit_port: 'chujingkou' })) {
if (Object.hasOwn(updates.filing, key)) applyField(`filing.${key}`, name, updates.filing[key]);
}
}
if (Object.hasOwn(updates, 'remark')) applyField('remark', 'beizhu0', updates.remark);
else if (!controlMatchesIncludingEmpty(getControl(form, 'beizhu0'), arrangement.remark)) {
else if (!controlMatchesIncludingEmpty(getArrangementControl(form, operation, 'beizhu0'), arrangement.remark)) {
// Native update_CZR() clears the existing remark when linked date/quantity
// controls blur. Restore the frozen value after all linked callbacks so an
// unrelated field update cannot erase the business remark.
const restored = setControl(getControl(form, 'beizhu0'), 'set', arrangement.remark);
if (!restored.ok || !controlMatchesIncludingEmpty(getControl(form, 'beizhu0'), arrangement.remark)) {
const restored = setControl(getArrangementControl(form, operation, 'beizhu0'), 'set', arrangement.remark);
if (!restored.ok || !controlMatchesIncludingEmpty(getArrangementControl(form, operation, 'beizhu0'), arrangement.remark)) {
blockers.push('arrangement_update_remark_restore_failed');
}
}
const finalState = arrangementFinalState(arrangement);
const assertions = [];
if (action === 'arrangement_hotel' && updates.resource) assertions.push(['danwei0', finalState.resource.name, false], ['riqi0', finalState.start_date, true], ['riqis0', finalState.end_date, true]);
if (updates.supplier) {
assertions.push(['danwei0', finalState.supplier.name, false], ['riqi0', finalState.start_date || finalState.date, true]);
if (action === 'arrangement_vehicle') assertions.push(['riqis0', finalState.end_date, true]);
}
if (Object.hasOwn(updates, 'item')) assertions.push([action === 'arrangement_vehicle' ? 'chehao0' : 'shuoming0', finalState.item, false]);
if (Object.hasOwn(updates, 'room_type')) assertions.push(['fangxing0', finalState.room_type, false]);
if (Object.hasOwn(updates, 'date')) assertions.push(['riqi0', finalState.date, true]);
if (Object.hasOwn(updates, 'start_date')) assertions.push(['riqi0', finalState.start_date, true]);
if (Object.hasOwn(updates, 'end_date')) assertions.push(['riqis0', finalState.end_date, true]);
if (Object.hasOwn(updates, 'quantity')) {
const quantity = firstControl(form, action === 'arrangement_vehicle' ? ['shuliang0', 'tianshu0'] : ['shuliang0']);
if (!quantity.control || !controlMatches(quantity.control, finalState.quantity)) blockers.push('arrangement_update_quantity_mismatch');
if (finalState.quantity !== undefined) {
const quantity = firstArrangementControl(form, operation, action === 'arrangement_vehicle' ? ['shuliang0', 'tianshu0'] : ['shuliang0']);
if (!quantity.control || !(action === 'arrangement_other' && finalState.quantity === 0 ? emptySlotValue(controlValue(quantity.control)) : controlMatches(quantity.control, finalState.quantity))) blockers.push('arrangement_update_quantity_mismatch');
}
if (Object.hasOwn(updates, 'room_count')) assertions.push(['jianshu0', finalState.room_count, false]);
if (Object.hasOwn(updates, 'remark')) assertions.push(['beizhu0', finalState.remark, false]);
for (const [field, expected, isDate] of assertions) {
const control = getControl(form, field);
const control = getArrangementControl(form, operation, field);
const matched = isDate ? dateControlMatches(control, expected) : controlMatches(control, expected);
if (!matched) blockers.push(`arrangement_update_value_mismatch:${field}`);
}
const ref = action === 'arrangement_guide' || action === 'arrangement_hotel' ? finalState.resource : finalState.supplier;
const resourceField = action === 'arrangement_guide' ? 'daoyouid0' : 'danweiid0';
if (controlValue(getControl(form, resourceField)) !== nativeRef(ref)) blockers.push('arrangement_update_resource_changed');
if (action !== 'arrangement_guide' && controlValue(getControl(form, 'id0')) !== String(arrangement.target?.row_id || '')) blockers.push('arrangement_update_row_id_changed');
if (getControl(form, 'zt0')) {
if (controlValue(getArrangementControl(form, operation, resourceField)) !== nativeRef(ref)) blockers.push('arrangement_update_resource_changed');
if (action !== 'arrangement_guide' && controlValue(getArrangementControl(form, operation, 'id0')) !== String(arrangement.target?.row_id || '')) blockers.push('arrangement_update_row_id_changed');
if (getArrangementControl(form, operation, 'zt0')) {
const statusMatches = action === 'arrangement_hotel'
? [...HOTEL_ARRANGEMENT_STATUSES].some((status) => controlMatches(getControl(form, 'zt0'), status))
: controlMatches(getControl(form, 'zt0'), '未确认');
? controlMatches(getArrangementControl(form, operation, 'zt0'), arrangement.status)
: controlMatches(getArrangementControl(form, operation, 'zt0'), '未确认');
if (!statusMatches) blockers.push('arrangement_update_status_changed');
}
if (!controlMatchesIncludingEmpty(getControl(form, 'beizhu0'), finalState.remark)) blockers.push('arrangement_update_remark_mismatch');
if (!controlMatchesIncludingEmpty(getArrangementControl(form, operation, 'beizhu0'), finalState.remark)) blockers.push('arrangement_update_remark_mismatch');
for (const name of action === 'arrangement_guide' ? [] : ['yf0', 'sh0', 'shenhe0']) {
if (getControl(form, name) && !emptySlotValue(controlValue(getControl(form, name)))) blockers.push(`arrangement_update_financial_or_audit_state:${name}`);
if (getArrangementControl(form, operation, name) && !emptySlotValue(controlValue(getArrangementControl(form, operation, name)))) blockers.push(`arrangement_update_financial_or_audit_state:${name}`);
}
if (action === 'arrangement_other' && isObject(arrangement.filing)) {
if (action === 'arrangement_other' && isObject(finalState.filing)) {
const filingFields = { number: 'beianhao', date: 'beianri', entry_port: 'rujingkou', exit_port: 'chujingkou' };
for (const [key, name] of Object.entries(filingFields)) {
if (arrangement.filing[key] === undefined) continue;
const actual = controlValue(getControl(form, name));
const matched = key === 'date'
? dateYyyyMD(actual) === dateYyyyMD(arrangement.filing[key])
: normalize(actual) === normalize(arrangement.filing[key]);
const expected = finalState.filing[key] || '';
const actual = controlValue(getArrangementControl(form, operation, name));
const matched = key === 'date' && expected
? dateYyyyMD(actual) === dateYyyyMD(expected)
: normalize(actual) === normalize(expected);
if (!matched) blockers.push(`arrangement_filing_changed_during_update:${name}`);
}
}
const realized = action === 'arrangement_guide' ? 'ap_dy' : action === 'arrangement_other' ? 'ap_qt' : '';
if (realized && !emptySlotValue(controlValue(getControl(form, realized)))) blockers.push('arrangement_update_realized_state_changed');
if (otherRows.some(({ control, value }) => controlValue(control) !== value)) blockers.push('arrangement_update_other_row_changed');
if (!changes.length) blockers.push('arrangement_update_no_effect');
return { changes, blockers, arrangementSlot: slot, updatedTarget: slot.target };
}
@@ -2345,6 +2470,8 @@
const action = operation.action;
const slot = arrangementSlotEvidence(form, operation);
if (!slot.ready) blockers.push(...slot.blockers);
if ((action === 'arrangement_vehicle' && value.vehicle_type !== undefined)
|| (action === 'arrangement_transport' && value.ticket_description !== undefined)) blockers.push(...arrangementResourceProof(form, operation).blockers);
if (blockers.length) return { changes, blockers, arrangementSlot: slot };
if (value.mode === 'clear') return await applyArrangementClear(form, operation, slot);
if (value.mode === 'update') return await applyArrangementUpdate(form, operation, slot);
@@ -2353,29 +2480,14 @@
if (!refId) blockers.push('resolved_resource_id_missing');
if (action === 'arrangement_guide') {
const lookup = getControl(form, 'daoyou0');
const candidateRows = String(lookup?.getAttribute?.('data') || '').split('◇')
.map((row) => row.trim()).filter(Boolean).map((row) => row.split('◆').map(normalize));
const linkedCandidates = candidateRows.filter((row) => row[1] === refId && row[2] === normalize(ref?.name));
if (linkedCandidates.length !== 1) blockers.push(`arrangement_guide_linked_candidate_count:${linkedCandidates.length}`);
const linkedCandidate = linkedCandidates[0] || [];
const coordinator = normalize(linkedCandidate[5]);
if (!coordinator) blockers.push('arrangement_guide_linked_coordinator_missing');
applyExact(form, 'daoyou0', 'set', ref?.name, changes, blockers);
applyExact(form, 'daoyouid0', 'set', refId, changes, blockers);
applyExact(form, 'dianhua0', 'set', linkedCandidate[3] || '', changes, blockers);
applyExact(form, 'dengji0', 'set', linkedCandidate[4] || '', changes, blockers);
applyExact(form, 'daoguan', 'set', coordinator, changes, blockers);
if (!await waitForStable(form, ['daoyou0', 'daoyouid0', 'dianhua0', 'dengji0', 'daoguan'])) blockers.push('linked_fields_not_stable');
requireControlValue(form, 'daoyouid0', refId, blockers);
requireControlValue(form, 'daoguan', coordinator, blockers);
await applyGuideResource(form, ref, changes, blockers);
applyExact(form, 'beizhu0', 'set', value.remark, changes, blockers);
applyExact(form, 'ap_dy', 'set', false, changes, blockers, { optional: true });
return { changes, blockers, arrangementSlot: slot };
}
if (action === 'arrangement_hotel') applyNativeHotelSelection(form, ref, changes, blockers);
else if (action === 'arrangement_transport') applyNativeTransportSelection(form, ref, changes, blockers);
else if (action === 'arrangement_transport') applyNativeTransportSelection(form, ref, changes, blockers, operation);
else if (action === 'arrangement_other') applyNativeOtherSelection(form, ref, changes, blockers);
else {
applyExact(form, 'danwei0', 'set', ref?.name, changes, blockers);
@@ -3553,12 +3665,13 @@
const fields = [];
const blockers = [];
const add = (name, expected, options = {}) => {
name = arrangementFieldName(operation, name);
const control = getControl(form, name);
if (!control) {
if (!options.optional) blockers.push(`arrangement_requery_field_missing:${name}`);
return;
}
fields.push({ name, expected, date: options.date === true, checkbox: options.checkbox === true });
fields.push({ name, expected, date: options.date === true, checkbox: options.checkbox === true, numeric: options.numeric === true });
};
if (action === 'arrangement_guide') {
@@ -3587,6 +3700,7 @@
return { fields, blockers };
}
if (arrangement.mode === 'update') add('id0', arrangement.target?.row_id);
const ref = action === 'arrangement_hotel' ? arrangement.resource : arrangement.supplier;
add('danwei0', ref?.name);
add('danweiid0', nativeRef(ref));
@@ -3598,24 +3712,24 @@
add('riqis0', arrangement.end_date, { date: true });
if (action === 'arrangement_hotel') add('jianshu0', arrangement.room_count);
else {
const quantity = firstControl(form, ['shuliang0', 'tianshu0']);
const quantity = firstArrangementControl(form, operation, ['shuliang0', 'tianshu0']);
if (!quantity.control) blockers.push('arrangement_requery_quantity_field_missing');
else add(quantity.name, arrangement.quantity);
else add(quantity.name, arrangement.quantity, { numeric: action === 'arrangement_other' && arrangement.quantity === 0 });
}
} else {
add('riqi0', arrangement.date, { date: true });
const quantity = firstControl(form, ['shuliang0', 'tianshu0']);
const quantity = firstArrangementControl(form, operation, ['shuliang0', 'tianshu0']);
if (!quantity.control && action !== 'arrangement_other') blockers.push('arrangement_requery_quantity_field_missing');
else if (quantity.control && (action !== 'arrangement_other' || arrangement.quantity !== undefined)) add(quantity.name, arrangement.quantity);
else if (quantity.control && (action !== 'arrangement_other' || arrangement.quantity !== undefined)) add(quantity.name, arrangement.quantity, { numeric: action === 'arrangement_other' && arrangement.quantity === 0 });
}
add('zt0', arrangement.status, { optional: action === 'arrangement_other' });
add('beizhu0', arrangement.remark);
if (action === 'arrangement_other') {
const filing = arrangement.filing || {};
if (filing.number !== undefined) add('beianhao', filing.number);
if (filing.date !== undefined) add('beianri', filing.date, { date: true });
if (filing.entry_port !== undefined) add('rujingkou', filing.entry_port);
if (filing.exit_port !== undefined) add('chujingkou', filing.exit_port);
if (filing.number !== undefined || arrangement.mode === 'update') add('beianhao', filing.number || '');
if (filing.date !== undefined || arrangement.mode === 'update') add('beianri', filing.date || '', { date: Boolean(filing.date) });
if (filing.entry_port !== undefined || arrangement.mode === 'update') add('rujingkou', filing.entry_port || '');
if (filing.exit_port !== undefined || arrangement.mode === 'update') add('chujingkou', filing.exit_port || '');
add('ap_qt', false, { checkbox: true, optional: true });
}
return { fields, blockers };
@@ -3632,7 +3746,9 @@
const actual = field.checkbox ? Boolean(control?.checked) : controlValue(control);
const matched = field.checkbox
? actual === Boolean(field.expected)
: field.date
: field.numeric
? Number(actual) === Number(field.expected)
: field.date
? dateYyyyMD(actual) === dateYyyyMD(field.expected)
: normalize(actual) === normalize(field.expected);
return { field: field.name, expected: String(field.expected ?? ''), actual: String(actual ?? ''), matched };
@@ -3642,7 +3758,10 @@
const testMode = isObject(contextFor(operation));
const markerPresent = !testMode || page.text.includes(TEST_MARKER);
const accountPresent = !testMode || Boolean(context.account && page.text.includes(context.account));
const arrangementTarget = form ? arrangementSlotSnapshot(form, operation) : null;
const arrangementTarget = form ? arrangementSlotSnapshot(form, {
...operation,
data: { ...operation.data, arrangement: arrangementFinalState(operation.data?.arrangement || {}) }
}) : null;
return {
matched: page.response.ok && !page.login_timeout && !page.permission_error && Boolean(form)
&& identity.matched && markerPresent && projection.blockers.length === 0
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "联泰下单助手",
"version": "0.5.173",
"version": "0.5.177",
"description": "在已登录 LTJT ERP 页面内规划并受控执行联泰 ERP 业务操作。",
"permissions": [
"activeTab",
@@ -56,14 +56,14 @@
label: '团队安排-导游',
route: 'arrangement_guide',
execution: 'browser_lifecycle',
capabilityState: 'validated_unconfirmed_create_clear_only',
capabilityState: 'native_create_clear_program_update_pending_live_acceptance',
source: 'native-live-validated-2026-09'
},
arrangement_vehicle: {
label: '团队安排-车辆',
route: 'arrangement_vehicle',
execution: 'browser_lifecycle',
capabilityState: 'validated_unconfirmed_create_clear_only',
capabilityState: 'native_create_clear_program_update_pending_live_acceptance',
source: 'native-live-validated-2026-09'
},
arrangement_hotel: {
@@ -77,14 +77,14 @@
label: '团队安排-大交通',
route: 'arrangement_transport',
execution: 'browser_lifecycle',
capabilityState: 'validated_unconfirmed_create_clear_only',
capabilityState: 'native_create_clear_program_update_pending_live_acceptance',
source: 'native-live-validated-2026-09'
},
arrangement_other: {
label: '团队安排-其他/备案',
route: 'arrangement_other',
execution: 'browser_lifecycle',
capabilityState: 'validated_unconfirmed_create_clear_preserve_filing_only',
capabilityState: 'native_create_clear_program_update_preserve_filing_date_pending_live_acceptance',
source: 'native-live-validated-2026-09'
},
order_update_independent: {
@@ -234,11 +234,11 @@
...['adult', 'child_bed', 'child_no_bed', 'infant', 'leader'].map((key) => `prices.${key}`)
]));
const ARRANGEMENT_UPDATE_KEYS = Object.freeze({
arrangement_guide: new Set(),
arrangement_vehicle: new Set(),
arrangement_hotel: new Set(['end_date', 'room_count']),
arrangement_transport: new Set(),
arrangement_other: new Set()
arrangement_guide: new Set(["resource", "remark"]),
arrangement_vehicle: new Set(["supplier", "item", "quantity", "remark"]),
arrangement_hotel: new Set(['end_date', 'room_count', 'resource', 'room_type', 'remark']),
arrangement_transport: new Set(["supplier", "item", "quantity", "remark"]),
arrangement_other: new Set(["supplier", "item", "quantity", "remark", "filing"])
});
const HOTEL_ARRANGEMENT_STATUSES = new Set(['未安排', '未确认']);
const PASSENGER_HEADERS = Object.freeze([
@@ -856,7 +856,11 @@
blockers.push('安排 action 必须包含 data.arrangement 对象。');
return;
}
const allowed = new Set(['mode', 'target', 'changes', 'resource', 'supplier', 'item', 'room_type', 'date', 'start_date', 'end_date', 'quantity', 'room_count', 'status', 'remark', 'side_effect_policy', 'marker', 'filing', 'preserve_filing']);
if (value.vehicle_type !== undefined && (action !== 'arrangement_vehicle' || !['create', 'update'].includes(value.mode)
|| typeof value.vehicle_type !== 'string' || !value.vehicle_type.trim())) blockers.push('用车类型只允许用于安排用车新增/变更,且必须是非空文本。');
if (value.ticket_description !== undefined && (action !== 'arrangement_transport' || !['create', 'update'].includes(value.mode)
|| typeof value.ticket_description !== 'string' || !value.ticket_description.trim())) blockers.push('车票说明只允许用于安排大交通新增/变更,且必须是非空文本。');
const allowed = new Set(['mode', 'target', 'changes', 'resource', 'supplier', 'item', 'room_type', 'date', 'start_date', 'end_date', 'quantity', 'room_count', 'status', 'remark', 'side_effect_policy', 'marker', 'filing', 'preserve_filing', 'vehicle_type', 'ticket_description']);
if (Object.keys(value).some((key) => !allowed.has(key))) blockers.push('data.arrangement 包含未声明字段。');
if (!['create', 'update', 'clear'].includes(value.mode)) blockers.push('data.arrangement.mode 必须是 create、update 或 clear。');
if (value.mode === 'create' && value.target !== undefined) blockers.push('create 模式必须由 ERP 页面证明空槽位,不能预填 target。');
@@ -864,16 +868,26 @@
if (!isPlainObject(value.target)) blockers.push(`${value.mode} 模式必须提供 data.arrangement.target。`);
else {
if (Object.keys(value.target).some((key) => !new Set(['slot_index', 'row_id']).has(key))) blockers.push('data.arrangement.target 只允许 slot_index 和 row_id。');
if (value.target.slot_index !== 0) blockers.push(`当前安排 ${value.mode} 只允许精确槽位 0。`);
if (action !== 'arrangement_guide' && value.mode === 'update'
? !Number.isInteger(value.target.slot_index) || value.target.slot_index < 0
: value.target.slot_index !== 0) blockers.push(`当前安排 ${value.mode} 的目标槽位无效。`);
if (action !== 'arrangement_guide' && (typeof value.target.row_id !== 'string' || !value.target.row_id.trim())) blockers.push(`${action} ${value.mode} 必须提供原生 row_id。`);
}
}
if (value.mode === 'update') {
const changes = value.changes;
const updateKeys = ARRANGEMENT_UPDATE_KEYS[action] || new Set();
if (action !== 'arrangement_hotel') blockers.push(`${action} 尚未实测开放 update;当前只开放未确认酒店安排的离店日期和房间数。`);
if (!isPlainObject(changes) || Object.keys(changes).length === 0) blockers.push('update 模式必须提供非空 data.arrangement.changes。');
for (const key of Object.keys(changes || {})) if (!updateKeys.has(key)) blockers.push(`${action} 尚未实测开放安排变更字段:${key}。`);
if (changes?.resource !== undefined) {
validateResolvedNamedRef(changes.resource, 'data.arrangement.changes.resource', blockers, { required: true });
if (action === 'arrangement_hotel' && (typeof changes.room_type !== 'string' || !changes.room_type.trim())) blockers.push('更换酒店必须由 ERP 联动解析新房型。');
} else if (changes?.room_type !== undefined) blockers.push(`${action} 尚未实测开放安排变更字段:room_type。`);
if (changes?.supplier !== undefined) {
validateResolvedNamedRef(changes.supplier, 'arrangement.changes.supplier', blockers, { required: true });
if (typeof changes.item !== 'string' || !changes.item.trim()) blockers.push('变更项目必须由 ERP 供应商候选联动带入。');
} else if (changes?.item !== undefined) blockers.push('禁止单独修改联动项目。');
if (changes?.filing !== undefined && (!isPlainObject(changes.filing) || !Object.keys(changes.filing).length || Object.keys(changes.filing).some((key) => !['number', 'entry_port', 'exit_port'].includes(key)) || Object.values(changes.filing).some((v) => typeof v !== 'string' || !v.trim()))) blockers.push('备案变更字段无效。');
if (!arrangementStatusAllowed(action, value.mode, value.status, { testMode })) blockers.push(action === 'arrangement_hotel'
? 'update 只能作用于 ERP 当前“未安排”或“未确认”酒店安排。'
: 'update 只能作用于 ERP 当前“未确认”安排。');
@@ -884,7 +898,7 @@
if (action === 'arrangement_other' && !isPlainObject(value.filing)) blockers.push('arrangement_other clear 必须携带待保留备案快照。');
}
if (value.mode !== 'clear' && value.preserve_filing !== undefined) blockers.push('preserve_filing 只允许用于 arrangement_other clear。');
const finalValue = value.mode === 'update' && isPlainObject(value.changes) ? { ...value, ...value.changes } : value;
const finalValue = value.mode === 'update' && isPlainObject(value.changes) ? { ...value, ...value.changes, ...(value.changes.filing ? { filing: { ...value.filing, ...value.changes.filing } } : {}) } : value;
if (action === 'arrangement_guide' || action === 'arrangement_hotel') {
validateResolvedNamedRef(value.resource, 'data.arrangement.resource', blockers, { required: true });
} else if (value.resource !== undefined) {
@@ -927,7 +941,7 @@
if (finalValue[key] !== undefined && (!isValidDate(finalValue[key]) || (testMode && !finalValue[key].startsWith('2026-09-')))) blockers.push(testMode ? `data.arrangement.${key} 必须是 2026 年 9 月有效日期。` : `data.arrangement.${key} 必须是有效的 YYYY-MM-DD 日期。`);
}
for (const key of ['quantity', 'room_count']) {
if (finalValue[key] !== undefined && (!Number.isInteger(finalValue[key]) || finalValue[key] <= 0)) blockers.push(`data.arrangement.${key} 必须是正整数。`);
if (finalValue[key] !== undefined && (!Number.isInteger(finalValue[key]) || (finalValue[key] <= 0 && !(key === 'quantity' && action === 'arrangement_other' && value.mode === 'update' && value.changes?.quantity === undefined && finalValue[key] === 0)))) blockers.push(`data.arrangement.${key} 必须是正整数。`);
}
if (action === 'arrangement_hotel' && isValidDate(finalValue.start_date) && isValidDate(finalValue.end_date)
&& Date.parse(`${finalValue.end_date}T00:00:00Z`) <= Date.parse(`${finalValue.start_date}T00:00:00Z`)) blockers.push('酒店安排 end_date 必须晚于 start_date。');
@@ -1158,26 +1172,75 @@
if (manualReviewTargets.length) warnings.push(`已解析但尚未纳入当前自动写入白名单的字段将转人工复核:${[...new Set(manualReviewTargets)].join('、')}。`);
}
function validateOriginalArrangementUpdate(value, action, errors) {
const guide = action === 'arrangement_guide';
const vehicle = action === 'arrangement_vehicle';
const other = action === 'arrangement_other';
const keys = new Set(guide ? ['resource', 'remark'] : ['supplier', 'quantity', 'remark', ...(other ? ['filing'] : [])]);
if (Object.keys(value).some((key) => !['mode', 'selection', 'changes', ...(vehicle ? ['vehicle_type'] : []), ...(action === 'arrangement_transport' ? ['ticket_description'] : [])].includes(key))) errors.push('安排变更包含未声明字段。');
const changes = value.changes;
if (!isPlainObject(changes)) { errors.push('安排变更缺少新的目标值。'); return; }
if (Object.keys(changes).some((key) => !keys.has(key))) errors.push('安排变更日期仅用于定位,不能修改日期或直接指定联动项目。');
validateDispatchNamedRef(changes[guide ? 'resource' : 'supplier'], 'arrangement.changes.resource_or_supplier', errors, { required: true });
if (changes.quantity !== undefined && (!Number.isInteger(changes.quantity) || changes.quantity < 1)) errors.push('安排变更数量必须为正整数。');
if ((vehicle || action === 'arrangement_transport') && changes.quantity === undefined) errors.push('安排变更缺少数量。');
if (changes.remark !== undefined && (typeof changes.remark !== 'string' || !changes.remark.trim())) errors.push('安排变更备注必须为非空文本。');
if (guide) {
if (value.selection !== undefined) errors.push('导游变更按团号定位唯一已有导游,不接受日期。');
} else {
const selection = value.selection;
const dateKeys = vehicle ? ['start_date', 'end_date'] : ['date', ...(other ? ['filing_date'] : [])];
if (!isPlainObject(selection) || Object.keys(selection).some((key) => !dateKeys.includes(key))) errors.push('安排变更需要原安排日期。');
if (vehicle) {
if (!isValidDate(selection?.start_date) || !isValidDate(selection?.end_date) || selection.end_date < selection.start_date) errors.push('原用车日期范围无效。');
} else if (!isValidDate(selection?.date)) errors.push('原业务日期无效。');
if (selection?.filing_date !== undefined && !isValidDate(selection.filing_date)) errors.push('原备案日期无效。');
}
if (changes.filing !== undefined) {
if (!other || !isPlainObject(changes.filing) || !Object.keys(changes.filing).length
|| Object.keys(changes.filing).some((key) => !['number', 'entry_port', 'exit_port'].includes(key))
|| Object.values(changes.filing).some((v) => typeof v !== 'string' || !v.trim())) errors.push('备案变更只允许明确填写的备案号和入出境口岸;日期保持原值。');
}
}
function validateDispatchArrangement(value, action, blockers) {
if (!isPlainObject(value)) {
blockers.push('data.arrangement 必须是对象。');
return;
}
if (value.vehicle_type !== undefined && (action !== 'arrangement_vehicle' || !['create', 'update'].includes(value.mode)
|| typeof value.vehicle_type !== 'string' || !value.vehicle_type.trim())) blockers.push('用车类型只允许用于安排用车新增/变更,且必须是非空文本。');
if (value.ticket_description !== undefined && (action !== 'arrangement_transport' || !['create', 'update'].includes(value.mode)
|| typeof value.ticket_description !== 'string' || !value.ticket_description.trim())) blockers.push('车票说明只允许用于安排大交通新增/变更,且必须是非空文本。');
if (value.mode === 'update' && action !== 'arrangement_hotel') {
validateOriginalArrangementUpdate(value, action, blockers);
return;
}
const common = {
arrangement_guide: new Set(['mode', 'resource', 'remark']),
arrangement_vehicle: new Set(['mode', 'supplier', 'start_date', 'end_date', 'quantity', 'remark']),
arrangement_transport: new Set(['mode', 'supplier', 'item', 'quantity', 'date', 'remark']),
arrangement_vehicle: new Set(['mode', 'supplier', 'start_date', 'end_date', 'quantity', 'remark', 'vehicle_type']),
arrangement_transport: new Set(['mode', 'supplier', 'item', 'quantity', 'date', 'remark', 'ticket_description']),
arrangement_other: new Set(['mode', 'supplier', 'item', 'quantity', 'date', 'remark', 'filing'])
};
const allowed = action === 'arrangement_hotel'
? value.mode === 'update' ? new Set(['mode', 'changes']) : new Set(['mode', 'resource', 'room_type', 'start_date', 'end_date', 'room_count', 'remark'])
? value.mode === 'update' ? new Set(['mode', 'changes', 'selection']) : new Set(['mode', 'resource', 'room_type', 'start_date', 'end_date', 'room_count', 'remark'])
: common[action] || new Set();
if (Object.keys(value).some((key) => !allowed.has(key))) blockers.push('data.arrangement 包含当前业务未开放或执行态字段。');
if (action === 'arrangement_hotel' && value.mode === 'update') {
const changes = value.changes;
if (!isPlainObject(changes) || !Object.keys(changes).length || Object.keys(changes).some((key) => !new Set(['end_date', 'room_count']).has(key))) blockers.push('酒店安排 update 只允许非空 changes.end_date/room_count。');
if (!isPlainObject(changes) || !Object.keys(changes).length || Object.keys(changes).some((key) => !new Set(['end_date', 'room_count', 'resource', 'remark']).has(key))) blockers.push('酒店安排 update 只允许非空 changes.end_date/room_count/resource。');
if (value.selection !== undefined) {
const selection = value.selection;
if (!isPlainObject(selection) || Object.keys(selection).some((key) => !['start_date', 'end_date'].includes(key))
|| !isValidDate(selection.start_date) || !isValidDate(selection.end_date) || selection.end_date <= selection.start_date) blockers.push('酒店变更必须提供有效的原安排日期。');
}
if (changes?.resource !== undefined) {
validateDispatchNamedRef(changes.resource, 'data.arrangement.changes.resource', blockers, { required: true });
if (!isPlainObject(value.selection)) blockers.push('更换酒店必须提供原安排的入住和离店日期。');
}
if (changes?.end_date !== undefined && !isValidDate(changes.end_date)) blockers.push('changes.end_date 必须是有效的 YYYY-MM-DD 日期。');
if (changes?.room_count !== undefined && (!Number.isInteger(changes.room_count) || changes.room_count < 1)) blockers.push('changes.room_count 必须是正整数。');
if (value.changes?.remark !== undefined && (typeof value.changes.remark !== 'string' || !value.changes.remark.trim())) blockers.push('变更备注必须为非空文本。');
return;
}
if (value.mode !== 'create') blockers.push(`${action} 当前只允许 create${action === 'arrangement_hotel' ? ' 或已开放的 update' : ''}。`);
@@ -944,7 +944,7 @@
window.LTJTOrderAssistant = {
...(window.LTJTOrderAssistant || {}),
version: '0.5.173',
version: '0.5.177',
openTeamBatchForm,
pingTeamBatchFrame,
preflightTeamBatchNative,