Files
LWLT-AIBOT/chrome-extension/ltjt-order-assistant/split-plan-reconciliation.js
2026-08-25 10:33:29 +08:00

321 lines
13 KiB
JavaScript

(function installLTJTPlanReconciliation(root, factory) {
const api = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = api;
if (root) root.LTJTPlanReconciliation = api;
}(typeof self !== 'undefined' ? self : globalThis, () => {
'use strict';
function compact(value) {
return String(value || '')
.normalize('NFKC')
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, '');
}
function dateToken(value) {
const parts = String(value || '').trim().split(/[.\-/]/u).map((part) => Number(part));
if (parts.length !== 3 || parts.some((part) => !Number.isFinite(part))) return compact(value);
return `${parts[0]}-${parts[1]}-${parts[2]}`;
}
function numberValue(value) {
const number = Number(value || 0);
return Number.isFinite(number) ? Math.max(0, Math.trunc(number)) : 0;
}
function extractDdidFromAction(value) {
const source = String(value || '');
const queryMatch = source.match(/[?&]ddid=([^&#]+)/i);
if (queryMatch?.[1]) {
try {
return decodeURIComponent(queryMatch[1]);
} catch (_) {
return queryMatch[1];
}
}
const callMatch = source.match(/OPEN_update\s*\(([^)]*)\)/i);
if (!callMatch) return '';
const args = callMatch[1]
.split(',')
.map((part) => String(part || '').trim().replace(/^["']|["']$/g, ''));
// Independent orders use OPEN_update(ddid, 0, groupNo), while a
// concrete shared child uses OPEN_update(parentTid, childDdid).
if (args.length >= 3) return args[0] && args[0] !== '0' ? args[0] : '';
if (args.length === 2) return args[1] && args[1] !== '0' ? args[1] : '';
return args[0] && args[0] !== '0' ? args[0] : '';
}
function extractSharedChildRefs(links = [], parentTid = '') {
const expectedTid = String(parentTid || '').trim();
if (!expectedTid) return [];
const refs = new Map();
for (const link of links) {
const onclick = String(link?.onclick || '');
const href = String(link?.href || '');
const source = `${onclick} ${href}`;
const callMatch = source.match(/OPEN_update\s*\(\s*["']?(\d+)["']?\s*,\s*["']?(\d+)["']?/i);
const queryTid = source.match(/[?&](?:tdid|tid)=([^&#]+)/i)?.[1] || '';
const queryDdid = source.match(/[?&]ddid=([^&#]+)/i)?.[1] || '';
const tid = callMatch?.[1] || queryTid;
const ddid = callMatch?.[2] || queryDdid;
if (tid !== expectedTid || !/^\d+$/.test(ddid) || ddid === '0') continue;
refs.set(ddid, {
tid: expectedTid,
ddid,
child_order_no: `D${ddid}`,
link_text_length: String(link?.text || '').trim().length,
value_redacted: true
});
}
return [...refs.values()].sort((left, right) => Number(left.ddid) - Number(right.ddid));
}
function passengerMarkers(passengerCounts = {}) {
const markers = [];
const adult = numberValue(passengerCounts.adult);
const childBed = numberValue(passengerCounts.child_bed);
const childNoBed = numberValue(passengerCounts.child_no_bed);
const infant = numberValue(passengerCounts.infant);
const leader = numberValue(passengerCounts.leader);
if (adult) markers.push(`成人${adult}`);
if (childBed) markers.push(`小占${childBed}`);
if (childNoBed) markers.push(`小不占${childNoBed}`);
if (infant) markers.push(`婴儿${infant}`);
if (leader) markers.push(`领队${leader}`);
const total = adult + childBed + childNoBed + infant + leader;
if (total) markers.push(`合计${total}`);
return markers;
}
function matchRowText(rowText, expected = {}) {
const normalizedRow = compact(rowText);
const productCandidates = [expected.product_name, expected.product_label]
.map(compact)
.filter(Boolean);
const productMatch = productCandidates.length > 0
&& productCandidates.some((candidate) => normalizedRow.includes(candidate));
const dateMatch = Boolean(expected.date)
&& (normalizedRow.includes(compact(expected.date))
|| normalizedRow.includes(compact(dateToken(expected.date))));
const capacity = numberValue(expected.planned_capacity);
const capacityMatch = capacity > 0 && normalizedRow.includes(compact(`计划${capacity}`));
const markers = passengerMarkers(expected.passenger_counts);
const passengerCriteriaPresent = markers.length > 0;
const passengerMatch = !passengerCriteriaPresent || markers.every((marker) => normalizedRow.includes(compact(marker)));
const customerToken = compact(expected.customer_name);
const customerCriteriaPresent = Boolean(customerToken);
const customerMatch = Boolean(customerToken && normalizedRow.includes(customerToken));
const groupSuffixToken = compact(expected.group_suffix);
const groupSuffixMatch = !groupSuffixToken || normalizedRow.includes(groupSuffixToken);
const markerToken = compact(expected.marker);
const markerMatch = !markerToken || normalizedRow.includes(markerToken);
const ownerToken = compact(expected.owner_account);
const ownerAccountMatch = !ownerToken || normalizedRow.includes(ownerToken);
const missing = [];
if (!productMatch) missing.push('product');
if (!dateMatch) missing.push('date');
if (!capacityMatch) missing.push('planned_capacity');
if (passengerCriteriaPresent && !passengerMatch) missing.push('passenger_counts');
if (expected.require_customer_match === true && customerCriteriaPresent && !customerMatch) missing.push('customer');
if (!groupSuffixMatch) missing.push('group_suffix');
if (!markerMatch) missing.push('marker');
if (!ownerAccountMatch) missing.push('owner_account');
return {
matched: missing.length === 0,
customer_match: customerMatch,
customer_criteria_present: customerCriteriaPresent,
passenger_match: passengerMatch,
missing,
passenger_markers: markers,
passenger_criteria_present: passengerCriteriaPresent,
group_suffix_match: groupSuffixMatch,
marker_match: markerMatch,
owner_account_match: ownerAccountMatch
};
}
function splitOrderFact(row = {}, expected = {}) {
const match = matchRowText(row.text, expected);
const childRefs = Array.isArray(row.child_refs) ? row.child_refs : [];
const identityMissing = match.missing.filter((field) => !['customer', 'passenger_counts'].includes(field));
const parentIdentityDetermined = Boolean(row.group_no && /^\d+$/.test(String(row.tid || '')) && identityMissing.length === 0);
const childReferenceScanComplete = row.child_reference_scan_complete === true;
const requestedFactsPresent = match.customer_criteria_present || match.passenger_criteria_present;
const factsDetermined = parentIdentityDetermined && childReferenceScanComplete && requestedFactsPresent;
const customerSatisfied = !match.customer_criteria_present || match.customer_match;
const passengerSatisfied = !match.passenger_criteria_present || match.passenger_match;
let outcome = 'split_order_fact_indeterminate';
if (factsDetermined && childRefs.length > 0 && customerSatisfied && passengerSatisfied) {
outcome = 'concrete_shared_children_with_requested_facts';
} else if (factsDetermined && childRefs.length > 0) {
outcome = 'concrete_shared_children_with_fact_mismatch';
} else if (factsDetermined && customerSatisfied && passengerSatisfied) {
outcome = 'parent_row_facts_without_concrete_child';
} else if (factsDetermined) {
outcome = 'requested_split_facts_not_visible_and_no_concrete_child';
}
return {
facts_determined: factsDetermined,
outcome,
parent_identity_determined: parentIdentityDetermined,
parent_group_no: String(row.group_no || ''),
parent_tid: String(row.tid || ''),
customer_requested: match.customer_criteria_present,
customer_persisted_on_native_list: match.customer_criteria_present ? match.customer_match : null,
passenger_counts_requested: match.passenger_criteria_present,
passenger_counts_persisted_on_native_list: match.passenger_criteria_present ? match.passenger_match : null,
child_reference_scan_complete: childReferenceScanComplete,
child_count: childRefs.length,
child_refs: childRefs,
ownership: {
group_suffix_matched: match.group_suffix_match,
marker_matched: match.marker_match,
account_matched: match.owner_account_match
},
identity_missing: identityMissing,
value_redacted: true
};
}
function chooseUniqueRows(rows = [], expected = {}) {
const matches = rows
.filter((row) => row && row.group_no)
.map((row) => ({ ...row, match: matchRowText(row.text, expected) }))
.filter((row) => row.match.matched);
const customerMatches = matches.filter((row) => row.match.customer_match);
const preferred = customerMatches.length ? customerMatches : matches;
return {
matches,
preferred,
selected: preferred.length === 1 ? preferred[0] : null,
ambiguous: preferred.length > 1
};
}
function splitParentExactSearchValues(dateStart, dateEnd, groupNumber) {
return {
S_chufariqi: String(dateStart || ''),
S_chufarizhi: String(dateEnd || dateStart || ''),
S_tuanxuhao: String(groupNumber || ''),
S_kehuming: '',
S_chanpinming: '',
S_youkexinxi: '',
S_lingdui: '',
S_zhuangtai: ''
};
}
function splitParentDateSearchValues(date) {
return splitParentExactSearchValues(date, date, '');
}
async function verifyReturnedGroupNumbers(options = {}) {
const groupNumbers = [...new Set((Array.isArray(options.group_numbers) ? options.group_numbers : [])
.map((value) => String(value || '').trim())
.filter(Boolean))];
const retryDelays = (Array.isArray(options.retry_delays_ms) && options.retry_delays_ms.length
? options.retry_delays_ms
: [0])
.map((value) => Math.max(0, Number(value) || 0));
const searchGroup = options.search_group;
if (typeof searchGroup !== 'function') throw new TypeError('search_group must be a function');
const wait = typeof options.wait === 'function'
? options.wait
: (delay) => new Promise((resolve) => setTimeout(resolve, delay));
const attempts = [];
let rows = [];
for (let attemptIndex = 0; attemptIndex < retryDelays.length; attemptIndex += 1) {
const delayMs = retryDelays[attemptIndex];
if (delayMs > 0) await wait(delayMs);
const attemptRows = [];
for (const groupNumber of groupNumbers) {
let outcome = null;
let searchError = '';
try {
outcome = await searchGroup(groupNumber, {
attempt: attemptIndex + 1,
delay_ms: delayMs
});
} catch (error) {
searchError = String(error?.message || error || 'split_parent_requery_error').slice(0, 200);
}
const found = outcome?.found === true;
attemptRows.push({
group_number: groupNumber,
source: attemptIndex === 0 ? 'erp_response' : 'erp_response_retry',
status: found
? 'parent_group_found'
: searchError
? 'parent_group_requery_error'
: 'parent_group_not_found',
row_text_length: found ? Math.max(0, Number(outcome?.row_text_length || 0)) : 0,
attempt: attemptIndex + 1,
search_error: searchError || undefined
});
}
rows = attemptRows;
const missingGroupNumbers = rows
.filter((row) => row.status !== 'parent_group_found')
.map((row) => row.group_number);
attempts.push({
attempt: attemptIndex + 1,
delay_ms: delayMs,
found_count: rows.length - missingGroupNumbers.length,
missing_count: missingGroupNumbers.length,
missing_group_numbers: missingGroupNumbers
});
if (!missingGroupNumbers.length) break;
}
const complete = groupNumbers.length > 0
&& rows.length === groupNumbers.length
&& rows.every((row) => row.status === 'parent_group_found');
return {
complete,
rows,
attempts,
attempt_count: attempts.length,
retry_count: Math.max(0, attempts.length - 1),
group_numbers: groupNumbers
};
}
function shouldReconcileSplitParent(options = {}) {
if (options.write_attempted !== true) return false;
if (options.fact_probe_enabled === true) return true;
if (options.capture_success !== true) return true;
const groupNumbers = Array.isArray(options.group_numbers) ? options.group_numbers : [];
const requestedDateCount = Math.max(0, Number(options.requested_date_count || 0));
if (groupNumbers.length !== requestedDateCount) return true;
return options.verification_complete !== true;
}
function mergeVerificationRows(rows = []) {
const merged = new Map();
for (const row of rows) {
const groupNumber = String(row?.group_number || '').trim();
if (!groupNumber) continue;
const prior = merged.get(groupNumber);
if (!prior || (prior.status !== 'parent_group_found' && row.status === 'parent_group_found')) {
merged.set(groupNumber, row);
}
}
return [...merged.values()];
}
return {
compact,
dateToken,
extractDdidFromAction,
extractSharedChildRefs,
passengerMarkers,
matchRowText,
chooseUniqueRows,
splitOrderFact,
splitParentExactSearchValues,
splitParentDateSearchValues,
verifyReturnedGroupNumbers,
shouldReconcileSplitParent,
mergeVerificationRows
};
}));