/* © 2026 K2B NETWORK SOLUTIONS LLC. All rights reserved. */ /* ──────────────────────────────────────────────────────────────────────────── K2B DMS — the imports pack (RC25, lane imp): ONE set of rules for the station AND the server. src/lib/imp-calc.js == web ImpCalc.jsx.txt (byte for byte; UMD: window.ImpCalc in the browser, module.exports in node) What lives here (pure functions, integer cents, no I/O): • the old system's repair-order export: what the pay-type flags mean, which row is a VOID, how a row is normalised (normRoRow) and fingerprinted (roFingerprint) so "re-import updates only where the file differs" means the same thing on the preview and on the server; • the parts list rows (normPartRow); • the inventory-vs-general-ledger reconciliation: which general-ledger accounts belong to which schedule (scheduleMap), which unit / part / repair order sits behind which schedule, the lines of the two postings (adjustLines, atCostLines) and the plain words that say when each posting is the right one (wordsFor). ──────────────────────────────────────────────────────────────────────── */ (function (root, factory) { if (typeof module === 'object' && module.exports) module.exports = factory(); else root.ImpCalc = factory(); })(typeof self !== 'undefined' ? self : this, function () { 'use strict'; const CALC_VERSION = 'imp-6'; // imp-6 = RC33 (lane svi): the service side ties to the ledger — schedules sublet (246) + warranty (263), work in process = open + held // old ROs still open in K2B (+ bulk oil on the WIP account), body shop with a list; Tie_Out words wip / sublet / warranty / body / information / parts on // open ROs (tested BEFORE the generic receivable / inventory words); the service tie-out causes; the customer-name rules (roCustFields — never in the // RO fingerprint), the A/R aging (agingRow), warranty claims (claimRow), body jobs (bodyJobRow); the code-word rule reads the first WORD (a surname that // only starts with INT / SHOP / VOID … is not a code word) + BODY. imp-5 = RC31 (fx31, RC30 acceptance F3 / F5): partAcct — a part with no GL account belongs to THE parts inventory account (one reading for the review, the approval and Reconcile Inventory), laterEntryOf + rule dated_before_entries (a file dated before entries already on the books); imp-4 = the RC30 fix round (customer-number rules at staging incl. cust_not_issued, custNumCell without exponent guessing, ruleOfWarning / ruleOfReason customer words, schedule money, wrong-account ranking); imp-3 = RC30 (lane imp2): staged imports — the explanation rule table, the Tie_Out defaults / causes, the GL netting check; imp-2 = the RC25 fix round const MAX_CENTS = 1000000000; // $10,000,000.00 — the same ceiling as the other commands /* ── text / money helpers ── */ const txt = (v) => (v === undefined || v === null ? '' : String(v)).replace(/[\u0000-\u001f]/g, ' ').trim(); const up = (v) => txt(v).toUpperCase(); const blank = (v) => v === undefined || v === null || (typeof v === 'number' ? isNaN(v) : String(v).trim() === ''); /** "1,234.50" · "(500)" · "500-" · "$12" · 12.5 → integer cents; '' / null → 0; anything else (a list, hex, letters) → NaN; three decimals → 'decimals' */ function toCents(v) { if (v === undefined || v === null || v === '') return 0; let n; if (typeof v === 'number') n = v; else if (typeof v === 'string') { // RC28 (fx, finding 1 class): read the way the posting reads it (lib/deal-calc.js) — a comma only as a thousands separator // ("1,00" is not $100.00) and no space inside the digits ("1 0 0"); before RC28 both were read as 100 here and refused there if (/\d\s+[\d.]|\.\s+\d/.test(v)) return NaN; if (/,/.test(v) && !/^\s*\(?\s*-?\s*\$?\s*-?\d{1,3}(,\d{3})+(\.\d*)?\s*\)?\s*-?\s*$/.test(v)) return NaN; let s = v.replace(/[,$\s]/g, ''); let neg = false; if (/^\(.*\)$/.test(s)) { neg = true; s = s.slice(1, -1); } if (/-$/.test(s)) { neg = !neg; s = s.slice(0, -1); } if (!/^-?(\d+\.?\d*|\.\d+)$/.test(s)) return NaN; n = Number(s); if (neg) n = -n; } else return NaN; if (!Number.isFinite(n)) return NaN; const c = Math.round(n * 100); if (Math.abs(n * 100 - c) > 1e-6) return 'decimals'; return c; } /** RC25 fix round (attack W3a): an Excel formula cell with no saved result reaches the importer as its formula text "=N2+P2" — never read it as a blank / $0.00 */ const isFormula = (v) => typeof v === 'string' && /^\s*=/.test(v); const FORMULA_WHY = 'is an Excel formula with no saved result — open the file in Excel, save it, and import it again (or paste the values)'; /** money that may carry more than two decimals (a cost worked out by a program): rounded to the cent, with a note — for the reconciliation lists only (an import row with three decimals is still refused) */ function centsLoose(v) { const c = toCents(v); if (c === 'decimals') { const n = typeof v === 'number' ? v : Number(String(v).replace(/[,$\s]/g, '')); if (Number.isFinite(n)) return { cents: Math.round(n * 100), note: 'rounded' }; return { cents: 0, note: 'unreadable' }; } if (!Number.isFinite(c)) return { cents: 0, note: 'unreadable' }; return { cents: c, note: '' }; } const dollars = (c) => Math.round(c) / 100; const fmt = (c) => { const neg = c < 0, a = Math.abs(Math.round(c)); return (neg ? '-$' : '$') + String(Math.floor(a / 100)).replace(/\B(?=(\d{3})+(?!\d))/g, ',') + '.' + String(a % 100).padStart(2, '0'); }; const intOf = (v) => { const s = txt(v).replace(/,/g, ''); if (!s) return null; if (!/^-?\d+(\.0+)?$/.test(s)) return NaN; return parseInt(s, 10); }; /** 2026-10-19 · 10/19/2026 · 10/19/26 · 20261019 → 'YYYY-MM-DD'; blank → ''; anything else → null */ function isoDate(v) { if (v instanceof Date) return isNaN(v) ? null : v.getFullYear() + '-' + String(v.getMonth() + 1).padStart(2, '0') + '-' + String(v.getDate()).padStart(2, '0'); const s = txt(v); if (!s) return ''; let m, y, mo, d; if ((m = /^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T\s].*)?$/.exec(s))) { y = +m[1]; mo = +m[2]; d = +m[3]; } else if ((m = /^(\d{1,2})[\/.-](\d{1,2})[\/.-](\d{2}|\d{4})$/.exec(s))) { mo = +m[1]; d = +m[2]; y = +m[3]; if (y < 100) y += y < 70 ? 2000 : 1900; } else if ((m = /^(\d{4})(\d{2})(\d{2})$/.exec(s))) { y = +m[1]; mo = +m[2]; d = +m[3]; } else return null; if (mo < 1 || mo > 12 || d < 1 || d > 31 || y < 1900 || y > 2100) return null; const dt = new Date(Date.UTC(y, mo - 1, d)); if (dt.getUTCMonth() !== mo - 1 || dt.getUTCDate() !== d) return null; return y + '-' + String(mo).padStart(2, '0') + '-' + String(d).padStart(2, '0'); } const usOf = (iso) => (iso && /^\d{4}-\d{2}-\d{2}$/.test(iso) ? iso.slice(5, 7) + '/' + iso.slice(8, 10) + '/' + iso.slice(2, 4) : String(iso || '')); const acctKey = (v) => up(v).replace(/\s+/g, ''); /* ── the old system's repair-order export ── pay_type_flag (checked on the owner's export, 65,863 rows): I = invoiced (closed, 65,837 rows); O = open (never invoiced — the 14 O rows have no invoice date); H = held (an RO opened and parked, almost always with no money — 11 rows); W = warranty (invoiced, paid by the warranty — 1 row). x_customer_pay_total + x_warranty_or_internal_total == ro_total on every row, but the export itself says the x_ columns' meaning is unconfirmed (the second could simply be "total minus customer pay", and on an open RO the customer pay is 0 until it is invoiced): they are kept on the HISTORY as printed, labelled "as the old export split it", and never decide anything (not the pay type on the service board, not A/R). A row whose customer account reads VOID is a voided RO. */ const FLAGS = { I: 'closed', O: 'open', H: 'held', W: 'closed' }; const FLAG_WORDS = { I: 'invoiced (closed)', O: 'open', H: 'held', W: 'warranty (closed)' }; const isVoidRow = (r) => /^\s*VOID/i.test(txt(r.customerAccount)) || /^\s*VOID\s*$/i.test(txt(r.status)); const RO_NO_OK = /^[A-Z0-9][A-Z0-9-]{0,19}$/; const MONEY_KEYS = ['partsSale', 'partsCost', 'laborSale', 'laborCost', 'subletSale', 'subletCost', 'supplies', 'salesTax', 'total', 'customerPay', 'otherPay']; const TEXT_KEYS = ['customerAccount', 'vin', 'stock', 'make', 'model', 'color', 'tech', 'advisor']; /** One file row (already in the importer's field keys) → the normalised repair order the station previews and the server stores. Never throws: problems[] lists what is wrong (a row with a problem is rejected). */ function normRoRow(r) { r = r && typeof r === 'object' ? r : {}; const problems = [], warnings = []; const roNo = up(r.roNo).replace(/^#/, '').replace(/\.0+$/, ''); if (isFormula(r.roNo)) problems.push({ field: 'RO #', reason: '"' + txt(r.roNo) + '" ' + FORMULA_WHY }); else if (!roNo) problems.push({ field: 'RO #', reason: 'no repair-order number' }); else if (!RO_NO_OK.test(roNo)) problems.push({ field: 'RO #', reason: '"' + txt(r.roNo) + '" is not a repair-order number (letters, digits and - only, up to 20)' }); const flagRaw = up(r.flag).slice(0, 1); const voided = isVoidRow(r); let flag = flagRaw; if (!flag) flag = 'I'; else if (!FLAGS[flag]) { warnings.push({ field: 'pay type', reason: 'pay type "' + txt(r.flag) + '" is not I, O, H or W — treated as invoiced (closed)' }); flag = 'I'; } const date = isFormula(r.date) ? null : isoDate(r.date); if (date === null) problems.push({ field: 'RO date', reason: '"' + txt(r.date) + '" ' + (isFormula(r.date) ? FORMULA_WHY : 'is not a date') }); const status = voided ? 'void' : FLAGS[flag]; if (status === 'closed' && !date) warnings.push({ field: 'RO date', reason: 'no invoice date on a closed repair order' }); const cents = {}; MONEY_KEYS.forEach((k) => { if (isFormula(r[k])) { problems.push({ field: k, reason: '"' + txt(r[k]) + '" ' + FORMULA_WHY }); return; } const c = toCents(r[k]); if (c === 'decimals') problems.push({ field: k, reason: '"' + txt(r[k]) + '" has more than two decimals' }); else if (Number.isNaN(c)) problems.push({ field: k, reason: '"' + txt(r[k]) + '" is not an amount' }); else if (Math.abs(c) > MAX_CENTS) problems.push({ field: k, reason: fmt(c) + ' is larger than the server allows' }); else cents[k] = c; }); const hasSplit = !blank(r.customerPay) || !blank(r.otherPay); if (hasSplit && cents.customerPay !== undefined && cents.otherPay !== undefined && cents.total !== undefined && cents.customerPay + cents.otherPay !== cents.total) { warnings.push({ field: 'payer split', reason: 'customer pay ' + fmt(cents.customerPay) + ' + warranty / internal ' + fmt(cents.otherPay) + ' does not equal the RO total ' + fmt(cents.total) + ' — the total is kept as printed' }); } let payer = 'none'; if (flag === 'W') payer = 'warranty'; else if (hasSplit) { const cp = cents.customerPay || 0, op = cents.otherPay || 0; payer = cp && op ? 'mixed' : cp ? 'customer' : op ? 'warranty-internal' : 'none'; } else if ((cents.total || 0) > 0) payer = 'unknown'; const yr = blank(r.year) ? null : intOf(r.year); if (yr !== null && (Number.isNaN(yr) || yr < 1900 || yr > 2100)) warnings.push({ field: 'year', reason: '"' + txt(r.year) + '" is not a model year — left blank' }); const odo = blank(r.odometer) ? null : intOf(r.odometer); if (odo !== null && Number.isNaN(odo)) warnings.push({ field: 'odometer', reason: '"' + txt(r.odometer) + '" is not a mileage — left blank' }); let taxPpm = null; if (!blank(r.taxRatePct)) { const n = Number(txt(r.taxRatePct).replace(/%/g, '')); if (Number.isFinite(n) && n >= 0 && n < 100) taxPpm = Math.round(n * 10000); else warnings.push({ field: 'tax rate', reason: '"' + txt(r.taxRatePct) + '" is not a tax rate — left blank' }); } const t = {}; TEXT_KEYS.forEach((k) => { t[k] = up(r[k]).slice(0, k === 'vin' ? 20 : 60); }); if (t.vin && t.vin.length !== 17) warnings.push({ field: 'VIN', reason: 'VIN "' + t.vin + '" is ' + t.vin.length + ' characters (17 expected) — kept as printed' }); const lines = Array.isArray(r.lines) ? r.lines.map(normRoLine).filter(Boolean) : []; return { roNo, flag, flagWord: FLAG_WORDS[flag], status, voided, date: date || '', year: yr !== null && !Number.isNaN(yr) ? yr : null, odometer: odo !== null && !Number.isNaN(odo) ? odo : null, taxPpm, cents, payer, text: t, lines, problems, warnings }; } /** one job line of the old export: { line, text (text_1..3 joined), op } */ function normRoLine(l) { if (!l || typeof l !== 'object') return null; const n = intOf(l.line); const parts = ['text1', 'text2', 'text3'].map((k) => txt(l[k])).filter(Boolean); const text = (parts.length ? parts.join(' ') : txt(l.text)).slice(0, 400); const op = up(l.op).slice(0, 20); if (!text && !op) return null; return { line: n === null || Number.isNaN(n) ? 0 : n, text, op }; } /** the values of a normalised row that count as "the file differs": money, dates, flag, text, lines */ function roFingerprint(n) { const o = { f: n.flag, s: n.status, d: n.date, y: n.year, o: n.odometer, t: n.taxPpm, c: MONEY_KEYS.map((k) => (n.cents[k] === undefined ? '' : n.cents[k])).join(','), x: TEXT_KEYS.map((k) => n.text[k]).join('|'), l: n.lines.map((l) => l.line + ':' + l.text + ':' + l.op).join('\u0001') }; return JSON.stringify(o); } const PAYER_WORDS = { customer: 'customer pay (as the old export split it)', warranty: 'warranty (pay type W)', 'warranty-internal': 'warranty / internal (as the old export split it — its meaning is unconfirmed)', mixed: 'customer pay + warranty / internal (as the old export split it)', none: 'no charge', unknown: 'not in the file' }; /* ── the parts list ── */ function normPartRow(r) { r = r && typeof r === 'object' ? r : {}; const problems = []; const part = up(r.part).replace(/\s+/g, ''); if (!part) problems.push({ field: 'part #', reason: 'no part number' }); else if (part.length > 30) problems.push({ field: 'part #', reason: 'part number longer than 30 characters' }); const money = {}; ['cost', 'list', 'trade', 'core'].forEach((k) => { if (blank(r[k])) { money[k] = null; return; } if (isFormula(r[k])) { problems.push({ field: k, reason: '"' + txt(r[k]) + '" ' + FORMULA_WHY }); return; } const c = toCents(r[k]); if (c === 'decimals' || Number.isNaN(c) || c < 0) problems.push({ field: k, reason: '"' + txt(r[k]) + '" is not an amount' }); else money[k] = c; }); let onHand = null; if (!blank(r.onHand)) { const n = Number(txt(r.onHand).replace(/,/g, '')); if (!Number.isFinite(n)) problems.push({ field: 'on hand', reason: '"' + txt(r.onHand) + '" is not a quantity' }); else onHand = Math.round(n * 1000) / 1000; } return { part, desc: up(r.desc).slice(0, 80), bin: up(r.bin).slice(0, 20), source: txt(r.source).slice(0, 20), mfr: txt(r.mfr).slice(0, 30), onHand, costCents: money.cost, listCents: money.list, tradeCents: money.trade, coreCents: money.core, problems }; } /* ── the schedules: which general-ledger accounts tie to which imported list ── Matched by the account NAME in the chart (the owner's ACS chart and K2B's own default chart both use these words), so the screen works on any chart; the office can change the map on the screen and the server remembers it. */ const SCHEDULES = [ { key: 'new', label: 'New vehicles', unit: 'unit', detail: 'the units in stock marked New (GL cost each)', range: /^2[34]\d[A-Z]*$/, test: /INVENTORY\s*-?\s*NEW|NEW\s+(CARS?|TRUCKS?|VEH)|DEMO/i, hint: ['231', '237', '230'] }, { key: 'used', label: 'Used vehicles (incl. program)', unit: 'unit', detail: 'the units in stock marked Used (GL cost each)', range: /^2[34]\d[A-Z]*$/, test: /INVENTORY\s*-?\s*USED|USED\s+(CARS?|TRUCKS?)|GM\s*-?\s*PROGRAM|PROGRAM\s+(CARS?|TRUCKS?)/i, hint: ['240', '240A', '241', '241A'] }, { key: 'parts', label: 'Parts', unit: 'part', detail: 'parts on hand × cost from the parts file', range: /^2[34]\d[A-Z]*$/, test: /INVENTORY\s*-?\s*PARTS|PARTS\s+INVENT/i, hint: ['242'] }, // RC33 (svi): the body shop gets a list (an imported Body_Shop_WIP sheet: the old jobs in progress, their cost) — partial like every service list { key: 'body', label: 'Body-shop inventory', unit: 'job', partial: true, detail: 'the cost of the body-shop jobs in progress of an imported Body_Shop_WIP list (K2B itself never posts 245)', range: /^2[34]\d[A-Z]*$/, test: /BODY\s*SHOP\s*INVENT/i, hint: ['245'] }, // RC25 fix round (audit H1): WIP and A/R lists are PARTIAL by nature (the WIP list is only the labor cost of the imported open ROs; // A/R detail is only what an imported aged A/R schedule brought) — shown for information; a posting needs the office to confirm // on the screen that the list is the whole balance (listComplete). An open RO's unbilled customer pay is NOT a receivable. // RC33 (svi): open AND held old ROs, while they are still open in K2B (an old RO closed in K2B relieves 247 at its close), + bulk oil K2B received into the account { key: 'wip', label: 'Work in process (open repair orders)', unit: 'repair order', partial: true, detail: 'the labor cost on the imported OPEN and HELD repair orders still open in K2B (their parts stay in the parts list until they close), plus bulk oil K2B received into this account', range: /^2[34]\d[A-Z]*$/, test: /WORK\s*IN\s*PRO/i, hint: ['247'] }, // RC33 (svi): sublet (246) and warranty claims (263) — the service balances the owner asked to tie ("this all needs to reconcile against the balances") { key: 'sublet', label: 'Sublet repairs (open repair orders)', unit: 'repair order', partial: true, detail: 'the sublet cost on the imported open / held repair orders and on K2B\'s own open repair orders, plus the cores K2B parks on this account', range: /^24\d[A-Z]*$/, test: /SUBLET\s*(REPAIRS?|INV)/i, hint: ['246'] }, { key: 'warranty', label: 'Warranty claims receivable', unit: 'claim', partial: true, detail: 'the open claims of an imported Warranty_Claims list and K2B\'s own warranty claims not paid or debited yet', range: /^26\d[A-Z]*$/, test: /WARRANTY\s*CLAIM/i, hint: ['263'] }, { key: 'ar', label: 'Service & parts A/R', unit: 'balance', partial: true, detail: 'the customer balances of an imported aged A/R schedule (an open repair order is not billed yet, so it is not a receivable)', range: /^22\d[A-Z]*$/, test: /^A\/R\s*SERV/i, hint: ['220'] }, ]; const INV_RANGE = /^(23\d|24\d)[A-Z]*$/; // the 230-249 inventory range of the GM chart /** chart = [{ acct, name }] → { map: { new: ['231','237'], … }, other: [{ acct, name }] (inventory-range accounts tied to nothing) } An account joins a schedule by its NAME, but only inside the schedule's number range (230-249 inventories, 220s receivables): a sales or cost account whose name also says "NEW TRUCKS" never does. RC25 fix round (audit M1): use = { bal: { acct: cents }, refs: Set(acct) } — when given, an account the dealer's books do not use (no balance as of the date and no unit / part carrying it on purpose — e.g. K2B's built-in 231 INVENTORY NEW CARS in a chart that has only 237) is left off the automatic map; when NO account of a schedule is in use (a fresh ledger) the name matches stay. */ function scheduleMap(chart, saved, use) { const byKey = {}; (chart || []).forEach((a) => { const k = acctKey(a.acct || a.account || a.no); if (k) byKey[k] = txt(a.name || a.desc || a.description); }); const inUse = (k) => !use || (use.bal && use.bal[k]) || (use.refs && use.refs.has && use.refs.has(k)); const map = {}; SCHEDULES.forEach((s) => { const fromSaved = saved && Array.isArray(saved[s.key]) ? saved[s.key].map(acctKey).filter((k) => byKey[k] !== undefined && s.range.test(k)) : null; if (fromSaved) { map[s.key] = fromSaved; return; } const hits = Object.keys(byKey).filter((k) => s.range.test(k) && s.test.test(byKey[k])); s.hint.forEach((h) => { if (byKey[h] !== undefined && hits.indexOf(h) === -1) hits.push(h); }); const live = hits.filter(inUse); map[s.key] = (live.length ? live : hits).sort(cmpAcct); }); const used = new Set(); Object.keys(map).forEach((k) => map[k].forEach((a) => used.add(a))); const other = Object.keys(byKey).filter((k) => INV_RANGE.test(k) && !used.has(k) && (!use || inUse(k))).sort(cmpAcct).map((k) => ({ acct: k, name: byKey[k] })); return { map, other, names: byKey }; } const cmpAcct = (a, b) => { const x = /^(\d+)([A-Z]*)$/.exec(a) || [0, '0', a], y = /^(\d+)([A-Z]*)$/.exec(b) || [0, '0', b]; return (parseInt(x[1], 10) - parseInt(y[1], 10)) || (x[2] < y[2] ? -1 : x[2] > y[2] ? 1 : 0); }; /** RC25 fix round (attack M2, audit H3): the KIND of an account — 'balance' (asset / liability / equity: a balance-sheet account) or 'pl' (sales / cost / expense / income: the profit & loss). From the chart's type (K2B's chart: A / L / S / C / E (= expense) / I; an imported chart: Asset / Liability / Equity / Sales / Cost / Expense / Income / Contra), else from the GM numbering (1xx–3xx balance sheet, 4xx and up profit & loss). */ function acctKind(acct, type) { const t = txt(type).toLowerCase(); if (/^(a|l|q|asset|assets|current asset|fixed asset|liab|liability|liabilities|equity|capital|net worth|owners? equity|contra|contra asset)$/.test(t)) return 'balance'; if (/^(s|c|e|i|sales|sale|revenue|revenues|cost|costs|cogs|cost of sales|expense|expenses|exp|income|other income)$/.test(t)) return 'pl'; const d = (/^(\d)/.exec(acctKey(acct)) || [])[1]; return d === '1' || d === '2' || d === '3' ? 'balance' : 'pl'; } const KIND_WORDS = { balance: 'balance sheet', pl: 'profit & loss' }; const typeWord = (t) => ({ a: 'Asset', l: 'Liability', q: 'Equity', s: 'Sales', c: 'Cost', e: 'Expense', i: 'Income' })[txt(t).toLowerCase()] || txt(t); /** accounts whose name says conversion / clearing / suspense — the suggested offset of the postings */ const CONVERSION_RE = /CONVERS|CLEARING|SUSPENSE|OPENING\s*BAL/i; /** may this account be the other side of a reconciliation posting? null = yes, else the plain reason it may not. scheduleAccts = every account on any schedule. A profit & loss account would put the difference into this month's earnings (K2B's 999 CLEARING is the labor-applied clearing account, typed Cost); an inventory or receivable account would just move money between two lists. */ function conversionProblem(acct, type, name, scheduleAccts) { const a = acctKey(acct); if (!a) return 'Pick the conversion / clearing account — the other side of the entry.'; const label = a + (txt(name) ? ' ' + txt(name) : ''); if (scheduleAccts && scheduleAccts.indexOf(a) !== -1) return label + ' is on an inventory / receivable schedule itself — the entry would only move the difference from one list to another. Pick a balance-sheet account that is on no schedule.'; if (INV_RANGE.test(a) || /^22\d[A-Z]*$/.test(a)) return label + ' is an inventory / receivable account (220–249) — pick a balance-sheet account outside the inventories (an equity or clearing account your accountant names).'; if (acctKind(a, type) !== 'balance') return label + ' is a profit & loss account (' + (typeWord(type) || 'by its number') + ') — the difference would land in this month\'s earnings. Pick a balance-sheet account (asset, liability or equity) your accountant names, for example an opening-balance / conversion equity account.'; return null; } const conversionSuggest = (chart, scheduleAccts) => (chart || []).filter((a) => CONVERSION_RE.test(txt(a.name || a.desc)) && !conversionProblem(a.acct || a.account, a.type, a.name, scheduleAccts)).map((a) => ({ acct: acctKey(a.acct || a.account), name: txt(a.name || a.desc), type: txt(a.type) })); /* ── what sits behind a schedule ── */ /* RC28 fix round (attack B1 / A3 / M1): ONE on-lot predicate for every reader — lib/deal-calc.js unitOnLot (window.DealCalc in the browser: loaded after this file, read when called). Until RC28 this file had its own UNANCHORED test (/sold|deliver|…/ anywhere in the status), so a unit saved as "Pending Delivery" or "Unsold" left the list while it was still owned and on the books, and the adjusting entry wrote it off. */ const dealCalc = () => (typeof window !== 'undefined' && window.DealCalc) ? window.DealCalc : (typeof require === 'function' ? require('./deal-calc') : null); const unitOnLot = (u) => { const D = dealCalc(); if (!D || !D.unitOnLot) throw new Error('ImpCalc needs DealCalc (lib/deal-calc.js) for the on-lot test'); return D.unitOnLot(u); }; const unitSchedule = (u) => (/^n/i.test(String((u && u.type) || '')) ? 'new' : 'used'); /** the GL cost of a unit: the booked cost when it has one (the old ledger's GL cost on an imported unit, the purchase posting on a K2B unit), else its cost (+ repairs on an imported unit, whose "total cost" the old report split that way). unitCost(u) → { cents, note: '' | 'rounded' (more than two decimals: rounded to the cent) | 'unreadable' (counted as 0) | 'missing' } RC28 fix round (attack A4, audit FU1 — finding 1 class): a unit with NO cost at all (no booked GL cost, the cost blank or $0.00) can't be valued — it is 'missing', listed and blocks every posting of its schedule, exactly like an unreadable cost (before, a blank cost was silently $0.00 and the adjusting entry wrote the unit off; the deal posting already refused the same unit as "no cost"). */ function unitCost(u) { if (!u) return { cents: 0, note: '' }; // RC30 (imp2, RC28 acceptance F1): a K2B-stocked unit's recon (an internal RO closed on it posts the work to the inventory account) is // part of what the ledger carries for the unit — the list counts it too. An IMPORTED unit's booked GL cost already holds its repairs // (the old report's "total cost"), so recon is never added on top of a booked cost there. if (u.costBooked !== undefined && u.costBooked !== null && u.costBooked !== '') { const b = centsLoose(u.costBooked); if (u.imported || blank(u.recon)) return b; const r0 = centsLoose(u.recon); return { cents: b.cents + r0.cents, note: b.note === 'unreadable' || r0.note === 'unreadable' ? 'unreadable' : (b.note || r0.note) }; } const c = blank(u.cost) ? { cents: 0, note: 'missing' } : centsLoose(u.cost), r = !blank(u.recon) ? centsLoose(u.recon) : { cents: 0, note: '' }; if (c.note === '' && c.cents === 0) c.note = 'missing'; // $0.00: the unit has no cost entered (the edit screen saves a blank cost as 0) return { cents: c.cents + r.cents, note: c.note === 'unreadable' || r.note === 'unreadable' ? 'unreadable' : (c.note || r.note) }; } const unitCostCents = (u) => unitCost(u).cents; /** a unit's inventory account only counts when it was given on purpose: the old-inventory import marks the car / truck GUESS it makes when the file has no account column (invAcctFrom 'guess') — such a unit is "without an account" on the schedule */ const unitAcct = (u) => (u && u.invAcctFrom === 'guess' ? '' : acctKey(u && u.invAcct)); function partValue(p) { if (!p) return { cents: 0, note: '' }; // RC28 fix round (attack A4, audit FU1): a part WITH stock and a blank cost can't be valued — 'missing' (listed, blocks the postings); // a cost of $0.00 typed on purpose stays $0.00 const q = Number(p.onHand); const c = blank(p.cost) ? { cents: 0, note: q ? 'missing' : '' } : centsLoose(p.cost); if (!Number.isFinite(q)) return { cents: 0, note: 'unreadable' }; if (c.note === 'rounded') { const n = Number(String(p.cost).replace(/[,$\s]/g, '')); return { cents: Math.round(q * n * 100), note: 'rounded' }; } return { cents: Math.round(q * c.cents), note: c.note }; } const partValueCents = (p) => partValue(p).cents; /** RC31 (fx31, RC30 acceptance F3 — owner decision): the account a part counts on. A part carries its own inventory account (invAcct / glAcct); a part WITHOUT one (every part imported before RC30 — the RC25-RC29 imports never stored it) belongs to THE parts inventory account when the store has exactly one (partsAccts = the parts schedule's accounts as Reconcile Inventory maps them); with several, it is on no account ('' — the office ties it by the schedule total). ONE reading for the import review's tie-out, the approval's adjusting entry and Reconcile Inventory's per-account line, so the entry the review offers is the entry the approval posts. */ function partAcct(p, partsAccts) { const own = acctKey(p && (p.invAcct || p.glAcct)); if (own) return own; const list = Array.isArray(partsAccts) ? partsAccts.map(acctKey).filter(Boolean) : []; return list.length === 1 ? list[0] : ''; } /** RC31 (fx31, RC30 acceptance F5): the newest posted entry dated AFTER the as-of day on any of the accounts → { id, iso, date, acct } | null. last = { acct: { iso, id, date } } (imports.glBalances). The SAME test the postings use (imports.commonChecks, per schedule): an entry after the as-of day means the list (today's) and the ledger on that day cannot be compared — nothing is offered / posted. */ function laterEntryOf(accts, last, asOfIso, todayIso) { let out = null; (accts || []).forEach((a) => { const l = last && last[a]; if (l && l.iso > asOfIso && (!out || l.iso > out.iso)) out = { id: l.id, iso: l.iso, date: l.date, acct: a }; }); // RC31 fix round (fx31 checkers, attacker T6): an entry dated AFTER TODAY — no as-of date can reach it (the as-of is today or earlier), so the // words never send the office to "set the as-of date to that day": the entry's date is what is wrong (todayIso given → future: true) if (out && todayIso && out.iso > todayIso) out.future = true; return out; } /** RC31 fix round (fx31 checkers — the F3 class for every list): where an item of a stock list counts. acct = the item's own account (unitAcct / partAcct), key = its schedule, acctOf = { acct: schedule } of the books' map, chartHas(acct) → is it in the chart. 'account' counted on that account's line (it carries an account of its own schedule) 'elsewhere' booked in ANOTHER account of the chart (a Used unit in a New account, a part on 242A) — its value is not on this schedule's accounts 'none' NO usable account: none on the record, the import's guess, or an account the chart does not have (a typo, "0240") — it may sit on any account of the schedule, so no entry for ONE account of that schedule can be right while it is there */ function stockPlace(acct, key, acctOf, chartHas) { const a = acctKey(acct); if (!a) return 'none'; if (acctOf && acctOf[a] === key) return 'account'; return chartHas && chartHas(a) ? 'elsewhere' : 'none'; } /* ── the two postings ── */ const ymd = (iso) => String(iso || '').replace(/-/g, ''); /** the adjusting entry for ONE schedule: diff = sub-ledger − GL. Positive → the books are short: DR inventory / CR conversion. */ function adjustLines(account, conversion, diffCents) { const a = Math.abs(diffCents); if (!a) return []; return diffCents > 0 ? [{ acct: acctKey(account), dr: dollars(a), cr: 0 }, { acct: acctKey(conversion), dr: 0, cr: dollars(a) }] : [{ acct: acctKey(conversion), dr: dollars(a), cr: 0 }, { acct: acctKey(account), dr: 0, cr: dollars(a) }]; } /** the at-cost entry: byAccount = { '237': cents, … } → DR each account / CR the conversion account for the total */ function atCostLines(byAccount, conversion) { const lines = []; let tot = 0; Object.keys(byAccount).sort(cmpAcct).forEach((a) => { const c = Math.round(byAccount[a] || 0); if (!c) return; tot += c; lines.push(c > 0 ? { acct: acctKey(a), dr: dollars(c), cr: 0 } : { acct: acctKey(a), dr: 0, cr: dollars(-c) }); }); if (!lines.length) return []; lines.push(tot > 0 ? { acct: acctKey(conversion), dr: 0, cr: dollars(tot) } : { acct: acctKey(conversion), dr: dollars(-tot), cr: 0 }); return lines; } const adjustId = (asOfIso, account) => 'IA-' + ymd(asOfIso) + '-' + acctKey(account); const atCostId = (asOfIso, scheduleKey) => 'IC-' + ymd(asOfIso) + '-' + String(scheduleKey || '').toUpperCase(); /** a journal id the reconciliation commands own (never written by hand) */ const RESERVED_RE = /^I[AC]-/i; /* ── plain words ── */ function wordsFor(s) { const gl = s.glCents || 0, sub = s.subledgerCents || 0, diff = sub - gl; const out = {}; out.state = !s.hasDetail ? 'No imported list for this schedule yet, so there is nothing to compare the ledger with.' : diff === 0 ? 'The list and the general ledger agree to the cent.' : diff > 0 ? 'The list is ' + fmt(diff) + ' HIGHER than the general ledger (the books carry less than what is on the lot / shelf).' : 'The general ledger is ' + fmt(-diff) + ' HIGHER than the list (the books carry more than what is on the lot / shelf).'; out.adjust = 'Right when the OPENING TRIAL BALANCE was imported (the accounts already carry the old books\' balances): one entry for the difference brings the ledger to the list, against the conversion / clearing account.' + (gl === 0 && sub !== 0 ? ' These accounts have NO balance yet — if the opening balances were not imported, "Post the inventory to the books at cost" is the right one instead.' : ''); // RC28 (fx, reviewer finding 4): EVERY account of the schedule must be empty — a +$100 and a −$100 add up to zero but are not empty const carrying = (Array.isArray(s.accounts) ? s.accounts : []).filter((a) => a && a.glCents); out.atCost = 'Right when NO opening balance was imported (the accounts are empty — EVERY account of the schedule at $0.00): every unit / part is booked at its cost — debit the inventory account(s), credit the conversion / clearing account.' + (carrying.length ? ' ' + carrying.map((a) => a.acct + ' carries ' + fmt(a.glCents)).join(', ') + ' — posting at cost on top would count the inventory twice (or move it between accounts), so this is refused; use the adjusting entry, one per account.' : (gl !== 0 ? ' These accounts already carry ' + fmt(gl) + ' — posting at cost on top would count the inventory twice, so this is refused; use the adjusting entry.' : '')); // RC28 (fx, reviewer finding 1): a cost or quantity that can't be read is NEVER counted as $0.00 in a posting if (s.unreadableItems) out.unreadable = s.unreadableItems + ' ' + (s.unit || 'item') + (s.unreadableItems === 1 ? '' : 's') + ' on this list carr' + (s.unreadableItems === 1 ? 'ies' : 'y') + ' a cost or quantity that is not a number, or no cost at all (marked below). Nothing can be posted for this schedule until ' + (s.unreadableItems === 1 ? 'it is' : 'they are') + ' corrected on the record — a posting would otherwise count ' + (s.unreadableItems === 1 ? 'it' : 'them') + ' as $0.00 and write the books down to match.'; if (s.partial) out.partial = 'Shown for information. This list is not the whole balance of the account (' + s.detail + '), so neither posting is offered until the office confirms on this screen that the list IS complete — ask the accountant first; otherwise correct the account with a journal entry in Accounting.'; if (s.laterEntry) out.later = s.laterEntry.future // RC31 fix round (attacker T6): an entry dated after today — no as-of date reaches it ? 'The ledger has an entry on these accounts dated in the FUTURE (' + s.laterEntry.id + ', ' + s.laterEntry.date + ' — after today). An entry dated after today is most likely a typing mistake in its date: correct the date of ' + s.laterEntry.id + ' in Accounting (Journal), then look at this screen again. Nothing is posted for this schedule until then.' : 'The ledger already has an entry on these accounts dated after this day (' + s.laterEntry.id + ', ' + s.laterEntry.date + '). The imported list is what is on hand TODAY, so a posting as of an earlier day would count that entry again. Pick ' + s.laterEntry.date + ' or a later day as the as-of date.'; // RC31 fix round (fx31 checkers, auditor HIGH): items with no usable account — an entry for ONE account of the schedule would write them off if (s.noAccountItems) out.noAccount = s.noAccountItems + ' ' + (s.unit || 'item') + (s.noAccountItems === 1 ? '' : 's') + ' on this list carr' + (s.noAccountItems === 1 ? 'ies' : 'y') + ' no general-ledger account of ' + (s.noAccountItems === 1 ? 'its' : 'their') + ' own (none on the record, only the import\'s guess, or an account the chart does not have) — so the list cannot be split by account: an entry for one account alone would leave ' + (s.noAccountItems === 1 ? 'it' : 'them') + ' out and write ' + (s.noAccountItems === 1 ? 'its' : 'their') + ' value off. Give ' + (s.noAccountItems === 1 ? 'it' : 'them') + ' an account, or post one entry for the whole schedule.'; return out; } /** RC25 fix round (audit M3): a job line list in ONE canonical shape, so the same lines read back from the database (whose JSON keys come back in another order) compare equal */ const linesKey = (ls) => JSON.stringify((Array.isArray(ls) ? ls : []).map((l) => [Number(l && l.line) || 0, String((l && l.text) || ''), String((l && l.op) || '')])); /* ══════════════════════════ RC30 (lane imp2): staged imports — "if it doesn't line up, tell me what you think it means" ══════════════════════════ The owner's words. ONE rule table, in plain English, used by the server's review (every station says the same thing) and shown on the review screen next to every problem row and every tie-out difference. A rule has: code, when it fires (the server decides), WHAT IT MOST LIKELY MEANS (ranked, first = most likely) and THE FIX. "If a rule is wrong, send us: the rule code, the row (or account) it fired on, and what the right answer was" — docs/k2b/imports.md repeats this. */ const RULES = { // GL tie-out (a list total vs the ledger balance on the as-of date, per account) list_gt_gl: { label: 'The list is HIGHER than the ledger', means: ['units in stock whose purchase was never posted to the books (the unit is on the lot, the ledger does not carry it)', 'recon (repairs) on a unit that is on the list but not in the ledger', 'a unit booked in the wrong account (a New in the Used account, or the other way round) — check the N/U column against the GL Account column'], fix: 'Look at the units behind the difference on Reconcile Inventory; when the list is right, approve with the adjusting entry for this account (the difference goes to the conversion clearing account).' }, gl_gt_list: { label: 'The ledger is HIGHER than the list', means: ['sold units whose cost was never relieved from the inventory account (the sale posted the revenue, not the cost)', 'units missing from the list (still in the ledger, not in the file — sold, traded or scrapped without a posting)', 'pack or other add-ons in the GL cost that the list does not carry'], fix: 'Find the missing units (compare the stock numbers in the ledger detail with the list); when the ledger is wrong, approve with the adjusting entry for this account.' }, // RC31 (fx31, RC30 acceptance F5 — owner decision): a file dated BEFORE entries already on the books — its totals cannot be compared to the ledger dated_before_entries: { label: 'The file is dated before entries already on the books', means: ['the file is an older export (or one sent again): the store has posted to this account since its date — units sold, received or reconciled after it — so today\'s list and the ledger on that date cannot be compared', 'the as-of date was typed wrong on the file (it should be the day the list was printed)'], fix: 'Re-export today\'s list from the old system and import it again, or set the as-of date on this screen to the day the list was printed (on or after the newest entry). No entry is offered for this account until the dates agree.' }, // RC31 fix round (fx31 checkers): the entry the review offers is the entry the approval posts — for every Tie_Out row and every list entry_dated_future: { label: 'An entry on the books is dated in the future', means: ['the date of that journal entry was typed wrong (a month or a year ahead — 12/31/26 for 10/31/26)', 'the entry was dated ahead on purpose (a reversal or an accrual for next month) on an inventory account, which the list of today cannot be compared with'], fix: 'Correct the date of that entry in Accounting (Journal), then show the tie-out again. No as-of date after today can be picked, so nothing is offered for these accounts until the entry is dated today or earlier.' }, as_of_future: { label: 'The file is dated in the future', means: ['the as-of date was typed wrong on the file (a year ahead, 2099)', 'the computer the file was made on had the wrong date'], fix: 'Set the as-of date on this screen to the day the list was printed (today or an earlier day) and show the tie-out again. Nothing is offered or posted for a day after today.' }, tie_row_mismatch: { label: 'The Tie_Out sheet ties this account to another list than the books do', means: ['the "Ties to" cell of the row names the wrong list (Parts for a vehicle account, Inventory for the parts account)', 'the schedule map on Accounting › Reconcile Inventory was changed — the books now tie this account to another list'], fix: 'Correct the "Ties to" cell of this row on the Tie_Out sheet (or, if the books are wrong, the schedule map on Accounting › Reconcile Inventory) and import again. The review reads the account the books\' way, and offers no entry for a row the sheet and the books disagree on.' }, tie_not_on_schedule: { label: 'The account is not on any list the books tie to', means: ['the account is not in the chart of accounts (a typo — 2420 for 242 — or the Chart_of_Accounts sheet does not have it)', 'the account is in the chart but on no schedule of Reconcile Inventory — for example 244 GAS, OIL, GREASE - INV, where bulk oil sits: oil in a tank is not on the parts list, so tying it to Parts would write the oil off'], fix: 'Correct the GL account on the Tie_Out row, or leave the row out. No entry is offered for it — the approval posts only to an account on the books\' schedule map. If the balance itself is wrong, correct it with a journal entry in Accounting.' }, items_without_account: { label: 'Items on this list carry no GL account', means: ['the sheet has no GL Account column (the ACS Inventory layout) — K2B only guessed New / Used from the model, so it cannot tell which account each unit is booked in', 'a GL Account cell is blank, or names an account that is not in the chart (a typo, a leading 0)', 'parts without an account on a store with more than one parts account'], fix: 'Add the GL Account (the account each unit / part is booked in) to the sheet and import again — or approve without these entries and tie the schedule by its total on Accounting › Reconcile Inventory. No entry for one account is offered: it would write those items off the account they are really booked in.' }, gl_cost_zero: { label: 'GL Cost is 0 on a unit', means: ['the unit\'s purchase was never posted in the old system (it is on the lot, but the books never got the invoice)', 'the GL Cost column was left blank on this row of the export'], fix: 'Enter the GL Cost from the old ledger (or the purchase invoice) on this row and import again; a unit with no cost cannot be tied to the ledger.' }, dup_stock: { label: 'The same stock # appears twice with different details', means: ['a trade-back: the unit was sold and re-stocked under the same stock number (two different VINs)', 'a typing mistake in the stock number of one of the rows', 'the same unit printed twice with a changed figure (identical repeats are simply counted once)'], fix: 'Neither row is imported until the file is fixed: give the re-stocked unit a new stock number (K2B keeps one unit per stock #), or correct the typo, and import the sheet again.' }, ob_unbalanced: { label: 'The opening balances do not net to zero', means: ['an account is missing from the sheet (the exact amount is the missing balance — look for an account whose balance equals it)', 'a report page was cut off or not pasted in (the last rows of a group are missing)', 'a balance has the wrong sign (a credit typed as a debit) — a row with HALF the amount is the usual culprit'], fix: 'Add the missing account(s) or correct the sign so the Balance column adds up to 0.00, then import the GL_Opening_Balances sheet again. Nothing is posted until it balances.' }, ro_cust_unknown: { label: 'The repair order\'s customer # is not in Customers', means: ['the customer list in the file is incomplete (the ACS export of customers covers the sales buyers, not the service-only customers)', 'the account was deleted in ACS after the repair order was written'], fix: 'Nothing to do for the import — the RO is kept with its 7-digit customer # and links to the customer the moment a customer with that number is added (Customers sheet, or the CRM).' }, cust_not_7: { label: 'The customer # is not 7 digits', means: ['a phone number WITH the area code was typed into the account field (10 digits) — the customer # is the phone without the area code', 'an account number from another system, or a word', 'the spreadsheet changed the number: thousands separators (5,550,142), a decimal (5550142.0) or exponent form (5.55E+06) — Excel does that to a Number column'], fix: 'In the file, make customer_number the 7-digit number (the phone without the area code; never starts with 0 or 1); phones go in home_phone / work_phone. Format the column as Text or plain digits — no separators, no .0.' }, cust_code_word: { label: 'A code word instead of a customer #', means: ['INTERNAL = an internal repair order (the dealership\'s own unit) — not a customer', 'WARRANTY = a warranty repair order paid by the factory — not a customer', 'VOID = a voided repair order'], fix: 'Nothing to do: the row is imported as internal / warranty / void and no customer is created for it.' }, // RC30 fix round (attacker H1, auditor F2): the customer-number rule on the Customers sheet itself cust_issued: { label: 'No phone — K2B issues the customer #', means: ['the customer has no phone on file, so there is no 7-digit number to take from it (the customer # is the phone without the area code)', 'the phone column was left blank on this row'], fix: 'Nothing to do: at approval K2B gives this customer a number starting with 1 (1000001 upward, never reused). If the customer does have a phone, put it on the row and import again — the number is then the phone without the area code.' }, cust_not_issued: { label: 'A number starting with 1 that K2B never gave out', means: ['K2B gives the numbers starting with 1 (1000001 upward) to customers WITHOUT a phone — this one was typed by hand or comes from another system'], fix: 'Leave customer_number blank on this row: K2B issues the customer a number when the import is approved (or put the customer\'s phone on the row — the number is then the phone without the area code).' }, cust_mismatch: { label: 'The customer # does not match the phone', means: ['the number in customer_number belongs to another phone (an old account number, a typo)', 'the phone column holds another person\'s phone (a spouse, a work number)'], fix: 'Make the two agree — the customer # IS the phone without the area code: fix the number or the phone on this row and import again.' }, cust_household: { label: 'A second person on an existing account (household)', means: ['the same 7 digits AND the same full phone as a customer on file — spouses or family sharing a phone: ONE account, both names on it'], fix: 'Nothing to do: at approval the name is added to the account (deals and repair orders name the person).' }, cust_ask_office: { label: 'Same 7 digits, another area code — the office decides', means: ['two different people whose phones end in the same 7 digits (a 573 and an 870 number)', 'the same household with a second phone on another area code'], fix: 'Not imported. Open the CRM › New Customer with this person: it asks "same household" (the name goes on the account) or "two different people" (K2B issues this one a number).' }, cust_number_used: { label: 'The customer # is a retired number', means: ['an old number of a customer who was renumbered — the account lives on under its new number (the search finds the old one)', 'a deleted customer\'s number — a number is never given out twice'], fix: 'Open the account the number points at instead of creating one; to re-create a deleted customer the owner adds them in the CRM.' }, not_written: { label: 'Not written at approval', means: ['the row was fine when it was staged, but by the time the run was approved the record it needed was gone or changed (another import, the CRM, the office)'], fix: 'Read the note on the row, then import the row again or make the change by hand.' }, // row problems the planners already explain; listed so the review can word them the same way everywhere unreadable_money: { label: 'An amount cannot be read', means: ['the cell holds text, a formula with no saved result, or more than two decimals'], fix: 'Open the file, fix the cell (plain number, two decimals), save, and import the sheet again.' }, bad_date: { label: 'A date cannot be read', means: ['the cell is text (e.g. 9/31) or in a format the import does not know'], fix: 'Enter the date as MM/DD/YYYY and import again.' }, unknown_account: { label: 'An account is not in the chart', means: ['the Chart_of_Accounts sheet is missing the account, or it was not imported first', 'Excel dropped a leading zero (0111 became 111)'], fix: 'Add the account to Chart_of_Accounts (or tick "Add accounts missing from the chart") and import again.' }, dup_row: { label: 'The same record twice', means: ['the report page was printed / pasted twice', 'the old system reused the number'], fix: 'The first row is used; remove the repeated row if it is not the same record.' }, rejected: { label: 'Row rejected by the import rules', means: ['see the reason on the row'], fix: 'Correct the row in the file and import the sheet again; the other rows are not held up by it.' }, role: { label: 'Your role may not import into this list', means: ['the import was planned by a login without the right'], fix: 'Ask the office to import this sheet.' }, // ── RC33 (svi): customer names from the repair orders / a service customer list / the A/R aging (the owner: "the service repair import has no // option for customer names") — a row WITH a name may create the customer under its 7-digit number; a row without one never does ── cust_name_new: { label: 'A customer is created from the service lists', means: ['the 7-digit customer # on the repair orders (or the service list / A/R aging) is not on file yet — a service-only customer the sales export did not have'], fix: 'Nothing to do: at approval the customer is created under that number with the name and address printed, and every old repair order of the number links to it.' }, cust_name_fill: { label: 'Blank fields of a customer on file are filled', means: ['the customer is on file under this number with the SAME name; the file has an address / phone / email the account does not have yet'], fix: 'Nothing to do: only BLANK fields are filled — an address, phone or email the office typed is never overwritten.' }, cust_name_differs: { label: 'Another name on a customer # already on file — listed for the office', means: ['the same household (a spouse or family member brought the car in)', 'a different person whose phone ended in the same 7 digits years ago (the number was reused)', 'a typing mistake in the old system (one letter off)'], fix: 'Nothing is changed on the account. The office decides on Customers › "Customers to settle": same household (the name is added to the account), keep the account as it is (a typo), use the file\'s spelling, or a different person (K2B gives them a number of their own).' }, cust_name_other: { label: 'Another name on older rows of the same customer # — listed for the office', means: ['the same household (a spouse or family member brought the car in on the older repair orders)', 'a different person whose phone ended in the same 7 digits years ago (the number was reused)', 'a typing mistake in the old system (one letter off)'], fix: 'Nothing to do on the import: the newest name is the customer\'s; each other name is listed on Customers › "Customers to settle" at approval — same household (the name is added to the account), keep the account as it is (a typo), use that spelling, or a different person (K2B gives them a number of their own). Nothing about the other name is written to the account until the office decides.' }, cust_name_two: { label: 'Two different names for one customer # in the file', means: ['the old system kept one account for two people (a household, or a reused number)', 'one of the rows has a typing mistake'], fix: 'Nothing is created for this number. Put the right name on a Service_Customers row for the number (the list wins over the repair orders) and import again, or add the customer in the CRM.' }, cust_name_no_number: { label: 'A name on a row whose account is not a customer #', means: ['an internal / warranty / void repair order (a code word in the account column)', 'a business charge account (letters, not 7 digits)', 'a phone with its area code, or a number starting with 0 or 1, in the account column'], fix: 'Nothing to do: no customer is made from it (a customer # is the phone without the area code, 7 digits). The name stays on the repair-order history.' }, aging_total_mismatch: { label: 'The aging buckets do not add up to the total', means: ['a bucket was cut off or typed wrong on this row', 'the total column includes a finance charge or an unapplied payment that is in no bucket'], fix: 'Correct the row so current + 30 + 60 + 90 + 120+ equals the total (to the cent) and import the sheet again. The row is not imported until it adds up.' }, aging_bad_schedule: { label: 'The aging row names an account K2B cannot tie here', means: ['the GL account column holds something other than 220 or 220A'], fix: 'Put 220 (A/R service & parts) or 220A (charge accounts 180 days) in gl_account, or leave it blank for 220.' }, // ── RC33 (svi): the service tie-outs — what a difference most likely means (most likely first) ── wip_list_gt_gl: { label: 'The open repair orders carry MORE labor than work in process (247) on the books', means: ['labor relieved from 247 on closed repair orders at the flat-rate cost, above the tech pay that was booked into it (flat rate vs clock hours) — a credit balance on 247 is the usual sign', 'the technicians\' pay for the last period was not posted yet (it posts into 247)', 'a labor cost typed on an open repair order that no tech pay matches'], fix: 'Ask the accountant: when the open repair orders are the whole work in process, tick "the list is complete" and approve with the entry — 247 then equals the open ROs\' labor cost, against 390 CONVERSION CLEARING. The entry does not decide where the difference belongs: flat-rate labor relieved above the tech pay is a COST-OF-LABOR (profit and loss) difference, so 390 holds it until the accountant moves it (DR 390 / CR cost of labor, 660A) — K2B never posts a profit-and-loss account from an import. When an old RO closes in K2B, K2B relieves its old labor from 247.' }, wip_gl_gt_list: { label: 'Work in process (247) on the books is HIGHER than the open repair orders\' labor', means: ['tech pay booked to 247 for repair orders that are already closed (their labor was never relieved)', 'an open repair order missing from the export', 'bulk oil booked to 247 that is no longer in a tank (K2B\'s default bulk-oil account is 247 — the accountant may move bulk oil to 244)'], fix: 'Compare the 247 detail with the open RO list; when the list is right, tick "the list is complete" and approve with the entry (against 390). Tech pay never relieved from 247 is a cost-of-labor (profit and loss) amount: 390 holds it until the accountant moves it (DR cost of labor, 660A / CR 390).' }, wip_closed_in_k2b: { label: 'Old repair orders on the work-in-process list were finished in K2B', means: ['an old open RO was VOIDED or deleted in K2B — its old labor cost stays in 247 (only a close relieves it)', 'an old open RO closed in K2B with less labor cost than the old system carried — the rest stays in 247'], fix: 'Move what is left with a journal entry (DR 660A cost of labor / CR 247), or ask the accountant.' }, bulk_on_wip: { label: 'Bulk oil is booked into the work-in-process account', means: ['K2B receives bulk oil into 247 by default (Parts settings › bulk-oil account) — the oil in the tanks is counted on this list so the tie-out stays right'], fix: 'Nothing to do for the tie-out. The accountant may switch bulk oil to 244 GAS, OIL, GREASE - INV (Parts settings) so 247 holds only labor.' }, sublet_gl_gt_list: { label: 'Sublet (246) on the books is HIGHER than the sublet on open repair orders', means: ['a sublet bill booked before its repair order was written, or after it closed (the relief at the close was missed)', 'cores returned to the vendor and still WAITING FOR THEIR CREDIT, booked to 246 — K2B itself parks cores on 246 until the vendor credits them (Parts › Core Bank), and the old system may have done the same; those are not a sublet difference', 'a sublet bill for a repair order that was voided', 'a bill coded to 246 that is not sublet at all'], fix: 'Look at the 246 detail for bills without an open RO. If any of it is cores waiting for a vendor credit, do NOT tick it: the credit is still owed, so the cores stay in 246 (or the accountant moves them to 242A PARTS CORE RETURN) — clearing them to 390 would lose track of it. When the rest is sublet timing and the list is right, tick "the list is complete" and approve with the entry (against 390).' }, sublet_list_gt_gl: { label: 'The open repair orders carry MORE sublet than 246 on the books', means: ['a sublet bill not entered yet (K2B parks the vendor bill on 246 until the RO closes)', 'a sublet cost typed on an RO that no bill matches'], fix: 'Enter the missing sublet bills; when the list is right, tick "the list is complete" and approve with the entry.' }, cores_on_sublet: { label: 'Cores on the shelf are booked on 246', means: ['K2B parks a returned core on 246 SUBLET REPAIRS - INV until the vendor credits it (Parts › Core Bank) — counted on this list as their own line'], fix: 'Nothing to do for the tie-out. The accountant may prefer 242A PARTS CORE RETURN for cores (ask K2B).' }, warranty_gl_lt_list: { label: 'Warranty claims (263) on the books are LOWER than the open claims (a credit balance is the usual sign)', means: ['a claim paid before its repair order closed (or paid twice) — the payment credited 263 with no claim behind it', 'a chargeback / debit memo booked as a credit to 263', 'a service-contract claim credited to 263 that belonged to 264'], fix: 'Match the factory\'s payments to the open claims; when the claim list is right, tick "the list is complete" and approve with the entry (against 390).' }, warranty_gl_gt_list: { label: 'Warranty claims (263) on the books are HIGHER than the open claims', means: ['claims the factory paid that were never cleared from 263', 'a rejected claim that was never debited', 'an open claim missing from the list'], fix: 'Find the paid / rejected claims still in 263; when the list is right, tick "the list is complete" and approve with the entry.' }, ar_list_short: { label: 'A/R (220) on the books is HIGHER than the customers\' balances on the aging', means: ['a customer with a balance missing from the aging (the report was cut off, or printed for one type of account)', 'a payment posted to a customer\'s account but not to the ledger', 'the deal posting books its F&I income receivable to 220 with no customer behind it', 'a K2B repair order closed with a balance for a customer that has no A/R account (the balance is on 220 but on nobody\'s statement)'], fix: 'Compare the aging with the 220 detail; when the aging is the whole list, tick "the list is complete" and approve with the entry (against 390).' }, ar_list_higher: { label: 'The customers\' balances on the aging are HIGHER than A/R (220) on the books', means: ['payments posted to the ledger but not to the customers\' accounts (an unapplied payment)', 'a finance charge added to the accounts but not to the ledger', 'an account written off in the ledger only'], fix: 'Apply the missing payments / write-offs; when the aging is right, tick "the list is complete" and approve with the entry.' }, body_gl_gt_list: { label: 'Body-shop inventory (245) on the books is HIGHER than the jobs in progress', means: ['body jobs closed without relieving 245 (K2B itself never posts 245)', 'materials bought for jobs that are finished'], fix: 'When the list is right, tick "the list is complete" and approve with the entry (against 390).' }, body_list_gt_gl: { label: 'The body-shop jobs in progress carry MORE cost than 245 on the books', means: ['materials / parts for open jobs booked straight to cost of sales', 'a job cost typed on the list that the books never received'], fix: 'When the list is right, tick "the list is complete" and approve with the entry.' }, svc_credit_no_list: { label: 'No list imported for this account yet — but it carries a CREDIT balance, which this account should never have', means: [], fix: 'Nothing is posted without a list. Import the list (see below) to compare it, or ask the accountant to look at the credits in the account\'s detail.' }, info_no_list: { label: 'Information only — no list to compare', means: ['this account has no list in the import (cash sales, factory receivables, the finance-company receivables): K2B shows its balance so the books can be read in one place, and never posts it'], fix: 'Nothing to do on the import. Tie it in Accounting the way the accountant does today.' }, open_ro_parts: { label: 'Parts on open repair orders are still in 242', means: ['parts issued to an open repair order left the shelf (and the on-hand count) but stay in 242 until the RO is invoiced — K2B relieves 242 when it closes the RO'], fix: 'Add a Tie_Out row "242 — Parts on open ROs" (the template has it) and they count on the parts list; otherwise they are shown here for information.' }, }; // RC33 (svi, map gap 2): the code word is the FIRST WORD of the cell — "INTERNAL", "INT 4", "WARR-12", "BODY SHOP" — never a prefix of a longer word // (a surname that only starts with INT / SHOP / POLICY / DEALER / VOID / WARR is a name, not an internal / warranty repair order); BODY added const CUST_CODE_WORDS = /^(INTERNAL|INT|WARRANTY|WARR|WTY|VOID|SHOP|POLICY|DEALER|BODY)(?![A-Z])/i; const isCustNum7 = (s) => /^[2-9]\d{6}$/.test(txt(s)) || /^1\d{6}$/.test(txt(s)); // the phone without the area code, or a K2B-issued 1xxxxxx number (lane cn) // the name of a PART of a staged run (a sheet / a list): letters, digits, space, _ . ( ) -, at most 60 — the page and the server spell it the same way // ("Repair orders — history" → "Repair orders - history"); empty when nothing readable is left const partName = (s) => txt(s).replace(/[—–]/g, '-').replace(/[^A-Za-z0-9 _.()-]+/g, ' ').replace(/\s+/g, ' ').trim().replace(/^[^A-Za-z0-9]+/, '').slice(0, 60).trim(); /** RC30 fix round (migration F6): a customer-number cell the way a spreadsheet hands it over — "5,551,242" (Excel's thousands separators), "5551245.0" (a numeric column saved with .0) → the plain digits when a 7- or 10-digit number is left ("1" + 10 digits → the 10); anything else comes back trimmed as typed and the rules say what is wrong with it — an exponent form ("5.55124E+06") is NEVER turned into digits: Excel dropped digits from it, so any number made from it could be another customer's */ function custNumCell(v) { const s = txt(v); if (!s) return s; let t = /^\d{1,3}(,\d{3})+(\.0+)?$/.test(s) ? s.replace(/,/g, '') : s; if (/^\d+\.0+$/.test(t)) t = t.replace(/\.0+$/, ''); if (/^1\d{10}$/.test(t)) t = t.slice(1); return /^\d+$/.test(t) ? t : s; } const FORMAT_WORDS = 'format the column as Text or plain digits (no separators, no .0) and export again'; /** the 10 digits of a phone cell (a leading US 1 dropped), else '' */ const phoneDigits = (v) => { let d = txt(v).replace(/\D/g, ''); if (d.length === 11 && d[0] === '1') d = d.slice(1); return d.length === 10 ? d : ''; }; /** the phone a customer row's number comes from: the FIRST phone field holding 10 digits, in lane cn's order (lib/custnum.js PHONE_FIELDS; + the template's raw home_phone / work_phone) — the same phone the approval's rule reads */ const CUST_PHONE_FIELDS = ['phone', 'cell', 'cellPhone', 'mobile', 'mobilePhone', 'homePhone', 'home_phone', 'workPhone', 'work_phone', 'phone2', 'altPhone', 'otherPhone', 'businessPhone']; const rowPhone = (rec) => { for (const k of CUST_PHONE_FIELDS) { const d = phoneDigits(rec && rec[k]); if (d) return d; } return ''; }; /** which rule a reason text / field hits — the planners' own words are kept; the rule adds "what it most likely means" */ function ruleOfReason(dest, field, reason) { const f = String(field || '').toLowerCase(), r = String(reason || ''); if (/formula|not an amount|more than two decimals|not a number|larger than the server allows|not a quantity/i.test(r)) return 'unreadable_money'; if (/is not a date|not a valid date/i.test(r)) return 'bad_date'; if (/not in the chart/i.test(r)) return 'unknown_account'; // RC30 fix round: the customer-number rule's own refusals (lane cn's planCustomers words, the server's rowRules words) → the rule that explains them if (dest === 'customers' || /customer/.test(f)) { if (/code word/i.test(r)) return 'cust_code_word'; if (/another area code|the office decides/i.test(r)) return 'cust_ask_office'; if (/does not match the phone|disagree|one row, one number/i.test(r)) return 'cust_mismatch'; if (/starting with 1 is given out by K2B|K2B never gave it out|did not issue/i.test(r)) return 'cust_not_issued'; if (/old number of|belonged to a customer that was deleted|never given out twice/i.test(r)) return 'cust_number_used'; if (/not a customer (number|#)|exponent form|is a phone with its area code|cannot be a phone|7 digits/i.test(r)) return 'cust_not_7'; } if (/duplicate|also on row|appears again|repeats row|was already in batch|listed again|is listed on rows .* with different/i.test(r)) return dest === 'inventory' ? 'dup_stock' : 'dup_row'; if (/^role$/.test(f)) return 'role'; // RC33 (svi): the A/R aging rows' own refusals (the page's planArAging words = agingRow's) → their rules if (/the buckets add up to/i.test(r)) return 'aging_total_mismatch'; if (/aging ties only to the A\/R accounts/i.test(r)) return 'aging_bad_schedule'; return 'rejected'; } /** RC30 fix round (auditor, cosmetic): a planner WARNING (the row is imported, with a note) → the rule that explains it, or '' when no rule has words for it — so a row the planner re-keyed (5555550105 → 5550105) shows "what this most likely means" like every other */ function ruleOfWarning(dest, field, reason) { const f = String(field || '').toLowerCase(), r = String(reason || ''); if (/code word/i.test(r)) return 'cust_code_word'; if (/K2B issues a (customer )?number/i.test(r)) return 'cust_issued'; if (/household/i.test(r) && /customer|account/i.test(f + ' ' + r)) return 'cust_household'; if (/is a phone with its area code|last 7 digits|not (a )?7[- ]digit|not 7 digits|area code (was )?typed/i.test(r) || (/customer/.test(f) && /area code|7 digits/i.test(r))) return 'cust_not_7'; if (/not in Customers|waits for (that|the) customer/i.test(r)) return 'ro_cust_unknown'; if (/GL Cost is 0/i.test(r)) return 'gl_cost_zero'; if (/is not a date|not a valid date/i.test(r)) return 'bad_date'; if (/not in the chart/i.test(r)) return 'unknown_account'; return ''; } /** a row's own checks on top of the planner's: the customer-number rules and GL Cost 0 (returns [{ rule, level: 'warn'|'reject', text }]) */ function rowRules(dest, rec, ctx) { const out = []; rec = rec || {}; ctx = ctx || {}; if (dest === 'roHistory') { const acct = txt(rec.customerAccount || rec.customer_account); if (acct) { if (CUST_CODE_WORDS.test(acct)) out.push({ rule: 'cust_code_word', level: 'warn', text: acct.toUpperCase() + ' is a code word — ' + (/^VOID/i.test(acct) ? 'a voided repair order' : /^WARR|^WTY/i.test(acct) ? 'a warranty repair order' : /^BODY/i.test(acct) ? 'a body-shop repair order' : 'an internal repair order') + ', not a customer' }); else if (!isCustNum7(acct)) out.push({ rule: 'cust_not_7', level: 'warn', text: '"' + acct + '" is not a 7-digit customer # — ' + (/^\d{10}$/.test(acct.replace(/\D/g, '')) ? 'a phone with the area code was typed into the account field (K2B keeps the last 7 digits as the customer #)' : 'K2B keeps it as printed; it links to no customer') }); else if (ctx.customerKnown && !ctx.customerKnown(acct) && !hasCustName(rec)) out.push({ rule: 'ro_cust_unknown', level: 'warn', text: 'customer # ' + acct + ' is not in Customers (nor in this file) — the repair order waits for that customer' }); // RC33 (svi): a row WITH the customer's name makes the customer at approval — it does not wait } } if (dest === 'customers' && (ctx.action === undefined || ctx.action === 'add')) { // RC30 fix round (attacker H1): a Customers row is judged by the 7-digit rule BEFORE it is staged — a word, a code word, a 7-digit // number starting with 0 / 1, a number that disagrees with the row's phone are problem rows (never written); a 10-digit phone in the // number column is a note (the approval keeps its last 7 digits, the rule's answer); no phone and no number = K2B issues one const cn = custNumCell(rec.custNum !== undefined && rec.custNum !== null && rec.custNum !== '' ? rec.custNum : rec.customer_number); const ph = rowPhone(rec); const derived = ph && /^[2-9]/.test(ph.slice(3)) ? ph.slice(3) : ''; const hh = !!(ctx.plan && ctx.plan.householdAdd); if (cn && !isCustNum7(cn)) { const d = cn.replace(/\D/g, ''); if (/^\d+(\.\d+)?E[+-]?\d+$/i.test(cn)) out.push({ rule: 'cust_not_7', level: 'reject', text: '"' + cn + '" is a number in exponent form — Excel dropped digits from it; ' + FORMAT_WORDS }); else if (CUST_CODE_WORDS.test(cn)) out.push({ rule: 'cust_code_word', level: 'reject', text: '"' + cn + '" is the old system\'s code word, not a customer' }); else if (d.length === 10 || (d.length === 11 && d[0] === '1')) { const last7 = d.slice(-7); if (!/^[2-9]/.test(last7)) out.push({ rule: 'cust_not_7', level: 'reject', text: '"' + cn + '" cannot be a phone (its last 7 digits start with ' + last7[0] + ')' }); else if (derived && derived !== last7) out.push({ rule: 'cust_mismatch', level: 'reject', text: '"' + cn + '" is a phone with its area code (customer # ' + last7 + '), but the row\'s phone says ' + derived + ' — fix one of the two' }); else out.push({ rule: 'cust_not_7', level: 'warn', text: '"' + cn + '" is a phone with its area code — the customer # is its last 7 digits (' + last7 + ')' }); } else out.push({ rule: 'cust_not_7', level: 'reject', text: '"' + cn + '" is not a customer # — the customer # is the phone without the area code (7 digits' + (/^\d{7}$/.test(d) ? ', never starting with 0 or 1' : '') + ')' + (/\d/.test(cn) ? '; ' + FORMAT_WORDS : '') }); } else if (cn && /^[2-9]\d{6}$/.test(cn) && derived && derived !== cn && !hh) out.push({ rule: 'cust_mismatch', level: 'reject', text: 'customer # ' + cn + ' does not match the phone on the row (its customer # is ' + derived + ')' }); else if (cn && /^1\d{6}$/.test(cn) && derived) out.push({ rule: 'cust_mismatch', level: 'reject', text: 'customer # ' + cn + ' is a number K2B gives out to customers WITHOUT a phone, but the row has a phone — its customer # is ' + derived }); else if (cn && /^1\d{6}$/.test(cn) && ctx.nextIssued && Number(cn) >= Number(ctx.nextIssued)) out.push({ rule: 'cust_not_issued', level: 'reject', text: 'customer # ' + cn + ' starts with 1, but K2B never gave it out (K2B\'s numbers for customers without a phone are issued in order — the next is ' + ctx.nextIssued + '); leave customer_number blank and K2B issues one at approval' }); else if (cn && /^1\d{6}$/.test(cn)) out.push({ rule: 'cust_issued', level: 'warn', text: 'customer # ' + cn + ' is a number K2B issued to a customer without a phone' }); else if (!cn && !derived && !hh) out.push({ rule: 'cust_issued', level: 'warn', text: 'no phone and no customer # — K2B issues a number (starting with 1) at approval' }); } if (dest === 'deals') { const cn = custNumCell(rec.customer_number !== undefined ? rec.customer_number : rec.custNum); if (cn && !isCustNum7(cn)) out.push({ rule: 'cust_not_7', level: 'warn', text: '"' + cn + '" is not a 7-digit customer # (the phone without the area code) — the deal keeps it as typed and links to no customer' }); } if (dest === 'schedule' && (ctx.action === undefined || ctx.action === 'add')) { // RC30 fix round (attacker M3): an aged-schedule balance that cannot be read is a problem row, not a silent drop at approval const c = toCents(rec.balance); if (!Number.isFinite(c)) out.push({ rule: 'unreadable_money', level: 'reject', text: 'balance "' + txt(rec.balance).slice(0, 30) + '" cannot be read as an amount (plain number, two decimals)' }); if (!txt(rec.control)) out.push({ rule: 'rejected', level: 'reject', text: 'no customer / control number on the aged row' }); if (!acctKey(rec.gl)) out.push({ rule: 'unknown_account', level: 'reject', text: 'no GL account (schedule) on the aged row' }); } if (dest === 'inventory') { const gc = rec.costBooked !== undefined ? rec.costBooked : rec.glCost; if (gc !== undefined && gc !== null && gc !== '' && toCents(gc) === 0) out.push({ rule: 'gl_cost_zero', level: 'warn', text: 'GL Cost is 0.00 on stock ' + txt(rec.stock) }); } // RC33 (svi): the customer printed on a repair order, the service customer list, the A/R aging, warranty claims, body-shop jobs if (dest === 'roHistory' && hasCustName(rec)) { const n = svcCustNum(rec.customerAccount || rec.customer_account); if (!n.num) out.push({ rule: 'cust_name_no_number', level: 'warn', text: roCustFields(rec).name + ': ' + n.why + ' — the name stays on the repair-order history, no customer is made' }); } if (dest === 'svcCustomers' && (ctx.action === undefined || ctx.action === 'add')) { const n = svcCustNum(rec.custNum !== undefined ? rec.custNum : rec.customer_number); if (!n.num) out.push({ rule: 'cust_not_7', level: 'reject', text: n.why + ' — a service customer row needs the 7-digit customer # (the phone without the area code)' }); if (!hasCustName(rec)) out.push({ rule: 'rejected', level: 'reject', text: 'no name on the row — a customer needs a name' }); } if (dest === 'arAging' && (ctx.action === undefined || ctx.action === 'add')) agingRow(rec).problems.forEach((p) => out.push({ rule: p.rule, level: 'reject', text: p.field + ': ' + p.reason })); if (dest === 'warrantyClaims' && (ctx.action === undefined || ctx.action === 'add')) claimRow(rec).problems.forEach((p) => out.push({ rule: p.rule, level: 'reject', text: p.field + ': ' + p.reason })); if (dest === 'bodyWip' && (ctx.action === undefined || ctx.action === 'add')) bodyJobRow(rec).problems.forEach((p) => out.push({ rule: p.rule, level: 'reject', text: p.field + ': ' + p.reason })); return out; } /** GL_Opening_Balances rows (as the sheet has them: { acct, cents }) → do they net to zero? With the exact amount and the suspects. */ function glRowsCheck(rows) { const list = (rows || []).filter((r) => r && acctKey(r.acct)).map((r) => ({ acct: acctKey(r.acct), cents: Math.round(Number(r.cents) || 0) })); const net = list.reduce((s, r) => s + r.cents, 0); const out = { ok: net === 0, netCents: net, accounts: list.length, suspects: [] }; if (net !== 0) { const a = Math.abs(net); list.forEach((r) => { if (Math.abs(r.cents) === a) out.suspects.push({ acct: r.acct, cents: r.cents, why: 'exactly the amount — a missing account with the opposite balance, or this row twice' }); else if (Math.abs(r.cents) * 2 === a) out.suspects.push({ acct: r.acct, cents: r.cents, why: 'half the amount — this balance may have the wrong sign' }); }); out.text = 'The opening balances do not net to zero: ' + (net > 0 ? 'debits exceed credits' : 'credits exceed debits') + ' by ' + fmt(a) + '.' + (out.suspects.length ? ' Likely: ' + out.suspects.slice(0, 3).map((s) => s.acct + ' ' + fmt(s.cents) + ' (' + s.why + ')').join('; ') + '.' : ' No single row carries that amount — a page or a group of accounts is probably missing.'); } return out; } /* ── the Tie_Out sheet: which GL account each list must agree with — or the defaults the owner was told (scope 2026-10-23): 237 new; 240 / 240A / 241 / 241A used; the parts inventory account (by name, else 242); A/R 220; 238 / 238A excluded (property). ── */ // RC33 (svi): + wip / sublet / warranty / body / info (shown, never posted) / roparts (parts on open ROs, counted on the parts list) const TIE_KINDS = { inventory: 'new/used', parts: 'parts', ar: 'ar', exclude: 'exclude', wip: 'wip', sublet: 'sublet', warranty: 'warranty', body: 'body', info: 'info', roparts: 'roparts' }; // the SERVICE words are tested FIRST (map gap 9): "Warranty receivable" is warranty (not A/R), "Sublet inventory" is sublet (not vehicles), // "Parts on open ROs" is the open-RO parts line (not the parts list), "Work in process" / "WIP labor" is work in process const tieKindOf = (s) => { const t = txt(s).toLowerCase(); if (/exclud|property|not inventory/.test(t)) return 'exclude'; if (/^\s*info|information|no list|reference only/.test(t)) return 'info'; if (/part.*open\s*r\.?o|open\s*r\.?o.*part|parts? on (open )?r\.?o/.test(t)) return 'roparts'; if (/work\s*in\s*pro|\bwip\b/.test(t)) return 'wip'; if (/sublet/.test(t)) return 'sublet'; if (/warrant/.test(t)) return 'warranty'; if (/body/.test(t)) return 'body'; if (/part/.test(t)) return 'parts'; if (/a\s*\/?\s*r|receiv/.test(t)) return 'ar'; if (/invent|new|used|vehicle|car|truck/.test(t)) return 'inventory'; return ''; }; /** RC33 (svi): the accounts shown for information on the review (never posted): cash sales, factory / holdback receivables, the finance-company receivables (262x — DUE FROM ALLY …) and 220A charge accounts; the review drops an information row whose ledger balance is 0 */ const INFO_RE = /^(225|261|262)[A-Z]*$/; function tieOutDefaults(chart) { const by = {}; (chart || []).forEach((a) => { const k = acctKey(a.acct || a.account); if (k) by[k] = txt(a.name || a.desc); }); const rows = []; const add = (acct, kind, note) => { if (by[acct] !== undefined) rows.push({ acct, kind, note, source: 'default' }); }; add('237', 'inventory', 'new vehicles'); ['240', '240A', '241', '241A'].forEach((a) => add(a, 'inventory', 'used vehicles')); const parts = Object.keys(by).filter((k) => INV_RANGE.test(k) && /INVENTORY\s*-?\s*PARTS|PARTS\s+INVENT/i.test(by[k])); (parts.length ? parts : (by['242'] !== undefined ? ['242'] : [])).forEach((a) => add(a, 'parts', 'parts on hand × cost')); add('220', 'ar', 'service & parts A/R'); // RC33 (svi): the service accounts the owner's books carry — each with its list (or "no list imported yet" until it comes) const byName = (re, fallback) => { const hit = Object.keys(by).filter((k) => INV_RANGE.test(k) && re.test(by[k])).sort(cmpAcct); return hit.length ? hit : (by[fallback] !== undefined ? [fallback] : []); }; byName(/WORK\s*IN\s*PRO/i, '247').forEach((a) => add(a, 'wip', 'labor cost on the open / held repair orders')); byName(/SUBLET\s*(REPAIRS?|INV)/i, '246').forEach((a) => add(a, 'sublet', 'sublet cost on the open / held repair orders')); const w26 = Object.keys(by).filter((k) => /^26\d[A-Z]*$/.test(k) && /WARRANTY\s*CLAIM/i.test(by[k])).sort(cmpAcct); (w26.length ? w26 : ['263']).forEach((a) => add(a, 'warranty', 'the open warranty claims')); byName(/BODY\s*SHOP\s*INVENT/i, '245').forEach((a) => add(a, 'body', 'body-shop jobs in progress')); add('220A', 'info', 'charge accounts 180 days — shown with the aging\'s 220A rows; the accountant ties it (no entry)'); Object.keys(by).filter((k) => INFO_RE.test(k)).sort(cmpAcct).forEach((a) => { if (!rows.some((r) => r.acct === a)) rows.push({ acct: a, kind: 'info', note: /^225/.test(a) ? 'cash sales — information' : /^261/.test(a) ? 'factory / holdback receivable — information' : 'finance-company receivable — information', source: 'default' }); }); ['238', '238A'].forEach((a) => { if (by[a] !== undefined) rows.push({ acct: a, kind: 'exclude', note: 'dealership property (saw mill lot) — not inventory', source: 'default' }); }); Object.keys(by).forEach((k) => { if (/SAW\s*MILL/i.test(by[k]) && !rows.some((r) => r.acct === k)) rows.push({ acct: k, kind: 'exclude', note: 'dealership property — not inventory', source: 'default' }); }); return rows; } /** the Tie_Out sheet rows ({ glAccount, tiesTo, note }) → [{ acct, kind, note, source: 'sheet' }] + the rows that could not be read */ function tieOutFromSheet(rows) { const out = [], bad = []; (rows || []).forEach((r, i) => { const acct = acctKey(r.glAccount !== undefined ? r.glAccount : (r['GL Account'] !== undefined ? r['GL Account'] : r.acct)); const kind = tieKindOf(r.tiesTo !== undefined ? r.tiesTo : (r['Ties to'] !== undefined ? r['Ties to'] : r.kind)); if (!acct) { if (Object.keys(r || {}).some((k) => txt(r[k]))) bad.push({ row: i + 2, why: 'no GL account' }); return; } if (!kind) { bad.push({ row: i + 2, acct, why: '"' + txt(r.tiesTo !== undefined ? r.tiesTo : r['Ties to']) + '" is not one of Inventory, Parts, Customer A/R, WIP labor, Sublet, Warranty, Body shop, Parts on open ROs, Information, Exclude' }); return; } out.push({ acct, kind, note: txt(r.note !== undefined ? r.note : r.Note).slice(0, 120), source: 'sheet' }); }); return { rows: out, bad }; } /** the ranked likely causes of one tie-out difference (list − GL) */ function tieOutCauses(diffCents, info) { const i = info || {}; const out = []; const take = (code) => { const r = RULES[code]; return { rule: code, label: r.label, means: r.means.slice(), fix: r.fix }; }; if (!diffCents) return out; if (i.kind === 'parts') { out.push(diffCents > 0 ? { rule: 'list_gt_gl', label: 'The parts list is HIGHER than the ledger', means: ['parts received whose invoice was never posted', 'a cost on the parts file higher than what was paid', 'parts counted that were already sold (the ticket posted the sale, not the relief)'], fix: 'Check the parts behind the difference; when the list is right, approve with the adjusting entry for the parts account.' } : { rule: 'gl_gt_list', label: 'The ledger is HIGHER than the parts list', means: ['parts sold or used on repair orders whose cost was never relieved', 'parts missing from the count', 'freight / core charges booked into the parts account'], fix: 'Recount the bins with the largest value; when the ledger is wrong, approve with the adjusting entry.' }); return out; } // RC33 (svi): the service lists — A/R aging, work in process, sublet, warranty claims, body-shop jobs — each with its own ranked words; what the // books themselves show (an old RO finished in K2B, bulk oil / cores on the account, one item of exactly the difference) is said FIRST const SVC = { ar: ['ar_list_higher', 'ar_list_short'], wip: ['wip_list_gt_gl', 'wip_gl_gt_list'], sublet: ['sublet_list_gt_gl', 'sublet_gl_gt_list'], warranty: ['warranty_gl_lt_list', 'warranty_gl_gt_list'], body: ['body_list_gt_gl', 'body_gl_gt_list'] }; if (SVC[i.kind] && i.noList) { // RC33 fix round (auditor): no list for the account, but a CREDIT balance on a receivable / inventory account says something by itself — the // kind's own "list higher than the books" words (the list is $0.00), and which list to import to compare const LIST = { ar: 'the A/R aging by customer (AR_Aging sheet or the aging printout)', wip: 'the open / held repair orders with their labor cost (Repair_Orders)', sublet: 'the open / held repair orders with their sublet cost (Repair_Orders)', warranty: 'the open warranty claims (Warranty_Claims sheet or the open-claims report)', body: 'the body-shop jobs in progress (Body_Shop_WIP)' }; if (diffCents <= 0) return out; const r = RULES.svc_credit_no_list; out.push({ rule: 'svc_credit_no_list', label: r.label, means: RULES[SVC[i.kind][0]].means.slice(), fix: r.fix.replace('(see below)', '— ' + LIST[i.kind] + ' —') }); return out; } if (SVC[i.kind]) { const c = take(diffCents > 0 ? SVC[i.kind][0] : SVC[i.kind][1]); if (i.exactItem) c.means.unshift('ONE ' + (i.kind === 'ar' ? 'customer' : i.kind === 'warranty' ? 'claim' : i.kind === 'body' ? 'job' : 'repair order') + ' on the list carries exactly this amount: ' + i.exactItem + ' — it is probably ' + (diffCents > 0 ? 'not on the books (or on the list by mistake)' : 'missing from the list')); if (i.kind === 'wip' && diffCents < 0 && i.finishedInK2bCents) c.means.unshift(i.finishedInK2bWords || ('old repair orders finished in K2B left ' + fmt(i.finishedInK2bCents) + ' of old labor in 247')); if (i.kind === 'sublet' && diffCents > 0 && i.k2bOpenCents) c.means.unshift(i.k2bOpenCents === diffCents ? 'K2B\'s own open repair orders carry ' + fmt(i.k2bOpenCents) + ' of sublet — exactly the difference: their sublet bills are not entered yet' : 'K2B\'s own open repair orders carry ' + fmt(i.k2bOpenCents) + ' of sublet; a bill not entered yet is part of the difference'); if (i.kind === 'ar' && diffCents < 0 && i.unlinkedArCents) c.means.unshift(fmt(i.unlinkedArCents) + ' of K2B repair-order balances went to 220 for customers without an A/R account — on the books, on nobody\'s statement'); if (i.kind === 'wip' && i.bulkCents) c.means.push('also: bulk oil in the tanks booked into this account (' + fmt(i.bulkCents) + ') is counted on the list — the accountant may move bulk oil to 244'); if (i.kind === 'sublet' && i.coresCents) c.means.push('also: cores on the shelf booked into this account (' + fmt(i.coresCents) + ') are counted on the list'); out.push(c); return out; } const c = take(diffCents > 0 ? 'list_gt_gl' : 'gl_gt_list'); if (diffCents > 0 && i.zeroCostUnits) c.means.unshift(i.zeroCostUnits + ' unit' + (i.zeroCostUnits === 1 ? '' : 's') + ' on the list carr' + (i.zeroCostUnits === 1 ? 'ies' : 'y') + ' GL Cost 0.00 (the purchase was never posted) — see "GL Cost is 0"'); if (i.exactUnit) c.means.unshift('ONE unit on the list has exactly this GL Cost: stock ' + i.exactUnit + ' — it is probably ' + (diffCents > 0 ? 'not in the ledger' : 'in the ledger twice')); if (i.wrongAccountUnits) { // RC30 fix round (auditor): the wrong-account unit(s) rank FIRST only when their amount explains the difference exactly (counting them // would make the list agree); otherwise they are a note at the end — a $31,000 clue is not "most likely" for a $5,000 gap const w = i.wrongAccountUnits + ' unit' + (i.wrongAccountUnits === 1 ? '' : 's') + ' booked in this account whose N/U does not match it (New in a Used account or the other way) ' + (i.wrongAccountCents !== undefined ? fmt(i.wrongAccountCents) + ' ' : '') + '— not counted on the list until the N/U or the GL Account is fixed' + (i.wrongAccountList ? ': ' + i.wrongAccountList : ''); if (i.wrongAccountCents !== undefined && i.wrongAccountCents === -diffCents) c.means.unshift(w + ' — counting ' + (i.wrongAccountUnits === 1 ? 'it' : 'them') + ' would make the list agree to the cent'); else c.means.push('also: ' + w + ' (this alone does not explain the ' + fmt(Math.abs(diffCents)) + ' difference)'); } out.push(c); return out; } /* ══════════════════════ RC33 (svi): customer names on the service lists + the A/R aging / warranty claims / body jobs rows ══════════════════════ The owner's rule: a row WITH a name may create the customer under its 7-digit number (or fill the BLANK fields of the one on file); a row without a name never does; another name on a number on file is never written — it is listed for the office. These are pure readings of one row; the server judges them against the customers on file (routes/imp-stage.js). The name fields are NEVER part of normRoRow / roFingerprint (a re-import of the same repair orders without names still reads "unchanged"). */ /** the 7-digit customer # a service row's account names → { num, why } (num only for a phone-shaped 7-digit number — the phone without its area code) */ function svcCustNum(v) { const s = custNumCell(v); if (!s) return { num: '', why: 'no customer #' }; if (CUST_CODE_WORDS.test(s)) return { num: '', why: '"' + s + '" is a code word (internal / warranty / void / body-shop work), not a customer' }; if (/^[2-9]\d{6}$/.test(s)) return { num: s, why: '' }; if (/^1\d{6}$/.test(s)) return { num: '', why: '"' + s + '" starts with 1 — an old-system account starting with 1 is never a K2B customer # (K2B gives those out to customers without a phone)' }; const d = s.replace(/\D/g, ''); if (d.length === 10 || (d.length === 11 && d[0] === '1')) return { num: '', why: '"' + s + '" is a phone with its area code in the account column — no customer is made from it' }; if (/^\d{7}$/.test(s)) return { num: '', why: '"' + s + '" starts with 0 — a phone\'s 7 digits never do' }; return { num: '', why: '"' + s + '" is not a customer # (a business charge account, or a word)' }; } /** "LAST, FIRST" or "FIRST LAST" → { first, last } (the same reading as CustNum.splitName) */ const splitName = (full) => { const s = txt(full); if (!s) return { first: '', last: '' }; if (s.indexOf(',') !== -1) { const p = s.split(','); return { last: txt(p[0]), first: txt(p.slice(1).join(' ')) }; } const p = s.split(/\s+/); return { first: p[0], last: p.slice(1).join(' ') }; }; /** a name as one key (the same as CustNum.nameKey): "SMITH, JOHN" == "John Smith" */ const nameKeyOf = (first, last) => [txt(first), txt(last)].join(' ').toUpperCase().replace(/[.,'"]/g, '').split(/\s+/).filter(Boolean).sort().join(' '); const CUST_FIELD_KEYS = ['customerName', 'custFirst', 'custLast', 'custStreet', 'custCity', 'custState', 'custPostal', 'custPhone', 'custWorkPhone', 'custEmail']; /** the customer printed on a service row (repair order / service list / aging): { first, last, name ("LAST, FIRST"), key, street, city, state, zip, phone, workPhone, email } */ function roCustFields(rec) { rec = rec || {}; let first = txt(rec.custFirst).slice(0, 60), last = txt(rec.custLast).slice(0, 60); if (!first && !last && txt(rec.customerName)) { const p = splitName(txt(rec.customerName).slice(0, 120)); first = p.first.slice(0, 60); last = p.last.slice(0, 60); } const name = [last, first].filter(Boolean).join(', '); return { first, last, name, key: nameKeyOf(first, last), street: txt(rec.custStreet).slice(0, 120), city: txt(rec.custCity).slice(0, 60), state: up(rec.custState).slice(0, 20), zip: txt(rec.custPostal).slice(0, 20), phone: txt(rec.custPhone).slice(0, 30), workPhone: txt(rec.custWorkPhone).slice(0, 30), email: txt(rec.custEmail).toLowerCase().slice(0, 120) }; } const hasCustName = (rec) => !!roCustFields(rec).key; /** one A/R aging row (the "current / 31-60 / 61-90 / 91-120 / over 120" layout) → { control, name, gl, buckets: [{ key, days, cents }], totalCents, problems } — every bucket an amount to the cent; total (when given) must equal the buckets' sum to the cent; the account 220 (blank) or 220A */ const AGING_BUCKETS = [['current', 0], ['d31_60', 31], ['d61_90', 61], ['d91_120', 91], ['over120', 121]]; function agingRow(r) { r = r || {}; const problems = []; const control = up(r.control).replace(/^#/, '').replace(/\.0+$/, '').replace(/\s+/g, ' ').trim().slice(0, 24); if (!control) problems.push({ field: 'customer #', reason: 'no customer / account number on the aging row', rule: 'rejected' }); const buckets = []; let sum = 0, bad = false; const money = (field, v) => { if (isFormula(v)) { problems.push({ field, reason: '"' + txt(v) + '" ' + FORMULA_WHY, rule: 'unreadable_money' }); return NaN; } const c = toCents(v); if (c === 'decimals' || Number.isNaN(c)) { problems.push({ field, reason: '"' + txt(v).slice(0, 30) + '" is not an amount (plain number, two decimals)', rule: 'unreadable_money' }); return NaN; } if (Math.abs(c) > MAX_CENTS) { problems.push({ field, reason: fmt(c) + ' is larger than the server allows', rule: 'unreadable_money' }); return NaN; } return c; }; AGING_BUCKETS.forEach(([k, days]) => { const c = money(k, r[k]); if (!Number.isFinite(c)) { bad = true; return; } if (c) buckets.push({ key: k, days, cents: c }); sum += c; }); let totalCents = sum; if (!blank(r.total)) { const t = money('total', r.total); if (Number.isFinite(t)) { totalCents = t; if (!bad && t !== sum) problems.push({ field: 'total', reason: 'the buckets add up to ' + fmt(sum) + ' but the total says ' + fmt(t) + ' (' + fmt(t - sum) + ' in no bucket)', rule: 'aging_total_mismatch' }); } else bad = true; } const gl = acctKey(r.gl) || '220'; if (gl !== '220' && gl !== '220A') problems.push({ field: 'gl account', reason: 'account "' + txt(r.gl) + '" is not 220 or 220A — the aging ties only to the A/R accounts', rule: 'aging_bad_schedule' }); return { control, name: txt(r.name).slice(0, 120), gl, buckets, totalCents, problems, unreadable: bad }; } /** one open warranty claim → { claim, ro, date, amountCents, paidCents, status: open | submitted | paid | debited | rejected, openCents, problems } */ const CLAIM_STATUS = { '': 'open', open: 'open', new: 'open', 'not submitted': 'open', submitted: 'submitted', pending: 'submitted', authorized: 'submitted', approved: 'submitted', paid: 'paid', closed: 'paid', debited: 'debited', debit: 'debited', chargeback: 'debited', 'charged back': 'debited', rejected: 'rejected', denied: 'rejected' }; function claimRow(r) { r = r || {}; const problems = []; const claim = up(r.claim).replace(/\s+/g, '').replace(/\.0+$/, '').slice(0, 30), ro = up(r.ro).replace(/^#/, '').replace(/\.0+$/, '').slice(0, 20); if (!claim) problems.push({ field: 'claim #', reason: 'no claim number', rule: 'rejected' }); if (!ro) problems.push({ field: 'RO #', reason: 'no repair-order number on the claim', rule: 'rejected' }); const date = isFormula(r.date) ? null : isoDate(r.date); if (date === null) problems.push({ field: 'claim date', reason: '"' + txt(r.date) + '" is not a date', rule: 'bad_date' }); const cents = (field, v, req) => { if (blank(v)) { if (req) problems.push({ field, reason: 'no amount', rule: 'unreadable_money' }); return req ? NaN : 0; } if (isFormula(v)) { problems.push({ field, reason: '"' + txt(v) + '" ' + FORMULA_WHY, rule: 'unreadable_money' }); return NaN; } const c = toCents(v); if (c === 'decimals' || Number.isNaN(c) || Math.abs(c) > MAX_CENTS) { problems.push({ field, reason: '"' + txt(v).slice(0, 30) + '" is not an amount (plain number, two decimals)', rule: 'unreadable_money' }); return NaN; } return c; }; const amountCents = cents('amount', r.amount, true), paidCents = cents('amount paid', r.paid, false); const sw = txt(r.status).toLowerCase(); const status = CLAIM_STATUS[sw]; if (status === undefined) problems.push({ field: 'status', reason: '"' + txt(r.status) + '" is not a claim status (open, submitted, paid, debited, rejected)', rule: 'rejected' }); // RC33 fix round: a claim still open can never be paid MORE than it claims (what is owed would be below $0.00) — the row says so, nothing is written if (status !== 'paid' && status !== 'debited' && Number.isFinite(amountCents) && Number.isFinite(paidCents) && paidCents > amountCents) problems.push({ field: 'amount paid', reason: 'the amount paid (' + fmt(paidCents) + ') is more than the claim (' + fmt(amountCents) + ') — an over-payment is not an open claim: mark it paid (an extra amount is its own claim) or correct the amounts', rule: 'rejected' }); const openCents = !Number.isFinite(amountCents) || !Number.isFinite(paidCents) || status === 'paid' || status === 'debited' ? 0 : amountCents - paidCents; return { claim, ro, date: date || '', amountCents, paidCents, status: status || 'open', openCents, problems }; } /** one body-shop job in progress → { roNo, date, custNum, laborCents, partsCents, materialsCents, subletCents, totalCents, status: open | closed | void, problems } */ function bodyJobRow(r) { r = r || {}; const problems = []; const roNo = up(r.roNo).replace(/^#/, '').replace(/\.0+$/, '').slice(0, 20); if (!roNo) problems.push({ field: 'RO #', reason: 'no repair-order number', rule: 'rejected' }); else if (!RO_NO_OK.test(roNo)) problems.push({ field: 'RO #', reason: '"' + txt(r.roNo) + '" is not a repair-order number', rule: 'rejected' }); const date = isFormula(r.date) ? null : isoDate(r.date); if (date === null) problems.push({ field: 'date', reason: '"' + txt(r.date) + '" is not a date', rule: 'bad_date' }); const c = {}; let total = 0; [['laborCents', 'laborCost', 'labor cost'], ['partsCents', 'partsCost', 'parts cost'], ['materialsCents', 'materialsCost', 'materials cost'], ['subletCents', 'subletCost', 'sublet cost']].forEach(([k, f, w]) => { const v = r[f]; if (blank(v)) { c[k] = 0; return; } const x = isFormula(v) ? NaN : toCents(v); if (x === 'decimals' || Number.isNaN(x) || Math.abs(x) > MAX_CENTS) { problems.push({ field: w, reason: '"' + txt(v).slice(0, 30) + '" is not an amount (plain number, two decimals)', rule: 'unreadable_money' }); c[k] = 0; return; } c[k] = x; total += x; }); const sw = txt(r.status).toLowerCase(); const status = /void/.test(sw) ? 'void' : /clos|invoic|done|deliver/.test(sw) ? 'closed' : 'open'; return Object.assign({ roNo, date: date || '', custNum: custNumCell(r.custNum), status, totalCents: total, problems }, c); } return { CALC_VERSION, MAX_CENTS, toCents, centsLoose, isFormula, FORMULA_WHY, dollars, fmt, intOf, isoDate, usOf, acctKey, blank, txt, up, FLAGS, FLAG_WORDS, PAYER_WORDS, MONEY_KEYS, TEXT_KEYS, RO_NO_OK, isVoidRow, normRoRow, normRoLine, roFingerprint, normPartRow, linesKey, SCHEDULES, INV_RANGE, scheduleMap, acctKind, KIND_WORDS, typeWord, conversionProblem, conversionSuggest, CONVERSION_RE, cmpAcct, unitOnLot, unitSchedule, unitCost, unitCostCents, unitAcct, partValue, partValueCents, partAcct, laterEntryOf, stockPlace, adjustLines, atCostLines, adjustId, atCostId, RESERVED_RE, wordsFor, // RC30 (imp2) RULES, CUST_CODE_WORDS, isCustNum7, partName, custNumCell, phoneDigits, rowPhone, ruleOfReason, ruleOfWarning, rowRules, glRowsCheck, TIE_KINDS, tieKindOf, tieOutDefaults, tieOutFromSheet, tieOutCauses, // RC33 (svi) INFO_RE, svcCustNum, splitName, nameKeyOf, CUST_FIELD_KEYS, roCustFields, hasCustName, AGING_BUCKETS, agingRow, CLAIM_STATUS, claimRow, bodyJobRow }; });