289 lines
11 KiB
JavaScript
289 lines
11 KiB
JavaScript
(function installLTJTTeamBatchHelpers(root, factory) {
|
|
const api = factory();
|
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
if (root) root.LTJTTeamBatchHelpers = api;
|
|
}(typeof self !== 'undefined' ? self : globalThis, () => {
|
|
function normalizeText(value) {
|
|
return String(value ?? '')
|
|
.normalize('NFKC')
|
|
.toLowerCase()
|
|
.replace(/[\s\p{P}\p{S}]+/gu, '')
|
|
.trim();
|
|
}
|
|
|
|
function chineseOrLatinSegments(value) {
|
|
const normalized = String(value ?? '').normalize('NFKC').toLowerCase();
|
|
return normalized.match(/[\p{Script=Han}]+|[a-z0-9]+/giu) || [];
|
|
}
|
|
|
|
function tokenizeKeyword(value) {
|
|
const tokens = [];
|
|
for (const segment of chineseOrLatinSegments(value)) {
|
|
if (/^[\p{Script=Han}]+$/u.test(segment)) {
|
|
if (segment.length <= 2) {
|
|
tokens.push(segment);
|
|
continue;
|
|
}
|
|
for (let index = 0; index < segment.length; index += 2) {
|
|
tokens.push(segment.slice(index, index + 2));
|
|
}
|
|
} else if (segment) {
|
|
tokens.push(segment);
|
|
}
|
|
}
|
|
return [...new Set(tokens.map(normalizeText).filter(Boolean))];
|
|
}
|
|
|
|
function rowColumns(row) {
|
|
return String(row ?? '').split('◆').map((value) => value.trim());
|
|
}
|
|
|
|
function parseSelectBoxRows(serialized) {
|
|
return String(serialized ?? '')
|
|
.split(String(serialized ?? '').includes('◇') ? '◇' : /\r?\n/)
|
|
.map((raw, rowIndex) => ({ raw: raw.trim(), rowIndex, columns: rowColumns(raw) }))
|
|
.filter((row) => row.raw);
|
|
}
|
|
|
|
function searchableText(candidate, columns = []) {
|
|
if (Array.isArray(candidate)) return candidate.join(' ');
|
|
if (candidate && Array.isArray(candidate.columns)) {
|
|
return columns.length ? columns.map((index) => candidate.columns[index] || '').join(' ') : candidate.columns.join(' ');
|
|
}
|
|
return String(candidate ?? '');
|
|
}
|
|
|
|
function classifyCandidate(candidate, keyword, columns = []) {
|
|
const haystack = normalizeText(searchableText(candidate, columns));
|
|
const needle = normalizeText(keyword);
|
|
const tokens = tokenizeKeyword(keyword);
|
|
const exact = Array.isArray(candidate?.columns)
|
|
&& candidate.columns.some((value) => normalizeText(value) === needle && needle);
|
|
const contiguous = Boolean(needle && haystack.includes(needle));
|
|
const tokenMatch = Boolean(tokens.length && tokens.every((token) => haystack.includes(token)));
|
|
return {
|
|
candidate,
|
|
exact,
|
|
contiguous,
|
|
tokenMatch,
|
|
score: exact ? 300 : (contiguous ? 200 : (tokenMatch ? 100 : 0))
|
|
};
|
|
}
|
|
|
|
function resolveUniqueSelectBoxRow(serialized, keyword, options = {}) {
|
|
const rows = Array.isArray(serialized) ? serialized : parseSelectBoxRows(serialized);
|
|
const columns = Array.isArray(options.searchableColumns) ? options.searchableColumns : [];
|
|
const classified = rows.map((row) => classifyCandidate(row, keyword, columns));
|
|
const choose = (rule, list) => {
|
|
if (list.length !== 1) return null;
|
|
return {
|
|
ok: true,
|
|
row: list[0].candidate,
|
|
rule,
|
|
exact_count: classified.filter((item) => item.exact).length,
|
|
contains_count: classified.filter((item) => item.contiguous).length,
|
|
token_match_count: classified.filter((item) => item.tokenMatch).length,
|
|
candidate_count: list.length
|
|
};
|
|
};
|
|
const exact = classified.filter((item) => item.exact);
|
|
const contiguous = classified.filter((item) => item.contiguous);
|
|
const token = classified.filter((item) => item.tokenMatch);
|
|
const match = choose('exact', exact) || choose('unique_contains', contiguous) || choose('unique_tokenized_contains', token);
|
|
if (match) return match;
|
|
const candidateCount = exact.length > 1 ? exact.length : (contiguous.length > 1 ? contiguous.length : token.length);
|
|
return {
|
|
ok: false,
|
|
row: null,
|
|
rule: exact.length > 1 ? 'ambiguous_exact' : (contiguous.length > 1 ? 'ambiguous_contains' : (token.length > 1 ? 'ambiguous_tokenized_contains' : 'not_found')),
|
|
exact_count: exact.length,
|
|
contains_count: contiguous.length,
|
|
token_match_count: token.length,
|
|
candidate_count: candidateCount,
|
|
blocker: `${options.label || 'lookup'}: expected exactly one deterministic match, found ${candidateCount}`
|
|
};
|
|
}
|
|
|
|
function resolveUniqueTextCandidate(candidates, keyword, options = {}) {
|
|
const source = Array.isArray(candidates) ? candidates : [];
|
|
const wrapped = source.map((candidate) => ({
|
|
...classifyCandidate({ columns: [candidate.text || candidate.label || ''] }, keyword, [0]),
|
|
candidate
|
|
}));
|
|
const exact = wrapped.filter((item) => normalizeText(item.candidate.label || item.candidate.text || '') === normalizeText(keyword));
|
|
const contiguous = wrapped.filter((item) => item.contiguous);
|
|
const token = wrapped.filter((item) => item.tokenMatch);
|
|
const selected = exact.length === 1
|
|
? { list: exact, rule: 'exact' }
|
|
: (contiguous.length === 1
|
|
? { list: contiguous, rule: 'unique_contains' }
|
|
: (token.length === 1 ? { list: token, rule: 'unique_tokenized_contains' } : null));
|
|
if (selected) {
|
|
return {
|
|
ok: true,
|
|
candidate: selected.list[0].candidate,
|
|
rule: selected.rule,
|
|
exact_count: exact.length,
|
|
contains_count: contiguous.length,
|
|
token_match_count: token.length,
|
|
candidate_count: selected.list.length
|
|
};
|
|
}
|
|
const count = exact.length > 1 ? exact.length : (contiguous.length > 1 ? contiguous.length : token.length);
|
|
return {
|
|
ok: false,
|
|
candidate: null,
|
|
rule: exact.length > 1 ? 'ambiguous_exact' : (contiguous.length > 1 ? 'ambiguous_contains' : (token.length > 1 ? 'ambiguous_tokenized_contains' : 'not_found')),
|
|
exact_count: exact.length,
|
|
contains_count: contiguous.length,
|
|
token_match_count: token.length,
|
|
candidate_count: count,
|
|
blocker: `${options.label || 'lookup'}: expected exactly one deterministic match, found ${count}`
|
|
};
|
|
}
|
|
|
|
function canonicalDate(value) {
|
|
const match = String(value ?? '').trim().match(/^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})$/);
|
|
if (!match) return '';
|
|
const year = Number(match[1]);
|
|
const month = Number(match[2]);
|
|
const day = Number(match[3]);
|
|
const date = new Date(Date.UTC(year, month - 1, day));
|
|
if (date.getUTCFullYear() !== year || date.getUTCMonth() + 1 !== month || date.getUTCDate() !== day) return '';
|
|
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
|
}
|
|
|
|
function erpDate(value) {
|
|
const canonical = canonicalDate(value);
|
|
if (!canonical) return '';
|
|
const [year, month, day] = canonical.split('-');
|
|
return `${year}-${Number(month)}-${Number(day)}`;
|
|
}
|
|
|
|
function dateNumber(value) {
|
|
const canonical = canonicalDate(value);
|
|
if (!canonical) return NaN;
|
|
return Date.parse(`${canonical}T00:00:00Z`);
|
|
}
|
|
|
|
function dateInRange(value, start, end) {
|
|
const current = dateNumber(value);
|
|
const from = dateNumber(start);
|
|
const to = dateNumber(end);
|
|
return Number.isFinite(current) && Number.isFinite(from) && Number.isFinite(to) && current >= from && current <= to;
|
|
}
|
|
|
|
function cycleShortcutValues(pattern, weekdays = []) {
|
|
const value = String(pattern || '').trim().toLowerCase();
|
|
if (value === 'daily' || value === 'all' || value === '天天') return ['All'];
|
|
if (value === 'odd_days' || value === 'odd' || value === '单日') return ['d1d', 'd3d', 'd5d', 'd7d', 'd9d'];
|
|
if (value === 'even_days' || value === 'even' || value === '双日') return ['d2d', 'd4d', 'd6d', 'd8d', 'd0d'];
|
|
if (value === 'weekly' || value === 'weekdays' || value === '星期') {
|
|
return [...new Set((Array.isArray(weekdays) ? weekdays : [weekdays])
|
|
.map((day) => Number(day))
|
|
.filter((day) => day >= 1 && day <= 7)
|
|
.map((day) => String(day === 7 ? 0 : day)))];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function uniqueCanonicalDates(values = []) {
|
|
return [...new Set((Array.isArray(values) ? values : [])
|
|
.map(canonicalDate)
|
|
.filter(Boolean))];
|
|
}
|
|
|
|
function addCanonicalDays(value, days) {
|
|
const canonical = canonicalDate(value);
|
|
if (!canonical) return '';
|
|
const [year, month, day] = canonical.split('-').map(Number);
|
|
const date = new Date(Date.UTC(year, month - 1, day + Number(days || 0)));
|
|
return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')}`;
|
|
}
|
|
|
|
function weekdayOneToSeven(value) {
|
|
const canonical = canonicalDate(value);
|
|
if (!canonical) return 0;
|
|
const day = new Date(`${canonical}T00:00:00Z`).getUTCDay();
|
|
return day === 0 ? 7 : day;
|
|
}
|
|
|
|
function expandCycleDates(data = {}) {
|
|
const explicitDates = uniqueCanonicalDates(data.departure_dates);
|
|
const recurrence = data.recurrence || {};
|
|
const pattern = String(recurrence.pattern || data.cycle || '').trim().toLowerCase();
|
|
if (explicitDates.length) return explicitDates;
|
|
const start = canonicalDate(recurrence.start_date);
|
|
const end = canonicalDate(recurrence.end_date);
|
|
if (!start || !end || dateNumber(start) > dateNumber(end)) return [];
|
|
const weekdays = new Set((Array.isArray(recurrence.weekday) ? recurrence.weekday : [])
|
|
.map((value) => Number(value))
|
|
.filter((value) => value >= 1 && value <= 7));
|
|
const selected = [];
|
|
for (let cursor = start; dateNumber(cursor) <= dateNumber(end); cursor = addCanonicalDays(cursor, 1)) {
|
|
const [, , calendarDay] = cursor.split('-').map(Number);
|
|
const weekday = weekdayOneToSeven(cursor);
|
|
const keep = pattern === 'daily'
|
|
? true
|
|
: pattern === 'weekly'
|
|
? weekdays.has(weekday)
|
|
: pattern === 'odd_days'
|
|
? calendarDay % 2 === 1
|
|
: pattern === 'even_days'
|
|
? calendarDay % 2 === 0
|
|
: false;
|
|
if (keep) selected.push(cursor);
|
|
}
|
|
return selected;
|
|
}
|
|
|
|
function cycleSelectionPlan(data = {}) {
|
|
const explicitDates = uniqueCanonicalDates(data.departure_dates);
|
|
if (explicitDates.length) {
|
|
return {
|
|
ok: true,
|
|
mode: 'explicit_dates',
|
|
explicit_dates: explicitDates,
|
|
expanded_dates: explicitDates,
|
|
shortcut_values: []
|
|
};
|
|
}
|
|
const recurrence = data.recurrence || {};
|
|
const shortcutValues = cycleShortcutValues(recurrence.pattern || data.cycle, recurrence.weekday || []);
|
|
if (!shortcutValues.length) {
|
|
return {
|
|
ok: false,
|
|
mode: 'invalid',
|
|
explicit_dates: [],
|
|
expanded_dates: [],
|
|
shortcut_values: [],
|
|
blocker: '发团周期未能映射到 ERP 的日期枚举或快捷标签。'
|
|
};
|
|
}
|
|
return {
|
|
ok: true,
|
|
mode: 'shortcut',
|
|
explicit_dates: [],
|
|
expanded_dates: expandCycleDates(data),
|
|
shortcut_values: shortcutValues
|
|
};
|
|
}
|
|
|
|
return {
|
|
cycleSelectionPlan,
|
|
canonicalDate,
|
|
chineseOrLatinSegments,
|
|
classifyCandidate,
|
|
cycleShortcutValues,
|
|
dateInRange,
|
|
dateNumber,
|
|
erpDate,
|
|
expandCycleDates,
|
|
normalizeText,
|
|
parseSelectBoxRows,
|
|
resolveUniqueSelectBoxRow,
|
|
resolveUniqueTextCandidate,
|
|
tokenizeKeyword
|
|
};
|
|
}));
|