/* eslint-disable */ /* © 2026 K2B NETWORK SOLUTIONS LLC. All rights reserved. Proprietary and confidential. Unauthorized copying, distribution, or use of this file, via any medium, is strictly prohibited. Original work product. */ // Jared Brewer DMS — JBImporters. // Real document parsing in the browser. Loads proven libraries on demand: // • Excel (.xlsx/.xls/.csv) ....... SheetJS (xlsx) // • Word (.docx) .................. Mammoth // • PDF (text layer) .............. PDF.js // • Scanned PDF / images (OCR) .... Tesseract.js (real optical char recognition) // Then extracts customer fields (name, phone, address, DL, DOB, email, VIN) // from the recovered text and turns them into importable records. // // Everything runs client-side. Excel (SheetJS) and PDF (pdf.js + its worker) // load from OUR OWN server first (vendor/ next to index.html — no internet and // no CDN needed, and the site's security policy always allows them); only if // that copy is missing do they fall back to jsDelivr. Word (Mammoth) and // OCR (Tesseract) still come from jsDelivr the first time they are needed. window.JBImporters = (() => { // ── lazy library loader (each lib loads once) ── const _loaded = {}; const loadScript = (url, check) => { if (_loaded[url]) return _loaded[url]; _loaded[url] = new Promise((resolve, reject) => { const s = document.createElement("script"); s.src = url; s.async = true; s.onload = () => { if (check && !check()) { _loaded[url] = null; s.remove(); reject(new Error("Loaded " + url + " but it did not provide the library")); } else resolve(); }; s.onerror = () => { _loaded[url] = null; s.remove(); reject(new Error("Failed to load " + url)); }; document.head.appendChild(s); }); return _loaded[url]; }; const CDN = { xlsx: "https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js", mammoth: "https://cdn.jsdelivr.net/npm/mammoth@1.6.0/mammoth.browser.min.js", pdf: "https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.min.js", pdfWorker: "https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.worker.min.js", tesseract: "https://cdn.jsdelivr.net/npm/tesseract.js@5.1.0/dist/tesseract.min.js", }; // Where the vendored copies live: /vendor/, then the // fixed production path. Same-origin only — never a third-party host. const VENDOR_FILES = { xlsx: "xlsx.full.min.js", pdf: "pdf.min.js", pdfWorker: "pdf.worker.min.js" }; const localUrls = (file) => { const out = []; try { out.push(new URL("vendor/" + file, document.baseURI).href); } catch (e) {} try { out.push(new URL("/ui_kits/k2b-dms/vendor/" + file, location.href).href); } catch (e) {} return out.filter((u, i) => out.indexOf(u) === i); }; // key → { global, urls: [same-origin copies…, CDN] } const LIBS = { xlsx: { global: "XLSX", urls: () => [...localUrls(VENDOR_FILES.xlsx), CDN.xlsx] }, pdf: { global: "pdfjsLib", urls: () => [...localUrls(VENDOR_FILES.pdf), CDN.pdf] }, mammoth: { global: "mammoth", urls: () => [CDN.mammoth] }, tesseract: { global: "Tesseract", urls: () => [CDN.tesseract] }, }; const _libFrom = {}; // key → the URL that actually worked const _libP = {}; // Load a library (once). Tries each source in order and checks that the // global really exists (a 404 page served as text would otherwise "load"). const loadLib = (key) => { const L = LIBS[key]; if (!L) return Promise.reject(new Error("Unknown library " + key)); if (window[L.global]) return Promise.resolve(window[L.global]); if (_libP[key]) return _libP[key]; _libP[key] = (async () => { const errs = []; for (const u of L.urls()) { try { await loadScript(u, () => !!window[L.global]); _libFrom[key] = u; return window[L.global]; } catch (e) { errs.push(String(e && e.message || e)); } } _libP[key] = null; throw new Error("Could not load the " + key + " library (" + errs.join("; ") + ")"); })(); return _libP[key]; }; const loadXlsx = () => loadLib("xlsx"); // pdf.js with its worker already pointed at the SAME place the library came from. const loadPdfJs = async () => { const lib = await loadLib("pdf"); const from = _libFrom.pdf || ""; const worker = from && !/^https?:\/\/cdn\./.test(from) ? from.replace(/pdf\.min\.js(\?.*)?$/, "pdf.worker.min.js") : CDN.pdfWorker; if (lib.GlobalWorkerOptions && lib.GlobalWorkerOptions.workerSrc !== worker) lib.GlobalWorkerOptions.workerSrc = worker; return lib; }; // Which source each library loaded from ("local" | "cdn" | "") — for diagnostics and tests. const libSource = (key) => { const u = _libFrom[key]; return !u ? "" : /^https?:\/\/cdn\./.test(u) ? "cdn" : "local"; }; const ext = (name) => (name.split(".").pop() || "").toLowerCase(); const readArrayBuffer = (file) => new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = rej; r.readAsArrayBuffer(file); }); // RC30 fix round (attacker L3): a PDF is opened with pdf.js's own guards (no embedded JavaScript evaluated, a ceiling on image size) and read // page by page only up to PDF_MAX_PAGES — a file that declares thousands of pages is refused in plain words instead of hanging the importer's browser const PDF_MAX_PAGES = 300; const PDF_MAX_BYTES = 60 * 1024 * 1024; const openPdf = async (file) => { if (file && file.size > PDF_MAX_BYTES) throw new Error(`${file.name} is ${Math.round(file.size / 1048576)} MB — the import reads PDFs up to ${Math.round(PDF_MAX_BYTES / 1048576)} MB. Split the printout, or export the report as CSV / XLSX.`); const buf = await readArrayBuffer(file); const pdf = await window.pdfjsLib.getDocument({ data: buf, isEvalSupported: false, maxImageSize: 16 * 1024 * 1024, disableAutoFetch: true, disableStream: true }).promise; if (pdf.numPages > PDF_MAX_PAGES) throw new Error(`${file.name} has ${pdf.numPages} pages — the import reads at most ${PDF_MAX_PAGES} pages at a time. Split the printout (or export the report as CSV / XLSX).`); return pdf; }; const readText = (file) => new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(String(r.result)); r.onerror = rej; r.readAsText(file); }); // ── Excel / CSV via SheetJS → array of row objects per sheet ── const parseExcel = async (file) => { await loadXlsx(); const buf = await readArrayBuffer(file); const wb = window.XLSX.read(buf, { type: "array" }); const out = {}; wb.SheetNames.forEach(name => { const rows = window.XLSX.utils.sheet_to_json(wb.Sheets[name], { defval: "", raw: false }); if (rows.length) out[name] = rows; }); return out; // { SheetName: [ {col:val,...}, ... ] } }; // ── Word .docx via Mammoth → plain text + html (tables preserved) ── const parseDocx = async (file) => { await loadLib("mammoth"); const buf = await readArrayBuffer(file); const res = await window.mammoth.extractRawText({ arrayBuffer: buf }); return res.value || ""; }; // ── PDF via PDF.js → text layer; if a page has no text, OCR it ── const parsePdf = async (file, onProgress) => { await loadPdfJs(); const pdf = await openPdf(file); let text = ""; let needsOcr = false; for (let p = 1; p <= pdf.numPages; p++) { onProgress && onProgress(Math.round((p - 1) / pdf.numPages * 40), `Reading PDF page ${p}/${pdf.numPages}…`); const page = await pdf.getPage(p); const content = await page.getTextContent(); const pageText = content.items.map(i => i.str).join(" ").trim(); if (pageText.length > 12) { text += pageText + "\n"; } else { needsOcr = true; } } // Scanned PDF (little/no text) → render each page to canvas and OCR it. if (needsOcr && text.replace(/\s/g, "").length < 40) { onProgress && onProgress(42, "No text layer — running OCR…"); let ocrText = ""; for (let p = 1; p <= pdf.numPages; p++) { const page = await pdf.getPage(p); const viewport = page.getViewport({ scale: 2 }); const canvas = document.createElement("canvas"); canvas.width = viewport.width; canvas.height = viewport.height; await page.render({ canvasContext: canvas.getContext("2d"), viewport }).promise; const t = await ocrCanvas(canvas, (pct, st) => onProgress && onProgress(42 + Math.round(((p - 1) + pct / 100) / pdf.numPages * 56), st || `OCR page ${p}/${pdf.numPages}…`)); ocrText += t + "\n"; } text = ocrText; } onProgress && onProgress(100, "Done"); return text; }; // ── RC30 (imp2): a TEXT-LAYER PDF read into TABLES — one row per printed line, the columns from the header line's x positions ── // The same vendored pdf.js Old reports uses: nothing new to install, no internet, allowed by the site's security policy. Output = // readTable's sheet shape ({ name, rows, headers, preface }); every row carries a NON-enumerable __origin { kind: 'pdf-text', file, // page } so the staged row says "read from PDF — check" until the owner approves. A page with no text (a scanned printout) is listed // in noText — those pages are read in Old reports (AI), never guessed here. Title lines above the header become `preface` (the as-of // date of a printed trial balance is found there, as for a CSV). const _nhp = (s) => String(s || "").toLowerCase().replace(/[^a-z0-9]/g, ""); const _pdfNumish = (s) => /^[\s$(\-]*[\d,]+(\.\d+)?[)\s%\-]*$/.test(String(s || "").trim()) || /^\d{1,2}\/\d{1,2}\/\d{2,4}$/.test(String(s || "").trim()); const pdfTables = async (file, onProgress) => { await loadPdfJs(); const pdf = await openPdf(file); const sheets = []; const noText = []; let cur = null; const looksHeader = (cells) => cells.length >= 3 && !cells.some(c => _pdfNumish(c.s)) && cells.every(c => c.s.trim().length <= 40); const startSheet = (cells, pageNo, preface) => { const headers = _uniqHeaders(cells.map(c => c.s.trim())); cur = { name: sheets.length ? `${file.name} (${sheets.length + 1})` : file.name, headers, cols: cells.map((c, i) => ({ x: c.x, name: headers[i] })), hkey: cells.map(c => _nhp(c.s)).join("|"), rows: [], preface: preface || [], pages: [pageNo] }; sheets.push(cur); }; for (let p = 1; p <= pdf.numPages; p++) { onProgress && onProgress(Math.round((p - 1) / pdf.numPages * 90), `Reading PDF page ${p}/${pdf.numPages}…`); const page = await pdf.getPage(p); const content = await page.getTextContent(); const items = content.items.filter(i => i.str && i.str.trim()).map(i => ({ s: i.str, x: i.transform[4], y: i.transform[5], w: i.width || i.str.length * 4 })); if (items.length < 2) { noText.push(p); continue; } items.sort((a, b) => (Math.abs(b.y - a.y) > 2.5 ? b.y - a.y : a.x - b.x)); const lines = []; items.forEach(it => { const L = lines[lines.length - 1]; if (L && Math.abs(L.y - it.y) <= 2.5) L.items.push(it); else lines.push({ y: it.y, items: [it] }); }); const preface = []; lines.forEach(L => { L.items.sort((a, b) => a.x - b.x); const cells = []; L.items.forEach(it => { const c = cells[cells.length - 1]; if (c && it.x - (c.x + c.w) < 4.5) { c.s += (it.x - (c.x + c.w) > 0.3 ? " " : "") + it.s; c.w = it.x + it.w - c.x; } else cells.push({ s: it.s, x: it.x, w: it.w }); }); const key = cells.map(c => _nhp(c.s)).join("|"); if (cur && key === cur.hkey) { if (!cur.pages.includes(p)) cur.pages.push(p); return; } // the header printed again at the top of a page if (!cur) { if (looksHeader(cells)) startSheet(cells, p, preface.splice(0)); else { const t = cells.map(c => c.s.trim()).join(" ").trim(); if (t) preface.push(t); } return; } // a NEW heading further down (a report that changes columns) = a new sheet: header-shaped, and most of its words are not this table's columns if (cur.rows.length && looksHeader(cells) && cells.length >= Math.max(3, cur.cols.length - 1) && cells.filter(c => cur.cols.some(k => _nhp(k.name) === _nhp(c.s))).length < cells.length / 2) { startSheet(cells, p, []); return; } if (cells.length < 2) return; // a page number, a footer, a stray title if (!cells.some(c => _pdfNumish(c.s)) && cells.length < Math.max(2, Math.ceil(cur.cols.length / 2))) return; // a title line re-printed on a later page (a few words, no figure) is not a row const row = {}; cur.headers.forEach(h => { row[h] = ""; }); cells.forEach(c => { let best = 0; for (let k = 1; k < cur.cols.length; k++) { if (c.x + 2 >= cur.cols[k].x) best = k; } const h = cur.cols[best].name; row[h] = row[h] ? row[h] + " " + c.s.trim() : c.s.trim(); }); Object.defineProperty(row, "__origin", { value: { kind: "pdf-text", file: file.name, page: p }, enumerable: false }); cur.rows.push(row); if (!cur.pages.includes(p)) cur.pages.push(p); }); } onProgress && onProgress(100, "Done"); return { sheets: sheets.filter(s => s.rows.length).map(s => ({ name: s.name, rows: s.rows, headers: s.headers, preface: s.preface, pdf: true, pages: s.pages })), noText, pages: pdf.numPages }; }; // ── Tesseract OCR for an image file or a canvas ── // The site's security policy lets the browser talk only to this server, // cdn.jsdelivr.net and unpkg.com. Tesseract's DEFAULT language-data host // (tessdata.projectnaptha.com) is therefore blocked, so the English data is // read from jsDelivr's copy of the npm package @tesseract.js-data/eng // (…/4.0.0_best_int/eng.traineddata.gz is in that package). const OCR_LANG_PATH = "https://cdn.jsdelivr.net/npm/@tesseract.js-data/eng@1.0.0/4.0.0_best_int"; const OCR_HELP = "Scanned or photographed printouts are read far more reliably by AI reading — open Old reports in this tool and read the pages with AI, or ask for a CSV / Excel export of the report instead."; // A plain-English failure that points the owner at AI reading (never a stack trace). const ocrError = (why, cause) => { const e = new Error(`The built-in scan reader could not read this file (${why}). ${OCR_HELP}`); e.code = "ocr_failed"; e.plain = true; e.cause = cause; return e; }; // tesseract.js 5.1.0 does NOT reject when its language data cannot be fetched (the browser blocks the host, // the network is down …): the worker dies with an uncaught "Failed to fetch" (the page only sees a masked // "Script error." event) and the promise never settles, so a plain `await recognize()` would hang forever on // "Loading OCR engine…". The read therefore also listens for that failure and for silence (no progress for // 45 s) and ends with a plain-English message that says what it was doing when it stopped. const OCR_STALL_MS = 45000; const ocrCanvas = async (canvasOrBlob, onProgress) => { let Tess; try { Tess = await loadLib("tesseract"); } catch (e) { throw ocrError("its reader program could not be downloaded — check the internet connection", e); } let last = Date.now(), lastStatus = "", onRej = null, onErr = null, timer = null; const whatFailed = (msg) => /loading language|traineddata/i.test(lastStatus) || /traineddata|language/i.test(msg) ? "its language data could not be downloaded" : /loading tesseract core/i.test(lastStatus) ? "its reader program could not be downloaded" : /fetch|network|download/i.test(msg) ? "it could not download or open what it needs" : "the reader stopped with an error"; const watchdog = new Promise((_, reject) => { onRej = (ev) => { const why = String((ev && ev.reason && (ev.reason.message || ev.reason)) || ""); if (/fetch|network|traineddata|tesseract|importScripts|wasm|worker/i.test(why)) { try { ev.preventDefault && ev.preventDefault(); } catch (x) {} reject(ocrError(whatFailed(why), ev.reason)); } }; onErr = (ev) => { const why = String((ev && ev.message) || ""); if (/script error|failed to fetch|networkerror|tesseract|wasm|worker/i.test(why)) { try { ev.preventDefault && ev.preventDefault(); } catch (x) {} reject(ocrError(whatFailed(why), why)); } }; window.addEventListener("unhandledrejection", onRej); window.addEventListener("error", onErr); timer = setInterval(() => { if (Date.now() - last > OCR_STALL_MS) reject(ocrError("the reader stopped responding" + (/language|traineddata/i.test(lastStatus) ? " while loading its language data" : ""), null)); }, 2000); }); let result; try { const run = Tess.recognize(canvasOrBlob, "eng", { langPath: OCR_LANG_PATH, logger: (m) => { last = Date.now(); if (m && m.status) lastStatus = String(m.status); if (m && m.status === "recognizing text" && onProgress) onProgress(Math.round((m.progress || 0) * 100), "Recognizing text…"); }, }); run.catch(() => {}); // a late failure after the race is settled must not become another unhandled rejection result = await Promise.race([run, watchdog]); } catch (e) { if (e && e.code === "ocr_failed") throw e; throw ocrError(whatFailed(String((e && e.message) || e || "")), e); } finally { clearInterval(timer); if (onRej) window.removeEventListener("unhandledrejection", onRej); if (onErr) window.removeEventListener("error", onErr); } return (result && result.data && result.data.text) || ""; }; // The File itself goes to Tesseract (it reads it with a FileReader). A blob: URL string would make Tesseract // fetch() it, and the site's connect-src policy does not allow blob: — the read would be refused in production. const parseImage = async (file, onProgress) => { onProgress && onProgress(5, "Loading OCR engine…"); return ocrCanvas(file, (pct, st) => onProgress && onProgress(pct, st)); }; // ── Customer-field extraction from free text ────────────────────────── const RX = { phone: /(\(?\d{3}\)?[\s.\-]?\d{3}[\s.\-]?\d{4})/g, email: /[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}/g, vin: /\b([A-HJ-NPR-Z0-9]{17})\b/g, cityStateZip: /([A-Za-z][A-Za-z .'\-]+),\s*([A-Z]{2})\s*(\d{5}(?:-\d{4})?)/, dob: /(?:DOB|D\.O\.B\.?|Date of Birth|Birth\s*Date)[ \t]*[:\-][ \t]*([01]?\d[\/\-][0-3]?\d[\/\-]\d{2,4})/i, dl: /(?:DL|D\.L\.?|Driver'?s?\s*License|License|Lic\.?)\s*(?:No\.?|#|Number)?[ \t]*[:\-][ \t]*([A-Z0-9\-]{5,20})/i, // Name labels — capture stays on the SAME line (no newline crossing). labelName: /(?:Customer|Buyer|Applicant|Purchaser)(?:\s*Name)?[ \t]*[:\-][ \t]*([A-Z][A-Za-z'\-]+(?:[ \t]+[A-Z][A-Za-z'\-]+){0,3})/, plainName: /\bName[ \t]*[:\-][ \t]*([A-Z][A-Za-z'\-]+(?:[ \t]+[A-Z][A-Za-z'\-]+){0,3})/, nameCommaName: /\b([A-Z][A-Za-z'\-]+)\s*,\s*([A-Z][A-Za-z'\-]+)\b/, lastFirst: /(?:Last\s*Name)[ \t]*[:\-][ \t]*([A-Za-z'\-]+)[\s\S]{0,40}?(?:First\s*Name)[ \t]*[:\-][ \t]*([A-Za-z'\-]+)/i, firstLast: /(?:First\s*Name)[ \t]*[:\-][ \t]*([A-Za-z'\-]+)[\s\S]{0,40}?(?:Last\s*Name)[ \t]*[:\-][ \t]*([A-Za-z'\-]+)/i, address: /(?:Address|Street|Addr)[ \t]*[:\-][ \t]*(\d+\s+[A-Za-z0-9 .'\-]+?)(?:\n|,|\s{2,}|City|Apt)/i, }; const splitDigits = (s) => (s || "").replace(/\D/g, "").slice(-10); // RC30 (cn): the customer # is the phone WITHOUT the area code (7 digits), or a number K2B issued (1xxxxxx) — ONE rule, CustNum.jsx.txt const custNumRule = () => window.CustNum || null; const isCustNum7 = (v) => { const C = custNumRule(); return C ? C.isCustNum(v) : /^[2-9]\d{6}$/.test(String(v || "")); }; const custNumOfPhone = (p) => { const C = custNumRule(); if (C) return C.custNumFromPhone(p).num7 || ""; const d = String(p || "").replace(/\D/g, ""); return d.length >= 10 ? d.slice(-7) : ""; }; // Pull a single customer record from one text block. const extractOne = (block) => { const c = {}; let m; if ((m = RX.lastFirst.exec(block))) { c.lastName = m[1]; c.firstName = m[2]; } else if ((m = RX.firstLast.exec(block))) { c.firstName = m[1]; c.lastName = m[2]; } else if ((m = RX.labelName.exec(block)) || (m = RX.plainName.exec(block))) { const parts = m[1].trim().split(/[ \t]+/); if (parts.length >= 2) { c.firstName = parts[0]; c.lastName = parts[parts.length - 1]; } else c.lastName = m[1].trim(); } else if ((m = RX.nameCommaName.exec(block))) { c.lastName = m[1]; c.firstName = m[2]; } const phones = block.match(RX.phone); if (phones && phones.length) { c.phone = phones[0].trim(); c.custNum = custNumOfPhone(phones[0]); } // RC30 (cn): 7 digits const emails = block.match(RX.email); if (emails && emails.length) c.email = emails[0]; if ((m = RX.address.exec(block))) c.street = m[1].trim(); if ((m = RX.cityStateZip.exec(block))) { c.city = m[1].trim(); c.state = m[2]; c.postal = m[3]; } if ((m = RX.dob.exec(block))) c.dob = m[1]; if ((m = RX.dl.exec(block))) c.dlNumber = m[1]; const vins = block.match(RX.vin); if (vins && vins.length) c.vin = vins[0]; // valid only if we got at least a name or a phone const ok = (c.firstName || c.lastName || c.custNum); return ok ? c : null; }; // Extract one or many customers. Splits on form feeds or repeated // "Customer:/Buyer:" markers so multi-record sheets come through whole. const extractCustomers = (text) => { if (!text) return []; let blocks = text.split(/\f|\n-{3,}\n|\n\s*={3,}\s*\n/).filter(b => b.trim().length > 8); // If it looks like one long doc with multiple "Customer:" headers, split there. if (blocks.length === 1) { const byHeader = text.split(/\n(?=(?:Customer|Buyer|Applicant)\s*(?:Name)?[:\s])/i); if (byHeader.length > 1) blocks = byHeader; } const records = []; blocks.forEach(b => { const c = extractOne(b); if (c) records.push(c); }); // de-dupe by custNum/name const seen = new Set(); return records.filter(c => { const key = c.custNum || `${c.firstName || ""}|${c.lastName || ""}`; if (seen.has(key)) return false; seen.add(key); return true; }); }; // ── Collection classifier ───────────────────────────────────────────── // Given a set of tabular rows (one sheet / CSV), figure out which DMS // ledger collection it represents by scoring header keywords. Differentiates // GL accounts, inventory, parts, deals, A/R accounts, CRM customers, // floorplan, payables, repair orders. const COLLECTION_SIGNATURES = [ { key: "glAccounts", label: "GL / Chart of Accounts", strong: ["account no", "acct no", "gl account", "account number", "acct #"], weak: ["account", "acct", "debit", "credit", "balance", "account type", "normal balance", "ledger"] }, { key: "inventory", label: "Vehicle Inventory", strong: ["vin", "stock no", "stock #", "stock number"], weak: ["stock", "year", "make", "model", "odometer", "mileage", "msrp", "lot", "trim", "body"] }, { key: "parts", label: "Parts Inventory", strong: ["part no", "part number", "part #", "partno"], weak: ["part", "bin", "on hand", "onhand", "qty", "list price", "core", "supersede", "description"] }, { key: "deals", label: "Deals", strong: ["deal no", "deal #", "deal number"], weak: ["buyer", "deal", "sale price", "saleprice", "vehicle", "salesperson", "front gross", "f&i"] }, { key: "repairOrders", label: "Repair Orders", strong: ["ro no", "ro #", "repair order"], weak: ["ro", "technician", "tech", "labor", "op code", "complaint", "hours", "parts", "balance due", "total due"] }, { key: "floorplanPayoffsDue", label: "Floorplan", strong: ["floorplan", "floor plan", "curtailment"], weak: ["lender", "floor", "payoff", "stock", "amount", "flooring"] }, { key: "payables", label: "Accounts Payable", strong: ["vendor no", "payee", "invoice no"], weak: ["vendor", "invoice", "due date", "amount", "bill", "po no", "terms", "vendor phone", "owed", "amount due"] }, { key: "arCustomers", label: "A/R Accounts", strong: ["a/r account", "ar account", "receivable"], weak: ["account", "acct", "balance", "credit limit", "terms", "customer", "aging", "past due"] }, { key: "journalEntries", label: "GL Journals", strong: ["journal", "jrn", "je no", "je #", "entry no", "journal no"], weak: ["debit", "credit", "acct", "account", "date", "description", "reference", "ref", "source", "doc"] }, { key: "customers", label: "Customers (CRM)", strong: ["mobile", "cell phone", "email address", "customer no", "cust no"], weak: ["phone", "email", "first name", "last name", "customer name", "address", "city", "zip"] }, ]; // ── Vehicle inventory column matching ───────────────────────────────── // Headers are compared case/space/punctuation-insensitive ("Stock #", // "STOCK_NO", "stk#" → stock). Every field lands on the key DMS.importInventory // (DMSCore _normUnitRow) reads: stock, vin, asking, msrp, cost, miles, // newUsed, yr, mk, mdl, trim, body, color, description. const _nh = (h) => String(h == null ? "" : h).toLowerCase().replace(/[^a-z0-9]/g, ""); const INV_FIELDS = [ { key: "stock", label: "Stock #", syn: ["stock", "stock #", "stock no", "stock num", "stock number", "stock id", "stk", "stk#", "stk no", "unit #", "unit no", "unit number"] }, { key: "vin", label: "VIN", syn: ["vin", "vin #", "vin no", "vin number", "vehicle id", "vehicle identification number", "serial", "serial #", "serial no", "serial number", "vin / serial", "vin/serial"] }, // RC30 (imp2): the template's "VIN / Serial" { key: "asking", label: "Retail price", syn: ["price", "retail", "retail price", "asking", "asking price", "internet price", "web price", "advertised price", "list", "list price", "sale price", "selling price", "retail value"] }, { key: "msrp", label: "MSRP", syn: ["msrp", "sticker", "sticker price", "window sticker"] }, { key: "cost", label: "Vehicle cost", syn: ["cost", "unit cost", "vehicle cost", "inv cost", "inventory cost", "invoice", "invoice cost", "invoice amount", "acv", "book", "book value", "purchase price", "total cost", "dealer cost", "net cost"] }, { key: "miles", label: "Miles", syn: ["miles", "mileage", "odometer", "odometer reading", "odo", "mi", "current miles"] }, { key: "newUsed", label: "New / Used", syn: ["new/used", "n/u", "nu", "new used", "new or used", "n/u/c", "nuc", "new/used/cpo"] }, { key: "yr", label: "Year", syn: ["year", "yr", "model year", "veh year"] }, { key: "mk", label: "Make", syn: ["make", "mk", "manufacturer", "mfr"] }, { key: "mdl", label: "Model", syn: ["model", "mdl", "model name"] }, { key: "trim", label: "Trim", syn: ["trim", "series", "trim level"] }, { key: "body", label: "Body", syn: ["body", "body style", "body type", "style"] }, { key: "color", label: "Color", syn: ["color", "colour", "exterior", "exterior color", "ext color", "ext colour"] }, { key: "description", label: "Description", syn: ["description", "desc", "vehicle description"] }, // ── vehicle master (old-DMS "Vehicle Master Report") columns ── { key: "invAcct", label: "Inventory account", syn: ["inventory account", "inventory acct", "inventory acct #", "inventory account #", "inv acct", "inv account", "inv acct #", "inv account #", "inv acct no", "inventory gl", "inventory gl account", "inv gl", "gl inventory account", "gl account", "gl acct", "gl account #", "inventory gl account #"] }, // RC30 (imp2): the template's "GL Account" { key: "invoiceDate", label: "Invoice date", syn: ["invoice date", "inv date", "invoiced", "invoice dt", "purchase date", "date purchased", "date invoiced", "stocked date", "date stocked", "date in"] }, { key: "invoiceCost", label: "Purchase / invoice cost", syn: ["purchase / invoice cost", "purchase/invoice cost", "purchase cost", "purchase amount", "invoice price", "vehicle invoice"] }, { key: "repairs", label: "Total repairs", syn: ["total repairs", "repairs", "repair cost", "repair costs", "total repair", "total repairs (reference)"] }, { key: "glCost", label: "GL cost (reference)", syn: ["gl cost", "gl cost (reference)", "gl amount", "gl balance", "ledger cost", "book cost", "booked cost", "cost per gl", "general ledger cost"] }, { key: "age", label: "Days in stock (reference)", syn: ["days in stock", "days in inventory", "days on lot", "age", "age days", "inventory age", "stock age", "days in stock (reference)"] }, // the old vehicle master prints TOTAL COST = purchase + repairs + pack; the pack is the difference (merge fix: without it the unit's pack was lost) { key: "pack", label: "Pack", syn: ["pack", "pack amount", "dealer pack", "pack lot", "pack lot amount"] }, ]; const INV_MONEY = { asking: 1, msrp: 1, cost: 1, miles: 1, invoiceCost: 1, repairs: 1, glCost: 1, age: 1, pack: 1 }; // Headers that mean new/used ONLY when the values look like N/U/New/Used/CPO. // Otherwise: "type"/"vehicle type" → body style; the rest stay unmapped. const _INV_AMBIG = { type: "body", vehicletype: "body", condition: "", status: "", stocktype: "", inventorytype: "", newusedstatus: "" }; // New / Used from a cell value. Returns "New" | "Used" | null (not recognized). const invNewUsed = (v) => { const s = String(v == null ? "" : v).trim().toLowerCase(); if (!s) return null; if (/^(n|new|demo)\b/.test(s)) return "New"; if (/^(u|used|c|cpo|certified|pre-?owned|preowned)\b/.test(s)) return "Used"; return null; }; const _nuLike = (vals, share) => { const v = vals.map(x => String(x == null ? "" : x).trim()).filter(Boolean); return v.length > 0 && v.filter(x => invNewUsed(x)).length >= Math.ceil(v.length * (share || 0.6)); }; // Money / miles: "$21,995.00", "12,345 mi", "(500)" → number; blank → ""; // anything else stays the raw text so the import reports it (never dropped). const invNumber = (v) => { const raw = String(v == null ? "" : v).trim(); if (!raw) return ""; let s = raw.replace(/[$,\s]/g, "").replace(/(usd|miles|mi|km)$/i, ""); const neg = /^\(.*\)$/.test(s) || /-$/.test(s); s = s.replace(/[()]/g, "").replace(/-$/, ""); if (!/^-?\d+(\.\d+)?$/.test(s)) return raw; const n = parseFloat(s); return neg ? -Math.abs(n) : n; }; // headers[] + sample rows → { header: fieldKey | "" }. One column per field // (first match wins; the rest stay unmapped for the user to reassign). const guessInventoryMapping = (headers, rows) => { const sample = (rows || []).slice(0, 200); const map = {}; const taken = new Set(); const set = (h, f) => { if (f && !taken.has(f)) { map[h] = f; taken.add(f); return true; } return false; }; (headers || []).forEach(h => { map[h] = ""; }); const exact = {}; INV_FIELDS.forEach(F => [...F.syn, F.label].forEach(s => { exact[_nh(s)] = exact[_nh(s)] || F.key; })); // 1) exact synonyms (and the field's own label) + value-checked ambiguous headers (headers || []).forEach(h => { const n = _nh(h); if (Object.prototype.hasOwnProperty.call(_INV_AMBIG, n)) { if (_nuLike(sample.map(r => r[h]))) set(h, "newUsed"); else set(h, _INV_AMBIG[n]); return; } if (exact[n]) set(h, exact[n]); }); // 2) contains-match for longer synonyms ("Vehicle Cost ($)", "Odometer In") (headers || []).forEach(h => { if (map[h] || Object.prototype.hasOwnProperty.call(_INV_AMBIG, _nh(h))) return; const n = _nh(h); let best = null, bestLen = 0; // "Recon Cost", "Pack", "Floorplan Payoff", "Days in Stock"… are not the unit's cost/price. if (/recon|pack|holdback|floor|fee|tax|payoff|gross|profit|days|age|date|pct|percent/.test(n)) return; INV_FIELDS.forEach(F => F.syn.forEach(s => { const sn = _nh(s); if (sn.length >= 4 && n.includes(sn) && sn.length > bestLen && !taken.has(F.key)) { best = F.key; bestLen = sn.length; } })); if (best) set(h, best); }); // 3) still no new/used column? find one by its values if (!taken.has("newUsed")) { const h = (headers || []).find(x => !map[x] && _nuLike(sample.map(r => r[x]), 0.8)); if (h) set(h, "newUsed"); } return map; }; // Raw rows + mapping → rows for DMS.importInventory, 1:1 with the file // (row i is spreadsheet row i+2), plus field-level issues found here. const mapInventoryRows = (rows, mapping) => { const byField = {}; Object.keys(mapping || {}).forEach(h => { const f = mapping[h]; if (f && !byField[f]) byField[f] = h; }); const issues = []; const out = (rows || []).map((raw, i) => { const o = {}; INV_FIELDS.forEach(F => { const h = byField[F.key]; if (!h) return; const v = String((raw || {})[h] == null ? "" : raw[h]).trim(); if (F.key === "newUsed") { if (!v) { o.newUsed = ""; return; } const t = invNewUsed(v); if (t) o.newUsed = t; else { o.newUsed = ""; issues.push({ row: i + 2, field: "type", reason: `new/used value "${v}" not recognized (use N, U, New, Used, CPO) — New/Used left blank` }); } } else if (INV_MONEY[F.key]) { o[F.key] = invNumber(v); } else o[F.key] = v; }); return o; }); return { rows: out, issues }; }; // Rows as handed to DMS.importInventory. With "blank cells clear" ON the // core clears EVERY blank field — including fields the file has no column // for — so each field that is NOT mapped is filled with the matched unit's // current value (a no-op). Only mapped columns can clear. A file VIN that // conflicts with the unit's VIN (same stock #) is reported and not applied. // Matching mirrors the core: stock # first, then VIN. // (repairs / glCost are stored on the unit as recon / costBooked: without these two a plain price-list file with "blank cells clear" ON wiped them) const INV_CORE_KEY = { newUsed: "type", repairs: "recon", glCost: "costBooked" }; const prepareInventoryRows = (ds, opts) => { const o = opts || {}; const rows = (ds && ds.rows) || []; if (!o.clearBlanks) return { rows, issues: [] }; const units = o.units || []; const mapped = new Set(Object.values((ds && ds.mapping) || {}).filter(Boolean)); const U = (v) => String(v == null ? "" : v).toUpperCase().trim(); const issues = []; const out = rows.map((r, i) => { const st = U(r.stock), vin = U(r.vin).replace(/\s+/g, ""); let u = st ? units.find(x => U(x.stock) === st) : null; if (!u && vin) u = units.find(x => U(x.vin) === vin) || null; if (!u) return r; const n = { ...r }; INV_FIELDS.forEach(F => { if (F.key === "stock" || mapped.has(F.key)) return; const cur = u[INV_CORE_KEY[F.key] || F.key]; n[F.key] = cur == null ? "" : cur; }); if (st && vin && u.vin && U(u.vin) !== vin && U(u.stock) === st) { issues.push({ row: i + 2, field: "vin", reason: `stock ${u.stock} is already VIN ${u.vin} — file VIN ${vin} NOT applied (check the file)` }); n.vin = u.vin; } return n; }); return { rows: out, issues }; }; const _invHeaders = (rows) => { const s = []; (rows || []).slice(0, 50).forEach(r => Object.keys(r || {}).forEach(k => { if (!s.includes(k)) s.push(k); })); return s; }; // Build / rebuild an inventory dataset (keeps raw + mapping so the preview // can re-map columns without re-reading the file). const inventoryDataset = (rows, sheetName, base, mapping) => { const headers = _invHeaders(rows); const m = mapping || guessInventoryMapping(headers, rows); const r = mapInventoryRows(rows, m); return { ...(base || {}), collection: "inventory", label: COLLECTION_LABELS_INV, sheet: sheetName != null ? sheetName : ((base && base.sheet) || ""), count: r.rows.length, rows: r.rows, raw: rows, headers, mapping: m, mapIssues: r.issues, unmapped: headers.filter(h => !m[h]) }; }; const COLLECTION_LABELS_INV = "Vehicle Inventory"; const remapInventory = (ds, mapping) => inventoryDataset(ds.raw || [], ds.sheet, ds, mapping); // Does this sheet look like vehicle inventory by its columns? const _invSignal = (rows) => { const m = guessInventoryMapping(_invHeaders(rows), rows); const f = new Set(Object.values(m).filter(Boolean)); const vehicleOnly = ["vin", "miles", "newUsed", "yr", "mk", "mdl"].some(k => f.has(k)); const others = ["miles", "newUsed", "yr", "mk", "mdl", "asking", "cost", "msrp"].filter(k => f.has(k)).length; return (f.has("stock") || f.has("vin")) && vehicleOnly && others >= 2; }; // ── Old-DMS report detection (by column headings only) ─────────────── // Recognises the datasets the Old reports packet produces, whether the file // is a CSV/Excel export or the converter's own output: // glTrial trial balance (account, MTD / YTD amount) // schedule aged schedule (control #, days, balance) // deals deal recap (deal #, selling price, net/net, commissions …) // inventory vehicle master (stock, VIN, inventory account, GL cost …) // glChart chart of accounts with alter # / D-C / cost + inventory accounts // Returns { dest, confidence 0..1, why } or null. const detectDest = (rows) => { if (!rows || !rows.length) return null; const H = Object.keys(rows[0]).map(_nh); const has = (...names) => names.some(n => H.includes(_nh(n))); const hasC = (...names) => names.some(n => { const x = _nh(n); return H.some(h => h.includes(x)); }); const cnt = (...names) => names.filter(n => { const x = _nh(n); return H.some(h => h.includes(x)); }).length; const best = []; const add = (dest, confidence, why) => best.push({ dest, confidence, why }); const acctCol = has("account number", "account no", "account #", "acct", "acct no", "acct #", "account", "gl account", "gl acct", "account num", "acct number"); const ytdCol = has("ytd amount", "ytd amt", "ytd", "year to date", "ytd balance", "year-to-date", "year to date amount", "ytd total"); const mtdCol = has("mtd amount", "mtd amt", "mtd", "month to date", "month-to-date", "mtd balance", "mtd count", "mtd cnt", "mtd total"); const drcr = has("debit", "dr", "debits") && has("credit", "cr", "credits"); if (acctCol && ytdCol && (mtdCol || has("description", "desc", "account description", "account name", "name")) && !drcr) add("glTrial", mtdCol ? 0.95 : 0.8, "account + year-to-date amount columns (trial balance)"); const ctl = has("control", "control #", "control no", "control number", "ctl", "ctrl", "control id"); const days = has("days", "age", "age days", "days old", "aging days", "days aged", "days outstanding"); const amt = has("balance", "amount", "amt", "open balance", "net amount"); if (ctl && days && amt) add("schedule", 0.92, "control # + days + balance columns (aged schedule)"); else if (hasC("schedule") && days && amt && has("control", "customer", "cust #", "cust no")) add("schedule", 0.7, "schedule + days + balance"); const dealCol = has("deal", "deal #", "deal no", "deal number", "deal num", "deal #"); const recapN = cnt("net net", "net/net", "total dealer profit", "dealer profit", "holdback", "special finance", "sfd", "gl repairs", "buy rate", "contract rate", "cash received", "f&i profit", "fi profit", "commission", "amount financed", "finance charge", "trade acv", "front gross", "gross profit", "reserve"); if (dealCol && recapN >= 2) add("deals", 0.92, "deal # + recap columns (deal recap)"); else if (dealCol && hasC("buyer", "salesperson", "selling price", "sale price")) add("deals", 0.6, "deal # + buyer / price"); const masterN = cnt("inventory account", "inv acct", "inv account", "gl cost", "invoice date", "total repairs", "purchase cost", "purchase / invoice", "days in stock"); if (masterN >= 2 && hasC("stock", "vin")) add("inventory", 0.95, "stock / VIN + vehicle-master columns"); const altCol = has("alter", "alter #", "alter number", "alter no", "alt", "alt no", "alt #", "alternate"); if (acctCol && (altCol || (has("d/c", "dc", "d c", "d/c flag") && hasC("cost acct", "cost account", "inv acct", "inventory acct", "inventory account"))) && !ytdCol) add("glChart", 0.9, "account + alter # / D-C / cost + inventory account columns (chart of accounts)"); best.sort((x, y) => y.confidence - x.confidence); return best[0] || null; }; const OLD_REPORT_KEYS = { glTrial: ["glTrial", "Trial balance (opening balances)"], schedule: ["schedule", "Aged schedule"], deals: ["dealRecap", "Deal recaps (history)"] }; const classifyRows = (rows) => { if (!rows || !rows.length) return { key: null, label: "Empty", confidence: 0 }; const dd = detectDest(rows); if (dd && dd.confidence >= 0.8 && OLD_REPORT_KEYS[dd.dest]) return { key: OLD_REPORT_KEYS[dd.dest][0], label: OLD_REPORT_KEYS[dd.dest][1], score: 99, confidence: "high", dest: dd.dest }; const keys = Object.keys(rows[0]).map(k => k.toLowerCase().trim()); const hay = keys.join(" | "); // Inventory by its columns — unless another dataset's defining column is // present (a deal / RO / parts / floorplan file also carries stock + year). const otherStrong = COLLECTION_SIGNATURES.some(sig => sig.key !== "inventory" && sig.strong.some(s => hay.includes(s))); const invLike = !otherStrong && _invSignal(rows); const scored = COLLECTION_SIGNATURES.map(sig => { let score = 0; sig.strong.forEach(s => { if (hay.includes(s)) score += 10; }); sig.weak.forEach(w => { if (keys.some(k => k === w || k.includes(w))) score += 2; }); if (sig.key === "inventory" && invLike) score += 10; return { key: sig.key, label: sig.label, score }; }).sort((a, b) => b.score - a.score); let best = scored[0]; // Clear vehicle-inventory columns (stock/VIN + miles/year/new-used…) are // not re-guessed from ID shapes below (a 6-digit stock # is not an RO). if (best.key === "inventory" && invLike) return { ...best, confidence: "high" }; // Disambiguation where headers overlap. Use word-aware matching so // "credit limit" doesn't count as a GL "credit" column. const cols = keys; // already lowercased const hasCol = (...n) => n.some(x => cols.some(k => k === x || k.includes(x))); const hasWord = (...n) => n.some(x => cols.some(k => k.split(/[^a-z0-9]+/).includes(x))); const isGLish = (hasCol("account type", "normal balance") || (hasWord("debit") && hasWord("credit"))); const hasPerson = hasCol("phone", "email", "credit limit", "terms", "address", "mobile"); // Journal lines: dated rows with debit+credit and a ref/description — NOT a // chart of accounts (which has type/balance and no per-row dates). if (hasWord("debit") && hasWord("credit") && hasCol("date") && (hasCol("reference", "ref", "journal", "jrn", "description", "desc", "doc")) && best.key !== "journalEntries") { best = { key: "journalEntries", label: "GL Journals", score: best.score }; } // A real chart of accounts: an account column + GL type/dr-cr, and no person fields. if (isGLish && hasCol("account", "acct") && !hasPerson && best.key !== "glAccounts" && best.key !== "journalEntries") { best = { key: "glAccounts", label: "GL / Chart of Accounts", score: best.score }; } // A/R account (open-item): account + balance/terms/credit-limit + a customer name. if (best.key === "glAccounts" && hasPerson && hasCol("customer", "name")) { best = { key: "arCustomers", label: "A/R Accounts", score: best.score }; } // CRM vs A/R: phone/email but no balance/terms/credit-limit/aging → CRM. if (best.key === "arCustomers" && hasCol("phone", "email") && !hasCol("balance", "credit limit", "terms", "aging")) { best = { key: "customers", label: "Customers (CRM)", score: best.score }; } // ── ID-shape override: sniff the actual values in id-ish columns ── // This store's numbering scheme: RO# = 6 digits starting 1 · deal# = // 5 digits starting 0 · customer/A-R# = 7-digit phone never starting 1. const idVals = (names) => { const key = Object.keys(rows[0] || {}).find(k => names.some(n => k.toLowerCase().trim() === n || k.toLowerCase().includes(n))); if (!key) return []; return rows.slice(0, 40).map(r => String(r[key] ?? "").trim()).filter(Boolean); }; const shapeIs = (vals, re) => vals.length >= 2 && vals.filter(v => re.test(v)).length >= Math.ceil(vals.length * 0.8); const anyId = idVals(["ro no", "ro #", "ro number", "account", "acct", "customer no", "cust no", "number", "no", "id", "ref"]); if (shapeIs(anyId, /^1\d{5}$/) && best.key !== "repairOrders") { best = { key: "repairOrders", label: "Repair Orders", score: Math.max(best.score, 10) }; } else if (shapeIs(anyId, /^0\d{4}$/) && best.key !== "deals" && best.key !== "journalEntries") { best = { key: "deals", label: "Deals (history)", score: Math.max(best.score, 10) }; } else if (shapeIs(anyId, /^[2-9]\d{6}$/) && (best.key === "glAccounts" || best.key === "customers" || best.key === "arCustomers")) { // 7-digit phone-shaped account numbers → customer records, not GL. const isAR = hasCol("balance", "credit limit", "terms", "aging"); best = { key: isAR ? "arCustomers" : "customers", label: isAR ? "A/R Accounts" : "Customers (CRM)", score: Math.max(best.score, 10) }; } return { ...best, confidence: best.score >= 10 ? "high" : best.score >= 4 ? "medium" : "low" }; }; // Per-collection row → record mappers. Each takes a raw row (header→value) // and returns the record shape that DMS collection expects. const _low = (row) => { const o = {}; Object.keys(row).forEach(k => o[k.toLowerCase().trim()] = row[k]); return o; }; // Match a header by exact name, or substring only for tokens ≥4 chars // (so short tokens like "st"/"yr" don't false-match inside "first"/"year"). const _pick = (low, ...names) => { for (const n of names) for (const k in low) { const hit = k === n || (n.length >= 4 && k.includes(n)) || (n.length < 4 && k.split(/[^a-z0-9]+/).includes(n)); if (hit) { const v = String(low[k] ?? "").trim(); if (v) return v; } } return ""; }; const _num = (s) => { const n = parseFloat(String(s).replace(/[^0-9.\-]/g, "")); return isNaN(n) ? 0 : n; }; const MAPPERS = { glAccounts: (row) => { const l = _low(row); return { acct: _pick(l, "account no", "acct no", "account number", "account", "acct", "number", "gl"), name: _pick(l, "name", "description", "title", "account name"), type: _pick(l, "type", "account type", "classification") || "Asset", bal: _num(_pick(l, "balance", "ending balance", "amount", "ytd")) }; }, // Single-row fallback; whole sheets go through inventoryDataset() so the // mapping (and new/used-by-values detection) sees every row. inventory: (row) => mapInventoryRows([row], guessInventoryMapping(Object.keys(row || {}), [row])).rows[0], parts: (row) => { const l = _low(row); return { part: _pick(l, "part no", "part number", "part #", "partno", "part"), desc: _pick(l, "description", "desc", "name"), bin: _pick(l, "bin", "location"), onHand: _num(_pick(l, "on hand", "onhand", "qty", "quantity", "stock")), cost: _num(_pick(l, "cost", "dealer cost")), list: _num(_pick(l, "list", "list price", "retail", "price")), source: _pick(l, "source", "vendor", "supplier") || "GM" }; }, deals: (row) => { const l = _low(row); return { id: _pick(l, "deal no", "deal #", "deal number", "deal", "number", "no", "id") || ("DR-" + Math.random().toString().slice(2, 9)), buyer: _pick(l, "buyer", "customer", "customer name", "name"), vehicle: _pick(l, "vehicle", "unit"), stock: _pick(l, "stock"), salePrice: _num(_pick(l, "sale price", "saleprice", "price", "selling price")), salesperson: _pick(l, "salesperson", "sales rep", "rep"), status: _pick(l, "status") || "Working" }; }, repairOrders: (row) => { const l = _low(row); return { id: _pick(l, "ro no", "ro #", "ro number", "ro") || ("RO-" + Math.random().toString().slice(2, 8)), customer: _pick(l, "customer", "name", "buyer"), custNum: (() => { const c = _pick(l, "customer no", "cust no", "account", "acct").replace(/[^0-9]/g, ""); if (isCustNum7(c)) return c; // RC30 (cn): the shared rule (a K2B-issued 1xxxxxx counts) return custNumOfPhone(_pick(l, "phone", "mobile", "cell")); })(), vehicle: _pick(l, "vehicle", "unit", "vin"), tech: _pick(l, "technician", "tech"), advisor: _pick(l, "advisor", "writer", "service advisor"), laborSale: _num(_pick(l, "labor", "labor sale", "labor total")), partsSale: _num(_pick(l, "parts", "parts sale", "parts total")), balanceDue: _num(_pick(l, "balance due", "total due", "amount due", "open amount", "balance")), payType: /warranty/i.test(_pick(l, "pay type", "paytype")) ? "Warranty" : /internal/i.test(_pick(l, "pay type", "paytype")) ? "Internal" : "Customer", status: _pick(l, "status") || "Open" }; }, floorplanPayoffsDue: (row) => { const l = _low(row); return { stock: _pick(l, "stock", "stock no", "unit"), vin: _pick(l, "vin"), lender: _pick(l, "lender", "floorplan", "bank", "source"), amount: _num(_pick(l, "amount", "payoff", "balance")), paid: /paid|cleared|y|yes/i.test(_pick(l, "paid", "status")) }; }, payables: (row) => { const l = _low(row); return { vendor: _pick(l, "vendor", "payee", "vendor name", "name", "company"), vendorNo: _pick(l, "vendor no", "vendor #", "vendor number"), phone: (() => { const p = _pick(l, "phone", "vendor phone", "contact phone", "telephone").replace(/[^0-9]/g, ""); return p.length >= 7 ? p : ""; })(), invoice: _pick(l, "invoice no", "invoice", "inv #", "ref"), amount: _num(_pick(l, "amount", "owed", "amount due", "balance", "total", "bill")), due: _pick(l, "due date", "due", "date"), terms: _pick(l, "terms") }; }, arCustomers: (row) => { const l = _low(row); return { acct: (() => { const a = _pick(l, "a/r account", "ar account", "account no", "acct no", "account", "acct", "customer no", "cust no").replace(/[^0-9]/g, ""); if (isCustNum7(a)) return a; // RC30 (cn): a customer's A/R account = the customer # return custNumOfPhone(_pick(l, "phone", "telephone", "mobile")) || (a || ""); })(), name: _pick(l, "customer name", "customer", "name", "company"), type: (_pick(l, "type", "account type") || "RTL").slice(0, 3).toUpperCase(), phone: _pick(l, "phone", "telephone"), terms: _pick(l, "terms") || "Net 30", creditLimit: _num(_pick(l, "credit limit", "limit")), balance: _num(_pick(l, "balance", "amount due", "open balance")), taxCode: _pick(l, "tax code", "tax") || "0100", txns: [] }; }, customers: (row) => rowToCustomer(row), }; // CRM customer mapper (kept as-is, used by MAPPERS.customers) const rowToCustomer = (row) => { const low = _low(row); const pick = (...names) => _pick(low, ...names); const phone = pick("phone", "mobile", "cell", "tel"); const full = pick("full name", "customer name", "name"); let first = pick("first"), last = pick("last"); if (!first && !last && full) { const p = full.split(/[\s,]+/).filter(Boolean); if (full.includes(",")) { last = p[0]; first = p[1] || ""; } else { first = p[0]; last = p.slice(1).join(" "); } } return { custNum: (() => { // RC30 (cn): an explicit customer number column wins when it follows the rule (7 digits of a phone, or a K2B-issued 1xxxxxx — RO numbers // at this store are 6 digits, so the length tells them apart); else the phone without the area code; else nothing (the server issues one) const c = pick("customer no", "cust no", "customer number", "account no", "acct no").replace(/\D/g, ""); if (isCustNum7(c)) return c; return custNumOfPhone(phone) || undefined; })(), firstName: first, lastName: last, phone, email: pick("email", "e-mail"), street: pick("address", "street", "addr"), city: pick("city"), state: pick("state", "st"), postal: pick("zip", "postal"), dlNumber: pick("dl", "license", "lic"), dob: pick("dob", "birth"), }; }; // Detect a columnar "chart of accounts" / list inside free text (PDF/Word/OCR). // Lines like: 1010 CASH IN BANK 152,340.21 // or: 240 INVENTORY-NEW Asset 1,204,300 const extractGLFromText = (text) => { const lines = (text || "").split(/\r?\n/).map(l => l.trim()).filter(Boolean); const recs = []; lines.forEach(line => { // acct (3-5 digits, optional dash) + name (words) + trailing amount const m = line.match(/^(\d{3,5}(?:-\d{1,4})?)\s+([A-Za-z][A-Za-z0-9 &.,'\/\-]+?)\s+(?:(Asset|Liability|Equity|Sales|Income|Revenue|Cost|Expense|Contra)\s+)?\(?-?\$?([\d,]+\.\d{2})\)?$/i); if (m) recs.push({ acct: m[1], name: m[2].trim(), type: m[3] || "Asset", bal: _num(m[4]) }); }); return recs; }; const datasetFromRows = (rows, sheetName) => { const cls = classifyRows(rows); if (!cls.key) return null; // Inventory: rows stay 1:1 with the file (no silent filtering) — rows // without stock/VIN are reported by DMS.importInventory. if (cls.key === "inventory") return inventoryDataset(rows, sheetName || "", { confidence: cls.confidence, label: cls.label }); const mapper = MAPPERS[cls.key] || ((r) => r); const mapped = rows.map(mapper).filter(r => r && Object.values(r).some(v => v !== "" && v != null && !(Array.isArray(v) && !v.length))); // Journals: group line-rows into balanced entries by reference/journal id. if (cls.key === "journalEntries") { const entries = groupJournalRows(rows); return { collection: "journalEntries", label: cls.label, confidence: cls.confidence, sheet: sheetName || "", count: entries.length, rows: entries, raw: rows }; } return { collection: cls.key, label: cls.label, confidence: cls.confidence, sheet: sheetName || "", count: mapped.length, rows: mapped, raw: rows }; }; // Group journal line-rows into balanced entries by reference/journal id. const groupJournalRows = (rows) => { const groups = {}; rows.forEach((row, i) => { const l = _low(row); const ref = _pick(l, "je no", "je #", "entry no", "journal no", "reference", "ref", "doc", "journal") || _pick(l, "date") || ("IMP-" + i); const g = groups[ref] = groups[ref] || { id: String(ref).slice(0, 14), date: _pick(l, "date") || new Date().toLocaleDateString("en-US", { month: "2-digit", day: "2-digit", year: "2-digit" }), src: "Import", desc: _pick(l, "description", "desc", "memo", "detail") || "Imported journal", jrn: parseInt(_pick(l, "jrn", "journal")) || 90, lines: [] }; const acct = _pick(l, "acct", "account", "account no", "acct no", "gl account"); const dr = _num(_pick(l, "debit", "dr")); const cr = _num(_pick(l, "credit", "cr")); if (acct && (dr || cr)) g.lines.push({ acct: String(acct), dr: dr || 0, cr: cr || 0, desc: _pick(l, "description", "desc", "memo") || "" }); }); return Object.values(groups).filter(g => g.lines.length); }; // Human labels for every importable collection (drives the category // override dropdown in the Import tool's review step). const COLLECTION_LABELS = { customers: "Customers (CRM)", arCustomers: "A/R Accounts", inventory: "Vehicle Inventory", parts: "Parts Inventory", deals: "Deals (history)", repairOrders: "Repair Orders", payables: "Accounts Payable (vendors)", glAccounts: "GL Chart of Accounts", journalEntries: "GL Journals", }; // Re-map a parsed dataset to a user-chosen collection (fixes any // misclassification — rows re-run through that collection's mapper). const remapDataset = (ds, newKey) => { const raw = ds.raw || []; const label = COLLECTION_LABELS[newKey] || newKey; if (newKey === "journalEntries") { const entries = groupJournalRows(raw); return { ...ds, collection: newKey, label, confidence: 1, count: entries.length, rows: entries }; } if (newKey === "inventory") return inventoryDataset(raw, ds.sheet, { ...ds, confidence: 1 }); const mapper = MAPPERS[newKey] || ((r) => r); const mapped = raw.map(mapper).filter(r => r && Object.values(r).some(v => v !== "" && v != null && !(Array.isArray(v) && !v.length))); return { ...ds, collection: newKey, label, confidence: 1, count: mapped.length, rows: mapped }; }; // ── Legacy .dat / fixed-width / delimited text → row objects ────────── // Handles old DMS exports: pipe/tab/caret/tilde-delimited or fixed-width // column files, with or without a header line, including Btrieve-style // dumps with binary padding. Produces row objects with real or inferred // headers, then the normal classifier routes them to a collection. const _VIN_RE = /^[A-HJ-NPR-Z0-9]{17}$/; const _MONEY_RE = /^-?\(?\$?\d{1,3}(,\d{3})*(\.\d{2})\)?-?$/; const _DATE_RE = /^(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4}|\d{4}-\d{2}-\d{2}|\d{8})$/; const _guessCol = (samples, idx, nMoneySoFar) => { const vals = samples.map(s => (s[idx] || "").trim()).filter(Boolean); if (!vals.length) return "field" + (idx + 1); const all = (re) => vals.every(v => re.test(v)); const most = (re) => vals.filter(v => re.test(v)).length >= Math.ceil(vals.length * 0.7); if (all(_VIN_RE)) return "vin"; // ── Dealership ID-number shapes (this store's numbering scheme) ── // RO numbers: 6 digits, ALWAYS start with 1 (e.g. 104512) // Customer/A-R #s: 7-digit local phone (the customer # = the phone without the area code); K2B-issued numbers 1000001+ are 7 digits too // Deal numbers: 5 digits, currently start with 0 (e.g. 01234) if (most(/^1\d{5}$/)) return "ro no"; if (most(/^0\d{4}$/)) return "deal no"; if (most(/^[2-9]\d{6}$/) || most(/^1\d{6}$/)) return "customer no"; // RC30 (cn): + the K2B-issued range if (most(/^\d{10}$/) || most(/^\(\d{3}\)\s?\d{3}-\d{4}$/) || most(/^\d{3}[.\-]\d{3}[.\-]\d{4}$/)) return "phone"; if (most(/^[^@\s]+@[^@\s]+\.[^@\s]+$/)) return "email"; if (most(_DATE_RE)) return "date"; if (most(_MONEY_RE)) return nMoneySoFar === 0 ? "debit" : nMoneySoFar === 1 ? "credit" : "amount"; if (most(/^\d{3,4}[A-Z]?$/)) return "acct"; if (most(/^[A-Z]?\d{4,7}[A-Z]?$/)) return "stock"; if (most(/^[A-Z0-9\-]{5,14}$/) && vals.some(v => /\d/.test(v))) return "part no"; if (most(/^[A-Za-z][A-Za-z .,'\-]+$/)) return vals.some(v => v.includes(",")) ? "customer name" : "description"; return "field" + (idx + 1); }; const parseDatText = (raw, fileName) => { // strip binary padding / control chars from legacy dumps const clean = raw.replace(/\u0000/g, " ").replace(/[\x01-\x08\x0b\x0c\x0e-\x1f]/g, ""); const lines = clean.split(/\r?\n/).map(l => l.replace(/\s+$/, "")).filter(l => l.trim().length > 2); if (lines.length < 2) return null; // 1) delimiter detection: the char with the most consistent per-line count let delim = null, delimScore = 0; ["|", "\t", "^", "~", ";", ","].forEach(d => { const counts = lines.slice(0, 40).map(l => l.split(d).length - 1).filter(c => c > 0); if (counts.length < lines.slice(0, 40).length * 0.8) return; const mode = counts.sort((a, b) => a - b)[Math.floor(counts.length / 2)]; if (mode >= 2) { const consistent = counts.filter(c => c === mode).length / counts.length; const score = consistent * mode; if (score > delimScore) { delim = d; delimScore = score; } } }); let cells; if (delim) { cells = lines.map(l => l.split(delim).map(c => c.trim())); } else { // 2) fixed-width: char positions that are blank on ≥90% of lines are boundaries const sample = lines.slice(0, 60); const width = Math.max(...sample.map(l => l.length)); const blankAt = []; for (let i = 0; i < width; i++) { const blanks = sample.filter(l => (l[i] || " ") === " ").length; blankAt.push(blanks / sample.length >= 0.9); } const bounds = []; for (let i = 1; i < width; i++) if (blankAt[i - 1] && !blankAt[i]) bounds.push(i); if (bounds.length < 1) return null; const starts = [0, ...bounds]; cells = lines.map(l => starts.map((s, j) => l.slice(s, starts[j + 1] != null ? starts[j + 1] : undefined).trim())); } const nCols = Math.max(...cells.map(c => c.length)); if (nCols < 2) return null; // 3) header detection: first row is a header if it's alphabetic and matches no data patterns const first = cells[0]; const looksHeader = first.every(c => !_MONEY_RE.test(c) && !_DATE_RE.test(c) && !_VIN_RE.test(c)) && first.filter(c => /^[A-Za-z][A-Za-z0-9 #\/._\-]*$/.test(c)).length >= Math.ceil(first.length * 0.7); let headers, dataRows; if (looksHeader) { headers = first.map((h, i) => h.trim() || "field" + (i + 1)); dataRows = cells.slice(1); } else { dataRows = cells; const samples = dataRows.slice(0, 50); let money = 0; headers = []; for (let i = 0; i < nCols; i++) { const h = _guessCol(samples, i, money); if (h === "debit" || h === "credit" || h === "amount") money++; headers.push(h); } // ensure unique header names const seen = {}; headers = headers.map(h => { seen[h] = (seen[h] || 0) + 1; return seen[h] > 1 ? h + " " + seen[h] : h; }); } const rows = dataRows.map(c => { const o = {}; headers.forEach((h, i) => o[h] = (c[i] || "").trim()); return o; }) .filter(o => Object.values(o).some(v => v)); if (!rows.length) return null; const ds = datasetFromRows(rows, fileName); return ds ? [ds] : null; }; // ── top-level dispatch ──────────────────────────────────────────────── // Returns one of: // { kind:"json", data } (backup envelope) // { kind:"jbz", blob } (encrypted backup) // { kind:"collections", data, label } (Excel/CSV tabular → collections) // { kind:"customers", customers:[…], label, text } (extracted people) // { kind:"text", text, label } (text we couldn't structure) const parseFile = async (file, onProgress) => { const e = ext(file.name); // Spreadsheets → SheetJS, classify EACH sheet into its collection if (e === "xlsx" || e === "xls") { onProgress && onProgress(20, "Reading workbook…"); const sheets = await parseExcel(file); const datasets = []; Object.entries(sheets).forEach(([name, rows]) => { const ds = datasetFromRows(rows, name); if (ds && ds.count) datasets.push(ds); }); onProgress && onProgress(100, "Done"); if (!datasets.length) return { kind: "text", text: "", label: "No recognizable data in workbook" }; return { kind: "multi", datasets, label: `${datasets.length} dataset(s) from ${Object.keys(sheets).length} sheet(s)` }; } // Word if (e === "docx") { onProgress && onProgress(25, "Extracting Word document…"); const text = await parseDocx(file); onProgress && onProgress(70, "Reading fields…"); const gl = extractGLFromText(text); if (gl.length >= 3) { onProgress && onProgress(100, "Done"); return { kind: "multi", datasets: [{ collection: "glAccounts", label: "GL / Chart of Accounts", confidence: "medium", count: gl.length, rows: gl }], label: `${gl.length} GL accounts (Word)` }; } const customers = extractCustomers(text); onProgress && onProgress(100, "Done"); return customers.length ? { kind: "customers", customers, label: `${customers.length} customer(s) (Word)`, text } : { kind: "text", text, label: "Word document (text)" }; } // PDF (text layer, OCR fallback) if (e === "pdf") { const text = await parsePdf(file, onProgress); const gl = extractGLFromText(text); if (gl.length >= 3) return { kind: "multi", datasets: [{ collection: "glAccounts", label: "GL / Chart of Accounts", confidence: "medium", count: gl.length, rows: gl }], label: `${gl.length} GL accounts (PDF)` }; const customers = extractCustomers(text); return customers.length ? { kind: "customers", customers, label: `${customers.length} customer(s) (PDF)`, text } : { kind: "text", text, label: "PDF (text)" }; } // Images → OCR if (["png", "jpg", "jpeg", "gif", "bmp", "tif", "tiff", "webp"].includes(e)) { const text = await parseImage(file, onProgress); const gl = extractGLFromText(text); if (gl.length >= 3) return { kind: "multi", datasets: [{ collection: "glAccounts", label: "GL / Chart of Accounts", confidence: "low", count: gl.length, rows: gl }], label: `${gl.length} GL accounts (OCR)` }; const customers = extractCustomers(text); return customers.length ? { kind: "customers", customers, label: `${customers.length} customer(s) (OCR)`, text } : { kind: "text", text, label: "Scanned image (OCR)" }; } // JSON / JBZ / CSV / text fall through const raw = await readText(file); onProgress && onProgress(100, "Done"); const trimmed = raw.trim(); if (trimmed.startsWith("JBZ1:")) return { kind: "jbz", blob: trimmed }; try { const obj = JSON.parse(trimmed); return { kind: "json", data: obj && obj.data ? obj.data : obj, label: obj && obj.format ? `${obj.format} backup` : "JSON export" }; } catch (x) {} // Legacy .dat / .seq / .txt exports — delimited or fixed-width, header optional. if (["dat", "seq", "btr", "exp", "txt", "prn"].includes(e)) { const dss = parseDatText(raw, file.name); if (dss && dss.length && dss[0].count) { return { kind: "multi", datasets: dss, label: dss.map(d => `${d.count} ${d.label}`).join(" · ") + " (legacy file)" }; } } // CSV → classify into a collection const rows = parseCSVText(raw); if (rows.length) { const ds = datasetFromRows(rows, file.name); if (ds && ds.count) return { kind: "multi", datasets: [ds], label: `${ds.count} ${ds.label} (CSV)` }; } // Last resort for .dat that didn't parse as columns: try customer extraction. if (e === "dat") { const customers = extractCustomers(raw); if (customers.length) return { kind: "customers", customers, label: `${customers.length} customer(s) (legacy .dat)`, text: raw }; } return { kind: "csvtext", text: raw }; }; // Minimal CSV parser. Quoted-field aware. parseCSVMatrix → array of cell arrays. const parseCSVMatrix = (text) => { const out = []; let row = [], cur = "", q = false; for (let i = 0; i < text.length; i++) { const c = text[i]; if (q) { if (c === '"' && text[i + 1] === '"') { cur += '"'; i++; } else if (c === '"') q = false; else cur += c; } else { if (c === '"') q = true; else if (c === ",") { row.push(cur); cur = ""; } else if (c === "\n" || c === "\r") { if (cur !== "" || row.length) { row.push(cur); out.push(row); row = []; cur = ""; } if (c === "\r" && text[i + 1] === "\n") i++; } else cur += c; } } if (cur !== "" || row.length) { row.push(cur); out.push(row); } return out; }; // Old-DMS exports often put report title lines (the store name, // "TRIAL BALANCE AS OF 09/30/2026") above the real column headings. When the // first row is clearly not a heading row (it fills under half of the table's // width) the heading is the first wide row after it, and the title lines are // returned as `preface` (the as-of date is often in there). const _nonEmpty = (r) => (r || []).filter(c => String(c == null ? "" : c).trim() !== "").length; const _numericish = (c) => /^[\s$(-]*[\d,]+(\.\d+)?[)\s%-]*$/.test(String(c == null ? "" : c)); const _splitPreface = (aoa) => { let width = 0; aoa.slice(0, 40).forEach(r => { width = Math.max(width, _nonEmpty(r)); }); const n0 = _nonEmpty(aoa[0]); let hi = 0; // title-like first row: one filled cell, or two when the table is wide (5+ columns) if (aoa.length > 1 && width >= 3 && (n0 <= 1 || (n0 <= 2 && width >= 5)) && n0 <= width * 0.5) { for (let i = 1; i < Math.min(aoa.length, 15); i++) { const r = aoa[i], n = _nonEmpty(r); // the heading row: nearly full width and made of words, not numbers if (n >= Math.max(3, Math.ceil(width * 0.7)) && r.filter(c => String(c == null ? "" : c).trim() && _numericish(c)).length < n * 0.4) { hi = i; break; } } } const preface = aoa.slice(0, hi).map(r => r.map(c => String(c == null ? "" : c).trim()).filter(Boolean).join(" ")).filter(Boolean); return { head: aoa[hi] || [], data: aoa.slice(hi + 1), preface }; }; // header row → objects (one per data row) const _objectsFrom = (head, data) => { const h = _uniqHeaders(head); return data.filter(r => r.some(c => String(c == null ? "" : c).trim() !== "")).map(r => { const o = {}; h.forEach((k, i) => o[k] = String(r[i] == null ? "" : r[i]).trim()); return o; }); }; const parseCSVText = (text) => { const m = parseCSVMatrix(text); if (m.length < 2) return []; const sp = _splitPreface(m); return _objectsFrom(sp.head, sp.data); }; // Header cells → unique, non-blank names (a repeated or empty header would // otherwise overwrite another column's values without anyone noticing). function _uniqHeaders(cells) { const seen = {}; return (cells || []).map((h, i) => { let n = String(h == null ? "" : h).trim() || `Column ${i + 1}`; const k = n.toLowerCase(); seen[k] = (seen[k] || 0) + 1; if (seen[k] > 1) n = `${n} (${seen[k]})`; return n; }); } // ── Unified import: read a file into plain tables (no classification, no // mapping — the Import tool maps columns to the chosen destination). ── // Returns { sheets: [{ name, rows:[{header:value}], headers:[] }], note }. const _headersOf = (rows) => { const s = []; (rows || []).slice(0, 200).forEach(r => Object.keys(r || {}).forEach(k => { if (!s.includes(k)) s.push(k); })); return s; }; const readTable = async (file, onProgress, opts) => { const e = ext(file.name); const o = opts || {}; const sheet = (name, rows) => ({ name, rows, headers: _headersOf(rows) }); if (e === "xlsx" || e === "xls") { onProgress && onProgress(20, "Reading workbook…"); await loadXlsx(); const buf = await readArrayBuffer(file); // RC25 (imp) fix round (attack W3a): read the cells that hold a formula with NO saved result too (sheetStubs) — such a cell // becomes its formula text ("=N2+P2"), which every import refuses as "not an amount", instead of a blank read as $0.00 const wb = window.XLSX.read(buf, { type: "array", sheetStubs: true }); const sheets = []; let formulas = 0; wb.SheetNames.forEach(name => { const ws = wb.Sheets[name]; let nf = 0; Object.keys(ws).forEach(a => { if (a[0] === "!") return; const c = ws[a]; if (!c || c.t !== "z") return; if (c.f) { ws[a] = { t: "s", v: "=" + c.f, w: "=" + c.f }; nf++; } else delete ws[a]; }); formulas += nf; const aoa = window.XLSX.utils.sheet_to_json(ws, { header: 1, defval: "", raw: false, blankrows: false }); if (!aoa.length) return; const sp = _splitPreface(aoa); const head = _uniqHeaders(sp.head); const rows = _objectsFrom(sp.head, sp.data); if (rows.length) sheets.push({ name, rows, headers: head, preface: sp.preface, formulas: nf }); }); onProgress && onProgress(100, "Done"); return { sheets, formulas, note: `${sheets.length} sheet(s) with data` + (formulas ? ` · ${formulas} formula cell${formulas === 1 ? "" : "s"} with no saved result` : "") }; } // RC30 (imp2): a PDF with a text layer is read into COLUMNS for every destination (pdfTables); a customers sheet without a column // heading falls back to the field hunt below; a PDF with no text layer is a scanned printout — Old reports reads it with AI if (e === "pdf" && o.allowPdf !== false) { const t = await pdfTables(file, onProgress); if (t.sheets.length) return { sheets: t.sheets, noText: t.noText, pages: t.pages, pdf: true, note: `read from the PDF's text — ${t.sheets.reduce((s, x) => s + x.rows.length, 0)} row(s) on ${t.pages - t.noText.length} page(s); every row is marked “read from PDF — check” until approved` + (t.noText.length ? ` · ${t.noText.length} page(s) have no text layer (scanned — read them in Old reports): ${t.noText.slice(0, 12).join(", ")}${t.noText.length > 12 ? "…" : ""}` : "") }; if (!o.allowDocs) { const err = new Error(t.noText.length === t.pages ? "This PDF has no text layer — it is a scanned printout. Open Old reports in the menu bar: it reads the pages with AI and brings the list here (nothing is guessed from a picture on this screen)." : "No table was found in this PDF's text: the first line of the table must hold the column names (see the import template). A scanned page is read in Old reports."); err.plain = true; throw err; } } // Documents / scans → customer fields (Customers destination only) if (["docx", "pdf", "png", "jpg", "jpeg", "gif", "bmp", "tif", "tiff", "webp"].includes(e)) { if (!o.allowDocs) throw new Error("This destination needs a spreadsheet (CSV or Excel) or a PDF printout with column names. Word files and photos can be read for Customers only; scanned pages go through Old reports."); const text = e === "docx" ? await parseDocx(file) : e === "pdf" ? await parsePdf(file, onProgress) : await parseImage(file, onProgress); const cs = extractCustomers(text); const cols = ["firstName", "lastName", "phone", "email", "street", "city", "state", "postal", "dlNumber", "dob", "vin"]; const rows = cs.map(c => { const x = {}; cols.forEach(k => { x[k] = c[k] || ""; }); return x; }); return { sheets: rows.length ? [sheet(file.name, rows)] : [], note: `${rows.length} customer record(s) read from the document` }; } const raw = await readText(file); onProgress && onProgress(100, "Done"); if (["dat", "seq", "btr", "exp", "txt", "prn"].includes(e)) { const dss = parseDatText(raw, file.name); if (dss && dss.length && dss[0].raw && dss[0].raw.length) return { sheets: [sheet(file.name, dss[0].raw)], note: "legacy text export" }; } // CSV / TSV const firstLine = raw.split(/\r?\n/)[0] || ""; let matrix; if (firstLine.includes("\t") && !firstLine.includes(",")) { matrix = raw.split(/\r?\n/).filter(l => l.trim()).map(l => l.split("\t")); } else matrix = parseCSVMatrix(raw); if (matrix.length < 2) return { sheets: [], note: "" }; const sp = _splitPreface(matrix); const rows = _objectsFrom(sp.head, sp.data); return { sheets: rows.length ? [{ name: file.name, rows, headers: _headersOf(rows), preface: sp.preface }] : [], note: "" }; }; // Which import destination a table most resembles (a hint only — the user // always picks the destination first). const suggestDest = (rows) => { if (!rows || !rows.length) return null; const dd = detectDest(rows); if (dd && dd.confidence >= 0.6) return dd.dest; const keys = Object.keys(rows[0]).map(k => k.toLowerCase()); const has = (...n) => n.some(x => keys.some(k => k === x || k.includes(x))); if (has("check no", "check #", "check number", "chk") && has("payee", "pay to", "amount")) return "checks"; const c = classifyRows(rows); return ({ glAccounts: "glChart", journalEntries: "glJournal", inventory: "inventory", customers: "customers", arCustomers: "customers", payables: "vendors" })[c.key] || null; }; return { parseFile, extractCustomers, readTable, suggestDest, detectDest, parseCSVText, parseCSVMatrix, ocrCanvas, parseExcel, parseDocx, parsePdf, pdfTables, parseImage, rowToCustomer, classifyRows, datasetFromRows, parseDatText, remapDataset, groupJournalRows, COLLECTION_LABELS, MAPPERS, // RC30 (imp2): + pdfTables INV_FIELDS, guessInventoryMapping, mapInventoryRows, remapInventory, inventoryDataset, prepareInventoryRows, invNewUsed, invNumber, // library loading (local copy first, CDN second) — reuse these, don't add new