168 lines
6.5 KiB
JavaScript
168 lines
6.5 KiB
JavaScript
(function installLTJTProductLookup(root, factory) {
|
|
const api = factory();
|
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
if (root) root.LTJTProductLookup = api;
|
|
}(typeof self !== 'undefined' ? self : globalThis, () => {
|
|
'use strict';
|
|
|
|
function compactProductLookupText(value) {
|
|
return String(value || '')
|
|
.normalize('NFKC')
|
|
.toLowerCase()
|
|
.replace(/[^\p{L}\p{N}]+/gu, '');
|
|
}
|
|
|
|
function normalizeProductQueryText(value) {
|
|
// ERP products commonly write duration as 8D7N while users may write
|
|
// 8天7晚. Only convert duration suffixes after a number so a product name
|
|
// containing an ordinary Chinese character such as “天” is not changed.
|
|
return compactProductLookupText(value)
|
|
.replace(/(\d+)天/gu, '$1d')
|
|
.replace(/(\d+)(?:晚|夜)/gu, '$1n');
|
|
}
|
|
|
|
function normalizeProductDuration(value) {
|
|
return normalizeProductQueryText(value);
|
|
}
|
|
|
|
function isOrderedSubsequence(query, value) {
|
|
let cursor = 0;
|
|
for (const character of query) {
|
|
const next = value.indexOf(character, cursor);
|
|
if (next < 0) return false;
|
|
cursor = next + character.length;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function collapseWhitespace(value) {
|
|
return String(value || '').replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
function rawCellText(element) {
|
|
return String(element?.innerText || element?.textContent || '').trim();
|
|
}
|
|
|
|
function productLabelFromRow(row) {
|
|
return collapseWhitespace(rawCellText(row?.cells?.[1] || row));
|
|
}
|
|
|
|
function productNameAndDuration(label) {
|
|
const normalizedLabel = collapseWhitespace(label);
|
|
// The current ERP renders the product name and duration in the same
|
|
// second cell, separated by whitespace (for example “江西老挝 10D9N”).
|
|
// Restrict the suffix parser to duration-shaped text so names containing
|
|
// dates or numbers such as “尊享老挝3.10” remain intact.
|
|
const durationMatch = normalizedLabel.match(/(?:^|\s)(\d+(?:\.\d+)?\s*(?:D|天)\s*\d+(?:\.\d+)?\s*(?:N|晚|夜)?)(?:\s*)$/iu);
|
|
if (!durationMatch) return { name: normalizedLabel, duration: '' };
|
|
const name = normalizedLabel.slice(0, durationMatch.index).trim();
|
|
return {
|
|
name: name || normalizedLabel,
|
|
duration: collapseWhitespace(durationMatch[1]).replace(/\s+/g, '')
|
|
};
|
|
}
|
|
|
|
function nativeProductSearchQueries(query) {
|
|
const keyword = collapseWhitespace(query);
|
|
if (!keyword) return [''];
|
|
|
|
// S_chanpinming is a contiguous product-name LIKE filter; duration is a
|
|
// separate ERP field. Preserve a complete product name verbatim, but trim
|
|
// a trailing Arabic duration shorthand before issuing the native query.
|
|
// Keep one bounded empty-query fallback for legacy shorthand whose name
|
|
// fragments are not contiguous in the ERP product name.
|
|
const productNameQuery = keyword
|
|
.replace(/\s*\d+(?:\.\d+)?\s*(?:D|天)(?:\s*\d+(?:\.\d+)?\s*(?:N|晚|夜))?\s*$/iu, '')
|
|
.trim();
|
|
return productNameQuery ? [productNameQuery, ''] : [''];
|
|
}
|
|
|
|
function productCandidateFromRow(row, input = null) {
|
|
const label = productLabelFromRow(row);
|
|
const parts = productNameAndDuration(label);
|
|
return {
|
|
input,
|
|
row,
|
|
text: collapseWhitespace(rawCellText(row)),
|
|
label,
|
|
name: parts.name,
|
|
duration: parts.duration,
|
|
cpid: String(input?.value || '').trim()
|
|
};
|
|
}
|
|
|
|
function productQueryTokens(query) {
|
|
const normalizedQuery = normalizeProductQueryText(query);
|
|
const hanRuns = normalizedQuery.match(/\p{Script=Han}+/gu) || [];
|
|
const nonHanTokens = normalizedQuery.match(/\d+[a-z]+|[a-z]+\d*|\d+/g) || [];
|
|
const durationTokens = nonHanTokens.filter((token) => /\d+d|\d+n/i.test(token));
|
|
const nameTokens = nonHanTokens.filter((token) => !durationTokens.includes(token));
|
|
return { normalizedQuery, hanRuns, durationTokens, nameTokens };
|
|
}
|
|
|
|
function productQueryMatch(candidateOrLabel, query) {
|
|
const candidate = typeof candidateOrLabel === 'string'
|
|
? { label: candidateOrLabel, name: candidateOrLabel, duration: '' }
|
|
: (candidateOrLabel || {});
|
|
const normalizedLabel = compactProductLookupText(candidate.label || candidate.name || '');
|
|
const normalizedQuery = normalizeProductQueryText(query);
|
|
if (!normalizedQuery) return { matched: true, rule: 'empty_keyword' };
|
|
if (normalizedLabel.includes(normalizedQuery)) return { matched: true, rule: 'unique_contiguous' };
|
|
|
|
const { hanRuns, durationTokens, nameTokens } = productQueryTokens(query);
|
|
if (!hanRuns.length && !durationTokens.length && !nameTokens.length) {
|
|
return { matched: false, rule: 'no_tokens' };
|
|
}
|
|
|
|
const normalizedName = compactProductLookupText(candidate.name || candidate.label || '');
|
|
const normalizedDuration = normalizeProductDuration(candidate.duration || '');
|
|
if (hanRuns.some((run) => !isOrderedSubsequence(run, normalizedName))) {
|
|
return { matched: false, rule: 'name_ordered_tokens' };
|
|
}
|
|
if (durationTokens.some((token) => !normalizedDuration.includes(token) && !normalizedLabel.includes(token))) {
|
|
return { matched: false, rule: 'duration_tokens' };
|
|
}
|
|
if (nameTokens.some((token) => !normalizedName.includes(token) && !normalizedLabel.includes(token))) {
|
|
return { matched: false, rule: 'name_numeric_tokens' };
|
|
}
|
|
return {
|
|
matched: true,
|
|
rule: durationTokens.length ? 'unique_name_duration_tokens' : 'unique_ordered_tokens'
|
|
};
|
|
}
|
|
|
|
function collectProductCandidates(scopeDocument) {
|
|
return Array.from(scopeDocument?.querySelectorAll?.('input[name="cp_id"], input[name="cpid"]') || [])
|
|
.map((input) => {
|
|
const row = input.closest('tr') || input.parentElement?.parentElement || input.parentElement;
|
|
return productCandidateFromRow(row, input);
|
|
});
|
|
}
|
|
|
|
function matchProductCandidates(candidates, query, cpid = '') {
|
|
const expectedCpid = String(cpid || '').trim();
|
|
return (candidates || [])
|
|
.filter((candidate) => !expectedCpid || String(candidate.cpid || '').trim() === expectedCpid)
|
|
.map((candidate) => ({
|
|
...candidate,
|
|
match: productQueryMatch(candidate, query)
|
|
}))
|
|
.filter((candidate) => candidate.match.matched);
|
|
}
|
|
|
|
return {
|
|
compactProductLookupText,
|
|
normalizeProductQueryText,
|
|
normalizeProductDuration,
|
|
isOrderedSubsequence,
|
|
productLabelFromRow,
|
|
productNameAndDuration,
|
|
nativeProductSearchQueries,
|
|
productCandidateFromRow,
|
|
productQueryTokens,
|
|
productQueryMatch,
|
|
collectProductCandidates,
|
|
matchProductCandidates
|
|
};
|
|
}));
|