/* © 2026 K2B NETWORK SOLUTIONS LLC. All rights reserved. */ /* eslint-disable */ // ImportCore — the ONE import path (logic only; the screens are in UploadTool). // // destination → file → column mapping → validation + dry run → explicit commit // // DMS.importDests() destinations + their fields / match rules // DMS.importGuessMapping(dest, headers, rows) { mapping, conf, why, suggest } // DMS.importTemplateFor(dest, headers) saved template whose columns match // DMS.importSaveTemplate({ name, source, dest, headers, mapping }) // DMS.importPlan(dest, rows, mapping, opts) dry run — NOTHING is written // DMS.importCommit(dest, plan, meta) writes exactly the previewed plan + history // DMS.importErrorsCsv(planOrHistory) downloadable error list // // Rules: nothing is saved before importCommit; updates never blank existing // values unless opts.clearBlanks; columns the user skips are never imported but // their values are kept in the import-history record; journal entries post only // through DMS.postJournalEntry (balanced, open period); check-register rows are // register records only — no GL is posted for them. // // Old-DMS ("old reports") destinations — added with the Old reports packet: // glTrial trial balance → ONE balanced opening-balances entry (OB-yyyymmdd) // schedule aged schedules 261 / 220 / 220A → factory receivables / A/R accounts (records only) // deals closed-deal history from old recaps (records only, flagged imported) // + glChart (alter # / D-C / cost + inventory account), glJournal (control #), // inventory (vehicle-master columns). Their plan options (opts.asOf, // periodMode, addMissingAccounts, glAcct, append) are listed in // DMS.importDest(key).optionDefs; a plan may carry .blockers (nothing can be // committed while any exist), .summary and .warnings. // DMS.importUndo(historyId) removes what an unedited schedule / deals import // added, or reverses the trial-balance entry. (function () { const D = window.DMS; if (!D || !D._mutate) return; const SKIP = "__skip"; const THRESH = 0.75; // below this a column needs the user's choice const MAX_KEEP = 5000; // unmapped values kept per column in history // ── small helpers ───────────────────────────────────────────────────── const nh = (h) => String(h == null ? "" : h).toLowerCase().replace(/[^a-z0-9]/g, ""); const txt = (v) => (v == null ? "" : String(v).trim()); const up = (v) => txt(v).toUpperCase(); const blank = (v) => v == null || (typeof v === "number" ? isNaN(v) : String(v).trim() === ""); const pad = (n) => String(n).padStart(2, "0"); const R2 = (n) => +(+n || 0).toFixed(2); const $ = (n) => ((+n || 0) < 0 ? "-$" : "$") + Math.abs(+n || 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); // "$1,234.50" / "(500)" / "500-" → number; blank → null; anything else → NaN const money = (v) => { const raw = txt(v); if (!raw) return null; let s = raw.replace(/[$,\s]/g, "").replace(/usd$/i, ""); const neg = /^\(.*\)$/.test(s) || /-$/.test(s); s = s.replace(/[()]/g, "").replace(/-$/, ""); if (!/^-?\d+(\.\d+)?$/.test(s)) return NaN; const n = R2(parseFloat(s)); return neg ? -Math.abs(n) : n; }; // 9/15/26 · 09/15/2026 · 2026-09-15 · 20260915 → local Date; blank → null; bad → NaN const parseDate = (v) => { const s = txt(v); if (!s) return null; let m, y, mo, d; if ((m = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T ].*)?$/))) { y = +m[1]; mo = +m[2]; d = +m[3]; } else if ((m = s.match(/^(\d{1,2})[\/\-.](\d{1,2})[\/\-.](\d{2}|\d{4})$/))) { mo = +m[1]; d = +m[2]; y = +m[3]; if (y < 100) y += y < 70 ? 2000 : 1900; } else if ((m = s.match(/^(\d{4})(\d{2})(\d{2})$/))) { y = +m[1]; mo = +m[2]; d = +m[3]; } else return NaN; const dt = new Date(y, mo - 1, d, 12); if (dt.getFullYear() !== y || dt.getMonth() !== mo - 1 || dt.getDate() !== d || y < 1900 || y > 2100) return NaN; return dt; }; const isBadDate = (d) => typeof d === "number" && isNaN(d); const mdy = (dt) => `${pad(dt.getMonth() + 1)}/${pad(dt.getDate())}/${dt.getFullYear()}`; const perOf = (dt) => `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}`; const phone10 = (v) => { let d = txt(v).replace(/\D/g, ""); if (d.length === 11 && d[0] === "1") d = d.slice(1); return d; }; const fmtPhone = (d) => (d && d.length === 10 ? `(${d.slice(0, 3)}) ${d.slice(3, 6)}-${d.slice(6)}` : d || ""); const EMAIL = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; const yes = (v) => /^(y|yes|true|1|x)$/i.test(txt(v)); const acctKey = (v) => up(v).replace(/\s+/g, ""); // decimal number: "29.99" · "29.99%" · "1,234.5"; blank → null; anything else → NaN const num = (v) => { const raw = txt(v); if (!raw) return null; const s = raw.replace(/[%,\s]/g, ""); return /^-?\d+(\.\d+)?$/.test(s) ? parseFloat(s) : NaN; }; // whole number: "72" · "1,200" · "72.0"; blank → null; anything else → NaN const intOf = (v) => { const raw = txt(v).replace(/,/g, ""); if (!raw) return null; return /^-?\d+(\.0+)?$/.test(raw) ? parseInt(raw, 10) : NaN; }; const cents = (n) => Math.round((+n || 0) * 100); const ymd = (dt) => `${dt.getFullYear()}${pad(dt.getMonth() + 1)}${pad(dt.getDate())}`; const isoNoon = (dt) => new Date(dt.getFullYear(), dt.getMonth(), dt.getDate(), 12).toISOString(); const addDays = (dt, n) => new Date(dt.getFullYear(), dt.getMonth(), dt.getDate() + n, 12); const daysBetween = (a, b) => Math.round((new Date(b.getFullYear(), b.getMonth(), b.getDate()) - new Date(a.getFullYear(), a.getMonth(), a.getDate())) / 86400000); // an as-of value from the UI: Date · "2026-09-30" · "09/30/2026" → local Date, else null const asDate = (v) => { if (v instanceof Date) return isNaN(v) ? null : new Date(v.getFullYear(), v.getMonth(), v.getDate(), 12); const d = parseDate(v); return d && !isBadDate(d) ? d : null; }; const MONTH_NAMES = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]; // The first real calendar date written in a text (file name, sheet name, report heading): // 2026-09-30 · 09-30-2026 · 9.30.26 · 20260930 · "Sep 30, 2026". Null when there is none. const dateInText = (text) => { const t = String(text == null ? "" : text); const ok = (y, m, d) => { const dt = new Date(y, m - 1, d, 12); return dt.getFullYear() === y && dt.getMonth() === m - 1 && dt.getDate() === d && y >= 1990 && y <= 2100 ? dt : null; }; let m; const tries = [ () => (m = t.match(/(?:^|[^0-9])(\d{4})[-_.\/](\d{1,2})[-_.\/](\d{1,2})(?![0-9])/)) && ok(+m[1], +m[2], +m[3]), () => (m = t.match(/(?:^|[^0-9])(\d{1,2})[-_.\/](\d{1,2})[-_.\/](\d{4})(?![0-9])/)) && ok(+m[3], +m[1], +m[2]), () => (m = t.match(/(?:^|[^0-9])(\d{1,2})[-_.\/](\d{1,2})[-_.\/](\d{2})(?![0-9])/)) && ok(2000 + +m[3], +m[1], +m[2]), () => (m = t.match(/(?:^|[^0-9])(20\d{2})(\d{2})(\d{2})(?![0-9])/)) && ok(+m[1], +m[2], +m[3]), () => (m = t.match(/\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?[\s_\-]+(\d{1,2})(?:st|nd|rd|th)?,?[\s_\-]+(\d{4})\b/i)) && ok(+m[3], MONTH_NAMES.indexOf(m[1].toLowerCase()) + 1, +m[2]), ]; for (const f of tries) { const r = f(); if (r) return r; } return null; }; // …but for report headings only a date introduced by "as of / through / ending" counts (a "printed" date is not the as-of date) const asOfInHeading = (lines) => { const arr = Array.isArray(lines) ? lines : [lines]; for (const l of arr) { const t = String(l == null ? "" : l); const m = t.match(/(?:as\s*of|through|thru|ending|period\s*end(?:ing|ed)?|balance\s*date)\s*[:\-]?\s*(.{0,24})/i); if (m) { const d = dateInText(m[1]); if (d) return d; } } return null; }; // Effective chart of accounts: the shared DMS chart, else the Accounting // working chart (the built-in chart in a store whose chart is built in / the GM library) it falls back to. const chart = () => { const live = (D.glAccounts && D.glAccounts()) || []; if (live.length) return live; return (window.GL_ACCOUNTS && window.GL_ACCOUNTS.length) ? window.GL_ACCOUNTS : []; }; const chartSet = () => { const s = new Set(chart().map(a => acctKey(a.acct || a.account || a.no))); Object.values(D._ACCT || {}).forEach(a => s.add(acctKey(a))); return s; }; // ── destinations + fields ───────────────────────────────────────────── // type: text | money | date | phone | email | acct | int | bool const F = (key, label, syn, o) => ({ key, label, syn: syn || [], type: "text", ...(o || {}) }); const DESTS = { glChart: { key: "glChart", group: "gl", label: "General Ledger — Chart of accounts", short: "Chart of accounts", unit: "account", perm: "glEdit", match: "Existing accounts are matched by account #.", fields: [ F("acct", "Account #", ["account", "account no", "account number", "acct", "acct no", "acct #", "account #", "gl account", "gl acct", "gl #", "gl no"], { type: "acct" }), F("name", "Account name", ["name", "account name", "acct name", "description", "desc", "title", "account description"]), F("type", "Account type", ["type", "account type", "acct type", "classification", "class", "category"]), F("group", "Group / department", ["group", "department", "dept", "section", "sub type", "subtype"]), F("sched", "Scheduled account (Y/N)", ["schedule", "sched", "scheduled", "schedule y n"], { type: "bool" }), F("bal", "Balance (reference only — NOT posted)", ["balance", "ending balance", "current balance", "ytd balance", "amount"], { type: "money" }), // as printed on the old DMS chart of accounts F("alt", "Alter number", ["alter", "alter number", "alter no", "alter #", "alt", "alt no", "alt #", "alt number", "alternate", "alternate number", "alternate account"]), // NOTE: the old print's D/C column shows Y / N — it is NOT the debit / credit normal balance. F("dc", "D/C flag (as printed)", ["d/c", "dc", "d c", "d/c flag", "dc flag", "d/c flag (as printed)"]), F("costAcct", "Cost account", ["cost acct", "cost account", "cost account #", "cost acct #", "cos acct", "cos account", "cost of sales account", "cost", "cost no"], { type: "acct" }), F("invAcct", "Inventory account", ["inventory acct", "inventory account", "inventory account #", "inv acct", "inv account", "inv acct #", "invent", "invent acct", "inventory", "inv"], { type: "acct" }), ], required: [["acct"], ["name"]], }, glJournal: { key: "glJournal", group: "gl", label: "General Ledger — Journal entries", short: "Journal entries", unit: "journal entry", perm: "glEdit", match: "Lines are grouped into entries by reference. An entry whose reference is already posted is skipped (same lines) or rejected (different lines) — posted entries are never changed by an import.", fields: [ F("ref", "Entry reference (groups lines)", ["reference", "ref", "ref #", "ref no", "je", "je no", "je #", "je number", "entry", "entry no", "entry #", "entry number", "journal no", "journal entry", "doc", "doc no", "document", "document no"]), F("date", "Entry date", ["date", "entry date", "posting date", "post date", "gl date", "trans date", "transaction date"], { type: "date" }), F("acct", "GL account #", ["account", "acct", "account no", "account number", "acct no", "acct #", "account #", "gl account", "gl acct", "gl"], { type: "acct" }), F("dr", "Debit", ["debit", "dr", "debit amount", "debits"], { type: "money" }), F("cr", "Credit", ["credit", "cr", "credit amount", "credits"], { type: "money" }), F("amount", "Signed amount (+ debit / − credit)", ["amount", "net", "signed amount", "net amount"], { type: "money" }), F("desc", "Line description", ["description", "desc", "memo", "line description", "detail", "line memo", "explanation"]), F("entryDesc", "Entry description", ["entry description", "je description", "header description", "journal description"]), F("jrn", "Journal #", ["journal", "jrn", "journal code", "jnl", "journal #", "source journal"], { type: "int" }), F("control", "Control # (stock / deal / customer)", ["control", "control #", "control no", "control number", "ctl", "ctrl", "ctl #", "control id"]), F("controlName", "Control description (reference)", ["control name", "control description", "control desc", "ctl name", "ctl description", "control (name)"]), ], required: [["ref"], ["date"], ["acct"], ["dr", "cr", "amount"]], }, inventory: { key: "inventory", group: "inventory", label: "Inventory — vehicles", short: "Inventory", unit: "unit", perm: "inventory", match: "Existing units are matched by stock #, then VIN. A unit listed more than once with identical data (e.g. the same vehicle report printed in several sort orders) counts once. No journal entry is ever posted for an inventory import.", get fields() { const JI = window.JBImporters; return ((JI && JI.INV_FIELDS) || []).map(f => ({ key: f.key, label: f.label, syn: f.syn, type: ({ asking: "money", msrp: "money", cost: "money", miles: "int", invoiceCost: "money", repairs: "money", glCost: "money", pack: "money", age: "int", invoiceDate: "date", invAcct: "acct" })[f.key] || "text" })); }, required: [["stock", "vin"]], }, customers: { // RC30 (cn): the customer # is the phone WITHOUT the area code (7 digits; CustNum.jsx.txt == the server's rule). A row with a phone is // numbered from it; a row with a 7-digit customer_number and no phone keeps that number (the old account); a row with neither gets a number // K2B issues at import time (async: true → importCommitAsync asks the server for them). The same number already on file: the same full // phone = one household (the name is added to the account through the server); another area code = reported for the office (the CRM asks). key: "customers", group: "customers", label: "Customers", short: "Customers", unit: "customer", perm: "sales", async: true, match: "Existing customers are matched by Customer # (the phone without the area code), then email.", fields: [ F("custNum", "Customer # (7 digits — the phone without the area code)", ["customer_number", "customer no", "customer number", "customer #", "cust no", "cust #", "cust num", "custnum", "cust number", "account", "account no", "account number", "acct", "acct no"], { type: "custnum" }), F("phone", "Phone (10 digits; the last 7 become the customer # when the file has none)", ["phone", "phone number", "phone #", "mobile", "mobile phone", "cell", "cell phone", "home phone", "home_phone", "telephone", "tel", "primary phone", "phone 1"], { type: "phone" }), F("workPhone", "Work phone", ["work phone", "work_phone", "business phone", "office phone", "phone 2", "alt phone", "other phone"], { type: "phone" }), F("fullName", "Full name (split into first / last)", ["name", "full name", "customer name", "customer", "buyer", "buyer name"]), F("firstName", "First name", ["first", "first name", "fname", "given name", "firstname"]), F("lastName", "Last name", ["last", "last name", "lname", "surname", "family name", "lastname"]), F("email", "Email", ["email", "e-mail", "email address", "e mail", "mail"], { type: "email" }), F("street", "Street address", ["address", "street", "address 1", "address1", "addr", "street address", "address line 1", "home address"]), F("city", "City", ["city", "town"]), F("state", "State", ["state", "st", "province", "state code"]), F("postal", "ZIP", ["zip", "zip code", "zipcode", "postal", "postal code", "postcode"]), F("dlNumber", "Driver license #", ["dl", "dl #", "dl no", "dl number", "drivers license", "driver license", "license", "license no", "license number", "dlnumber"]), F("dob", "Date of birth", ["dob", "birth date", "birthdate", "date of birth", "birthday"], { type: "date" }), // RC26 (inv) fix round (audit F9): the old system's own customer number — kept so an old deal that names its buyer by it finds this customer F("oldCustNo", "Old system customer id (the export's C-number)", ["old customer #", "old customer no", "old customer number", "old cust #", "old system customer #", "previous customer #", "legacy customer #", "customer id", "customer_id", "cust id"]), ], required: [["fullName", "firstName", "lastName"]], }, vendors: { key: "vendors", group: "vendors", label: "Vendors", short: "Vendors", unit: "vendor", perm: "acct", match: "Existing vendors are matched by vendor #, then vendor name.", fields: [ F("vendorNo", "Vendor #", ["vendor no", "vendor #", "vendor number", "vendor id", "vendor code", "vend no", "vend #", "vendorno", "supplier no", "supplier #"]), F("name", "Vendor name", ["vendor", "vendor name", "name", "company", "company name", "payee", "payee name", "supplier", "supplier name"]), F("contact", "Contact", ["contact", "contact name", "attn", "attention", "rep"]), F("phone", "Phone", ["phone", "phone number", "telephone", "tel", "vendor phone", "contact phone"]), F("email", "Email", ["email", "e-mail", "email address"], { type: "email" }), F("address", "Address", ["address", "street", "address 1", "mailing address", "remit to", "remit address"]), F("acctNo", "Our account # with the vendor", ["account no", "acct no", "account number", "our account", "customer no", "our acct", "account #"]), F("terms", "Terms", ["terms", "payment terms", "pay terms"]), F("defaultAcct", "Default GL account", ["gl account", "default account", "default gl", "expense account", "gl acct", "gl", "default gl account"], { type: "acct" }), F("creditLimit", "Credit limit", ["credit limit", "limit", "credit line"], { type: "money" }), F("type", "Vendor type", ["type", "vendor type", "category", "class"]), F("notes", "Notes", ["notes", "note", "memo", "comments", "comment"]), F("oldBalance", "Old balance owed (reference — not posted; the review ties it to A/P)", ["old balance", "balance", "balance owed", "open balance", "amount owed", "a/p balance", "ap balance", "vendor balance"], { type: "money" }), // RC36 (imp36) ], required: [["name"]], }, checks: { key: "checks", group: "checks", label: "Check register", short: "Check register", unit: "check", perm: "acct", match: "Existing checks are matched by check # + bank account. Imported checks are register records only — no GL entry is posted.", fields: [ F("num", "Check #", ["check no", "check #", "check number", "check", "chk no", "chk #", "chk", "check num", "cheque no", "number"]), F("date", "Check date", ["date", "check date", "issue date", "issued", "date written"], { type: "date" }), F("payee", "Payee (vendor)", ["payee", "pay to", "paid to", "pay to the order of", "vendor", "vendor name", "name"]), F("amount", "Amount", ["amount", "check amount", "amt", "total", "payment amount"], { type: "money" }), F("bankAcct", "Bank GL account", ["bank account", "bank acct", "cash account", "bank gl", "bank", "bank gl account", "cash acct"], { type: "acct" }), F("acct", "Debit (expense / payable) GL account", ["gl account", "expense account", "account", "acct", "debit account", "distribution", "gl acct", "expense acct"], { type: "acct" }), F("memo", "Memo", ["memo", "description", "desc", "notes", "for"]), F("inv", "Invoice #", ["invoice", "invoice no", "invoice #", "inv #", "inv no", "invoice number"]), F("status", "Status", ["status", "cleared", "state", "check status"]), F("vendorNo", "Vendor #", ["vendor no", "vendor #", "vendor number", "vendor id", "vendor code"]), ], required: [["num"], ["date"], ["payee"], ["amount"]], }, // ── Old-DMS ("old reports") destinations ───────────────────────────── glTrial: { key: "glTrial", blanksNeverClear: true, group: "gl", label: "General Ledger — Trial balance (opening balances)", short: "Trial balance", unit: "account line", perm: "glEdit", match: "One balanced opening-balances entry is posted from the YEAR-TO-DATE column (+ debit / − credit), dated the report's as-of date. Subtotal rows (Account Total / Group … Total / Total) are never posted — they are only used to check the file. The month-to-date column is kept for reference only.", optionDefs: [ { key: "asOf", type: "date", label: "As-of date of the trial balance", required: true, hint: "The date the old report is as of (e.g. 09/30/2026) — not the date it was printed." }, { key: "periodMode", type: "select", label: "Which month the entry is dated in", default: "asOf", options: [["asOf", "The as-of date's own month (reopen it first if it is closed)"], ["openMonth", "The open month — dated the first day of it"]] }, { key: "addMissingAccounts", type: "bool", label: "Add accounts that are missing from the chart (name taken from the report)", default: false }, ], fields: [ F("acct", "Account #", ["account", "account no", "account number", "acct", "acct no", "acct #", "account #", "gl account", "gl acct", "gl #", "gl no", "account num", "acct number"], { type: "acct" }), F("name", "Account description", ["description", "desc", "account name", "name", "account description", "acct description", "acct name", "title"]), F("mtd", "Month to date", ["mtd", "mtd amount", "mtd amt", "month to date", "month-to-date", "mtd balance", "month amount", "current month", "period amount", "mtd total"], { type: "money" }), F("ytd", "Year to date", ["ytd", "ytd amount", "ytd amt", "year to date", "year-to-date", "ytd balance", "ytd total", "balance", "ending balance", "closing balance", "year amount"], { type: "money" }), F("mtdCount", "MTD count", ["mtd count", "mtd cnt", "mtd units", "mtd #", "month count", "month to date count", "mtd quantity", "mtd qty"], { type: "int" }), F("ytdCount", "YTD count", ["ytd count", "ytd cnt", "ytd units", "ytd #", "year count", "year to date count", "ytd quantity", "ytd qty"], { type: "int" }), F("rowKind", "Row kind (account / subtotal)", ["row kind", "rowkind", "kind", "row type", "line type", "record type"]), ], required: [["acct"], ["ytd"]], }, schedule: { key: "schedule", blanksNeverClear: true, group: "gl", label: "Aged schedule balances (sub-ledger detail)", short: "Aged schedule", unit: "schedule line", perm: "acct", match: "Schedule 261 (factory receivable) fills the Factory receivable list; schedules 220 and 220A (customer charge accounts) fill the A/R customer accounts, matched by control #. Rows are sub-ledger detail only — NOTHING is posted to the General Ledger (the totals come from the trial balance). Any other schedule is not supported here: post it as a journal entry.", optionDefs: [ { key: "asOf", type: "date", label: "As-of date of the schedule", required: true, hint: "Days are counted back from this date." }, { key: "glAcct", type: "text", label: "Schedule (GL account) when the file has no such column", options: [["261", "261 — Factory receivable"], ["220", "220 — A/R service & parts"], ["220A", "220A — Charge accounts 180 days"]] }, { key: "append", type: "bool", label: "Add to rows already imported for this schedule and date (several files, one schedule)", default: false }, ], fields: [ F("control", "Control # (customer account / stock)", ["control", "control #", "control no", "control number", "control id", "ctl", "ctrl", "ctl #", "customer account", "customer acct", "customer #", "customer no", "customer number", "cust #", "cust no", "a/r account", "ar account", "stock", "stock #"]), F("name", "Description", ["description", "desc", "name", "customer name", "customer", "account name", "control name", "control description", "ctl name"]), F("days", "Days", ["days", "age", "age days", "days old", "days aged", "aging days", "days outstanding", "days past", "days past due"], { type: "int" }), F("balance", "Balance", ["balance", "amount", "amt", "open balance", "net amount", "total", "ending balance", "schedule amount"], { type: "money" }), F("glAcct", "Schedule / GL account (261, 220, 220A)", ["gl account", "gl acct", "gl", "schedule", "schedule #", "schedule no", "schedule number", "schedule account", "account", "acct", "gl account #"], { type: "acct" }), F("asOf", "As-of date", ["as of", "as of date", "asof", "as-of", "as-of date", "aging date", "report date", "date"], { type: "date" }), ], required: [["control"], ["balance"]], }, deals: { key: "deals", blanksNeverClear: true, group: "deals", label: "Deals — closed-deal history (old recaps)", short: "Deal history", unit: "deal", perm: "desk", match: "Deals are matched by deal # exactly as printed. A deal already imported is updated only where the file differs (a blank never erases anything); a deal that was started in K2B is never touched. Imported deals are history: they are NOT posted again, are not in the funding or month-end queues, pay no commission and notify nobody — the accounting comes from the trial balance.", fields: [ F("dealNo", "Deal #", ["deal", "deal #", "deal no", "deal number", "deal num", "deal id", "recap", "recap #", "recap no"]), F("invNo", "Invoice #", ["invoice", "invoice #", "invoice no", "invoice number", "inv #", "inv no", "inv num"]), F("date", "Delivery date", ["date", "delivery date", "delivered", "sale date", "sold date", "deal date", "contract date", "date sold", "date delivered"], { type: "date" }), F("lender", "Lender", ["lender", "bank", "finance company", "financed by", "lienholder", "lien holder", "finance source", "lender code", "lender id", "bank code"]), F("fiMgr", "F&I manager", ["f&i manager", "fi manager", "fi mgr", "f&i mgr", "f&i", "finance manager", "business manager", "fi no", "f&i no"]), F("salesperson", "Salesperson", ["salesperson", "sales person", "sales rep", "salesman", "sales", "sp", "sp name", "salesperson name", "sales person name", "salesperson code", "salesperson1 code", "sp code", "salesman code"]), F("buyer", "Buyer", ["buyer", "buyer name", "customer", "customer name", "purchaser", "name", "buyers name"]), F("cobuyer", "Co-buyer", ["co-buyer", "co buyer", "cobuyer", "co-buyer name", "co buyer name", "coapplicant", "co-applicant"]), F("street", "Street", ["street", "address", "street address", "address 1", "addr", "buyer address"]), F("city", "City", ["city", "town", "buyer city"]), F("state", "State", ["state", "st", "province", "buyer state"]), F("postal", "ZIP", ["zip", "zip code", "zipcode", "postal", "postal code", "buyer zip"]), F("phone", "Phone", ["phone", "phone number", "telephone", "tel", "home phone", "cell", "mobile", "buyer phone", "buyer_home_phone"], { type: "phone" }), F("custNum", "Customer # (7 digits — the phone without the area code)", ["customer_number", "customer #", "customer no", "customer number", "cust #", "cust no", "custnum", "buyer customer #", "account", "account no"], { type: "custnum" }), // RC30 (cn) F("oldCustNo", "Buyer's old system customer id (the export's C-number)", ["customer id", "customer_id", "cust id", "buyer customer id", "old customer #"]), // RC26 (inv) fix round (audit F9) F("stock", "Stock # sold", ["stock", "stock #", "stock no", "stock number", "stk", "stk #", "unit", "unit #"]), F("vin", "VIN sold", ["vin", "vin #", "vin no", "vin number", "serial", "serial #"]), F("vehicle", "Vehicle sold (description)", ["vehicle", "vehicle sold", "vehicle description", "description", "desc", "unit description", "year make model", "ymm"]), F("miles", "Miles", ["miles", "mileage", "odometer", "odo", "miles sold"], { type: "int" }), F("type", "New / Used", ["n/u", "nu", "new/used", "new used", "new or used", "type", "deal type", "new / used", "vehicle type"]), F("tradeStock", "Trade stock #", ["trade stock", "trade stock #", "trade stock no", "trade-in stock", "trade in stock", "trade stk", "trade1 stock number", "trade stock number"]), F("tradeDesc", "Trade description", ["trade", "trade description", "trade desc", "trade vehicle", "trade-in", "trade in", "trade in description", "trade ymm"]), F("tradeVin", "Trade VIN", ["trade vin", "trade-in vin", "trade in vin", "trade serial", "trade1 vin"]), F("tradeMiles", "Trade miles", ["trade miles", "trade mileage", "trade odometer", "trade-in miles", "trade in miles", "trade1 odometer"], { type: "int" }), F("invoicePrice", "Invoice price", ["invoice price", "invoice", "vehicle invoice", "invoice cost", "inv price", "vehicle cost", "unit cost"], { type: "money" }), F("holdback", "Holdback", ["holdback", "hold back", "holdback amount", "hb"], { type: "money" }), F("pack", "Pack", ["pack", "pack amount", "dealer pack"], { type: "money" }), F("add1Label", "Add 1 label", ["add 1 label", "add1 label", "add 1 desc", "add 1 description", "add on 1 label", "addition 1 label", "add1 desc"]), F("add1Amt", "Add 1 amount", ["add 1 amount", "add1 amount", "add 1", "add1", "add 1 amt", "add on 1", "addition 1", "add1 amt"], { type: "money" }), F("add2Label", "Add 2 label", ["add 2 label", "add2 label", "add 2 desc", "add 2 description", "add on 2 label", "addition 2 label", "add2 desc"]), F("add2Amt", "Add 2 amount", ["add 2 amount", "add2 amount", "add 2", "add2", "add 2 amt", "add on 2", "addition 2", "add2 amt"], { type: "money" }), F("add3Label", "Add 3 label", ["add 3 label", "add3 label", "add 3 desc", "add 3 description", "add on 3 label", "addition 3 label", "add3 desc"]), F("add3Amt", "Add 3 amount", ["add 3 amount", "add3 amount", "add 3", "add3", "add 3 amt", "add on 3", "addition 3", "add3 amt"], { type: "money" }), F("add4Label", "Add 4 label", ["add 4 label", "add4 label", "add 4 desc", "add 4 description", "add on 4 label", "addition 4 label", "add4 desc"]), F("add4Amt", "Add 4 amount", ["add 4 amount", "add4 amount", "add 4", "add4", "add 4 amt", "add on 4", "addition 4", "add4 amt"], { type: "money" }), F("sfd", "Special finance discount", ["special finance discount", "sfd", "special finance", "spec fin discount", "finance discount", "special finance disc"], { type: "money" }), F("glRepairs", "GL repairs", ["gl repairs", "gl repair", "repairs gl", "gl recon", "repairs per gl", "gl repair cost"], { type: "money" }), F("totalRepairs", "Total repairs", ["total repairs", "repairs", "total repair", "repair total", "recon", "total recon"], { type: "money" }), F("totalCost", "Total cost", ["total cost", "cost", "vehicle total cost", "total vehicle cost", "net cost"], { type: "money" }), F("salePrice", "Selling price", ["selling price", "sale price", "sales price", "price", "sell price", "cash price", "vehicle price", "retail price"], { type: "money" }), F("cashReceived", "Cash received", ["cash received", "cash down", "down payment", "down", "cash down payment", "cash recd", "deposit"], { type: "money" }), F("cashStated", "Cash (stated)", ["cash stated", "stated cash", "cash (stated)", "cash as stated", "cash stated on contract"], { type: "money" }), F("tradeAcv", "Trade ACV", ["trade acv", "acv", "trade actual cash value", "trade-in acv", "trade in acv", "trade value acv"], { type: "money" }), F("tradeAllow", "Trade allowance", ["trade allowance", "allowance", "trade allow", "trade-in allowance", "trade in allowance", "trade value"], { type: "money" }), F("trade2Acv", "Trade 2 ACV", ["trade 2 acv", "trade2 acv", "second trade acv", "2nd trade acv"], { type: "money" }), F("trade2Allow", "Trade 2 allowance", ["trade 2 allowance", "trade2 allowance", "trade 2 allow", "second trade allowance", "2nd trade allowance"], { type: "money" }), F("grossProfit", "Gross profit", ["gross profit", "front gross", "front end gross", "gross", "front profit", "vehicle gross", "front gross profit"], { type: "money" }), F("contractRate", "Contract rate", ["contract rate", "apr", "rate", "finance rate", "interest rate", "contract apr", "contract %"], { type: "num" }), F("buyRate", "Buy rate", ["buy rate", "buyrate", "bank buy rate", "lender buy rate", "buy %"], { type: "num" }), F("reserve", "Reserve", ["reserve", "finance reserve", "dealer reserve", "fi reserve", "participation", "finance income"], { type: "money" }), F("clPrem", "Credit life premium", ["credit life premium", "cl premium", "cl prem", "credit life sale", "cl sale", "credit life"], { type: "money" }), F("clCost", "Credit life cost", ["credit life cost", "cl cost"], { type: "money" }), F("ahPrem", "A&H premium", ["a&h premium", "ah premium", "ah prem", "accident health premium", "a&h sale", "ah sale", "a&h"], { type: "money" }), F("ahCost", "A&H cost", ["a&h cost", "ah cost", "accident health cost"], { type: "money" }), F("vscSale", "VSC sale", ["vsc sale", "vsc", "service contract", "service contract sale", "extended warranty", "warranty sale", "vsc price", "vsc premium"], { type: "money" }), F("vscCost", "VSC cost", ["vsc cost", "service contract cost", "extended warranty cost", "warranty cost"], { type: "money" }), F("gapSale", "GAP sale", ["gap sale", "gap", "gap price", "gap premium", "gap insurance", "gap insurance sale"], { type: "money" }), F("gapCost", "GAP cost", ["gap cost", "gap insurance cost"], { type: "money" }), F("miscFi", "Misc F&I", ["misc f&i", "misc fi", "other f&i", "other fi", "misc f&i income", "f&i other"], { type: "money" }), F("fiProfit", "F&I profit", ["f&i profit", "fi profit", "f&i gross", "fi gross", "back gross", "back end gross", "finance profit", "total f&i", "f&i total"], { type: "money" }), F("holdbackProfit", "Holdback profit", ["holdback profit", "hb profit", "holdback income"], { type: "money" }), F("miscDealer", "Misc dealer income", ["misc dealer", "misc dealer income", "other dealer income", "misc dealer profit", "dealer misc"], { type: "money" }), F("totalDealerProfit", "Total dealer profit", ["total dealer profit", "dealer profit", "total profit", "total gross", "total dealership profit", "dealer gross"], { type: "money" }), F("comm1Name", "Commission 1 name", ["commission 1 name", "comm 1 name", "comm1 name", "commission 1 salesperson", "comm 1"]), F("comm1Pct", "Commission 1 %", ["commission 1 %", "comm 1 %", "comm1 pct", "commission 1 pct", "commission 1 percent", "comm 1 pct", "comm1 %"], { type: "num" }), F("comm1Flat", "Commission 1 flat", ["commission 1 flat", "comm 1 flat", "comm1 flat", "commission 1 flat amount"], { type: "money" }), F("comm1Amt", "Commission 1 amount", ["commission 1 amount", "comm 1 amount", "comm1 amount", "commission 1", "comm1 amt", "comm 1 amt"], { type: "money" }), F("comm2Name", "Commission 2 name", ["commission 2 name", "comm 2 name", "comm2 name", "commission 2 salesperson", "comm 2"]), F("comm2Pct", "Commission 2 %", ["commission 2 %", "comm 2 %", "comm2 pct", "commission 2 pct", "commission 2 percent", "comm 2 pct", "comm2 %"], { type: "num" }), F("comm2Flat", "Commission 2 flat", ["commission 2 flat", "comm 2 flat", "comm2 flat", "commission 2 flat amount"], { type: "money" }), F("comm2Amt", "Commission 2 amount", ["commission 2 amount", "comm 2 amount", "comm2 amount", "commission 2", "comm2 amt", "comm 2 amt"], { type: "money" }), F("comm3Name", "Commission 3 name (F&I manager)", ["commission 3 name", "comm 3 name", "comm3 name", "commission 3 name (f&i manager)", "f&i commission name", "fi commission name", "comm 3"]), F("comm3Pct", "Commission 3 % (F&I)", ["commission 3 %", "comm 3 %", "comm3 pct", "commission 3 pct", "commission 3 percent", "comm 3 pct", "comm3 %", "commission 3 % (f&i)"], { type: "num" }), F("comm3Flat", "Commission 3 flat (F&I)", ["commission 3 flat", "comm 3 flat", "comm3 flat", "commission 3 flat amount", "commission 3 flat (f&i)"], { type: "money" }), F("comm3Amt", "Commission 3 amount (F&I)", ["commission 3 amount", "comm 3 amount", "comm3 amount", "commission 3", "comm3 amt", "comm 3 amt", "commission 3 amount (f&i)"], { type: "money" }), F("netNet", "Net / net", ["net/net", "net net", "netnet", "net to dealer", "net profit", "net after commission", "net / net"], { type: "money" }), F("amtFinanced", "Amount financed", ["amount financed", "amt financed", "financed", "amount finance", "finance amount", "total financed"], { type: "money" }), F("financeCharge", "Finance charge", ["finance charge", "fin charge", "finance chg", "total finance charge"], { type: "money" }), F("term", "Term (months)", ["term", "term (months)", "term months", "months", "term mo", "no of payments", "number of payments"], { type: "int" }), F("payment", "Payment", ["payment", "monthly payment", "pmt", "payment amount", "monthly pmt"], { type: "money" }), F("payoffLender", "Payoff lender", ["payoff lender", "trade payoff lender", "payoff to", "trade lienholder", "payoff bank", "lien payoff lender", "trade1 lienholder"]), F("payoff1", "Payoff amount", ["payoff", "payoff 1", "payoff amount", "trade payoff", "payoff1", "payoff amt", "lien payoff"], { type: "money" }), // RC36 (imp36): the deal history complete — the old system's (ACS) columns of a deal export, every one read (the owner's 63-column file) F("vehYear", "Vehicle year", ["year", "model year", "veh year", "vehicle year", "yr"], { type: "int" }), F("vehMake", "Vehicle make", ["make", "veh make", "vehicle make"]), F("vehModel", "Vehicle model", ["model", "veh model", "vehicle model"]), F("vehBody", "Vehicle body", ["body", "body style", "veh body"]), F("vehColor", "Vehicle color", ["color", "colour", "veh color", "exterior color"]), F("saleAcct", "Sale GL account (the account the sale was booked to)", ["sale gl account", "sale account", "sales account", "sale gl", "sales gl account", "gl sale account"], { type: "acct" }), F("invAcct", "Inventory GL account (the unit's stock account)", ["inventory gl account", "inventory account", "inv gl account", "stock gl account", "inv account"], { type: "acct" }), F("salesperson2", "Second salesperson", ["salesperson2", "salesperson 2", "salesperson2 code", "second salesperson", "sales person 2", "split salesperson", "sp2"]), F("stockInDate", "Stock-in date", ["stock in date", "stock-in date", "in stock date", "date in stock", "received date", "stocked date"], { type: "date" }), F("purchasedFrom", "Purchased from (where the unit came from)", ["purchased from", "bought from", "acquired from", "purchase source"]), F("county", "Buyer county", ["buyer county", "county"]), F("employer", "Buyer employer", ["buyer employer", "employer", "employer name"]), F("workPhone", "Buyer work phone", ["buyer work phone", "work phone", "business phone"], { type: "phone" }), F("cobuyerRelation", "Co-buyer relation", ["cobuyer relation", "co-buyer relation", "co buyer relation", "relation", "relationship"]), F("salesTax", "Sales tax", ["sales tax", "tax", "sales tax amount", "vehicle sales tax", "tax amount"], { type: "money" }), F("netTrade", "Net trade (allowance − payoff)", ["net trade", "net trade-in", "trade equity", "net trade in"], { type: "money" }), F("totalDown", "Total down (cash + net trade)", ["total down", "total down payment", "down total"], { type: "money" }), F("totalOfPayments", "Total of payments", ["total of payments", "total payments", "tot of payments"], { type: "money" }), F("totalSalePrice", "Total sale price (contract)", ["total sale price", "total sales price", "deferred price", "total price"], { type: "money" }), F("firstPayDate", "First payment date", ["first payment date", "first pay date", "1st payment date", "first due date"], { type: "date" }), F("tradeYear", "Trade year", ["trade1 year", "trade year", "trade-in year", "trade model year"], { type: "int" }), F("tradeMake", "Trade make", ["trade1 make", "trade make", "trade-in make"]), F("tradeModel", "Trade model", ["trade1 model", "trade model", "trade-in model"]), F("tradeBody", "Trade body", ["trade1 body", "trade body"]), F("tradeColor", "Trade color", ["trade1 color", "trade color", "trade colour"]), // the open items (contracts in transit / trade payoffs / sales tax / service contracts): Y = done (funded, paid, remitted) / N = still open; a date = done F("funded", "Funded? (Y / N or the funded date)", ["funded", "funded date", "date funded", "funded y/n", "cit funded", "contract funded"]), F("payoffPaid", "Trade payoff paid? (Y / N or the date)", ["payoff paid", "payoff paid date", "payoff sent", "trade payoff paid", "lien paid"]), F("taxRemitted", "Sales tax remitted? (Y / N or the date)", ["tax remitted", "sales tax remitted", "tax paid", "tax remitted date"]), F("contractRemitted", "Service contract remitted? (Y / N or the date)", ["contract remitted", "service contract remitted", "vsc remitted", "contract paid", "vsc paid"]), // "from the old system — unconfirmed": kept as information only, decides nothing (the export says their meaning is unconfirmed) F("ucDocFee", "Old system: doc fee or pack (unconfirmed — information)", ["unconfirmed doc fee or pack", "x doc fee or pack", "doc fee or pack"], { type: "money" }), F("ucTotalCost", "Old system: total cost (unconfirmed — information)", ["unconfirmed total cost", "x total cost"], { type: "money" }), F("ucTradeAcv", "Old system: trade ACV (unconfirmed — information)", ["unconfirmed trade acv", "x trade acv"], { type: "money" }), F("ucFrontGross", "Old system: front gross (unconfirmed — information)", ["unconfirmed front gross", "x front gross"], { type: "money" }), F("ucBackGross", "Old system: back gross (unconfirmed — information)", ["unconfirmed back gross", "x back gross"], { type: "money" }), F("ucCreated", "Old system: created date (unconfirmed — information)", ["unconfirmed created date", "x created date", "created date", "date created", "created"]), F("ucModified", "Old system: modified date (unconfirmed — information)", ["unconfirmed modified date", "x modified date", "modified date", "date modified", "last modified", "modified"]), ], required: [["dealNo"], ["salePrice"]], }, // ── RC25 (imp): the imports pack ───────────────────────────────────── // Repair-order history from the old system. The rows go to the SERVER in batches (it keeps them in its own history table and // puts only the OPEN ones on the service board); the station never holds 66,000 repair orders. async: true = importCommitAsync. roHistory: { key: "roHistory", blanksNeverClear: true, async: true, group: "service", label: "Service — repair orders (history from the old system)", short: "Repair orders — history", unit: "repair order", perm: "data", match: "Repair orders are matched by the old RO number exactly as printed. A re-import updates only what the file changed (a blank never erases anything); a repair order started in K2B is never touched; a VOID row becomes voided history. Closed / held / warranty ROs are history (their money is in the old books — NO journal entry is posted); OPEN ROs go on the service board. The job lines come from an RO_Lines sheet in the same workbook, a separate lines file, or a lines column.", optionDefs: [], fields: [ F("roNo", "RO # (the old number)", ["record_id", "ro", "ro #", "ro no", "ro number", "repair order", "repair order #", "ro_no", "invoice", "invoice #", "ro id", "ro num"]), F("flag", "Pay type / status flag (I O H W)", ["pay_type_flag", "pay type", "pay type flag", "paytype", "flag", "status flag", "ro status", "type"]), F("date", "RO (invoice) date", ["ro_date", "ro date", "date", "close date", "closed", "invoice date", "date closed", "date invoiced", "closed date"], { type: "date" }), F("customerAccount", "Customer # (7-digit account; INTERNAL / WARRANTY / VOID allowed)", ["customer_account", "customer account", "customer", "customer #", "customer_number", "customer number", "cust", "cust #", "account", "cust acct"]), // RC30 (imp2 + cn): the template's customer_number column (= the 7-digit customer #); RC33 (svi): a NAME column is the customer's name (customerName below), never the account // RC33 (svi): the customer printed on the RO — a row WITH a name creates the customer under its 7-digit number at approval (or fills the blank fields of // the one on file; another name on file is listed for the office). Never part of the RO's fingerprint (lib/imp-calc.js roCustFields). F("customerName", "Customer name (LAST, FIRST or FIRST LAST)", ["customer_name", "customer name", "cust name", "cust_name", "name", "customer full name", "owner", "owner name"]), F("custLast", "Customer last name", ["customer_last_name", "last name", "last_name", "lname", "surname"]), F("custFirst", "Customer first name", ["customer_first_name", "first name", "first_name", "fname"]), F("custStreet", "Customer address", ["address", "address1", "addr1", "street", "customer address", "address 1"]), F("custCity", "Customer city", ["city", "town"]), F("custState", "Customer state", ["state", "st"]), F("custPostal", "Customer ZIP", ["zip", "zip_code", "zip code", "postal", "postal code"]), F("custPhone", "Customer phone", ["phone", "home_phone", "home phone", "customer phone", "cell", "cell phone"], { type: "phone" }), F("custEmail", "Customer email", ["email", "e-mail", "customer email"], { type: "email" }), F("vin", "VIN", ["vin", "vin #", "vin no", "serial", "vin / serial"]), F("stock", "Stock # (internal work)", ["stock_number", "stock", "stock #", "stock no", "stk", "unit"]), F("year", "Year", ["year", "yr", "model year"], { type: "int" }), F("make", "Make", ["make", "mfr"]), F("model", "Model", ["model", "mdl"]), F("vehicle", "Vehicle (year make model, when not split)", ["vehicle", "year make model", "ymm", "vehicle description", "description"]), F("color", "Color", ["color", "colour"]), F("odometer", "Odometer", ["odometer", "miles", "mileage", "odo"], { type: "int" }), F("tech", "Technician #", ["technician_code", "technician", "tech", "tech #", "tech no", "technician #", "tech code"]), F("advisor", "Advisor / writer #", ["advisor_code", "advisor", "writer", "service writer", "advisor #", "advisor no", "service advisor", "writer #"]), F("partsSale", "Parts sale", ["parts_sale", "parts", "parts sale", "parts sales", "parts amount"], { type: "money" }), F("partsCost", "Parts cost", ["parts_cost", "parts cost"], { type: "money" }), F("laborSale", "Labor sale", ["labor_sale", "labor", "labor sale", "labor sales", "labour", "labor amount"], { type: "money" }), F("laborCost", "Labor cost", ["labor_cost", "labor cost", "labour cost"], { type: "money" }), F("subletSale", "Sublet sale", ["sublet_sale", "sublet", "sublet sale", "sublet sales"], { type: "money" }), F("subletCost", "Sublet cost", ["sublet_cost", "sublet cost"], { type: "money" }), F("supplies", "Shop supplies", ["shop_supplies", "shop supplies", "supplies", "misc", "shop supply"], { type: "money" }), F("taxRatePct", "Tax rate %", ["tax_rate_pct", "tax rate", "tax %", "tax pct", "rate"], { type: "num" }), F("salesTax", "Sales tax", ["sales_tax", "tax", "sales tax", "tax amount"], { type: "money" }), F("total", "RO total", ["ro_total", "total", "ro total", "invoice total", "grand total", "amount"], { type: "money" }), F("customerPay", "Customer pay total", ["x_customer_pay_total", "customer pay", "customer pay total", "cp", "cust pay", "customer total"], { type: "money" }), F("otherPay", "Warranty / internal total", ["x_warranty_or_internal_total", "warranty / internal", "warranty or internal", "warranty internal", "warranty", "internal", "warranty internal total", "w/i"], { type: "money" }), F("status", "Status word (VOID / OPEN / CLOSED as printed)", ["status", "void", "ro status word"]), F("lineText", "Job lines (one text, lines separated by |)", ["lines", "job lines", "complaint", "line text", "jobs", "work performed"]), // an RO_Lines file / sheet (one row per job line) — the same destination, "job lines only" F("lineNo", "Line # (lines file)", ["line_no", "line", "line #", "line no", "seq"], { type: "int" }), F("text1", "Line text 1 (lines file)", ["text_1", "text 1", "text", "line 1", "text1"]), F("text2", "Line text 2 (lines file)", ["text_2", "text 2", "line 2", "text2"]), F("text3", "Line text 3 (lines file)", ["text_3", "text 3", "line 3", "text3"]), F("opCode", "Op code (lines file)", ["op_code", "op code", "op", "opcode", "labor op", "operation"]), ], required: [["roNo"]], }, // ── RC33 (svi): the service lists — names for the customer #s, the A/R aging, open warranty claims, old body-shop jobs ── svcCustomers: { key: "svcCustomers", blanksNeverClear: true, async: true, group: "service", label: "Service customer list (names for the customer #s)", short: "Service customers", unit: "customer", perm: "data", match: "Each row names the customer of a 7-digit customer # (the phone without the area code). At approval: a number not on file becomes a customer with this name and address; a customer on file with the SAME name gets only its BLANK fields filled (nothing the office typed is overwritten); a customer on file with ANOTHER name is not changed — the name is listed for the office (Customers › Customers to settle). Nothing is saved before the owner approves.", fields: [ F("custNum", "Customer # (7 digits)", ["customer_number", "customer #", "customer no", "customer number", "cust #", "cust no", "account", "account #", "account number", "custnum"]), F("customerName", "Name (LAST, FIRST or FIRST LAST)", ["name", "customer_name", "customer name", "full name"]), F("custLast", "Last name", ["last_name", "last name", "lname", "surname"]), F("custFirst", "First name", ["first_name", "first name", "fname"]), F("custStreet", "Address", ["address", "address1", "street", "addr1", "address 1"]), F("custCity", "City", ["city", "town"]), F("custState", "State", ["state", "st"]), F("custPostal", "ZIP", ["zip", "zip_code", "zip code", "postal", "postal code"]), F("custPhone", "Phone", ["phone", "home_phone", "home phone", "cell", "cell phone", "phone 1"], { type: "phone" }), F("custWorkPhone", "Work phone", ["work_phone", "work phone", "business phone", "phone 2"], { type: "phone" }), F("custEmail", "Email", ["email", "e-mail", "email address"], { type: "email" }), ], required: [["custNum"], ["customerName", "custLast", "custFirst"]], }, arAging: { key: "arAging", blanksNeverClear: true, async: true, group: "gl", label: "A/R aging by customer (current / 30 / 60 / 90 / 120+)", short: "A/R aging", unit: "customer balance", perm: "data", match: "One row per customer: the age buckets become the customer's A/R lines on the account the row names (220, or 220A), as of the aging's date; the total must equal the buckets to the cent. The list is tied to 220 on the review (the owner ticks the entry when the aging is the whole balance). A name on the row names a customer # that has none yet. Nothing is posted at import.", optionDefs: [ { key: "asOf", type: "date", label: "As-of date of the aging", required: true, hint: "The day the aging was printed (the same day as the GL balances)." }, { key: "glAcct", type: "text", label: "Account when the file has no column (220 or 220A)", options: [["220", "220 — A/R service & parts"], ["220A", "220A — Charge accounts 180 days"]] }, ], fields: [ F("control", "Customer # / account", ["customer_number", "customer #", "customer no", "customer number", "account", "account #", "control", "control #", "cust #"]), F("name", "Name", ["name", "customer_name", "customer name", "description"]), F("current", "Current", ["current", "0-30", "0 30", "current balance"], { type: "money" }), F("d31_60", "31 - 60 days", ["days_31_60", "31-60", "31 60", "30 days", "over 30"], { type: "money" }), F("d61_90", "61 - 90 days", ["days_61_90", "61-90", "61 90", "60 days", "over 60"], { type: "money" }), F("d91_120", "91 - 120 days", ["days_91_120", "91-120", "91 120", "90 days", "over 90"], { type: "money" }), F("over120", "Over 120 days", ["over_120", "over 120", "120+", "120 days", "121+"], { type: "money" }), F("total", "Total", ["total", "balance", "total balance", "total due"], { type: "money" }), F("gl", "A/R account (220 / 220A)", ["gl_account", "gl account", "schedule", "account type", "gl"], { type: "acct" }), F("asOf", "As-of date", ["as_of", "as of", "as of date", "aging date", "report date"], { type: "date" }), ], required: [["control"], ["total", "current"]], }, warrantyClaims: { key: "warrantyClaims", blanksNeverClear: true, async: true, group: "service", label: "Warranty claims (open claims from the old system)", short: "Warranty claims", unit: "claim", perm: "data", match: "One row per claim. At approval each claim not on file yet becomes a warranty receivable (WC-OLD-); the open claims (not paid, not debited) are the list 263 WARRANTY CLAIMS is tied to on the review. Nothing is posted at import.", fields: [ F("claim", "Claim #", ["claim_number", "claim #", "claim no", "claim number", "claim"]), F("ro", "RO #", ["ro_number", "ro #", "ro no", "ro number", "repair order", "ro"]), F("date", "Claim date", ["claim_date", "claim date", "date", "submitted"], { type: "date" }), F("amount", "Claim amount", ["amount", "claim amount", "total", "claimed"], { type: "money" }), F("paid", "Amount paid", ["amount_paid", "amount paid", "paid", "received"], { type: "money" }), F("status", "Status (open / submitted / paid / debited)", ["status", "claim status", "state"]), ], required: [["claim"], ["ro"], ["amount"]], }, bodyWip: { key: "bodyWip", blanksNeverClear: true, async: true, group: "service", label: "Body-shop jobs in progress (old system)", short: "Body-shop jobs", unit: "job", perm: "data", match: "One row per body-shop job still in progress in the old system. At approval each job not on file yet is kept on the server's list of old body jobs; their cost is the list 245 BODY SHOP INVENTORY is tied to on the review. Nothing is posted at import.", fields: [ F("roNo", "RO #", ["ro_number", "ro #", "ro no", "ro number", "job", "job #"]), F("date", "Date", ["date", "ro_date", "opened"], { type: "date" }), F("custNum", "Customer #", ["customer_number", "customer #", "customer no", "account"]), F("laborCost", "Labor cost", ["labor_cost", "labor cost"], { type: "money" }), F("partsCost", "Parts cost", ["parts_cost", "parts cost"], { type: "money" }), F("materialsCost", "Materials cost", ["materials_cost", "materials cost", "paint and materials", "materials"], { type: "money" }), F("subletCost", "Sublet cost", ["sublet_cost", "sublet cost"], { type: "money" }), F("status", "Status", ["status", "state"]), ], required: [["roNo"]], }, // RC36 (fpd36): the LENDER'S FLOORPLAN STATEMENT — one row per unit the lender carries. Staged like every import; the review compares it // unit by unit with the floored units (and 310 / 314); approval keeps it on file (routes/floorplan.js) — nothing is posted. Accounting › // Floorplan then lists what differs and takes the floored amounts for units K2B shows not floored (an imported store). floorplanStmt: { key: "floorplanStmt", blanksNeverClear: true, async: true, group: "inventory", label: "Floorplan statement (from the lender)", short: "Floorplan statement", unit: "unit", perm: "data", match: "One row per unit on the lender's statement. Matched to inventory by stock #, then VIN. At approval the statement is kept on file (no entry is posted); the review and Accounting › Floorplan compare it with the floored units, 310 / 314 and the units not floored.", optionDefs: [ { key: "asOf", type: "date", label: "Statement date", required: true, hint: "The day the lender printed the statement." }, { key: "lender", type: "text", label: "Lender (when the file has no lender column)" }, ], fields: [ F("stock", "Stock #", ["stock", "stock #", "stock no", "stock number", "stk", "stk #", "dealer stock #", "unit #", "unit number"]), F("vin", "VIN", ["vin", "vin #", "vin number", "serial", "serial #", "serial number", "vehicle id", "vin / serial"]), F("desc", "Vehicle (year / make / model)", ["description", "vehicle", "vehicle description", "year make model", "unit description", "collateral"]), F("lender", "Lender", ["lender", "floorplan lender", "floor plan lender", "bank", "flooring source", "source"]), F("floorDate", "Floor date", ["floor date", "floored date", "date floored", "flooring date", "funding date", "advance date", "origination date"], { type: "date" }), F("advance", "Amount floored", ["amount floored", "floored amount", "flooring amount", "original amount", "original balance", "advance", "advance amount", "principal advanced"], { type: "money" }), F("curtail", "Curtailments paid", ["curtailments", "curtailment", "curtailments paid", "curtailment paid", "principal paid", "paid to date", "reductions"], { type: "money" }), F("balance", "Balance owed", ["balance", "balance owed", "principal balance", "current balance", "payoff", "payoff amount", "amount owed", "amount due", "outstanding", "outstanding balance"], { type: "money" }), F("stmtDate", "Statement date (when the file has a column)", ["statement date", "statement_date", "as of", "as of date", "as_of", "report date"], { type: "date" }), ], required: [["stock", "vin"], ["balance", "advance"]], }, // The parts list from the old system: the Parts screen's own master-list rules (DMS.importPartsMaster), plus the on-hand count // when the file has one (a physical inventory from the old system). Station-side records; the GL is tied out on the reconciliation screen. partsMaster: { key: "partsMaster", group: "parts", label: "Parts — master list and on-hand", short: "Parts list", unit: "part", perm: "service", match: "Parts are matched by part number. A part already on file gets its description / bin / source / core updated where the file differs (a blank never erases), its cost / list / trade when \"update prices\" is ticked, and its on-hand count when \"set on-hand\" is ticked. Nothing is posted to the General Ledger: the parts value on hand is tied out on Accounting › Reconcile inventory to GL.", optionDefs: [ { key: "applyOnHand", type: "bool", label: "Set on-hand quantities from the file (the old system's count)", default: true }, { key: "updatePrices", type: "bool", label: "Update cost / list / trade of parts already on file", default: true }, ], fields: [ F("part", "Part #", ["part no", "part #", "part number", "part", "partno", "part num", "pn", "item", "item #", "sku"]), F("desc", "Description", ["description", "desc", "part description", "name"]), F("bin", "Bin", ["bin", "bin location", "location", "loc", "shelf"]), F("onHand", "On hand", ["on hand", "onhand", "on-hand", "qty", "quantity", "qoh", "stock", "qty on hand", "count"], { type: "num" }), F("cost", "Cost", ["cost", "unit cost", "dealer cost", "net"], { type: "money" }), F("list", "List", ["list", "list price", "retail", "msrp", "price"], { type: "money" }), F("trade", "Trade / wholesale", ["trade", "trade price", "wholesale", "w/s"], { type: "money" }), F("source", "Source", ["source", "src", "vendor", "supplier", "mfg source"]), F("mfr", "Manufacturer", ["mfr", "manufacturer", "brand", "make"]), F("core", "Core charge", ["core", "core charge", "core price", "core amt"], { type: "money" }), F("glAcct", "Parts inventory GL account", ["gl account", "gl acct", "gl account #", "inventory account", "parts account", "parts inventory account"], { type: "acct" }), // RC30 (imp2): the template's "GL Account" column → the part's invAcct (the tie-out) ], required: [["part"]], }, // The old system's VIN list (every vehicle it ever saw: service, sold, trades). A lookup list on the SERVER for the repair-order // history and VIN searches — NOT units in stock (the Inventory sheet / vehicle master report is the stock list). vehicleMaster: { key: "vehicleMaster", blanksNeverClear: true, async: true, group: "inventory", label: "Vehicles — VIN list from the old system (lookup only)", short: "Vehicle list", unit: "vehicle", perm: "data", match: "Vehicles are matched by VIN. The list is kept on the server as a lookup (year / make / model for the repair-order history and VIN searches) — it does NOT put units in stock; use Inventory — vehicles for the units on the lot. A blank never erases anything.", optionDefs: [], fields: [ F("vin", "VIN", ["vin", "vin #", "vin no", "serial", "vin / serial"]), F("year", "Year", ["year", "yr", "model year"], { type: "int" }), F("make", "Make", ["make", "mfr"]), F("model", "Model", ["model", "mdl"]), F("color", "Color", ["color", "colour"]), F("sources", "Where the old system saw it (reference)", ["sources", "source", "seen in", "origin"]), ], required: [["vin"]], }, }; const destDef = (k) => DESTS[k] || null; // ── column guessing with a confidence score ──────────────────────────── const typeOk = (type, v) => { const s = txt(v); if (!s) return null; if (type === "money") { const n = money(s); return !(typeof n === "number" && isNaN(n)); } if (type === "date") return !isBadDate(parseDate(s)); if (type === "phone") return phone10(s).length === 10; if (type === "custnum") return window.CustNum ? window.CustNum.isCustNum(s) || phone10(s).length === 10 : /^[1-9]\d{6}$/.test(s) || phone10(s).length === 10; // RC30 (cn): 7 digits (or a 10-digit phone whose last 7 are taken) if (type === "email") return EMAIL.test(s); if (type === "int") return /^\d+$/.test(s.replace(/,/g, "")); if (type === "num") { const n = num(s); return !(typeof n === "number" && isNaN(n)); } if (type === "acct") return /^[0-9][0-9A-Z\-\.]{0,11}$/i.test(s.replace(/\s+/g, "")); if (type === "bool") return /^(y|n|yes|no|true|false|1|0|x)$/i.test(s); return true; }; const guessGeneric = (def, headers, rows) => { const sample = (rows || []).slice(0, 200); const cand = []; (headers || []).forEach(h => { const n = nh(h); if (!n) return; const vals = sample.map(r => (r || {})[h]).filter(v => !blank(v)); def.fields.forEach(f => { const syns = [f.key, f.label, ...f.syn].map(nh).filter(Boolean); let s = 0, why = ""; if (syns.includes(n)) { s = 1; why = "column name"; } else { syns.forEach(sn => { if (sn.length >= 4 && n.includes(sn) && s < 0.8) { s = 0.8; why = `name contains "${sn}"`; } else if (n.length >= 4 && sn.includes(n) && s < 0.7) { s = 0.7; why = "partial name"; } }); if (!s) { const ht = String(h).toLowerCase().split(/[^a-z0-9]+/).filter(t => t.length >= 3); const st = new Set(f.syn.join(" ").toLowerCase().split(/[^a-z0-9]+/).filter(t => t.length >= 3)); if (ht.some(t => st.has(t))) { s = 0.5; why = "similar name"; } } } if (vals.length && f.type !== "text") { const okN = vals.filter(v => typeOk(f.type, v)).length, frac = okN / vals.length; if (s > 0) { if (frac >= 0.8) s = Math.min(1, s + 0.05); else if (frac < 0.5) { s -= 0.3; why += " · values don't fit"; } } else if (frac >= 0.95 && ["email", "phone", "date"].includes(f.type)) { s = 0.6; why = `values look like ${f.type === "phone" ? "phone numbers" : f.type === "email" ? "emails" : "dates"}`; } } if (s > 0) cand.push({ h, f: f.key, s: +s.toFixed(2), why, lab: nh(f.label) === n || nh(f.key) === n ? 1 : 0 }); }); }); cand.sort((a, b) => b.s - a.s || b.lab - a.lab); const mapping = {}, conf = {}, why = {}, suggest = {}; (headers || []).forEach(h => { mapping[h] = ""; conf[h] = 0; why[h] = "no matching DMS field"; }); const takenF = new Set(), takenH = new Set(); cand.forEach(c => { if (takenH.has(c.h) || takenF.has(c.f) || c.s < THRESH) return; mapping[c.h] = c.f; conf[c.h] = c.s; why[c.h] = c.why; takenH.add(c.h); takenF.add(c.f); }); (headers || []).forEach(h => { if (mapping[h]) return; const best = cand.find(c => c.h === h && c.s >= 0.4); if (best) { suggest[h] = best.f; conf[h] = best.s; why[h] = best.why + (takenF.has(best.f) ? " (field already used by another column)" : ""); } }); return { mapping, conf, why, suggest }; }; const guessInventory = (headers, rows) => { const JI = window.JBImporters; if (!JI || !JI.guessInventoryMapping) return guessGeneric(DESTS.inventory, headers, rows); const m = JI.guessInventoryMapping(headers, rows); const exact = {}; (JI.INV_FIELDS || []).forEach(Fd => [...Fd.syn, Fd.label].forEach(s => { exact[nh(s)] = exact[nh(s)] || Fd.key; })); const mapping = {}, conf = {}, why = {}, suggest = {}; (headers || []).forEach(h => { const f = m[h] || "", n = nh(h); if (!f) { mapping[h] = ""; conf[h] = 0; why[h] = "no matching DMS field"; return; } if (exact[n] === f) { mapping[h] = f; conf[h] = 1; why[h] = "column name"; } else if (f === "newUsed") { mapping[h] = f; conf[h] = 0.85; why[h] = "values are New / Used"; } else if (n === "type" || n === "vehicletype") { mapping[h] = ""; suggest[h] = f; conf[h] = 0.6; why[h] = `"${h}" could be body style or new/used`; } else { mapping[h] = f; conf[h] = 0.8; why[h] = "name contains a known column"; } }); return { mapping, conf, why, suggest }; }; const importGuessMapping = (dest, headers, rows) => { const def = destDef(dest); if (!def) return { mapping: {}, conf: {}, why: {}, suggest: {} }; if (dest === "inventory") return guessInventory(headers, rows); const g = guessGeneric(def, headers, rows); if (dest === "roHistory") roCustomerColumn(g, headers, rows); // RC33 (svi) return g; }; // RC33 (svi, map gap 1): a "customer" column of a repair-order register that holds NAMES (letters with a space or comma, not a code word) is the // customer's name, not the account — before RC33 the name landed in the account field and no customer could ever be made from it // a file with BOTH a number column and a names column ("customer_number" + "customer"): the number column wins the account and the names column // was left unmapped (a field maps once) — it is looked at too, so the printed names reach customerName const roCustomerColumn = (g, headers, rows) => { if (Object.values(g.mapping).indexOf("customerName") !== -1) return; const def = destDef("roHistory"); const fa = def && def.fields.find((f) => f.key === "customerAccount"); const syns = new Set(fa ? [fa.key, fa.label].concat(fa.syn).map(nh).filter(Boolean) : []); const cands = (headers || []).filter((x) => g.mapping[x] === "customerAccount" || (!g.mapping[x] && syns.has(nh(x)))); const C = IC(); const code = (v) => !!(C && C.CUST_CODE_WORDS && C.CUST_CODE_WORDS.test(v)); for (const h of cands) { const vals = (rows || []).slice(0, 200).map((r) => txt((r || {})[h])).filter(Boolean); if (!vals.length) continue; const nameLike = vals.filter((v) => /[A-Za-z]{2}/.test(v) && /[\s,]/.test(v) && !code(v)).length; if (nameLike / vals.length < 0.6) continue; const wasAcct = g.mapping[h] === "customerAccount"; g.mapping[h] = "customerName"; g.conf[h] = 0.8; g.why[h] = "the values are names, not account numbers"; delete g.suggest[h]; if (wasAcct) { // another unmapped customer column with account-like values takes the account const other = cands.find((x) => x !== h && !g.mapping[x] && (rows || []).slice(0, 200).map((r) => txt((r || {})[x])).filter(Boolean).every((v) => /^\d{4,}$/.test(v) || code(v))); if (other) { g.mapping[other] = "customerAccount"; g.conf[other] = 0.8; g.why[other] = "the values are account numbers"; } } return; } }; // ── templates ───────────────────────────────────────────────────────── const hdrKey = (headers) => (headers || []).map(nh).filter(Boolean).sort().join("|"); const importTemplateFor = (dest, headers) => { const want = hdrKey(headers), wantSet = new Set((headers || []).map(nh)); let best = null; (D.importTemplates() || []).filter(t => t && t.dest === dest).forEach(t => { const th = (t.headers || []).map(nh); const exact = hdrKey(t.headers) === want; const overlap = th.length ? th.filter(x => wantSet.has(x)).length / Math.max(th.length, wantSet.size) : 0; const score = exact ? 2 : overlap; if (score >= 0.6 && (!best || score > best.score)) best = { template: t, exact, score }; }); return best; }; const importApplyTemplate = (t, headers) => { const byN = {}; Object.keys((t && t.mapping) || {}).forEach(h => { byN[nh(h)] = t.mapping[h]; }); const out = {}; (headers || []).forEach(h => { if (Object.prototype.hasOwnProperty.call(byN, nh(h))) out[h] = byN[nh(h)]; }); return out; }; const importSaveTemplate = ({ name, source, dest, headers, mapping }) => { const nm = txt(name); if (!nm) return { ok: false, msg: "Give the template a name." }; if (!destDef(dest)) return { ok: false, msg: "Unknown destination." }; const unresolved = (headers || []).filter(h => !(mapping || {})[h]); if (unresolved.length) return { ok: false, msg: `Choose a field or Skip for: ${unresolved.join(", ")}` }; return D._mutate((state, h) => { state.importTemplates = state.importTemplates || []; const ix = state.importTemplates.findIndex(t => t.dest === dest && txt(t.name).toLowerCase() === nm.toLowerCase()); const rec = { id: ix >= 0 ? state.importTemplates[ix].id : D._uid("ITPL"), name: nm, source: txt(source), dest, headers: (headers || []).slice(), mapping: { ...mapping }, by: D.userId ? D.userId() : "", at: h.iso(new Date()), createdAt: ix >= 0 ? state.importTemplates[ix].createdAt : h.iso(new Date()) }; if (ix >= 0) state.importTemplates[ix] = rec; else state.importTemplates.unshift(rec); D._audit("Import Template Saved", `${nm} (${destDef(dest).short}${rec.source ? " · " + rec.source : ""}) — ${Object.keys(mapping).length} columns`); return { ok: true, id: rec.id, template: rec, msg: `Template "${nm}" saved.` }; }); }; // ── plan helpers ───────────────────────────────────────────────────── const headersOf = (rows) => { const s = []; (rows || []).slice(0, 200).forEach(r => Object.keys(r || {}).forEach(k => { if (!s.includes(k)) s.push(k); })); return s; }; const fieldCols = (mapping) => { const o = {}; Object.keys(mapping || {}).forEach(h => { const f = mapping[h]; if (f && f !== SKIP && !o[f]) o[f] = h; }); return o; }; const valsOf = (raw, cols) => { const o = {}; Object.keys(cols).forEach(f => { o[f] = txt((raw || {})[cols[f]]); }); return o; }; // Field-by-field compare. A blank file value never changes anything unless clearBlanks. const diffRec = (cur, rec, keys, clearBlanks, numKeys) => { const ch = {}; numKeys = numKeys || {}; keys.forEach(k => { const nv = rec[k], ov = cur ? cur[k] : undefined; if (nv === undefined || blank(nv)) { if (nv !== undefined && clearBlanks && !blank(ov) && !(numKeys[k] && +ov === 0)) ch[k] = { from: ov, to: numKeys[k] ? 0 : "" }; return; } const same = numKeys[k] ? (!blank(ov) && R2(ov) === R2(nv)) : typeof nv === "boolean" ? !!ov === nv : txt(ov).toUpperCase() === txt(nv).toUpperCase(); if (!same) ch[k] = { from: ov == null ? "" : ov, to: nv }; }); return ch; }; const item = (o) => ({ reasons: [], warnings: [], changes: {}, ...o }); // RC30 (cn) fix round (mig F6): a number cell Excel shows with thousands separators or a trailing .0 ("5,551,242", "5551242.0") IS the plain // digits; a cell in exponent form ("5.55124E+06") lost digits in the display and is refused with the words that fix it (format the column as Text) const plainNum = (v) => { const t = txt(v); const m = /^(\d{1,3}(?:,\d{3})+|\d+)(?:\.0+)?$/.exec(t); return m ? m[1].replace(/,/g, "") : t; }; const expoNum = (v) => /^\d+(?:\.\d+)?E[+-]?\d+$/i.test(txt(v)); const FORMAT_WORDS = "format the column as Text or plain digits (no separators, no .0)"; // ── per-destination planners (dry run — pure reads) ─────────────────── const planCustomers = (rows, cols, opts) => { const cust = D.customers() || {}; const CNx = window.CustNum; const custNumOf = (v) => (CNx ? (CNx.custNumFromPhone(v).num7 || "") : (phone10(v).length === 10 ? phone10(v).slice(-7) : "")); const isNum = (v) => (CNx ? CNx.isCustNum(v) : /^[1-9]\d{6}$/.test(txt(v))); const byEmail = {}; Object.values(cust).forEach(c => { const e = txt(c && c.email).toLowerCase(); if (e && !byEmail[e]) byEmail[e] = c.custNum; }); const byAlias = {}; Object.values(cust).forEach(c => { (Array.isArray(c && c.aliases) ? c.aliases : []).forEach(a => { if (!byAlias[String(a)]) byAlias[String(a)] = c.custNum; }); }); const issued = (opts && opts.issued) || {}; // row → the number K2B issued for it (importCommitAsync fills this in) const hhDone = (opts && opts.householdDone) || {}; // row → true when the person was already added to the account (importCommitAsync) const hhNames = (opts && opts.householdNames) || {}; // RC30 (cn) fix round: account → the other people of a household this file creates (on its names[] as it is created) const seenNum = {}, seenEmail = {}; const touched = []; if (cols.fullName || cols.firstName) touched.push("firstName"); if (cols.fullName || cols.lastName) touched.push("lastName"); if (cols.phone) touched.push("phone"); ["workPhone", "email", "street", "city", "state", "postal", "dlNumber", "dob", "oldCustNo"].forEach(k => { if (cols[k]) touched.push(k); }); // RC26 (inv): + oldCustNo; RC30 (cn): + workPhone return rows.map((raw, i) => { const row = i + 2, v = valsOf(raw, cols), it = item({ rows: [row], raw }); let first = up(v.firstName), last = up(v.lastName); if (!first && !last && v.fullName) { const full = v.fullName.trim(); if (full.includes(",")) { const p = full.split(","); last = up(p[0]); first = up(p.slice(1).join(" ")); } else { const p = full.split(/\s+/); first = up(p[0]); last = up(p.slice(1).join(" ")); } } const given = plainNum(v.custNum); // "5,551,242" / "5551242.0" → 5551242 (mig F6) const ph = phone10(v.phone); const derived = ph.length === 10 ? custNumOf(ph) : ""; let cn = "", needsNumber = false; if (given && expoNum(given)) { it.reasons.push({ field: "customer #", rule: "cust_not_7", reason: `"${given}" is a number in exponent form — Excel dropped digits from it; ${FORMAT_WORDS} and export again` }); } else if (given && isNum(given)) { cn = given; if (derived && derived !== given && !(CNx && CNx.isIssued(given))) { it.reasons.push({ field: "customer #", rule: "cust_mismatch", reason: `customer # "${given}" does not match the phone ${fmtPhone(ph)} (its customer # is ${derived}) — the customer # is the phone without the area code; fix one of the two` }); } // RC30 (cn) fix round (mig F7): a number in K2B's range (1xxxxxx) that no customer carries was typed by hand — K2B gives those out itself // RC31 (fx31, RC30 acceptance F4): explained by the rule table's own cust_not_issued ("A number starting with 1 that K2B never gave out"), not cust_not_7 else if (CNx && CNx.isIssued(given) && !cust[given] && !byAlias[given] && Object.values(issued).map(String).indexOf(given) === -1) { it.reasons.push({ field: "customer #", rule: "cust_not_issued", reason: `"${given}" — a number starting with 1 is given out by K2B (for a customer without a phone); leave the customer_number cell blank and K2B issues one when the import is approved` }); } } else if (given && phone10(given).length === 10) { cn = custNumOf(given); it.warnings.push({ field: "customer #", rule: "cust_not_7", reason: `"${given}" is a phone with its area code — the customer # is its last 7 digits (${cn})` }); if (derived && derived !== cn) it.reasons.push({ field: "customer #", rule: "cust_mismatch", reason: `customer # "${given}" and phone "${v.phone}" disagree (${cn} vs ${derived}) — fix one of the two` }); } else if (given) { const word = CNx ? CNx.codeWordOf(given) : null; // RC31 (fx31, F4 class): a code word is explained by the rule table's cust_code_word // the rule's own words (a 0- / 1-leading number "cannot be a phone"); the formatting hint only when the cell looks like a formatted number (mig F6) const why = CNx ? CNx.shapeProblem(given).replace(/\.$/, "") : `"${given}" is not a customer number (7 digits = the phone without the area code)`; it.reasons.push({ field: "customer #", rule: word ? "cust_code_word" : "cust_not_7", reason: word ? `"${given}" is the old system's code word for ${word} work, not a customer` : `${why}${/\d/.test(given) && /[,.\sE+]/i.test(given) ? " — " + FORMAT_WORDS : ""}` }); } else if (derived) cn = derived; else if (issued[row]) cn = String(issued[row]); else needsNumber = true; if (v.phone && ph.length !== 10) it.warnings.push({ field: "phone", reason: `phone "${v.phone}" is not 10 digits — not imported as the phone` }); const wp = phone10(v.workPhone); if (v.workPhone && wp.length !== 10) it.warnings.push({ field: "work phone", reason: `work phone "${v.workPhone}" is not 10 digits — not imported` }); if (!first && !last) it.reasons.push({ field: "name", reason: "no customer name" }); let email = v.email || ""; // RC25 (imp): a file without an email column (the old system's customer list) used to crash the preview here if (email && !EMAIL.test(email)) { it.warnings.push({ field: "email", reason: `"${email}" is not a valid email — not imported` }); email = ""; } let dob = ""; if (v.dob) { const d = parseDate(v.dob); if (isBadDate(d)) it.warnings.push({ field: "dob", reason: `date of birth "${v.dob}" is not a date — not imported` }); else dob = mdy(d); } const rec = { custNum: cn, firstName: first, lastName: last, phone: ph.length === 10 ? fmtPhone(ph) : "", workPhone: wp.length === 10 ? fmtPhone(wp) : "", email, street: up(v.street), city: up(v.city), state: up(v.state), postal: txt(v.postal), dlNumber: up(v.dlNumber), dob, oldCustNo: up(v.oldCustNo).replace(/\.0+$/, "") }; it.key = cn || `row ${row}`; it.label = [last, first].filter(Boolean).join(", "); if (cn && seenNum[cn]) { // RC30 (cn): the same number twice in the file — the same full phone = one household (both names on one account); another phone = a question const prev = seenNum[cn]; if (ph.length === 10 && prev.ph === ph && [last, first].join("|") !== prev.name) { it.household = { first, last, into: cn }; } else it.reasons.push({ field: "customer #", reason: `same customer # as row ${prev.row}${prev.ph && ph.length === 10 && prev.ph !== ph ? " with another area code — the office decides (add the person in the CRM)" : " — duplicate in this file"}` }); } const el = email.toLowerCase(); if (el && seenEmail[el] && seenEmail[el].cn !== cn) it.reasons.push({ field: "email", reason: `same email as row ${seenEmail[el].row} — duplicate in this file` }); if (cn && !seenNum[cn]) seenNum[cn] = { row, ph, name: [last, first].join("|") }; if (el && !seenEmail[el]) seenEmail[el] = { cn, row }; if (it.reasons.length) { it.action = "reject"; return it; } if (needsNumber) { it.action = "add"; it.id = "(K2B issues a number)"; it.needsNumber = true; it.record = Object.fromEntries(Object.entries(rec).filter(([k, x]) => k !== "custNum" && !blank(x))); if (rec.postal) it.record.zip = rec.postal; it.note = "no phone — K2B issues a customer number (starting with 1) when the import runs"; return it; } let cur = cust[cn] || (byAlias[cn] ? cust[byAlias[cn]] : null) || null, by = cur ? (cust[cn] ? "customer #" : "an old customer #") : ""; if (!cur && el && byEmail[el]) { cur = cust[byEmail[el]]; by = "email"; } if (cur && it.household && cust[cn]) { /* a second row of this file already created / updated the account: the household add below handles the person */ } // RC30 (cn) fix round (auditor F2): a second person on the same phone is staged as an ADD on the account's number with householdAdd marked — // the staged approval (lane imp2's imp-stage.js approveCustomers, through cust-store.judgeWrite) puts the person on the account's names[]; // a "skip" row was never written, so the second person was silently dropped const householdRow = (acct) => { it.action = "add"; it.id = acct; it.key = acct; it.householdAdd = { custNum: acct, first, last }; it.record = Object.assign(Object.fromEntries(Object.entries(rec).filter(([, x]) => !blank(x))), { custNum: acct }); if (rec.postal) it.record.zip = rec.postal; it.note = `${it.label} is added to account ${acct} (same phone — one household) when the import is approved; the account keeps its own details`; return it; }; if (!cur && it.household) { if (hhDone[row]) { it.action = "skip"; it.note = `${it.label} added to account ${cn} (household)`; it.householdAdd = { custNum: cn, first, last }; return it; } return householdRow(cn); } if (!cur) { it.action = "add"; it.id = cn; it.record = Object.fromEntries(Object.entries(rec).filter(([, x]) => !blank(x))); if (rec.postal) it.record.zip = rec.postal; if (CNx && Array.isArray(hhNames[cn]) && hhNames[cn].length) it.record.names = CNx.namesOf(it.record).concat(hhNames[cn].map(n => ({ first: n.first, last: n.last, role: "household" }))); return it; } it.id = cur.custNum; it.matchBy = by; const keys = touched.slice(); if (by === "email" && cur.custNum !== cn) { it.warnings.push({ field: "customer #", reason: `matched customer # ${cur.custNum} by email — the file's ${cn ? "number " + cn : "phone"} is not applied (a customer # never changes; the owner renumbers)` }); const k = keys.indexOf("phone"); if (k >= 0) keys.splice(k, 1); } // RC30 (cn): another person on an existing account — the same full phone = a household add (through the server); another area code = the office decides const newPerson = CNx && (first || last) && !CNx.hasName(cur, first, last); if (newPerson) { const rel = CNx.phoneRelation(rec, cur); if (rel === "different") { it.reasons.push({ field: "customer #", rule: "cust_ask_office", reason: `customer # ${cn} is ${CNx.displayName(cur)} on another area code — the office decides in the CRM (+ New Customer asks: same household, or two people)` }); it.action = "reject"; return it; } it.householdAdd = { custNum: cur.custNum, first, last }; if (hhDone[row]) { it.action = "skip"; it.note = `${it.label} added to account ${cur.custNum} (household)`; return it; } return householdRow(cur.custNum); } it.changes = diffRec(cur, rec, keys, opts.clearBlanks); it.action = Object.keys(it.changes).length ? "update" : "skip"; if (it.action === "skip") it.note = "already on file — no changes"; return it; }); }; const planVendors = (rows, cols, opts) => { const vendors = D.vendors() || []; const accts = chartSet(); const seenNo = {}, seenName = {}; const keys = ["vendorNo", "name", "contact", "phone", "email", "address", "acctNo", "terms", "defaultAcct", "creditLimit", "type", "notes", "oldBalance"].filter(k => cols[k]); // RC36 (imp36): + oldBalance return rows.map((raw, i) => { const row = i + 2, v = valsOf(raw, cols), it = item({ rows: [row], raw }); const rec = { vendorNo: up(v.vendorNo), name: up(v.name), contact: v.contact, phone: v.phone, email: v.email, address: v.address, acctNo: v.acctNo, terms: v.terms, defaultAcct: acctKey(v.defaultAcct), type: v.type, notes: v.notes, creditLimit: null }; if (!rec.name) it.reasons.push({ field: "name", reason: "no vendor name" }); if (v.creditLimit) { const n = money(v.creditLimit); if (typeof n === "number" && isNaN(n)) it.reasons.push({ field: "credit limit", reason: `"${v.creditLimit}" is not a valid amount` }); else rec.creditLimit = n; } if (cols.oldBalance && txt(v.oldBalance)) { const n = money(v.oldBalance); if (typeof n === "number" && isNaN(n)) it.reasons.push({ field: "old balance", reason: `"${v.oldBalance}" is not a valid amount` }); else rec.oldBalance = n; } // RC36 (imp36) if (rec.defaultAcct && !accts.has(rec.defaultAcct)) it.reasons.push({ field: "default GL account", reason: `account ${rec.defaultAcct} is not in the chart of accounts` }); if (rec.email && !EMAIL.test(rec.email)) { it.warnings.push({ field: "email", reason: `"${rec.email}" is not a valid email — not imported` }); rec.email = ""; } it.key = rec.vendorNo || rec.name || `row ${row}`; it.label = rec.name; if (rec.vendorNo && seenNo[rec.vendorNo]) it.reasons.push({ field: "vendor #", reason: `vendor # ${rec.vendorNo} also on row ${seenNo[rec.vendorNo]} — duplicate in this file` }); else if (rec.name && seenName[rec.name]) it.reasons.push({ field: "name", reason: `vendor "${rec.name}" also on row ${seenName[rec.name]} — duplicate in this file` }); if (rec.vendorNo && !seenNo[rec.vendorNo]) seenNo[rec.vendorNo] = row; if (rec.name && !seenName[rec.name]) seenName[rec.name] = row; if (it.reasons.length) { it.action = "reject"; return it; } let cur = null, by = ""; if (rec.vendorNo) { cur = vendors.find(x => up(x.vendorNo) === rec.vendorNo || up(x.id) === rec.vendorNo || up(x.id) === "V-" + rec.vendorNo) || null; if (cur) by = "vendor #"; } if (!cur && rec.name) { cur = vendors.find(x => up(x.name) === rec.name) || null; if (cur) by = "name"; } if (cur && by === "name" && rec.vendorNo && cur.vendorNo && up(cur.vendorNo) !== rec.vendorNo) { it.action = "reject"; it.reasons.push({ field: "vendor #", reason: `name matches vendor ${cur.name} but its vendor # is ${cur.vendorNo}, not ${rec.vendorNo} — fix the file or the vendor` }); return it; } if (!cur) { it.action = "add"; const id = rec.vendorNo ? "V-" + rec.vendorNo.replace(/[^A-Z0-9]/g, "") : D._uid("V"); const r = { id, vendorNo: rec.vendorNo, name: rec.name, contact: rec.contact || "", phone: rec.phone || "", email: rec.email || "", address: rec.address || "", acctNo: rec.acctNo || "", terms: rec.terms || "Net 30", defaultAcct: rec.defaultAcct || "300", creditLimit: rec.creditLimit || 0, type: rec.type || "Trade", active: true, notes: rec.notes || "" }; if (rec.oldBalance !== undefined && rec.oldBalance !== null) r.oldBalance = rec.oldBalance; // RC36 (imp36) const def = []; if (!rec.terms) def.push("terms Net 30"); if (!rec.defaultAcct) def.push("GL 300"); if (!rec.type) def.push("type Trade"); if (def.length) it.note = "defaults: " + def.join(", "); it.record = r; it.id = id; return it; } it.id = cur.id; it.matchBy = by; it.changes = diffRec(cur, rec, keys, opts.clearBlanks, { creditLimit: 1, oldBalance: 1 }); it.action = Object.keys(it.changes).length ? "update" : "skip"; if (it.action === "skip") it.note = "already on file — no changes"; return it; }); }; const CK_STATUS = (s) => { const t = txt(s).toLowerCase(); if (!t || /^(o|out|outstanding|open|issued|printed)$/.test(t)) return "Outstanding"; if (/^(c|clr|cleared|reconciled|y|yes)$/.test(t)) return "Cleared"; if (/^(v|void|voided)$/.test(t)) return "Void"; return null; }; const planChecks = (rows, cols, opts) => { const checks = D.checks ? D.checks() : []; const vendors = D.vendors() || []; const accts = chartSet(); const defBank = acctKey(((D.checkConfig && D.checkConfig()) || {}).bankAcct || (D._ACCT || {}).cashBank || "202"); const seen = {}; return rows.map((raw, i) => { const row = i + 2, v = valsOf(raw, cols), it = item({ rows: [row], raw }); const num = up(v.num).replace(/^#/, ""); const bank = acctKey(v.bankAcct) || defBank; it.key = num ? `#${num}` : `row ${row}`; it.label = v.payee; if (!num) it.reasons.push({ field: "check #", reason: "no check number" }); else if (!/^[0-9A-Z\-]{1,12}$/.test(num)) it.reasons.push({ field: "check #", reason: `"${v.num}" is not a check number` }); const dt = parseDate(v.date); if (!v.date) it.reasons.push({ field: "date", reason: "no check date" }); else if (isBadDate(dt)) it.reasons.push({ field: "date", reason: `"${v.date}" is not a valid date` }); if (!v.payee) it.reasons.push({ field: "payee", reason: "no payee" }); const amt = money(v.amount); if (amt == null) it.reasons.push({ field: "amount", reason: "no amount" }); else if (isNaN(amt)) it.reasons.push({ field: "amount", reason: `"${v.amount}" is not a valid amount` }); else if (amt <= 0) it.reasons.push({ field: "amount", reason: `amount ${$(amt)} must be greater than zero` }); if (!accts.has(bank)) it.reasons.push({ field: "bank account", reason: `bank account ${bank} is not in the chart of accounts` }); const acct = acctKey(v.acct); if (acct && !accts.has(acct)) it.reasons.push({ field: "GL account", reason: `account ${acct} is not in the chart of accounts` }); const status = CK_STATUS(v.status); if (!status) it.reasons.push({ field: "status", reason: `status "${v.status}" not recognized (Outstanding, Cleared, Void)` }); // reference: the payee must be on the vendor list let vend = null; if (v.vendorNo) vend = vendors.find(x => up(x.vendorNo) === up(v.vendorNo) || up(x.id) === up(v.vendorNo) || up(x.id) === "V-" + up(v.vendorNo)) || null; if (!vend && v.payee) vend = vendors.find(x => up(x.name) === up(v.payee)) || null; if (v.payee && !vend) it.reasons.push({ field: "payee", reason: `payee "${v.payee}" is not on the vendor list — add the vendor first (Vendors import or Vendor Accounts)` }); const k = num + "|" + bank; if (num && seen[k]) it.reasons.push({ field: "check #", reason: `check #${num} on ${bank} also on row ${seen[k]} — duplicate in this file` }); if (num && !seen[k]) seen[k] = row; if (it.reasons.length) { it.action = "reject"; return it; } const je = D.journalEntry ? D.journalEntry("CK-" + num) : null; const rec = { num, payee: up(v.payee), vendor: vend.name, vendorId: vend.id, amount: amt, acct: acct || "", bankAcct: bank, memo: v.memo || "", inv: v.inv || "", date: new Date(dt.getFullYear(), dt.getMonth(), dt.getDate(), 12).toISOString(), status, imported: true, glPosted: !!je, jeId: je ? je.id : "" }; const cur = checks.find(c => String(c.num).toUpperCase() === num) || null; if (cur) { const curBank = acctKey(cur.bankAcct || defBank); if (curBank !== bank) { it.action = "reject"; it.reasons.push({ field: "check #", reason: `check #${num} already exists on bank account ${curBank} — the register is keyed by check #` }); return it; } const diffs = []; if (R2(cur.amount) !== amt) diffs.push(`amount (${$(cur.amount)} on file, ${$(amt)} in file)`); if (up(cur.payee) !== rec.payee) diffs.push(`payee (${cur.payee} on file)`); const cd = cur.date ? new Date(cur.date) : null; if (cd && !isNaN(cd) && mdy(cd) !== mdy(dt)) diffs.push(`date (${mdy(cd)} on file)`); if (cols.acct && acct && cur.acct && acctKey(cur.acct) !== acct) diffs.push(`GL account (${cur.acct} on file)`); if (diffs.length) { it.action = "reject"; it.reasons.push({ field: "check #", reason: `check #${num} is already in the register with a different ${diffs.join(", ")} — change it in Check Writing (void / re-issue); an import never changes a posted check` }); return it; } if (cols.status && v.status && status !== (cur.status || "Outstanding") && (status === "Void" || cur.status === "Void")) { it.action = "reject"; it.reasons.push({ field: "status", reason: `status ${cur.status || "Outstanding"} → ${status}: voiding / un-voiding is done in Check Writing (it posts the reversal)` }); return it; } it.id = String(cur.num); it.matchBy = "check # + bank account"; const keys = ["memo", "inv"].filter(x => cols[x]); if (cols.status && v.status) keys.push("status"); it.changes = diffRec(cur, rec, keys, opts.clearBlanks); it.action = Object.keys(it.changes).length ? "update" : "skip"; if (it.action === "skip") it.note = "already in the register — no changes"; return it; } it.action = "add"; it.id = num; it.record = rec; it.note = je ? `GL entry ${je.id} already exists — linked, not posted again` : "register record only — no GL entry posted"; return it; }); }; const ACCT_TYPES = (t) => { const s = txt(t).toLowerCase(); if (!s) return ""; if (/^(a|asset|assets|current asset|fixed asset)$/.test(s)) return "Asset"; if (/^(l|liab|liability|liabilities|current liability)$/.test(s)) return "Liability"; if (/^(equity|capital|net worth|owners? equity|q)$/.test(s)) return "Equity"; if (/^(s|sales|sale|revenue|revenues)$/.test(s)) return "Sales"; if (/^(c|cost|costs|cogs|cost of sales|cost of goods sold)$/.test(s)) return "Cost"; if (/^(e|exp|expense|expenses|operating expense)$/.test(s)) return "Expense"; if (/^(i|income|other income)$/.test(s)) return "Income"; if (/^(contra|contra asset|contra-asset)$/.test(s)) return "Contra"; return null; }; const planGlChart = (rows, cols, opts) => { const byAcct = {}; chart().forEach(a => { byAcct[acctKey(a.acct || a.account || a.no)] = a; }); const seen = {}; const keys = ["name", "type", "group", "sched", "openingBalRef", "alt", "dc", "costAcct", "invAcct"].filter(k => k === "openingBalRef" ? cols.bal : cols[k]); // every account number the file itself lists — a cost / inventory account may be defined further down the same file const inFile = new Set(); if (cols.acct) rows.forEach(r => { const a = acctKey((r || {})[cols.acct]); if (a) inFile.add(a); }); return rows.map((raw, i) => { const row = i + 2, v = valsOf(raw, cols), it = item({ rows: [row], raw }); const acct = acctKey(v.acct); it.key = acct || `row ${row}`; it.label = v.name; if (!acct) it.reasons.push({ field: "account #", reason: "no account number" }); else if (!/^[0-9A-Z][0-9A-Z\-\.]{0,11}$/.test(acct)) it.reasons.push({ field: "account #", reason: `"${v.acct}" is not an account number` }); const type = ACCT_TYPES(v.type); if (type === null) it.reasons.push({ field: "type", reason: `account type "${v.type}" not recognized (Asset, Liability, Equity, Sales, Cost, Expense, Income, Contra)` }); let bal = null; if (v.bal) { bal = money(v.bal); if (isNaN(bal)) { it.warnings.push({ field: "balance", reason: `"${v.bal}" is not an amount — not imported` }); bal = null; } } const altNo = txt(v.alt), dcFlag = up(v.dc), costAcct = acctKey(v.costAcct), invAcct = acctKey(v.invAcct); if (dcFlag && !/^[YN]$/.test(dcFlag)) it.warnings.push({ field: "D/C flag", reason: `D/C flag "${v.dc}" is not Y or N — stored as printed` }); [["cost account", costAcct], ["inventory account", invAcct]].forEach(([lab, a]) => { if (!a) return; if (!/^[0-9A-Z][0-9A-Z\-\.]{0,11}$/.test(a)) it.reasons.push({ field: lab, reason: `"${a}" is not an account number` }); else if (!byAcct[a] && !inFile.has(a)) it.warnings.push({ field: lab, reason: `${lab} ${a} is not in the chart or in this file — stored as printed` }); }); if (acct && seen[acct]) it.reasons.push({ field: "account #", reason: `account ${acct} also on row ${seen[acct]} — duplicate in this file` }); if (acct && !seen[acct]) seen[acct] = row; const cur = acct ? byAcct[acct] : null; if (!cur && !v.name) it.reasons.push({ field: "name", reason: "new account needs a name" }); if (!cur && !type && type !== null) it.reasons.push({ field: "type", reason: "new account needs an account type" }); if (it.reasons.length) { it.action = "reject"; return it; } const rec = { acct, name: txt(v.name), type: type || "", group: txt(v.group), sched: cols.sched && v.sched ? yes(v.sched) : undefined, openingBalRef: bal == null ? "" : bal, alt: altNo, dc: dcFlag, costAcct, invAcct }; if (!cur) { it.action = "add"; it.id = acct; it.record = { acct, name: rec.name, type, bal: 0, sched: !!rec.sched, ...(rec.group ? { group: rec.group } : {}), ...(bal != null ? { openingBalRef: bal } : {}), ...(altNo ? { alt: altNo } : {}), ...(dcFlag ? { dc: dcFlag } : {}), ...(costAcct ? { costAcct } : {}), ...(invAcct ? { invAcct } : {}), imported: true }; if (bal != null) it.note = `balance ${$(bal)} kept as reference only — not posted to the GL`; return it; } it.id = acct; it.matchBy = "account #"; it.changes = diffRec(cur, rec, keys, opts.clearBlanks, { openingBalRef: 1 }); it.action = Object.keys(it.changes).length ? "update" : "skip"; if (it.action === "skip") it.note = "already in the chart — no changes"; return it; }); }; const planGlJournal = (rows, cols) => { const accts = chartSet(); const canPost = !D.can || D.can("glEdit"); const groups = [], byRef = {}, out = []; rows.forEach((raw, i) => { const row = i + 2, v = valsOf(raw, cols); const ref = txt(v.ref); if (!ref) { out.push(item({ rows: [row], raw, key: `row ${row}`, action: "reject", reasons: [{ field: "reference", reason: "no entry reference — the line can't be grouped into an entry" }] })); return; } if (!byRef[ref]) { byRef[ref] = { ref, rows: [], raws: [], vals: [] }; groups.push(byRef[ref]); } byRef[ref].rows.push(row); byRef[ref].raws.push(raw); byRef[ref].vals.push(v); }); const allJE = D.journalEntries ? D.journalEntries() : []; groups.forEach(g => { const it = item({ rows: g.rows, raw: g.raws[0], raws: g.raws, key: g.ref }); if (!canPost) it.reasons.push({ field: "role", reason: "your role can't post journal entries — ask the office manager or an admin" }); const lines = [], dates = new Set(); let dt = null; g.vals.forEach((v, k) => { const row = g.rows[k]; const acct = acctKey(v.acct); if (!acct) it.reasons.push({ field: "account", row, reason: `row ${row}: no GL account` }); else if (!accts.has(acct)) it.reasons.push({ field: "account", row, reason: `row ${row}: account ${acct} is not in the chart of accounts` }); let dr = cols.dr ? money(v.dr) : null, cr = cols.cr ? money(v.cr) : null; if (typeof dr === "number" && isNaN(dr)) { it.reasons.push({ field: "debit", row, reason: `row ${row}: debit "${v.dr}" is not an amount` }); dr = 0; } if (typeof cr === "number" && isNaN(cr)) { it.reasons.push({ field: "credit", row, reason: `row ${row}: credit "${v.cr}" is not an amount` }); cr = 0; } if (!dr && !cr && cols.amount && v.amount) { const a = money(v.amount); if (isNaN(a)) it.reasons.push({ field: "amount", row, reason: `row ${row}: amount "${v.amount}" is not an amount` }); else if (a > 0) dr = a; else if (a < 0) cr = -a; } if ((dr || 0) < 0 || (cr || 0) < 0) it.reasons.push({ field: "amount", row, reason: `row ${row}: negative debit / credit — use the other column` }); if (dr && cr) it.reasons.push({ field: "amount", row, reason: `row ${row}: has both a debit and a credit` }); if (!dr && !cr) it.warnings.push({ field: "amount", row, reason: `row ${row}: no amount — line left out of the entry` }); const d = parseDate(v.date); if (!v.date) it.reasons.push({ field: "date", row, reason: `row ${row}: no date` }); else if (isBadDate(d)) it.reasons.push({ field: "date", row, reason: `row ${row}: "${v.date}" is not a valid date` }); else { dates.add(mdy(d)); dt = dt || d; } if (acct && (dr || cr)) lines.push({ acct, dr: R2(dr || 0), cr: R2(cr || 0), desc: v.desc || "", ...(txt(v.control) ? { control: txt(v.control).toUpperCase() } : {}), ...(txt(v.controlName) ? { controlName: txt(v.controlName) } : {}) }); }); if (dates.size > 1) it.reasons.push({ field: "date", reason: `lines have different dates (${[...dates].join(", ")}) — one entry, one date` }); const b = D.jeBalance ? D.jeBalance(lines) : { dr: 0, cr: 0, diff: 0, ok: true }; if (lines.length && !b.ok) it.reasons.push({ field: "balance", reason: b.dr ? `out of balance by ${$(Math.abs(b.diff))} (debits ${$(b.dr)}, credits ${$(b.cr)})` : "needs at least one debit and one credit" }); if (!lines.length && !it.reasons.length) it.reasons.push({ field: "amount", reason: "no lines with an amount" }); if (dt && D.periodIsClosed && D.periodIsClosed(perOf(dt))) it.reasons.push({ field: "date", reason: `${perOf(dt)} is closed — date the entry in an open period, or reopen ${perOf(dt)} first` }); const id = "IMP-" + g.ref.replace(/[^A-Za-z0-9\-]/g, "").slice(0, 24); it.id = id; it.label = dt ? `${mdy(dt)} · ${lines.length} line${lines.length === 1 ? "" : "s"} · ${$(b.dr)}` : ""; const desc = (g.vals.find(v => v.entryDesc) || {}).entryDesc || (g.vals.find(v => v.desc) || {}).desc || `Imported entry ${g.ref}`; const jrn = parseInt((g.vals.find(v => v.jrn) || {}).jrn, 10) || 90; it.entry = { id, ref: g.ref, date: dt ? mdy(dt) : "", desc, jrn, lines, src: "Import" }; it.totals = { dr: b.dr, cr: b.cr }; const existing = allJE.find(j => j && (String(j.id) === id || String(j.id) === g.ref || (txt(j.ref) === g.ref && j.src === "Import"))); if (existing && !it.reasons.length) { const sig = (ls) => (ls || []).map(l => `${acctKey(l.acct)}:${R2(l.dr)}:${R2(l.cr)}`).sort().join(";"); if (sig(existing.lines) === sig(lines)) { it.action = "skip"; it.id = existing.id; it.note = `already posted as ${existing.id} — not posted again`; } else { it.action = "reject"; it.reasons.push({ field: "reference", reason: `reference ${g.ref} is already posted as ${existing.id} with different lines — reverse it in Accounting first; posted entries are never changed by an import` }); } out.push(it); return; } it.action = it.reasons.length ? "reject" : "add"; out.push(it); }); return out.sort((a, b) => a.rows[0] - b.rows[0]); }; // Inventory keeps the rebuilt behaviour: the same prep + DMS.importInventory // dry run the import uses (match stock #, then VIN; blanks never overwrite). // Added for vehicle-master files: the same unit listed more than once (the old // report printed in several sort orders) collapses to ONE unit when the rows // agree, and is rejected — every copy — when they disagree. const INV_CMP = ["stock", "vin", "yr", "mk", "mdl", "trim", "body", "color", "miles", "cost", "asking", "msrp", "type", "invAcct", "invoiceDate", "invoiceCost", "recon", "costBooked", "age", "pack"]; const INV_CMPNUM = { miles: 1, cost: 1, asking: 1, msrp: 1, invoiceCost: 1, recon: 1, costBooked: 1, age: 1, pack: 1 }; const invBlank = (v) => v == null || v === "" || (typeof v === "number" && isNaN(v)); const invPrep = (rows, mapping, opts) => { const JI = window.JBImporters; const m = {}; Object.keys(mapping || {}).forEach(h => { const f = mapping[h]; m[h] = f && f !== SKIP ? f : ""; }); const mr = JI.mapInventoryRows(rows, m); const pr = JI.prepareInventoryRows({ rows: mr.rows, mapping: m }, { clearBlanks: !!opts.clearBlanks, units: D.inventory() }); const norm = pr.rows.map(r => (D._normUnitRow ? D._normUnitRow(r || {}) : {})); const byKey = {}; norm.forEach((n, i) => { const k = n.stock || n.vin; if (k) (byKey[k] = byKey[k] || []).push(i); }); const outRows = pr.rows.slice(), dup = {}; Object.keys(byKey).forEach(k => { const ix = byKey[k]; if (ix.length < 2) return; const first = ix[0], conflicts = []; ix.slice(1).forEach(i => INV_CMP.forEach(f => { const a = norm[first][f], b = norm[i][f]; if (invBlank(a) || invBlank(b)) return; const same = INV_CMPNUM[f] ? Math.abs(+a - +b) < 0.005 : f === "invoiceDate" ? String(a).slice(0, 10) === String(b).slice(0, 10) : String(a).trim().toUpperCase() === String(b).trim().toUpperCase(); if (!same) conflicts.push({ field: f, a, b, rows: [first + 2, i + 2] }); })); if (conflicts.length) { const c = conflicts[0]; const why = `${k} is listed on rows ${ix.map(i => i + 2).join(", ")} with different ${c.field === "recon" ? "total repairs" : c.field === "costBooked" ? "GL cost" : c.field === "invoiceCost" ? "purchase cost" : c.field} (${c.a} on row ${c.rows[0]}, ${c.b} on row ${c.rows[1]})${conflicts.length > 1 ? ` and ${conflicts.length - 1} other difference(s)` : ""} — none of the copies is imported; fix the file`; ix.forEach(i => { dup[i + 2] = { kind: "conflict", why, of: first + 2 }; outRows[i] = {}; }); } else { // identical copies: fill any blank on the first copy from the others, then the engine skips the repeats const base = { ...outRows[first] }; ix.slice(1).forEach(i => { Object.keys(outRows[i] || {}).forEach(f => { if (invBlank(base[f]) && !invBlank(outRows[i][f])) base[f] = outRows[i][f]; }); dup[i + 2] = { kind: "same", of: first + 2 }; }); outRows[first] = base; } }); return { rows: outRows, issues: [...mr.issues, ...pr.issues], dup }; }; const NUMF = { miles: 1, cost: 1, asking: 1, msrp: 1, invoiceCost: 1, recon: 1, costBooked: 1, age: 1, pack: 1 }; const invSame = (f, a, b) => NUMF[f] ? (a == null || a === "" ? 0 : +a) === (b == null || b === "" ? 0 : +b) : (f === "invoiceDate" || f === "stockedDate") ? String(a == null ? "" : a).slice(0, 10) === String(b == null ? "" : b).slice(0, 10) : String(a == null ? "" : a).trim() === String(b == null ? "" : b).trim(); const planInventory = (rows, mapping, opts) => { const prep = invPrep(rows, mapping, opts); const dry = D.importInventory(prep.rows, { dryRun: true, clearBlanks: !!opts.clearBlanks }); const byRow = {}; (dry.plan || []).forEach(p => { byRow[p.row] = p; }); const iss = {}; [...prep.issues, ...(dry.errors || [])].forEach(x => { (iss[x.row] = iss[x.row] || []).push({ field: x.field, reason: x.reason }); }); const mappedF = new Set(Object.values(mapping || {}).filter(f => f && f !== SKIP)); const warnings = []; if (mappedF.has("cost") && mappedF.has("invoiceCost")) warnings.push({ code: "cost_twice", title: "Two cost columns are mapped", detail: "Both “Vehicle cost” and “Purchase / invoice cost” are mapped. The purchase / invoice cost becomes the unit cost and repairs are kept separately (Total repairs), so repairs are never counted twice. Skip “Vehicle cost” if it is the total of purchase + repairs." }); if (mappedF.has("glCost")) warnings.push({ code: "gl_cost_ref", title: "GL cost is reference only", detail: "The old ledger's cost is stored as the unit's “booked in GL” cost so the unit's Accounting tab shows any difference from its cost. No journal entry is posted by this import." }); const sum = { postsGL: false, units: 0, collapsed: 0, conflicts: 0, typeFromAccount: 0, glCostTotal: 0, byAccount: {} }; const out = rows.map((raw, i) => { const row = i + 2, p = byRow[row], it = item({ rows: [row], raw }); const n = D._normUnitRow ? D._normUnitRow(prep.rows[i] || {}) : {}; const nRaw = (n.stock || n.vin) ? n : (D._normUnitRow ? D._normUnitRow(mapRowFor(raw, mapping)) : {}); it.key = (p && p.stock) || nRaw.stock || nRaw.vin || `row ${row}`; it.label = [nRaw.yr, nRaw.mk, nRaw.mdl].filter(Boolean).join(" "); const dp = prep.dup[row]; if (dp && dp.kind === "conflict") { sum.conflicts++; it.action = "reject"; it.reasons = [{ field: "stock", reason: dp.why }]; return it; } if (dp && dp.kind === "same") { sum.collapsed++; it.action = "skip"; it.note = `same vehicle as row ${dp.of} (listed again with identical data) — counted once`; it.matchBy = "stock # / VIN"; return it; } if (!p) { it.action = "reject"; it.reasons = iss[row] || [{ field: "row", reason: "not imported" }]; return it; } it.warnings = iss[row] || []; it.id = p.stock; sum.units++; if (n.typeFromAcct) sum.typeFromAccount++; if (!invBlank(n.costBooked)) sum.glCostTotal = R2(sum.glCostTotal + n.costBooked); if (n.invAcct) { const b = sum.byAccount[n.invAcct] || (sum.byAccount[n.invAcct] = { units: 0, glCost: 0 }); b.units++; b.glCost = R2(b.glCost + (invBlank(n.costBooked) ? 0 : n.costBooked)); } if (p.action === "create") { it.action = "add"; it.record = p.fields; if (n.typeFromAcct) it.note = `New / Used taken from inventory account ${n.invAcct}`; return it; } if (p.action === "update") { const ch = {}; Object.keys(p.changes || {}).forEach(f => { if (!invSame(f, p.changes[f].from, p.changes[f].to)) ch[f] = p.changes[f]; }); if (Object.keys(ch).length) { it.action = "update"; it.changes = ch; it.matchBy = "stock # / VIN"; return it; } } it.action = "skip"; it.note = "already in inventory — no changes"; it.matchBy = "stock # / VIN"; return it; }); return { items: out, warnings, summary: sum }; }; // one raw file row → the key/value row importInventory sees (used only to label rejected rows) const mapRowFor = (raw, mapping) => { const JI = window.JBImporters; const m = {}; Object.keys(mapping || {}).forEach(h => { const f = mapping[h]; m[h] = f && f !== SKIP ? f : ""; }); try { return JI.mapInventoryRows([raw], m).rows[0] || {}; } catch (e) { return {}; } }; // ══════════════════════════════════════════════════════════════════════ // TRIAL BALANCE → ONE balanced opening-balances entry (OB-yyyymmdd) // Sign convention = the old print's: + debit, − credit. The entry posts each // account's YEAR-TO-DATE amount (balance-sheet accounts = balance, P&L // accounts = year-to-date activity). The month-to-date column is reference only. // ══════════════════════════════════════════════════════════════════════ const OB_SRC = "Opening balances"; const PL_TYPES = { Sales: 1, Cost: 1, Expense: 1, Income: 1 }; const TB_ACCT_OK = /^[0-9][0-9A-Z\-\.]{0,11}$/; const MONTHS_LONG = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; const perLabel = (per) => { const m = /^(\d{4})-(\d{2})$/.exec(String(per || "")); return m ? `${MONTHS_LONG[+m[2] - 1]} ${m[1]}` : String(per || ""); }; const chartIndex = () => { const m = {}; chart().forEach(a => { m[acctKey(a.acct || a.account || a.no)] = a; }); return m; }; const inferAcctType = (acct, idx) => { const a = acctKey(acct); if (/^0\d{3}/.test(a)) return "Expense"; const base = (a.match(/^\d+/) || [""])[0]; if (base && idx[base] && idx[base].type) return idx[base].type; return ({ 1: "Asset", 2: "Asset", 3: "Liability", 4: "Sales", 5: "Sales", 6: "Cost", 7: "Expense", 8: "Income", 9: "Income" })[base[0]] || "Expense"; }; const typeOfAcct = (acct, idx) => { const r = idx[acctKey(acct)]; const t = r && txt(r.type); return t ? t[0].toUpperCase() + t.slice(1) : inferAcctType(acct, idx); }; const isEquityish = (acct, name) => /^(370|375|380|399)$/.test(acctKey(acct)) || /retained earn|net profit|profit or loss|capital stock|dividend|owner.?s? (equity|draw)/i.test(name || ""); const kindOfTbRow = (rowKind, acct, desc) => { const rk = txt(rowKind).toLowerCase().replace(/[\s\-]+/g, "_"); // some exports put the words in the account column itself ("TOTAL", "GRAND TOTAL", "ACCOUNT TOTAL") if (!rk && /^(GRAND|REPORT|FINAL)?TOTAL$/.test(acct)) return "grand_total"; if (!rk && /^(ACCOUNT|ACCT)TOTAL$/.test(acct)) return "account_total"; if (!rk && /^GROUP.*TOTAL$/.test(acct)) return "group_total"; if (rk) { if (/^(account|acct|detail|line)$/.test(rk)) return "account"; if (/account_total|acct_total|subtotal|sub_total/.test(rk)) return "account_total"; if (/group/.test(rk)) return "group_total"; if (/grand|report_total|final_total|^total$/.test(rk)) return "grand_total"; } const d = txt(desc); if (/(^|\s)account\s*total\s*:?$/i.test(d)) return "account_total"; if (/^group\b.*\btotal\s*:?$/i.test(d)) return "group_total"; if (/^(grand\s+|report\s+|final\s+)?total\s*:?$/i.test(d)) return "grand_total"; if (!acct && (!d || /\btotal\b/i.test(d))) return "account_total"; // an unlabeled subtotal line return "account"; }; const planGlTrial = (rows, cols, opts) => { const idx = chartIndex(), known = chartSet(); const blockers = [], warnings = []; const blocker = (code, title, detail, fix) => blockers.push({ code, title, detail, fix: fix || "" }); const seq = []; // every parsed row, in file order const seenAcct = {}; // account → seq entry of the counted row const items = rows.map((raw, i) => { const row = i + 2, v = valsOf(raw, cols), it = item({ rows: [row], raw }); const acct = acctKey(v.acct), desc = txt(v.name); const kind = kindOfTbRow(v.rowKind, acct, desc); let ytd = money(v.ytd), mtd = cols.mtd ? money(v.mtd) : null; const e = { row, it, kind, acct, desc, ytd: null, mtd: null, yc: 0, mc: 0 }; seq.push(e); it.key = acct || (desc ? desc.slice(0, 24) : `row ${row}`); it.label = desc; if (kind !== "account") { e.ytd = typeof ytd === "number" && !isNaN(ytd) ? ytd : null; e.mtd = typeof mtd === "number" && !isNaN(mtd) ? mtd : null; if ((typeof ytd === "number" && isNaN(ytd)) || (typeof mtd === "number" && isNaN(mtd))) it.warnings.push({ field: "amount", reason: "a subtotal amount is not a number — ignored in the checks" }); it.action = "skip"; it.note = `${kind === "account_total" ? "account total" : kind === "group_total" ? "group total" : "grand total"} row — used only to check the file, never posted`; return it; } if (!acct && ytd == null && mtd == null) { it.action = "skip"; it.note = "empty row"; e.kind = "empty"; return it; } if (!acct) { it.reasons.push({ field: "account #", reason: "this row has an amount but no account number — add the number, or delete the row" }); it.action = "reject"; return it; } if (!TB_ACCT_OK.test(acct)) it.reasons.push({ field: "account #", reason: `"${v.acct}" is not an account number` }); if (ytd == null && mtd == null) { it.action = "skip"; it.note = "no amounts on this row"; e.kind = "empty"; return it; } if (ytd == null) it.reasons.push({ field: "year to date", reason: "no year-to-date amount — the opening entry posts the YTD column" }); else if (isNaN(ytd)) it.reasons.push({ field: "year to date", reason: `"${v.ytd}" is not an amount` }); if (typeof mtd === "number" && isNaN(mtd)) { it.warnings.push({ field: "month to date", reason: `"${v.mtd}" is not an amount — month to date is reference only, ignored` }); mtd = null; } if (cols.mtdCount && txt(v.mtdCount) && isNaN(intOf(v.mtdCount))) it.warnings.push({ field: "MTD count", reason: `"${v.mtdCount}" is not a number — ignored` }); if (cols.ytdCount && txt(v.ytdCount) && isNaN(intOf(v.ytdCount))) it.warnings.push({ field: "YTD count", reason: `"${v.ytdCount}" is not a number — ignored` }); if (it.reasons.length) { it.action = "reject"; return it; } e.ytd = ytd; e.mtd = mtd; e.yc = cents(ytd); e.mc = mtd == null ? 0 : cents(mtd); e.mtdCount = cols.mtdCount ? intOf(v.mtdCount) : null; e.ytdCount = cols.ytdCount ? intOf(v.ytdCount) : null; // the same account twice: identical rows are one page printed twice; different amounts are a real conflict const first = seenAcct[acct]; if (first) { if (first.yc === e.yc && first.mc === e.mc) { e.kind = "dup"; it.action = "skip"; it.note = `same account and amounts as row ${first.row} (page printed twice?) — counted once`; it.warnings.push({ field: "account #", reason: `account ${acct} repeats row ${first.row} with identical amounts — counted once` }); return it; } e.kind = "conflict"; const why = `account ${acct} is on rows ${first.row} and ${row} with different amounts (${$(first.ytd)} vs ${$(ytd)} year to date) — a trial balance lists each account once`; it.reasons.push({ field: "account #", reason: why }); it.action = "reject"; if (!first.it.reasons.length || first.it.action !== "reject") { first.it.reasons.push({ field: "account #", reason: why }); first.it.action = "reject"; first.conflict = true; } return it; } seenAcct[acct] = e; return it; }); // ── what is a real account line, what is a subtotal ── const accts = seq.filter(e => e.kind === "account" && !e.it.reasons.length && e.ytd != null); const sumY = accts.reduce((s, e) => s + e.yc, 0), sumM = accts.reduce((s, e) => s + e.mc, 0); const rowProblems = items.filter(it => it.action === "reject").length; // ── as-of date ── let asOf = asDate(opts.asOf), asOfSource = asOf ? "your choice" : ""; if (!asOf) { const d = asOfInHeading(opts.preface); if (d) { asOf = d; asOfSource = "the report heading"; } } if (!asOf) { const d = dateInText(opts.fileName); if (d) { asOf = d; asOfSource = "the file name"; } } if (!asOf) { const d = dateInText(opts.sheetName); if (d) { asOf = d; asOfSource = "the sheet name"; } } if (txt(opts.asOf) && !asDate(opts.asOf)) blocker("asof_invalid", "The as-of date is not a date", `“${txt(opts.asOf)}” cannot be read as a date.`, "Enter the as-of date as month/day/year, e.g. 09/30/2026."); else if (!asOf) blocker("asof_required", "As-of date required", "A trial balance is a snapshot — the date it is as of is not in the file or its name.", "Enter the date the old report is as of (for example 09/30/2026 — not the date it was printed), then preview again."); else if (asOfSource !== "your choice") warnings.push({ code: "asof_default", title: `As-of date taken from ${asOfSource}`, detail: `${mdy(asOf)} — check that this is the date the report is AS OF (not the date it was printed).` }); // ── balance + subtotal checks ── const checks = []; const sumRows = (list) => list.reduce((a, e) => ({ y: a.y + e.yc, m: a.m + e.mc, n: a.n + 1 }), { y: 0, m: 0, n: 0 }); let groupStart = 0; seq.forEach((e, p) => { if (e.kind !== "account_total" && e.kind !== "group_total" && e.kind !== "grand_total") return; let list = []; if (e.kind === "account_total") { for (let j = p - 1; j >= 0; j--) { const q = seq[j]; if (q.kind === "account_total" || q.kind === "group_total" || q.kind === "grand_total") break; if (q.kind !== "account") { if (q.kind === "dup" || q.kind === "empty") continue; break; } if (e.acct && !q.acct.startsWith(e.acct)) break; if (q.it.reasons.length || q.ytd == null) continue; list.unshift(q); } } else if (e.kind === "group_total") { list = seq.slice(groupStart, p).filter(q => q.kind === "account" && !q.it.reasons.length && q.ytd != null); groupStart = p + 1; } else list = accts; const t = sumRows(list); const label = e.kind === "account_total" ? `Account Total ${e.acct}`.trim() : e.kind === "group_total" ? (e.desc || "Group total") : "Grand total"; const okY = e.ytd == null || cents(e.ytd) === t.y, okM = e.mtd == null || cents(e.mtd) === t.m; checks.push({ kind: e.kind, label, row: e.row, firstRow: list.length ? list[0].row : e.row, lastRow: list.length ? list[list.length - 1].row : e.row, accounts: t.n, printedYtd: e.ytd, computedYtd: R2(t.y / 100), printedMtd: e.mtd, computedMtd: R2(t.m / 100), ok: okY && okM, okYtd: okY, okMtd: okM }); }); const breaks = checks.filter(c => !c.ok); const diffC = sumY; // cents; must be 0 if (rowProblems) blocker("row_problems", `${rowProblems} row${rowProblems === 1 ? " has" : "s have"} problems`, "A trial balance can only be imported whole — a missing or rejected row would leave the entry out of balance. The rows marked REJECT say what is wrong.", "Fix those rows in the file (or delete the repeated ones) and preview again. Nothing was posted."); if (diffC !== 0 && accts.length && !rowProblems) { const dAbs = Math.abs(diffC); const sus = []; accts.forEach(e => { if (Math.abs(e.yc) === dAbs) sus.push(`row ${e.row} (${e.acct}, ${$(e.ytd)}) has exactly that amount`); else if (Math.abs(e.yc) * 2 === dAbs) sus.push(`row ${e.row} (${e.acct}, ${$(e.ytd)}) may have the wrong sign (+/−)`); }); const b = breaks[0]; const where = b ? ` The first subtotal that does not match its accounts is “${b.label}” (rows ${b.firstRow}–${b.row}): ${!b.okYtd ? `year to date printed ${$(b.printedYtd)} but its accounts add to ${$(b.computedYtd)}` : `month to date printed ${$(b.printedMtd)} but its accounts add to ${$(b.computedMtd)}`} — look at the rows in that group.` : (checks.length ? " Every subtotal in the file matches its own accounts, so a whole group of accounts may be missing (or a page was not included)." : " The file has no subtotal rows to narrow it down — check the last rows / pages for a missing or mistyped amount."); blocker("unbalanced", `Trial balance is out of balance by ${$(diffC / 100)}`, `Debits and credits must be equal (year-to-date total 0.00). This file's accounts add up to ${$(diffC / 100)} (${diffC > 0 ? "debits exceed credits" : "credits exceed debits"}).${where}${sus.length ? " Likely cause: " + sus.slice(0, 3).join("; ") + "." : ""}`, "Correct the file and preview again. Nothing was posted."); } if (accts.length && !rowProblems) { const printedGrand = seq.filter(e => e.kind === "grand_total" && e.ytd != null).pop(); if (printedGrand && cents(printedGrand.ytd) !== 0) warnings.push({ code: "grand_printed", title: "The printed grand total is not zero", detail: `The report's own “Total” row says ${$(printedGrand.ytd)} year to date — a trial balance should total 0.00.` }); } // ── accounts vs the chart ── const unknown = [], newAccounts = []; accts.forEach(e => { if (e.yc === 0) { if (e.it.action !== "reject") { e.it.action = "skip"; e.it.note = "zero balance — nothing to post"; e.zero = true; } return; } if (known.has(e.acct) || idx[e.acct]) return; const padded = /^\d{1,3}$/.test(e.acct) && idx[e.acct.padStart(4, "0")] ? e.acct.padStart(4, "0") : ""; // Excel drops a leading zero (0111 → 111) unknown.push({ acct: e.acct, name: e.desc, rows: [e.row], ytd: e.ytd, ...(padded ? { didYouMean: padded } : {}) }); if (opts.addMissingAccounts && e.desc && !padded) { e.newAcct = true; e.it.note = "not in the chart — will be added from this report's description"; newAccounts.push({ acct: e.acct, name: e.desc.toUpperCase(), type: inferAcctType(e.acct, idx), rows: [e.row] }); } else { e.it.action = "reject"; e.it.reasons.push({ field: "account #", reason: padded ? `account ${e.acct} is not in the chart of accounts — did Excel drop a leading zero? ${padded} is “${idx[padded].name || idx[padded].desc || ""}”. Format the account column as text and re-save` : opts.addMissingAccounts ? `account ${e.acct} is not in the chart and the report gives it no description to create it from` : `account ${e.acct}${e.desc ? ` (${e.desc})` : ""} is not in the chart of accounts — tick “Add accounts missing from the chart” to create it from the report's description, or import the chart first` }); } }); const unresolved = unknown.filter(u => !(opts.addMissingAccounts && u.name && !u.didYouMean)); if (unresolved.length) blocker("unknown_accounts", `${unresolved.length} account${unresolved.length === 1 ? " is" : "s are"} not in the chart of accounts`, unresolved.slice(0, 8).map(u => `${u.acct}${u.name ? " " + u.name : ""}${u.didYouMean ? " (did you mean " + u.didYouMean + "?)" : ""}`).join(", ") + (unresolved.length > 8 ? ` … and ${unresolved.length - 8} more` : ""), opts.addMissingAccounts ? "Those rows have no description to create the account from — add one in the file." : "Tick “Add accounts missing from the chart” (the name comes from the report), or import the chart of accounts first."); // ── period ── const ps = D.periodStatus ? D.periodStatus() : { open: perOf(new Date()), closed: [] }; const mode = opts.periodMode === "openMonth" ? "openMonth" : "asOf"; let period = null, post = null; if (asOf) { const per = perOf(asOf), isClosed = (ps.closed || []).includes(per); const state = isClosed ? "closed" : per > ps.open ? "ahead" : per === ps.open ? "open" : "earlier"; const [oy, om] = String(ps.open).split("-").map(Number); post = mode === "openMonth" && per < ps.open ? new Date(oy, om - 1, 1, 12) : asOf; period = { asOfPeriod: per, asOfPeriodLabel: perLabel(per), state, open: ps.open, openLabel: perLabel(ps.open), closed: (ps.closed || []).slice(), mode, postDate: mdy(post), postPeriod: perOf(post) }; if (mode === "asOf" && isClosed) blocker("period_closed", `${perLabel(per)} is closed`, `The opening-balances entry would be dated ${mdy(asOf)}, inside the closed month ${per}. It will not be put in any other month silently.`, `Reopen ${per} in Accounting › F6-Period End › Reopen period and preview again — or choose “The open month” (${perLabel(ps.open)}) so it posts on ${mdy(new Date(oy, om - 1, 1, 12))} with the as-of date in its description.`); else if (mode === "openMonth" && D.periodIsClosed && D.periodIsClosed(perOf(post))) blocker("period_closed", `${perLabel(perOf(post))} is closed`, "The entry's month is closed.", "Reopen it in Accounting › F6-Period End."); if (state === "ahead") warnings.push({ code: "post_ahead", title: "As-of month is after the open month", detail: `The entry will post ahead (${per} while ${ps.open} is open) and is released when ${ps.open} closes.` }); if (mode === "openMonth" && per < ps.open) warnings.push({ code: "open_month", title: "Entry dated in the open month", detail: `Balances as of ${mdy(asOf)} are posted on ${mdy(post)} (first day of ${perLabel(ps.open)}); the as-of date is in the entry's description and reference.` }); } // ── an opening-balances entry already in the ledger? never doubled ── const jes = D.journalEntries ? D.journalEntries() : []; const ref = asOf ? "OB-" + ymd(asOf) : ""; const obs = jes.filter(j => j && j.src === OB_SRC && !j.reverses && !j.reversedBy); const sameRef = ref ? obs.filter(j => txt(j.ref) === ref || String(j.id) === ref) : []; const otherRef = ref ? obs.filter(j => !(txt(j.ref) === ref || String(j.id) === ref)) : []; const planned = {}; accts.forEach(e => { if (e.yc !== 0 && !e.it.reasons.length) planned[e.acct] = (planned[e.acct] || 0) + e.yc; }); let alreadyPosted = null; if (sameRef.length) { const have = {}; sameRef.forEach(j => (j.lines || []).forEach(l => { const k = acctKey(l.acct); have[k] = (have[k] || 0) + cents(l.dr) - cents(l.cr); })); const diffs = []; new Set([...Object.keys(planned), ...Object.keys(have)]).forEach(k => { const a = planned[k] || 0, b = have[k] || 0; if (a !== b) diffs.push({ acct: k, file: a / 100, posted: b / 100, delta: Math.abs(a - b) }); }); if (!diffs.length) alreadyPosted = { id: sameRef[0].id, date: sameRef[0].date }; else { diffs.sort((x, y) => y.delta - x.delta); blocker("opening_exists_different", `${sameRef[0].id} is already posted with different amounts`, `An opening-balances entry for ${mdy(asOf)} is already in the ledger and ${diffs.length} account${diffs.length === 1 ? " differs" : "s differ"} from this file (e.g. ${diffs.slice(0, 4).map(d => `${d.acct}: posted ${$(d.posted)}, file ${$(d.file)}`).join("; ")}). Posted entries are never changed — and never doubled — by an import.`, `If this file is the right one, reverse ${sameRef[0].id} first (Accounting › Journal › open the entry › Reverse), then import again.`); } } if (otherRef.length && !alreadyPosted) blocker("other_opening_entry", `Another opening-balances entry is already posted (${otherRef.map(j => j.id).join(", ")})`, `Importing a second trial balance as of ${mdy(asOf)} on top of ${otherRef.map(j => `${j.id} (${j.date})`).join(", ")} would count every balance twice.`, `Reverse ${otherRef[0].id} first (Accounting › Journal › open the entry › Reverse) if this new file replaces it.`); if (asOf && !alreadyPosted) { const activity = jes.filter(j => j && !j.reverses && !j.reversedBy && j.src !== OB_SRC && (() => { const d = new Date(j.date); return !isNaN(d) && d <= new Date(asOf.getFullYear(), asOf.getMonth(), asOf.getDate(), 23, 59); })()); if (activity.length) warnings.push({ code: "existing_activity", title: `${activity.length} entr${activity.length === 1 ? "y" : "ies"} dated on or before ${mdy(asOf)} already in K2B`, detail: "If the old trial balance already includes those transactions, their effect would be counted twice. An opening-balances import normally goes into a ledger that starts after the as-of date." }); } // ── chart type totals, profit and the equity accounts (shown, never changed) ── const byType = {}; const equity = []; let plC = 0; accts.forEach(e => { const t = e.newAcct ? inferAcctType(e.acct, idx) : typeOfAcct(e.acct, idx); const b = byType[t] || (byType[t] = { type: t, accounts: 0, debit: 0, credit: 0 }); b.accounts++; if (e.yc > 0) b.debit += e.yc; else b.credit += -e.yc; if (PL_TYPES[t]) plC += e.yc; if (isEquityish(e.acct, e.desc || (idx[e.acct] && idx[e.acct].name))) equity.push({ acct: e.acct, name: e.desc || (idx[e.acct] && idx[e.acct].name) || "", ytd: e.ytd }); }); Object.values(byType).forEach(b => { b.debit = R2(b.debit / 100); b.credit = R2(b.credit / 100); b.net = R2(b.debit - b.credit); }); const profit = { plNet: R2(plC / 100), profit: R2(-plC / 100), note: "Profit & loss accounts are posted with their year-to-date activity exactly as printed, and the retained-earnings / net-profit accounts are posted exactly as printed — nothing is rolled into retained earnings or changed. Year-end closing then works the normal K2B way." }; // ── the entry ── const posting = accts.filter(e => e.yc !== 0 && !e.it.reasons.length && e.it.action !== "reject"); const lines = posting.map(e => ({ acct: e.acct, dr: e.yc > 0 ? R2(e.yc / 100) : 0, cr: e.yc < 0 ? R2(-e.yc / 100) : 0, desc: e.desc || (idx[e.acct] && idx[e.acct].name) || "" })); let entry = null; if (asOf && lines.length) { let id = alreadyPosted ? alreadyPosted.id : ref, n = 1; if (!alreadyPosted) while (D.journalEntry && D.journalEntry(id)) { n++; id = `${ref}-${n}`; } const mtdRef = {}; posting.forEach(e => { if (e.mtd != null) mtdRef[e.acct] = e.mtd; }); entry = { id, ref, date: period ? period.postDate : mdy(asOf), jrn: 90, src: OB_SRC, desc: `Opening balances — imported from old DMS trial balance as of ${mdy(asOf)}${period && period.postDate !== mdy(asOf) ? ` (dated ${period.postDate} — open month)` : ""}`, lines, extra: { openingBalances: true, asOf: isoNoon(asOf), mtdRef } }; } posting.forEach(e => { if (!e.it.reasons.length) { e.it.action = alreadyPosted ? "skip" : "add"; if (alreadyPosted) e.it.note = `already posted as ${alreadyPosted.id} — not posted again`; else if (!e.it.note) e.it.note = "posts " + (e.yc > 0 ? "debit " : "credit ") + $(Math.abs(e.ytd)); } }); if (alreadyPosted) warnings.push({ code: "already_posted", title: `Already posted as ${alreadyPosted.id}`, detail: `Every line is identical to the entry dated ${alreadyPosted.date}. Nothing will be posted again.` }); const dr = R2(lines.reduce((s, l) => s + l.dr, 0)), cr = R2(lines.reduce((s, l) => s + l.cr, 0)); const summary = { kind: "trialBalance", postsGL: true, asOf: asOf ? mdy(asOf) : "", asOfIso: asOf ? isoNoon(asOf) : "", asOfSource, suggestedAsOf: asOf && asOfSource !== "your choice" ? mdy(asOf) : "", entry: entry ? { id: entry.id, ref, date: entry.date, desc: entry.desc, src: OB_SRC, jrn: 90, lines: lines.length, debits: dr, credits: cr } : null, period, balance: { ytd: R2(sumY / 100), mtd: R2(sumM / 100), ok: sumY === 0 }, counts: { accountRows: accts.length, posted: lines.length, zero: accts.filter(e => e.yc === 0).length, subtotals: seq.filter(e => /_total$/.test(e.kind)).length, repeated: seq.filter(e => e.kind === "dup").length }, checks, breaks, unknown, newAccounts, byType: Object.values(byType), profit, equity, alreadyPosted, mtdReferenceTotal: R2(sumM / 100), }; const blocked = blockers.length > 0; return { items, blockers, warnings, summary, entry: blocked ? null : entry, newAccounts: blocked ? [] : newAccounts }; }; // ══════════════════════════════════════════════════════════════════════ // AGED SCHEDULES → sub-ledger detail. NEVER posts to the general ledger. // 261 factory receivable → Factory receivable list (DMS.factoryReceivables) // 220 A/R service & parts → A/R customer accounts (DMS.arCustomers, matched by control #) // 220A charge accounts 180 days → A/R customer accounts // The GL balance of those accounts comes from the trial balance; the preview shows // a reconciliation line (file total vs GL balance) so a difference is visible, never hidden. // One snapshot per schedule: re-importing the same file is skipped, a different file // for the same date is blocked, a different date is blocked until the earlier import is undone. // ══════════════════════════════════════════════════════════════════════ const SCHED_KIND = { "261": "factory", "220": "ar", "220A": "ar" }; const SCHED_NAME = { "261": "Factory receivable", "220": "A/R service & parts", "220A": "Charge accounts 180 days" }; const SCHED_CTL_OK = /^[A-Z0-9][A-Z0-9 \-.\/]{0,23}$/; const schedGlBalance = (acct, asOf) => { let has = false, c = 0; const lim = asOf ? new Date(asOf.getFullYear(), asOf.getMonth(), asOf.getDate(), 23, 59) : null; (D.journalEntries ? D.journalEntries() : []).forEach(j => { if (!j) return; (j.lines || []).forEach(l => { if (acctKey(l.acct) !== acct) return; has = true; const d = new Date(j.date); if (!lim || isNaN(d) || d <= lim) c += cents(l.dr) - cents(l.cr); }); }); return has ? R2(c / 100) : null; }; // rows already imported for one schedule (from earlier schedule imports) const schedExisting = (gl) => { const out = []; if (SCHED_KIND[gl] === "factory") (D.factoryReceivables() || []).forEach(f => { if (f && f.imported && f.schedule === gl) out.push({ id: f.id, asOfKey: f.asOfKey || "", control: f.deal, days: +f.ageAsOf || 0, c: cents(f.claimed), importId: f.importId }); }); else (D.arCustomers() || []).forEach(a => (a.txns || []).forEach(t => { if (t && t.imported && t.schedule === gl) out.push({ id: t.id, asOfKey: t.asOfKey || "", control: a.acct, days: +t.days || 0, c: cents(t.charge) - cents(t.credit), importId: t.importId }); })); return out; }; const schedSubledger = (gl) => { if (SCHED_KIND[gl] === "factory") return R2((D.factoryReceivables() || []).reduce((s, f) => s + (f && !f.paid ? (+f.claimed || +f.amount || 0) - (+f.received || 0) : 0), 0)); let c = 0; (D.arCustomers() || []).forEach(a => (a.txns || []).forEach(t => { if ((t.schedule || "220") === gl) c += cents(t.charge) - cents(t.credit); })); return R2(c / 100); }; const planSchedule = (rows, cols, opts) => { const blockers = [], warnings = []; const blocker = (code, title, detail, fix) => blockers.push({ code, title, detail, fix: fix || "" }); const defGl = acctKey(opts.glAcct); // ── as-of date: your choice → a single value in the file → report heading → file name → sheet name ── let asOf = asDate(opts.asOf), asOfSource = asOf ? "your choice" : ""; if (!asOf && cols.asOf) { const set = new Set(); rows.forEach(r => { const t = txt((r || {})[cols.asOf]); if (t) set.add(t); }); if (set.size === 1) { const d = asDate([...set][0]); if (d) { asOf = d; asOfSource = "the as-of column"; } } else if (set.size > 1) blocker("asof_mixed", "The as-of dates in the file differ", `The as-of column holds ${set.size} different dates (${[...set].slice(0, 4).join(", ")}).`, "Import one schedule date at a time, or choose a single as-of date."); } if (!asOf) { const d = asOfInHeading(opts.preface); if (d) { asOf = d; asOfSource = "the report heading"; } } if (!asOf) { const d = dateInText(opts.fileName); if (d) { asOf = d; asOfSource = "the file name"; } } if (!asOf) { const d = dateInText(opts.sheetName); if (d) { asOf = d; asOfSource = "the sheet name"; } } if (txt(opts.asOf) && !asDate(opts.asOf)) blocker("asof_invalid", "The as-of date is not a date", `“${txt(opts.asOf)}” cannot be read as a date.`, "Enter the as-of date as month/day/year, e.g. 09/30/2026."); else if (!asOf && !blockers.some(b => b.code === "asof_mixed")) blocker("asof_required", "As-of date required", "The days on a schedule are counted back from the date it is as of — that date is not in the file or its name.", "Enter the date the schedule is as of (for example 09/30/2026), then preview again."); else if (asOf && asOfSource !== "your choice") warnings.push({ code: "asof_default", title: `As-of date taken from ${asOfSource}`, detail: `${mdy(asOf)} — check that this is the date the schedule is AS OF.` }); if (!cols.glAcct && !defGl) blocker("glacct_required", "Which schedule is this?", "The file has no schedule / GL account column and none was chosen — the rows can go to the factory receivable list (261) or to A/R accounts (220 / 220A).", "Choose the schedule (261, 220 or 220A) before the preview."); const today = new Date(); const elapsed = asOf ? Math.max(0, daysBetween(asOf, today)) : 0; // ── rows ── const seen = {}; // tuple → occurrences so far (identical rows keep their own ordinal) const parsed = []; const items = rows.map((raw, i) => { const row = i + 2, v = valsOf(raw, cols), it = item({ rows: [row], raw }); const control = up(v.control).replace(/^#/, "").replace(/\s+/g, " ").trim(); const gl = acctKey(v.glAcct) || defGl; it.key = control || `row ${row}`; it.label = txt(v.name); if (!control) it.reasons.push({ field: "control #", reason: "no control # — the row can't be matched to a customer account or stock" }); else if (!SCHED_CTL_OK.test(control)) it.reasons.push({ field: "control #", reason: `"${v.control}" is not a control # (letters, digits, space, - . / up to 24)` }); let bal = money(v.balance); if (bal == null) it.reasons.push({ field: "balance", reason: "no balance" }); else if (isNaN(bal)) it.reasons.push({ field: "balance", reason: `"${v.balance}" is not an amount` }); let days = cols.days ? intOf(v.days) : null; if (cols.days && txt(v.days) && (days == null || isNaN(days))) it.reasons.push({ field: "days", reason: `"${v.days}" is not a number of days` }); else if (typeof days === "number" && days < 0) it.reasons.push({ field: "days", reason: `${days} days is negative` }); if (days == null) { days = 0; if (!it.reasons.length && bal) it.warnings.push({ field: "days", reason: "no days — shown as current (0 days)" }); } if (!gl) it.reasons.push({ field: "schedule", reason: "which schedule is this — 261, 220 or 220A? Map the schedule column or choose it before the preview" }); else if (!SCHED_KIND[gl]) it.reasons.push({ field: "schedule", reason: `schedule ${gl} is not supported here — post it as a journal entry` }); if (cols.asOf && txt(v.asOf) && !asDate(v.asOf)) it.reasons.push({ field: "as-of date", reason: `"${v.asOf}" is not a valid date` }); if (it.reasons.length) { it.action = "reject"; return it; } if (bal === 0) { it.action = "skip"; it.note = "zero balance — nothing to age"; return it; } const tk = `${gl}|${control}|${days}|${cents(bal)}`; seen[tk] = (seen[tk] || 0) + 1; const e = { row, it, gl, control, name: txt(v.name), days, c: cents(bal), bal, tk, occ: seen[tk] }; parsed.push(e); it.id = control; it.matchBy = SCHED_KIND[gl] === "factory" ? "factory receivable list" : "A/R account (control #)"; return it; }); // ── per schedule: earlier snapshots, identical re-imports ── const bySched = {}; parsed.forEach(e => (bySched[e.gl] = bySched[e.gl] || []).push(e)); const arAccts = {}; (D.arCustomers() || []).forEach(a => { arAccts[String(a.acct)] = a; }); const newAr = {}; const schedules = [], recon = []; Object.keys(bySched).forEach(gl => { const list = bySched[gl], ex = schedExisting(gl); const key = asOf ? ymd(asOf) : ""; const sameDay = ex.filter(x => x.asOfKey === key), other = ex.filter(x => x.asOfKey !== key); let willAdd = list, addC = 0; if (asOf && other.length) { const k0 = other[0].asOfKey; const d0 = `${k0.slice(4, 6)}/${k0.slice(6, 8)}/${k0.slice(0, 4)}`; const ids = [...new Set(other.map(x => x.importId).filter(Boolean))]; blocker("earlier_snapshot", `Schedule ${gl} was already imported as of ${d0}`, `${other.length} row${other.length === 1 ? "" : "s"} from an earlier import of schedule ${gl} (as of ${d0}) are still on file. Adding another date would count the same balances twice.`, `Undo that import first (Import history › open ${ids[0] || "the earlier import"} › Undo), then import this file again.`); } else if (asOf && sameDay.length) { const have = {}; sameDay.forEach(x => { const k = `${gl}|${x.control}|${x.days}|${x.c}`; have[k] = (have[k] || 0) + 1; }); if (!opts.append) { const want = {}; list.forEach(e => { want[e.tk] = (want[e.tk] || 0) + 1; }); const same = Object.keys(want).length === Object.keys(have).length && Object.keys(want).every(k => want[k] === have[k]); if (same) { list.forEach(e => { e.it.action = "skip"; e.it.note = "already imported — not added again"; }); willAdd = []; } else blocker("snapshot_differs", `Schedule ${gl} as of ${mdy(asOf)} is already imported with different rows`, `${sameDay.length} row${sameDay.length === 1 ? " is" : "s are"} on file from the earlier import and this file has ${list.length} that are not identical. Adding them would count balances twice.`, `If this file replaces the earlier one, undo the earlier import first (Import history › Undo). If it is the second half of the same schedule, tick “Add to rows already imported”.`); } else { willAdd = list.filter(e => { if (have[e.tk] > 0) { have[e.tk]--; e.it.action = "skip"; e.it.note = "already imported — not added again"; return false; } return true; }); } } let over = 0, overN = 0, tot = 0, newCt = 0; list.forEach(e => { tot += e.c; if (e.days > 30) { over += e.c; overN++; } }); willAdd.forEach(e => { addC += e.c; e.it.action = "add"; const target = SCHED_KIND[gl] === "factory" ? null : e.control; if (target && !arAccts[target] && !newAr[target]) { newAr[target] = e.name || target; newCt++; } e.it.record = { kind: SCHED_KIND[gl], gl, control: e.control, name: e.name, days: e.days, balance: R2(e.c / 100), occ: e.occ, tk: e.tk }; e.it.note = SCHED_KIND[gl] === "factory" ? `→ factory receivable list · ${e.days} days` : `→ ${arAccts[e.control] ? "A/R account " + e.control : "NEW A/R account " + e.control} · ${e.days} days`; }); const glB = schedGlBalance(gl, asOf); recon.push({ glAcct: gl, name: SCHED_NAME[gl], rows: list.length, imported: R2(tot / 100), subledgerBefore: schedSubledger(gl), glBalance: glB, diff: glB == null ? null : R2(tot / 100 - glB), asOf: asOf ? mdy(asOf) : "", note: glB == null ? "No general-ledger balance for this account yet — import the trial balance to tie it out." : (Math.abs(tot / 100 - glB) < 0.005 ? "Ties to the general ledger." : "Does not tie to the general ledger balance — check the trial balance and this schedule.") }); schedules.push({ glAcct: gl, name: SCHED_NAME[gl], target: SCHED_KIND[gl] === "factory" ? "Factory receivable list" : "A/R customer accounts", rows: list.length, add: willAdd.length, total: R2(tot / 100), toAdd: R2(addC / 100), over30: R2(over / 100), over30Count: overN, newArAccounts: newCt, ageAsOfToday: elapsed }); }); if (asOf && elapsed > 0) warnings.push({ code: "aged_since", title: `Ages are counted to today`, detail: `The schedule is as of ${mdy(asOf)}; the days printed are as of that date, so each balance shows ${elapsed} day${elapsed === 1 ? "" : "s"} older on today's A/R and factory screens.` }); Object.keys(newAr).length && warnings.push({ code: "new_ar_accounts", title: `${Object.keys(newAr).length} new A/R account${Object.keys(newAr).length === 1 ? "" : "s"} will be created`, detail: "A control # that is not an existing A/R account becomes one (named from the schedule's description). No customer (CRM) record is created." }); const summary = { kind: "schedule", postsGL: false, asOf: asOf ? mdy(asOf) : "", asOfIso: asOf ? isoNoon(asOf) : "", asOfSource, suggestedAsOf: asOf && asOfSource !== "your choice" ? mdy(asOf) : "", schedules, reconciliation: recon, rows: parsed.length, total: R2(parsed.reduce((s, e) => s + e.c, 0) / 100), ageNote: elapsed ? `ages shown today = printed days + ${elapsed}` : "ages shown today = printed days" }; return { items, blockers, warnings, summary, asOf, elapsed }; }; // ══════════════════════════════════════════════════════════════════════ // DEALS — closed-deal history from old recaps. RECORDS ONLY: nothing is posted. // An imported deal is history (flags imported / importedFrom / importedAt): // stage "Posted", postedToGL true, status "Funded" (closed — so it is in no // unposted / contracts-in-transit / funding queue), payoffSent true, no // commission, no compliance score, not adjustable. Its accounting is in the // trial balance. Nobody is notified: other stations just receive the records. // id = the deal # exactly as printed. A deal that K2B itself started (not // imported) with the same id is NEVER touched. // ══════════════════════════════════════════════════════════════════════ const pathGet = (o, p) => String(p).split(".").reduce((x, k) => (x == null ? undefined : x[k]), o); const pathSet = (o, p, val) => { const ks = String(p).split("."); let x = o; ks.slice(0, -1).forEach((k, i) => { if (x[k] == null || typeof x[k] !== "object") x[k] = /^\d+$/.test(ks[i + 1]) ? [] : {}; x = x[k]; }); x[ks[ks.length - 1]] = val; }; const DEAL_PATH = { invNo: "invNo", lender: "lender", fiMgr: "recap.fiMgr", salesperson: "salesperson", buyer: "buyer", cobuyer: "cobuyer", street: "street", city: "city", state: "state", postal: "postal", phone: "phone", oldCustNo: "oldCustNo", stock: "stock", vin: "vin", vehicle: "vehicle", miles: "soldMiles", type: "type", tradeStock: "tradeStock", tradeDesc: "tradeDesc", tradeVin: "tradeVin", tradeMiles: "tradeMiles", invoicePrice: "recap.invoicePrice", holdback: "recap.holdback", pack: "recap.pack", sfd: "recap.sfd", glRepairs: "recap.glRepairs", totalRepairs: "recap.totalRepairs", totalCost: "cost", salePrice: "salePrice", cashReceived: "cashDown", cashStated: "recap.cashStated", tradeAcv: "tradeACV", tradeAllow: "tradeAllow", trade2Acv: "recap.trade2Acv", trade2Allow: "recap.trade2Allow", grossProfit: "recap.printed.grossProfit", contractRate: "apr", buyRate: "recap.buyRate", reserve: "recap.reserve", clPrem: "recap.clPrem", clCost: "recap.clCost", ahPrem: "recap.ahPrem", ahCost: "recap.ahCost", vscSale: "vscSale", vscCost: "vscCost", gapSale: "gapSale", gapCost: "gapCost", miscFi: "recap.miscFi", fiProfit: "fiGross", holdbackProfit: "recap.holdbackProfit", miscDealer: "recap.miscDealer", totalDealerProfit: "recap.printed.totalDealerProfit", netNet: "recap.printed.netNet", amtFinanced: "amtFinanced", financeCharge: "recap.printed.financeCharge", term: "term", payment: "payment", payoffLender: "tradeLender", payoff1: "tradePayoff", // RC36 (imp36): the deal history complete vehYear: "year", vehMake: "make", vehModel: "model", vehBody: "body", vehColor: "color", saleAcct: "saleAcct", invAcct: "invAcct", salesperson2: "salesperson2", stockInDate: "stockInDate", purchasedFrom: "purchasedFrom", county: "county", employer: "employer", workPhone: "workPhone", cobuyerRelation: "cobuyerRelation", salesTax: "salesTax", netTrade: "netTrade", totalDown: "totalDown", totalOfPayments: "totalOfPayments", totalSalePrice: "totalSalePrice", firstPayDate: "firstPayDate", tradeYear: "trade.year", tradeMake: "trade.make", tradeModel: "trade.model", tradeBody: "trade.body", tradeColor: "trade.color", ucDocFee: "oldUnconfirmed.docFeeOrPack", ucTotalCost: "oldUnconfirmed.totalCost", ucTradeAcv: "oldUnconfirmed.tradeAcv", ucFrontGross: "oldUnconfirmed.frontGross", ucBackGross: "oldUnconfirmed.backGross", ucCreated: "oldUnconfirmed.created", ucModified: "oldUnconfirmed.modified", }; for (let n = 1; n <= 4; n++) { DEAL_PATH["add" + n + "Label"] = `recap.adds.${n - 1}.label`; DEAL_PATH["add" + n + "Amt"] = `recap.adds.${n - 1}.amount`; } for (let n = 1; n <= 3; n++) { DEAL_PATH["comm" + n + "Name"] = `recap.comm.${n - 1}.name`; DEAL_PATH["comm" + n + "Pct"] = `recap.comm.${n - 1}.pct`; DEAL_PATH["comm" + n + "Flat"] = `recap.comm.${n - 1}.flat`; DEAL_PATH["comm" + n + "Amt"] = `recap.comm.${n - 1}.amt`; } const DEAL_MONEY = ["invoicePrice", "holdback", "pack", "add1Amt", "add2Amt", "add3Amt", "add4Amt", "sfd", "glRepairs", "totalRepairs", "totalCost", "salePrice", "cashReceived", "cashStated", "tradeAcv", "tradeAllow", "trade2Acv", "trade2Allow", "grossProfit", "reserve", "clPrem", "clCost", "ahPrem", "ahCost", "vscSale", "vscCost", "gapSale", "gapCost", "miscFi", "fiProfit", "holdbackProfit", "miscDealer", "totalDealerProfit", "comm1Flat", "comm1Amt", "comm2Flat", "comm2Amt", "comm3Flat", "comm3Amt", "netNet", "amtFinanced", "financeCharge", "payment", "payoff1", "salesTax", "netTrade", "totalDown", "totalOfPayments", "totalSalePrice", "ucDocFee", "ucTotalCost", "ucTradeAcv", "ucFrontGross", "ucBackGross"]; // RC36 (imp36) const DEAL_NUM = ["contractRate", "buyRate", "comm1Pct", "comm2Pct", "comm3Pct"]; const DEAL_INT = ["miles", "tradeMiles", "term", "vehYear", "tradeYear"]; // RC36 (imp36): + the years const DEAL_NUMKEYS = {}; DEAL_MONEY.concat(DEAL_NUM, DEAL_INT).forEach(k => { DEAL_NUMKEYS[k] = 1; }); const DEAL_TEXT = ["invNo", "lender", "fiMgr", "salesperson", "buyer", "cobuyer", "street", "city", "state", "postal", "stock", "vin", "vehicle", "tradeStock", "tradeDesc", "tradeVin", "payoffLender", "oldCustNo", "add1Label", "add2Label", "add3Label", "add4Label", "comm1Name", "comm2Name", "comm3Name", "vehMake", "vehModel", "vehBody", "vehColor", "saleAcct", "invAcct", "salesperson2", "purchasedFrom", "county", "employer", "cobuyerRelation", "tradeMake", "tradeModel", "tradeBody", "tradeColor", "ucCreated", "ucModified"]; // RC36 (imp36) // RC36 (imp36): the open-item columns (Y / N / a date) → oldOpen { cit, payoff, tax, vsc } (true = still open; null = the file has no such column) const DEAL_OPEN = { funded: "cit", payoffPaid: "payoff", taxRemitted: "tax", contractRemitted: "vsc" }; const doneOf = (v) => { const t = up(v); if (!t) return null; if (/^(Y|YES|TRUE|1|X|PAID|FUNDED|REMITTED|SENT|DONE|CLEARED)$/.test(t)) return true; if (/^(N|NO|FALSE|0|OPEN|UNPAID|PENDING|NOT FUNDED)$/.test(t)) return false; if (parseDate(t) && !isBadDate(parseDate(t))) return true; return undefined; }; const UNCONFIRMED_NOTE = "from the old system — unconfirmed (information only, decides nothing in K2B)"; // RC36 (imp36): the deal file's own control totals (its Summary_by_Year sheet) — read back on the review against the deals the import reads const fileTotalsOf = (sheets) => { const sh = (sheets || []).find(x => x && /^summary.?by.?year$/i.test(String(x.name || "").replace(/\s+/g, "_"))); if (!sh || !Array.isArray(sh.rows) || !sh.rows.length) return undefined; const pick = (r, names) => { for (const k of Object.keys(r)) if (names.includes(nh(k))) return r[k]; return undefined; }; const cents = (v) => { if (v === undefined || v === null || txt(v) === "") return null; const n = money(v); return typeof n === "number" && !isNaN(n) ? Math.round(n * 100) : null; }; const int = (v) => { if (v === undefined || v === null || txt(v) === "") return null; const n = Number(String(v).replace(/,/g, "")); return Number.isFinite(n) ? Math.round(n) : null; }; const byYear = sh.rows.map(r => ({ year: txt(pick(r, ["year"])).slice(0, 4), deals: int(pick(r, ["deals", "count", "deal count"].map(nh))), new: int(pick(r, ["new"])), used: int(pick(r, ["used"])), salesCents: cents(pick(r, ["totalsales", "sales", "totalsale"])), financedCents: cents(pick(r, ["financed", "amountfinanced", "totalfinanced"])), avgCents: cents(pick(r, ["avgprice", "averageprice", "avg"])) })).filter(r => /^\d{4}$/.test(r.year)); return byYear.length ? { sheet: sh.name, byYear } : undefined; }; const normName = (n) => up(n).replace(/[^A-Z ,]/g, " ").replace(/\s+/g, " ").trim(); const nameKeys = (n) => { const a = normName(n); if (!a) return []; const out = [a]; if (a.includes(",")) { const p = a.split(",").map(x => x.trim()).filter(Boolean); if (p.length === 2) out.push(p[1] + " " + p[0]); } else { const p = a.split(" "); if (p.length === 2) out.push(p[1] + ", " + p[0], p[1] + " " + p[0]); } return out; }; // the K2B roster id for a printed salesperson name (case-insensitive, "LAST, FIRST" or "FIRST LAST"); else null const rosterMatch = (name, roster) => { const ks = nameKeys(name); if (!ks.length) return null; for (const u of roster) { const uk = nameKeys(u.name || u.id); if (uk.some(k => ks.includes(k))) return u; if (up(u.id) === up(name)) return u; } return null; }; // 2025 U CHEVROLET EQUINOX LT → { yr, nu, rest } (the old recap prints the N / U flag after the year) const parseVehicleDesc = (desc) => { const m = txt(desc).match(/^(\d{4})\s+([NU])\s+(.*)$/i); return m ? { yr: m[1], nu: m[2].toUpperCase() === "N" ? "New" : "Used", rest: `${m[1]} ${m[3]}`.trim() } : null; }; const dealNuOf = (v) => { const s = up(v); if (!s) return ""; if (/^(N|NEW)\b/.test(s)) return "New"; if (/^(U|USED|PRE|CPO|C)\b/.test(s)) return "Used"; return null; }; // derived recap numbers — the same arithmetic the Classic Reports deal recap uses const recapNumbers = (d) => { const r = d.recap || {}, pr = r.printed || {}; const n = (x) => +x || 0; const totalCost = R2(n(r.invoicePrice) + n(r.holdback) + n(r.pack) + (r.adds || []).reduce((s, a) => s + n(a && a.amount), 0) + n(r.sfd) + n(r.glRepairs) + n(r.totalRepairs)); const over = (acv, allow) => (n(acv) || n(allow) ? R2(n(acv) - n(allow)) : 0); const gross = pr.grossProfit != null ? n(pr.grossProfit) : R2(n(d.salePrice) - totalCost + over(d.tradeACV, d.tradeAllow) + over(r.trade2Acv, r.trade2Allow)); const fi = pr.fiProfit != null ? n(pr.fiProfit) : (d.fiGross != null && d.fiGross !== 0 ? n(d.fiGross) : R2(n(r.reserve) + (n(r.clPrem) - n(r.clCost)) + (n(r.ahPrem) - n(r.ahCost)) + (n(d.vscSale) - n(d.vscCost)) + (n(d.gapSale) - n(d.gapCost)) + n(r.miscFi))); return { totalCost, gross, fi }; }; // commissions: pct × base + flat, never below the minimum. When the recap printed an amount larger than // pct × base + flat the difference is the minimum, so the Classic Reports recap reproduces the printed amount. const finishRecap = (d) => { const r = d.recap = d.recap || {}; if (r.adds) { const a2 = []; for (let i = 0; i < r.adds.length; i++) { const a = r.adds[i] || {}; a2.push({ label: txt(a.label), amount: +a.amount || 0 }); } // positions are kept (add 2 stays add 2) while (a2.length && !a2[a2.length - 1].label && !a2[a2.length - 1].amount) a2.pop(); if (a2.length) r.adds = a2; else delete r.adds; } if (r.comm) { const nm = recapNumbers(d); for (let i = 0; i < r.comm.length; i++) { const c = r.comm[i] = r.comm[i] || {}; c.name = txt(c.name).toUpperCase(); c.base = i === 2 ? "fi" : "front"; c.pct = +c.pct || 0; c.flat = +c.flat || 0; const base = c.base === "fi" ? nm.fi : nm.gross, raw = R2(base * c.pct / 100 + c.flat); c.mini = c.name && c.amt != null && +c.amt > raw + 0.005 ? R2(+c.amt) : (c.mini && c.name ? c.mini : 0); } } if (r.fiMgr) d.fiManager = r.fiMgr; }; const dealTemplate = (id, iso, importId) => ({ id, buyer: "", custNum: "", vehicle: "", stock: "", vin: "", phone: "", email: "", street: "", city: "", state: "", zip: "", postal: "", type: "", dealType: "", salesperson: "", spId: "", salePrice: 0, cost: 0, tax: 0, docFee: 0, titleFee: 0, tradeAllow: 0, tradePayoff: 0, tradeDesc: "", tradeLender: "", tradeVin: "", tradeMiles: 0, tradeApprId: "", products: [], cashDown: 0, rebate: 0, lender: "", vscSale: 0, vscCost: 0, gapSale: 0, gapCost: 0, amtFinanced: 0, status: "Funded", apr: 0, term: 0, payment: 0, fiGross: 0, leadId: "", stage: "Posted", postedToGL: true, created: iso, rev: 1, updatedAt: iso, imported: true, importedFrom: "Old DMS recap", importedAt: iso, importId, payoffSent: true, recap: {}, }); const planDeals = (rows, cols, opts) => { const warnings = []; const existing = {}; (D.deals() || []).forEach(d => { if (d) existing[String(d.id)] = d; }); const roster = (D.roster && D.roster()) || []; const custs = (D.customers && D.customers()) || {}; const byOld = {}; Object.values(custs).forEach(c => { const o = up(c && c.oldCustNo); if (o && c.custNum) (byOld[o] = byOld[o] || []).push(String(c.custNum)); }); // RC26 (inv): the old system's customer # → the K2B customer const seenId = {}; const nowIso = new Date().toISOString(); const items = rows.map((raw, i) => { const row = i + 2, v = valsOf(raw, cols), it = item({ rows: [row], raw }); let id = txt(v.dealNo).replace(/^#/, "").replace(/\s+/g, "").replace(/,/g, "").replace(/\.0+$/, ""); it.key = id || `row ${row}`; it.label = txt(v.buyer); if (!id) it.reasons.push({ field: "deal #", reason: "no deal #" }); else if (!/^[0-9A-Za-z][0-9A-Za-z\-]{0,19}$/.test(id)) it.reasons.push({ field: "deal #", reason: `"${v.dealNo}" is not a deal number (letters, digits and - only)` }); // typed values const w = {}; // file field → normalized value (only fields the file has a value for) const bad = (field, label, val, what) => it.reasons.push({ field: label, reason: `${label.toLowerCase()} "${val}" is not ${what}` }); DEAL_MONEY.forEach(k => { if (!cols[k] || !txt(v[k])) return; const n = money(v[k]); if (isNaN(n)) bad(k, (DESTS.deals.fields.find(f => f.key === k) || {}).label || k, v[k], "an amount"); else w[k] = n; }); DEAL_NUM.forEach(k => { if (!cols[k] || !txt(v[k])) return; const n = num(v[k]); if (isNaN(n)) bad(k, (DESTS.deals.fields.find(f => f.key === k) || {}).label || k, v[k], "a number"); else w[k] = n; }); DEAL_INT.forEach(k => { if (!cols[k] || !txt(v[k])) return; const n = intOf(v[k]); if (isNaN(n)) bad(k, (DESTS.deals.fields.find(f => f.key === k) || {}).label || k, v[k], "a whole number"); else w[k] = n; }); DEAL_TEXT.forEach(k => { if (cols[k] && txt(v[k])) w[k] = txt(v[k]); }); // RC36 (imp36): the dates of the history (stock-in, first payment) as MM/DD/YYYY, the vehicle / trade description from its year / make / model when the // file has no description column, the GL accounts in K2B's spelling, the open items and the old system's unconfirmed figures (information) ["stockInDate", "firstPayDate"].forEach(k => { if (!cols[k] || !txt(v[k])) return; const d0 = parseDate(v[k]); if (!d0 || isBadDate(d0)) it.warnings.push({ field: (DESTS.deals.fields.find(f => f.key === k) || {}).label || k, reason: `"${v[k]}" is not a date — not imported` }); else w[k] = mdy(d0); }); if (cols.workPhone && txt(v.workPhone)) { const wp = phone10(v.workPhone); if (wp.length === 10) w.workPhone = fmtPhone(wp); else delete w.workPhone; } if (!w.vehicle && (w.vehYear || w.vehMake || w.vehModel)) w.vehicle = [w.vehYear, w.vehMake, w.vehModel].filter(x => x !== undefined && x !== null && x !== "").join(" ").toUpperCase(); if (!w.tradeDesc && (w.tradeYear || w.tradeMake || w.tradeModel)) w.tradeDesc = [w.tradeYear, w.tradeMake, w.tradeModel].filter(x => x !== undefined && x !== null && x !== "").join(" ").toUpperCase(); ["saleAcct", "invAcct"].forEach(k => { if (w[k]) w[k] = up(w[k]).replace(/\s+/g, ""); }); if (w.salesperson2) w.salesperson2 = up(w.salesperson2); const openOf = {}; let openCols = 0; Object.keys(DEAL_OPEN).forEach(k => { if (!cols[k]) return; openCols++; const d0 = doneOf(v[k]); if (d0 === undefined) { it.warnings.push({ field: (DESTS.deals.fields.find(f => f.key === k) || {}).label || k, reason: `"${v[k]}" is not Y, N or a date — read as not known` }); openOf[DEAL_OPEN[k]] = null; } else openOf[DEAL_OPEN[k]] = d0 === null ? null : !d0; }); // RC36 (imp36): the deal history complete — a $0.00 deal of the old system (a deal backed out / unwound, a no-charge transfer) is kept as history with a // note (the owner's deal export has hundreds; refusing them left the history short of the file's own Summary_by_Year); a negative price is still refused if (w.salePrice != null && w.salePrice < 0) it.reasons.push({ field: "selling price", reason: `selling price ${$(w.salePrice)} is negative` }); else if (w.salePrice === 0) it.warnings.push({ field: "selling price", reason: "selling price 0.00 — kept as history (most likely a deal backed out / unwound or a no-charge transfer in the old system)" }); let dt = null; if (cols.date && txt(v.date)) { dt = parseDate(v.date); if (isBadDate(dt)) { it.reasons.push({ field: "delivery date", reason: `"${v.date}" is not a valid date` }); dt = null; } else if (dt) w.date = mdy(dt); } if (cols.phone && txt(v.phone)) { const ph = phone10(v.phone); if (ph.length === 10) w.phone = fmtPhone(ph); else it.warnings.push({ field: "phone", reason: `phone "${v.phone}" is not 10 digits — not imported` }); } if (cols.postal && txt(v.postal)) w.postal = txt(v.postal); if (cols.state && w.state) w.state = w.state.toUpperCase(); // New / Used (also read from "2025 U CHEVROLET …") let nu = cols.type && txt(v.type) ? dealNuOf(v.type) : ""; if (nu === null) { it.warnings.push({ field: "new / used", reason: `"${v.type}" is not N, U, New or Used — taken from the vehicle description or the unit` }); nu = ""; } const vd = w.vehicle ? parseVehicleDesc(w.vehicle) : null; if (vd) { w.vehicle = vd.rest; if (!nu) nu = vd.nu; } if (nu) w.type = nu; else delete w.type; if (w.stock) w.stock = w.stock.toUpperCase(); if (w.vin) w.vin = w.vin.toUpperCase().replace(/\s+/g, ""); if (w.tradeStock) w.tradeStock = w.tradeStock.toUpperCase(); if (w.tradeVin) w.tradeVin = w.tradeVin.toUpperCase().replace(/\s+/g, ""); if (w.buyer) w.buyer = w.buyer.toUpperCase(); if (w.cobuyer) w.cobuyer = w.cobuyer.toUpperCase(); if (w.lender) w.lender = w.lender.toUpperCase(); if (w.oldCustNo) w.oldCustNo = w.oldCustNo.toUpperCase().replace(/\.0+$/, ""); if (!w.buyer && !(existing[id] && existing[id].buyer)) { w.buyer = "(NO BUYER ON RECAP)"; it.warnings.push({ field: "buyer", reason: "no buyer name — saved as “(NO BUYER ON RECAP)”" }); } if (id && seenId[id]) it.reasons.push({ field: "deal #", reason: `deal ${id} also on row ${seenId[id]} — duplicate in this file` }); if (id && !seenId[id]) seenId[id] = row; const cur = id ? existing[id] : null; if (!cur && w.salePrice == null) it.reasons.push({ field: "selling price", reason: "no selling price" }); if (cur && !cur.imported) it.reasons.push({ field: "deal #", reason: `deal ${id} already exists in K2B as a deal started here (${cur.buyer || "no buyer"}) — an import never overwrites a K2B deal. Change the deal # in the file if this is a different deal` }); if (it.reasons.length) { it.action = "reject"; return it; } // roster id for the salesperson / F&I manager (else the printed name stays as text) const spU = w.salesperson ? rosterMatch(w.salesperson, roster) : null; if (spU) w.salesperson = spU.name || spU.id; const fiU = w.fiMgr ? rosterMatch(w.fiMgr, roster) : null; if (fiU) w.fiMgr = fiU.name || fiU.id; // customer link — RC30 (cn): the file's customer # (7 digits) names the account (on file or not — the deal waits for it, no placeholder is // ever created); else the customer whose number is the buyer's phone without the area code; else the buyer's old customer id (one customer only) const ph10 = w.phone ? phone10(w.phone) : ""; const ph7 = ph10.length === 10 ? (window.CustNum ? (window.CustNum.custNumFromPhone(ph10).num7 || "") : ph10.slice(-7)) : ""; const given = cols.custNum && txt(v.custNum) ? plainNum(v.custNum) : ""; // "5,551,242" → 5551242 (mig F6) const givenOk = given && (window.CustNum ? window.CustNum.isCustNum(given) : /^[1-9]\d{6}$/.test(given)); if (given && !givenOk) { const g10 = phone10(given); if (g10.length === 10) it.warnings.push({ field: "customer #", rule: "cust_not_7", reason: `"${given}" is a phone with its area code — the customer # is its last 7 digits` }); else it.warnings.push({ field: "customer #", rule: "cust_not_7", reason: `"${given}" is not a customer number (7 digits) — ${expoNum(given) ? "Excel dropped digits from it (exponent form); " : ""}${FORMAT_WORDS}; the deal is linked by the buyer's phone instead` }); } const given7 = givenOk ? given : (given && phone10(given).length === 10 ? phone10(given).slice(-7) : ""); const byAliasHit = (k) => { if (!k) return ""; for (const c of Object.values(custs)) if (c && Array.isArray(c.aliases) && c.aliases.some(a => String(a) === k)) return String(c.custNum); return ""; }; const oldHit = w.oldCustNo && byOld[w.oldCustNo] && byOld[w.oldCustNo].length === 1 ? byOld[w.oldCustNo][0] : ""; // RC26 (inv): else the buyer's old customer # (one customer only) const custNum = given7 ? (custs[given7] ? given7 : (byAliasHit(given7) || given7)) : (ph7 && custs[ph7] ? ph7 : (ph10 && custs[ph10] ? ph10 : (byAliasHit(ph10) || oldHit))); // the unit: fill a blank VIN; never changes the unit const unit = w.stock && D._unit ? D._unit(w.stock) : null; if (unit && !w.vin && unit.vin) w.vin = String(unit.vin).toUpperCase(); if (unit && /in stock/i.test(String(unit.status || ""))) it.warnings.push({ field: "stock #", reason: `stock ${w.stock} is "In Stock" in K2B inventory — the unit is not changed by this import` }); if (cols.date && !dt && !(cur && cur.delivered)) it.warnings.push({ field: "delivery date", reason: "no delivery date" }); if (!cur) { const d = dealTemplate(id, dt ? isoNoon(dt) : nowIso, ""); Object.keys(w).forEach(k => { if (k === "date") return; pathSet(d, DEAL_PATH[k], w[k]); }); if (dt) d.delivered = isoNoon(dt); if (openCols) d.oldOpen = Object.assign({ cit: null, payoff: null, tax: null, vsc: null }, openOf); // RC36 (imp36) if (d.oldUnconfirmed) d.oldUnconfirmed.note = UNCONFIRMED_NOTE; if (w.postal) d.zip = w.postal; if (spU) d.spId = spU.id; if (custNum) d.custNum = custNum; if (d.tradeACV !== undefined) d.tradeAcv = d.tradeACV; if (!w.totalCost && (d.recap.invoicePrice || d.recap.pack)) d.cost = recapNumbers(d).totalCost; d.dealType = (+d.amtFinanced || 0) > 0 ? "Retail" : "Cash"; d.recap.importedFrom = "Old DMS recap"; finishRecap(d); if (d.recap.printed && !Object.keys(d.recap.printed).length) delete d.recap.printed; if (!d.vehicle && unit) d.vehicle = `${unit.yr || ""} ${unit.mk || ""} ${unit.mdl || ""}`.trim(); if (!d.type && unit) d.type = /^n/i.test(String(unit.type || "")) ? "New" : "Used"; it.action = "add"; it.id = id; it.record = d; it.note = `history only — stage Posted, status Funded${custNum ? ", linked to customer " + custNum : ""}${spU ? ", salesperson → " + spU.id : ""}`; return it; } // re-import: update only what changed; a blank never erases anything const have = {}, want = {}; Object.keys(w).forEach(k => { want[k] = w[k]; let h; if (k === "date") h = cur.delivered ? mdy(new Date(cur.delivered)) : ""; else { h = pathGet(cur, DEAL_PATH[k]); if (k === "tradeAcv" && h == null) h = cur.tradeAcv; if (k === "postal" && !h) h = cur.zip; } have[k] = h; }); it.changes = diffRec(have, want, Object.keys(want), false, DEAL_NUMKEYS); it.id = id; it.matchBy = "deal #"; it.patch = { w, spId: spU ? spU.id : "", custNum, oldOpen: openCols ? openOf : undefined }; // RC36 (imp36): the open items of a re-import it.action = Object.keys(it.changes).length ? "update" : "skip"; if (it.action === "skip") it.note = "already imported — no changes"; return it; }); const added = items.filter(i => i.action === "add").length; return { items, warnings, fileTotals: fileTotalsOf(opts && opts.allSheets), summary: { kind: "deals", postsGL: false, deals: items.filter(i => i.action !== "reject").length, added, updated: items.filter(i => i.action === "update").length, note: "Records only: no journal entry, no commission, no funding or month-end item, no notification. The accounting for these deals is in the opening balances (trial balance); the review ties them to the general ledger (open items vs 205 / 303 / 324A / 302, sales and cost of sales year to date)." } }; }; // commit one planned deal into state.deals (inside D._mutate) const applyDealPatch = (cur, patch, histId, iso) => { const w = patch.w; Object.keys(w).forEach(k => { if (k === "date") { const d = parseDate(w.date); cur.delivered = isoNoon(d); return; } pathSet(cur, DEAL_PATH[k], w[k]); if (k === "postal") cur.zip = w[k]; if (k === "tradeAcv") cur.tradeAcv = w[k]; }); if (patch.spId) cur.spId = patch.spId; if (patch.custNum && !cur.custNum) cur.custNum = patch.custNum; if (patch.oldOpen) cur.oldOpen = Object.assign({ cit: null, payoff: null, tax: null, vsc: null }, cur.oldOpen || {}, patch.oldOpen); // RC36 (imp36) if (cur.oldUnconfirmed) cur.oldUnconfirmed.note = UNCONFIRMED_NOTE; if (w.type === undefined) delete w.type; cur.dealType = (+cur.amtFinanced || 0) > 0 ? "Retail" : "Cash"; if (cur.recap && cur.recap.printed && !Object.keys(cur.recap.printed).length) delete cur.recap.printed; finishRecap(cur); cur.rev = (+cur.rev || 0) + 1; cur.updatedAt = iso; cur.importId = histId; }; // ══════════════════════════════════════════════════════════════════════ // RC25 (imp): REPAIR-ORDER HISTORY, the PARTS LIST and the VIN LIST. The rules (flags, void rows, money, fingerprints) are // ImpCalc.jsx.txt == the server's src/lib/imp-calc.js, so the preview and the server agree row for row. // ══════════════════════════════════════════════════════════════════════ const IC = () => window.ImpCalc || null; const IMP_BATCH = 1000; const splitVehicle = (s) => { const m = txt(s).match(/^(\d{4})\s+(?:[NU]\s+)?([A-Z][A-Z-]*)\s+(.*)$/i); return m ? { year: m[1], make: m[2], model: m[3] } : null; }; const LINES_HEADERS = { roNo: ["record_id", "ro", "ro #", "ro no", "ro number", "ro_no"], lineNo: ["line_no", "line", "line #", "line no", "seq"], text1: ["text_1", "text 1", "text", "line 1"], text2: ["text_2", "text 2"], text3: ["text_3", "text 3"], opCode: ["op_code", "op code", "op", "opcode"] }; // an RO_Lines sheet in the same workbook: one row per job line, keyed by the RO number const linesSheetOf = (sheets, ownName) => { for (const sh of (sheets || [])) { if (!sh || sh.name === ownName || !Array.isArray(sh.headers)) continue; const hs = sh.headers.map(nh); const col = {}; Object.keys(LINES_HEADERS).forEach(f => { const i = hs.findIndex(h => LINES_HEADERS[f].map(nh).includes(h)); if (i >= 0) col[f] = sh.headers[i]; }); if (col.roNo && col.lineNo && col.text1 && !hs.some(h => ["rototal", "laborsale", "partssale"].includes(h))) return { sheet: sh, col }; } return null; }; const linesByRo = (ls) => { const m = new Map(); if (!ls) return m; ls.sheet.rows.forEach(r => { const no = up(r[ls.col.roNo]).replace(/\.0+$/, ""); if (!no) return; if (!m.has(no)) m.set(no, []); m.get(no).push({ line: txt(r[ls.col.lineNo]), text1: txt(r[ls.col.text1]), text2: ls.col.text2 ? txt(r[ls.col.text2]) : "", text3: ls.col.text3 ? txt(r[ls.col.text3]) : "", op: ls.col.opCode ? txt(r[ls.col.opCode]) : "" }); }); return m; }; const linesFromText = (s) => txt(s).split(/\s*\|\s*/).map(t => t.trim()).filter(Boolean).map((t, i) => ({ line: i + 1, text1: t })); const planRoHistory = (rows, cols, opts) => { const C = IC(); if (!C) return { items: [], blockers: [{ code: "no_calc", title: "ImpCalc.jsx.txt is not loaded", detail: "Reload the DMS (Ctrl+F5).", fix: "" }], warnings: [], summary: null }; const warnings = []; const linesMode = !!(cols.lineNo || cols.text1) && !cols.total && !cols.laborSale && !cols.partsSale && !cols.flag; const ls = linesMode ? null : linesSheetOf(opts.allSheets, opts.sheetName); const byRo = linesByRo(ls); const seen = {}; const sum = { closed: 0, open: 0, held: 0, void: 0, totalC: 0, closedTotalC: 0, cpC: 0, opC: 0, openLaborCostC: 0, lines: 0, splitOff: 0, withLines: 0 }; const items = rows.map((raw, i) => { const row = i + 2, v = valsOf(raw, cols), it = item({ rows: [row], raw }); if (linesMode) { const no = up(v.roNo).replace(/\.0+$/, ""), ln = intOf(v.lineNo); it.key = no ? `${no} / ${txt(v.lineNo) || "?"}` : `row ${row}`; it.label = [v.text1, v.text2, v.text3].filter(Boolean).join(" "); if (!no) it.reasons.push({ field: "RO #", reason: "no repair-order number" }); else if (!C.RO_NO_OK.test(no)) it.reasons.push({ field: "RO #", reason: `"${v.roNo}" is not a repair-order number` }); if (typeof ln === "number" && isNaN(ln)) it.reasons.push({ field: "line #", reason: `"${v.lineNo}" is not a line number` }); if (!it.label && !v.opCode) it.reasons.push({ field: "text", reason: "empty line" }); if (it.reasons.length) { it.action = "reject"; return it; } it.action = "add"; it.note = "job line → attached to the imported repair order on the server (an RO not imported yet is reported)"; it.send = { roNo: no, line: v.lineNo, text1: v.text1, text2: v.text2, text3: v.text3, op: v.opCode }; sum.lines++; return it; } const input = { roNo: v.roNo, flag: v.flag, date: v.date, customerAccount: v.customerAccount, vin: v.vin, stock: v.stock, year: v.year, make: v.make, model: v.model, color: v.color, odometer: v.odometer, tech: v.tech, advisor: v.advisor, partsSale: v.partsSale, partsCost: v.partsCost, laborSale: v.laborSale, laborCost: v.laborCost, subletSale: v.subletSale, subletCost: v.subletCost, supplies: v.supplies, taxRatePct: v.taxRatePct, salesTax: v.salesTax, total: v.total, customerPay: v.customerPay, otherPay: v.otherPay, status: v.status }; // RC33 (svi): the customer printed on the RO travels with it (never part of normRoRow / the fingerprint); the server makes / fills / lists the customer at approval (C.CUST_FIELD_KEYS || []).forEach((k) => { if (v[k]) input[k] = v[k]; }); if (input.customerName === undefined && !input.custLast && !input.custFirst) delete input.customerName; if (v.vehicle && !(v.year || v.make || v.model)) { const sv = splitVehicle(v.vehicle); if (sv) Object.assign(input, sv); else input.model = v.vehicle; } const no = up(v.roNo).replace(/^#/, "").replace(/\.0+$/, ""); const fromSheet = byRo.get(no); input.lines = fromSheet ? fromSheet : (cols.lineText && v.lineText ? linesFromText(v.lineText) : []); const n = C.normRoRow(input); it.key = n.roNo || `row ${row}`; const cf = C.roCustFields ? C.roCustFields(input) : null; // RC33 (svi) it.label = [n.text.customerAccount, cf && cf.name, [n.year, n.text.make, n.text.model].filter(Boolean).join(" ")].filter(Boolean).join(" · "); if (cf && cf.key) { sum.named = (sum.named || 0) + 1; if (!C.svcCustNum(input.customerAccount).num) it.warnings.push({ field: "customer", reason: cf.name + ": " + C.svcCustNum(input.customerAccount).why + " — no customer is made from it (the name stays on the RO history)" }); } it.warnings = n.warnings.map(w => ({ field: w.field, reason: w.reason })); n.problems.forEach(p => it.reasons.push({ field: p.field, reason: p.reason })); if (n.roNo && seen[n.roNo]) it.reasons.push({ field: "RO #", reason: `RO ${n.roNo} is also on row ${seen[n.roNo]} — the old system reused the number? The first row is used; this one is not imported` }); if (n.roNo && !seen[n.roNo]) seen[n.roNo] = row; if (it.reasons.length) { it.action = "reject"; return it; } it.action = "add"; it.matchBy = "RO #"; it.status = n.status; it.send = input; it.lineCount = n.lines.length; sum[n.status === "void" ? "void" : n.status]++; if (n.status !== "void") { sum.totalC += n.cents.total || 0; sum.cpC += n.cents.customerPay || 0; sum.opC += n.cents.otherPay || 0; } if (n.status === "closed") sum.closedTotalC += n.cents.total || 0; if (n.status === "open") sum.openLaborCostC += n.cents.laborCost || 0; if (n.lines.length) { sum.withLines++; sum.lines += n.lines.length; } if (n.warnings.some(w => w.field === "payer split")) sum.splitOff++; it.note = n.status === "void" ? "VOID in the old system → voided history" : n.status === "open" ? "OPEN in the old system → goes on the service board as Customer pay (the advisor changes it on the RO if it is warranty / internal)" : n.status === "held" ? "held (H) in the old system → history, not on the board" : `${n.flagWord} → history, no journal entry (${C.PAYER_WORDS[n.payer] || n.payer})`; if (!fromSheet && cols.lineText === undefined && !ls) it.note += ""; return it; }); const n = items.filter(it => it.action === "add").length; if (!linesMode) { if (ls) warnings.push({ code: "lines_sheet", title: `Job lines taken from the sheet “${ls.sheet.name}”`, detail: `${ls.sheet.rows.length} line rows matched to the repair orders by RO number; ${sum.withLines} repair order${sum.withLines === 1 ? " has" : "s have"} lines.` }); else if (!cols.lineText) warnings.push({ code: "no_lines", title: "No job lines in this file", detail: "The repair orders are imported with their totals only. Import the RO_Lines sheet / file afterwards into the same destination and the lines are attached to them." }); if (sum.splitOff) warnings.push({ code: "split_off", title: `${sum.splitOff} repair order${sum.splitOff === 1 ? "" : "s"} whose customer pay + warranty / internal does not equal the RO total`, detail: "The RO total is kept exactly as printed; the two payer columns are kept as printed too." }); if (!cols.customerPay && !cols.otherPay) warnings.push({ code: "no_split", title: "No customer-pay / warranty columns", detail: "Who paid each repair order is not in the file; the imported history says \"not in the file\" for the payer." }); } const summary = linesMode ? { kind: "roLines", postsGL: false, lines: sum.lines, ros: Object.keys(seen).length, batches: Math.ceil(n / 5000), note: "Job lines only: attached to repair orders already imported (an RO that is not imported yet is listed as skipped on the result)." } : { kind: "roHistory", postsGL: false, rows: n, byStatus: { closed: sum.closed, open: sum.open, held: sum.held, void: sum.void }, total: R2(sum.totalC / 100), closedTotal: R2(sum.closedTotalC / 100), customerPay: R2(sum.cpC / 100), otherPay: R2(sum.opC / 100), openLaborCost: R2(sum.openLaborCostC / 100), named: sum.named || 0, lines: sum.lines, withLines: sum.withLines, linesSheet: ls ? ls.sheet.name : "", batches: Math.ceil(n / IMP_BATCH), payerSplit: !!(cols.customerPay || cols.otherPay), note: "History only: NO journal entry (closed repair orders are already in the old books). The open ones go on the service board; their labor is tied out on Accounting › Reconcile inventory to GL (work in process)." }; return { items, blockers: [], warnings, summary }; }; /* ── RC33 (svi): the service lists ── */ const noCalc = () => ({ items: [], blockers: [{ code: "no_calc", title: "ImpCalc.jsx.txt is not loaded", detail: "Reload the DMS (Ctrl+F5).", fix: "" }], warnings: [], summary: null }); const custNumOfCell = (raw) => { // a service list's customer #: 7 digits, or a 10-digit phone whose last 7 are taken (with a note) const t = plainNum(raw); const d = t.replace(/\D/g, ""); if ((d.length === 10 || (d.length === 11 && d[0] === "1")) && /^[2-9]/.test(d.slice(-7))) return { num: d.slice(-7), note: `"${t}" is a phone with its area code — the customer # is its last 7 digits (${d.slice(-7)})` }; return { num: t, note: "" }; }; const planSvcCustomers = (rows, cols) => { const C = IC(); if (!C || !C.roCustFields) return noCalc(); const seen = {}; const items = rows.map((raw, i) => { const row = i + 2, v = valsOf(raw, cols), it = item({ rows: [row], raw }); const cn = custNumOfCell(v.custNum); const rec = { custNum: cn.num }; C.CUST_FIELD_KEYS.forEach((k) => { if (v[k]) rec[k] = v[k]; }); const f = C.roCustFields(rec), n = C.svcCustNum(rec.custNum); it.key = n.num || txt(v.custNum) || `row ${row}`; it.label = f.name; if (cn.note) it.warnings.push({ field: "customer #", reason: cn.note }); if (!n.num) it.reasons.push({ field: "customer #", reason: n.why + " — a service customer row needs the 7-digit customer # (the phone without the area code)" }); if (!f.key) it.reasons.push({ field: "name", reason: "no name on the row — a customer needs a name" }); if (n.num && seen[n.num] && seen[n.num].key !== f.key) it.warnings.push({ field: "customer #", reason: `customer # ${n.num} is also on row ${seen[n.num].row} with another name — nothing is created for it until the office decides` }); if (n.num && !seen[n.num]) seen[n.num] = { row, key: f.key }; if (it.reasons.length) { it.action = "reject"; return it; } it.action = "add"; it.send = rec; it.id = n.num; it.matchBy = "customer #"; it.note = "a name for customer # " + n.num + " — at approval: created when not on file, blank fields filled when on file with this name, another name on file listed for the office"; return it; }); const n = items.filter((x) => x.action === "add").length; return { items, blockers: [], warnings: [], summary: { kind: "svcCustomers", postsGL: false, rows: n, note: "Names for the customer #s: nothing is written until the owner approves; a customer the office already has keeps everything it has (only blank fields are filled)." } }; }; const planArAging = (rows, cols, opts) => { const C = IC(); if (!C || !C.agingRow) return noCalc(); const blockers = [], warnings = []; let asOf = asDate(opts.asOf), asOfSource = asOf ? "your choice" : ""; if (!asOf && cols.asOf) { const set = new Set(); rows.forEach((r) => { const t = txt((r || {})[cols.asOf]); if (t) set.add(t); }); if (set.size === 1) { const d = asDate([...set][0]); if (d) { asOf = d; asOfSource = "the as-of column"; } } else if (set.size > 1) blockers.push({ code: "asof_mixed", title: "The as-of dates in the file differ", detail: `${set.size} different dates.`, fix: "Import one aging date at a time." }); } if (!asOf) { const d = asOfInHeading(opts.preface) || dateInText(opts.fileName) || dateInText(opts.sheetName); if (d) { asOf = d; asOfSource = "the file"; } } if (!asOf && !blockers.length) blockers.push({ code: "asof_required", title: "As-of date required", detail: "An aging is the customers' balances on one day — that day is not in the file.", fix: "Enter the as-of date (the same day as the GL balances), then preview again." }); else if (asOf && asOfSource !== "your choice") warnings.push({ code: "asof_default", title: `As-of date taken from ${asOfSource}`, detail: `${mdy(asOf)} — check it is the day the aging is AS OF.` }); const defGl = acctKey(opts.glAcct) || ""; const seen = {}; const tot = {}; const items = rows.map((raw, i) => { const row = i + 2, v = valsOf(raw, cols), it = item({ rows: [row], raw }); const rec = { control: plainNum(v.control), name: v.name, current: v.current, d31_60: v.d31_60, d61_90: v.d61_90, d91_120: v.d91_120, over120: v.over120, total: v.total, gl: acctKey(v.gl) || defGl || "220" }; const g = C.agingRow(rec); it.key = g.control || `row ${row}`; it.label = g.name; g.problems.forEach((p) => it.reasons.push({ field: p.field, reason: p.reason })); const k = g.gl + "|" + g.control; if (g.control && seen[k]) it.reasons.push({ field: "customer #", reason: `${g.control} is also on row ${seen[k]} (account ${g.gl}) — the first row is used` }); if (g.control && !seen[k]) seen[k] = row; if (it.reasons.length) { it.action = "reject"; return it; } if (!g.buckets.length) { it.action = "skip"; it.note = "zero balance — nothing to age"; return it; } it.action = "add"; it.send = rec; it.id = g.control; it.matchBy = "A/R account (customer #)"; tot[g.gl] = (tot[g.gl] || 0) + g.totalCents; it.note = `${C.fmt(g.totalCents)} on ${g.gl}` + (C.svcCustNum(g.control).num ? " · customer # " + g.control : " · a business / charge account (not a customer #)"); return it; }); const summary = { kind: "arAging", postsGL: false, asOf: asOf ? mdy(asOf) : "", asOfIso: asOf ? impIsoOf(asOf) : "", rows: items.filter((x) => x.action === "add").length, totals: Object.keys(tot).map((g) => ({ gl: g, cents: tot[g], total: R2(tot[g] / 100) })), note: "Records only — nothing is posted at import. The aging is tied to 220 (and 220A) on the import review; the owner ticks the entry when the aging is the whole balance." }; return { items, blockers, warnings, summary, asOf }; }; const planWarrantyClaims = (rows, cols) => { const C = IC(); if (!C || !C.claimRow) return noCalc(); const seen = {}; let openC = 0; const items = rows.map((raw, i) => { const row = i + 2, v = valsOf(raw, cols), it = item({ rows: [row], raw }); const rec = { claim: v.claim, ro: v.ro, date: v.date, amount: v.amount, paid: v.paid, status: v.status }; const c = C.claimRow(rec); it.key = c.claim || `row ${row}`; it.label = c.ro ? "RO " + c.ro : ""; c.problems.forEach((p) => it.reasons.push({ field: p.field, reason: p.reason })); if (c.claim && seen[c.claim]) it.reasons.push({ field: "claim #", reason: `claim ${c.claim} is also on row ${seen[c.claim]} — the first row is used` }); if (c.claim && !seen[c.claim]) seen[c.claim] = row; if (it.reasons.length) { it.action = "reject"; return it; } it.action = "add"; it.send = rec; it.id = c.claim; it.matchBy = "claim #"; openC += c.openCents; it.note = c.openCents ? `open: ${C.fmt(c.openCents)} still due from the factory` : `${c.status} — not on the open list`; return it; }); return { items, blockers: [], warnings: [], summary: { kind: "warrantyClaims", postsGL: false, rows: items.filter((x) => x.action === "add").length, openTotal: R2(openC / 100), note: "Records only — the open claims are tied to 263 WARRANTY CLAIMS on the import review." } }; }; const planBodyWip = (rows, cols) => { const C = IC(); if (!C || !C.bodyJobRow) return noCalc(); const seen = {}; let openC = 0; const items = rows.map((raw, i) => { const row = i + 2, v = valsOf(raw, cols), it = item({ rows: [row], raw }); const rec = { roNo: v.roNo, date: v.date, custNum: plainNum(v.custNum), laborCost: v.laborCost, partsCost: v.partsCost, materialsCost: v.materialsCost, subletCost: v.subletCost, status: v.status }; const j = C.bodyJobRow(rec); it.key = j.roNo || `row ${row}`; it.label = j.custNum ? "customer # " + j.custNum : ""; j.problems.forEach((p) => it.reasons.push({ field: p.field, reason: p.reason })); if (j.roNo && seen[j.roNo]) it.reasons.push({ field: "RO #", reason: `RO ${j.roNo} is also on row ${seen[j.roNo]} — the first row is used` }); if (j.roNo && !seen[j.roNo]) seen[j.roNo] = row; if (it.reasons.length) { it.action = "reject"; return it; } it.action = "add"; it.send = rec; it.id = j.roNo; it.matchBy = "RO #"; if (j.status === "open") openC += j.totalCents; it.note = j.status === "open" ? `in progress — ${C.fmt(j.totalCents)} of cost` : `${j.status} — not in progress`; return it; }); return { items, blockers: [], warnings: [], summary: { kind: "bodyWip", postsGL: false, rows: items.filter((x) => x.action === "add").length, openTotal: R2(openC / 100), note: "Records only — the jobs in progress are tied to 245 BODY SHOP INVENTORY on the import review." } }; }; // RC36 (fpd36): the lender's floorplan statement → one line per unit (integer cents; the balance = floored − curtailments when the file has no // balance column). The server checks every line again (routes/floorplan.js stmtLine) and keeps the statement at approval. const planFloorplanStmt = (rows, cols, opts) => { const blockers = [], warnings = []; let asOf = asDate(opts.asOf); if (!asOf && cols.stmtDate) { const set = new Set(); rows.forEach((r) => { const t = txt((r || {})[cols.stmtDate]); if (t) set.add(t); }); if (set.size === 1) asOf = asDate([...set][0]); else if (set.size > 1) blockers.push({ code: "asof_mixed", title: "More than one statement date in the file", detail: "One statement is what the lender says on ONE day.", fix: "Import one statement at a time." }); } if (!asOf && !blockers.length) blockers.push({ code: "asof_required", title: "Statement date required", detail: "A floorplan statement is what the lender says is owed on one day — enter that day.", fix: "Enter the statement date, then preview again." }); const c2 = (v) => { const m = money(v); return m === null ? null : (isNaN(m) ? NaN : Math.round(m * 100)); }; const d2 = (c) => (c === null ? "" : (c / 100).toFixed(2)); const seen = {}; let totC = 0; const items = rows.map((raw, i) => { const row = i + 2, v = valsOf(raw, cols), it = item({ rows: [row], raw }); const stock = up(v.stock).replace(/\s/g, ""), vin = up(v.vin).replace(/\s/g, ""); const adv = c2(v.advance), cur = c2(v.curtail); let bal = c2(v.balance); it.key = stock || vin || `row ${row}`; it.label = txt(v.desc); if (!stock && !vin) it.reasons.push({ field: "stock #", reason: "no stock # and no VIN — the line cannot be matched to a unit" }); [["amount floored", adv], ["curtailments paid", cur], ["balance owed", bal]].forEach(([f, c]) => { if (typeof c === "number" && isNaN(c)) it.reasons.push({ field: f, reason: `"${txt(v[f === "amount floored" ? "advance" : f === "curtailments paid" ? "curtail" : "balance"])}" is not a dollar amount` }); }); if (bal === null && typeof adv === "number" && !isNaN(adv)) { bal = adv - (typeof cur === "number" && !isNaN(cur) ? cur : 0); it.note = "balance = amount floored − curtailments"; } if (bal === null && !it.reasons.length) it.reasons.push({ field: "balance owed", reason: "no balance owed (and no amount floored to work it out from)" }); if (typeof bal === "number" && bal < 0) it.reasons.push({ field: "balance owed", reason: "the balance owed is below zero" }); const fd = txt(v.floorDate) ? asDate(v.floorDate) : null; if (txt(v.floorDate) && !fd) it.reasons.push({ field: "floor date", reason: `"${txt(v.floorDate)}" is not a date` }); const k = stock || vin; if (k && seen[k]) it.reasons.push({ field: "stock #", reason: `${k} is also on row ${seen[k]} — the first row is used` }); if (k && !seen[k]) seen[k] = row; if (it.reasons.length) { it.action = "reject"; return it; } it.action = "add"; it.id = k; it.matchBy = "stock # / VIN"; totC += bal; it.send = { stock, vin, desc: txt(v.desc), lender: txt(v.lender) || txt(opts.lender), floorDate: fd ? impIsoOf(fd) : "", advance: d2(typeof adv === "number" ? adv : null), curtail: d2(typeof cur === "number" ? cur : null), balance: d2(bal), stmtDate: asOf ? impIsoOf(asOf) : "", stmtLender: txt(opts.lender) }; return it; }); return { items, blockers, warnings, asOf, summary: { kind: "floorplanStmt", postsGL: false, asOf: asOf ? mdy(asOf) : "", asOfIso: asOf ? impIsoOf(asOf) : "", rows: items.filter((x) => x.action === "add").length, totalCents: totC, total: R2(totC / 100), note: "Records only — the statement is compared with the floored units and 310 / 314 on the review; nothing is posted." } }; }; const planPartsMaster = (rows, cols, opts) => { const C = IC(); if (!C) return { items: [], blockers: [{ code: "no_calc", title: "ImpCalc.jsx.txt is not loaded", detail: "Reload the DMS (Ctrl+F5).", fix: "" }], warnings: [], summary: null }; const parts = D.parts() || []; const byNo = {}; parts.forEach(p => { if (p && p.part) byNo[up(p.part)] = p; }); const applyOnHand = opts.applyOnHand !== false, updatePrices = opts.updatePrices !== false; const seen = {}; let valueC = 0, onHandRows = 0; const items = rows.map((raw, i) => { const row = i + 2, v = valsOf(raw, cols), it = item({ rows: [row], raw }); const n = C.normPartRow({ part: v.part, desc: v.desc, bin: v.bin, onHand: v.onHand, cost: v.cost, list: v.list, trade: v.trade, source: v.source, mfr: v.mfr, core: v.core }); it.key = n.part || `row ${row}`; it.label = n.desc; n.problems.forEach(p => it.reasons.push({ field: p.field, reason: p.reason })); if (n.part && seen[n.part]) it.reasons.push({ field: "part #", reason: `part ${n.part} also on row ${seen[n.part]} — duplicate in this file` }); if (n.part && !seen[n.part]) seen[n.part] = row; if (it.reasons.length) { it.action = "reject"; return it; } const send = { part: n.part, desc: n.desc || null, bin: n.bin || null, source: n.source || null, mfr: n.mfr || null, onHand: n.onHand, cost: n.costCents == null ? null : R2(n.costCents / 100), list: n.listCents == null ? null : R2(n.listCents / 100), trade: n.tradeCents == null ? null : R2(n.tradeCents / 100), core: n.coreCents == null ? null : R2(n.coreCents / 100), ...(acctKey(v.glAcct) ? { glAcct: acctKey(v.glAcct) } : {}) }; // RC30 (imp2): + the parts inventory GL account it.send = send; if (n.onHand != null && n.costCents != null) { valueC += Math.round(n.onHand * n.costCents); if (n.onHand) onHandRows++; } const cur = byNo[n.part]; if (!cur) { it.action = "add"; it.id = n.part; it.record = send; it.note = applyOnHand && n.onHand != null ? `new part · ${n.onHand} on hand` : "new part (0 on hand)"; return it; } const have = { desc: cur.desc, bin: cur.bin, source: cur.source, mfr: cur.mfr, core: cur.core, cost: cur.cost, list: cur.list, trade: cur.trade, onHand: cur.onHand }; const want = { desc: send.desc, bin: send.bin, source: send.source, mfr: send.mfr, core: send.core }; if (updatePrices) { want.cost = send.cost; want.list = send.list; want.trade = send.trade; } if (applyOnHand) want.onHand = send.onHand; it.changes = diffRec(have, want, Object.keys(want), false, { core: 1, cost: 1, list: 1, trade: 1, onHand: 1 }); it.id = n.part; it.matchBy = "part #"; it.action = Object.keys(it.changes).length ? "update" : "skip"; if (it.action === "skip") it.note = "already on file — no changes"; return it; }); const warnings = []; if (!applyOnHand && cols.onHand) warnings.push({ code: "onhand_off", title: "On-hand counts in the file are NOT applied", detail: "Tick “Set on-hand quantities from the file” to take the old system's count; otherwise quantities already on file are kept (new parts start at 0)." }); if (!cols.cost) warnings.push({ code: "no_cost", title: "No cost column", detail: "New parts are added with cost 0; the parts value on the reconciliation screen counts on-hand × cost." }); const summary = { kind: "parts", postsGL: false, parts: items.filter(it => it.action !== "reject").length, onHandValue: R2(valueC / 100), onHandRows, applyOnHand, updatePrices, note: "Records only — nothing is posted. The value on hand (on-hand × cost) is tied out to the parts inventory account on Accounting › Reconcile inventory to GL." }; return { items, blockers: [], warnings, summary }; }; const planVehicleMaster = (rows, cols) => { const seen = {}; const items = rows.map((raw, i) => { const row = i + 2, v = valsOf(raw, cols), it = item({ rows: [row], raw }); const vin = up(v.vin).replace(/\s+/g, ""); it.key = vin || `row ${row}`; it.label = [v.year, v.make, v.model].filter(Boolean).join(" "); if (!vin) it.reasons.push({ field: "VIN", reason: "no VIN" }); else if (vin.length < 5 || vin.length > 20) it.reasons.push({ field: "VIN", reason: `"${v.vin}" is not a VIN` }); else if (vin.length !== 17) it.warnings.push({ field: "VIN", reason: `VIN "${vin}" is ${vin.length} characters (17 expected) — kept as printed` }); if (v.year && isNaN(intOf(v.year))) it.warnings.push({ field: "year", reason: `"${v.year}" is not a year — left blank` }); if (vin && seen[vin]) it.reasons.push({ field: "VIN", reason: `VIN ${vin} also on row ${seen[vin]} — duplicate in this file` }); if (vin && !seen[vin]) seen[vin] = row; if (it.reasons.length) { it.action = "reject"; return it; } it.action = "add"; it.matchBy = "VIN"; it.note = "→ the server's VIN list (added, or updated where the file differs)"; it.send = { vin, year: v.year, make: v.make, model: v.model, color: v.color, sources: v.sources }; return it; }); const n = items.filter(it => it.action === "add").length; return { items, blockers: [], warnings: [], summary: { kind: "vehicles", postsGL: false, rows: n, batches: Math.ceil(n / IMP_BATCH), note: "A lookup list on the server (VIN → year / make / model) for the repair-order history. It does not put units in stock." } }; }; // ── the dry run ─────────────────────────────────────────────────────── const ACT2COUNT = { add: "added", update: "updated", skip: "skipped", reject: "rejected" }; // A plan can carry (new destinations): blockers [{code,title,detail,fix}] — while any exist NOTHING can be // committed (toImport is 0); warnings [{code,title,detail}]; summary {…} (what the preview shows besides the rows). const importPlan = (dest, rows, mapping, opts) => { const def = destDef(dest); const o = { clearBlanks: false, ...(opts || {}) }; rows = (rows || []).filter(r => r && Object.values(r).some(v => !blank(v))); const headers = o.headers || headersOf(rows); const map = { ...(mapping || {}) }; headers.forEach(h => { if (map[h] == null) map[h] = ""; }); const cols = fieldCols(map); const unresolved = headers.filter(h => !map[h]); const missing = def ? def.required.filter(grp => !grp.some(f => cols[f])).map(grp => grp.map(f => (def.fields.find(x => x.key === f) || { label: f }).label).join(" or ")) : []; let items = [], blockers = [], warnings = [], summary = null, extra = {}; if (def && !missing.length) { const run = (res) => { if (Array.isArray(res)) items = res; else { items = res.items; blockers = res.blockers || []; warnings = res.warnings || []; summary = res.summary || null; extra = res; } }; if (dest === "customers") run(planCustomers(rows, cols, o)); else if (dest === "vendors") run(planVendors(rows, cols, o)); else if (dest === "checks") run(planChecks(rows, cols, o)); else if (dest === "glChart") run(planGlChart(rows, cols, o)); else if (dest === "glJournal") run(planGlJournal(rows, cols, o)); else if (dest === "glTrial") run(planGlTrial(rows, cols, o)); else if (dest === "schedule") run(planSchedule(rows, cols, o)); else if (dest === "deals") run(planDeals(rows, cols, o)); else if (dest === "inventory") run(planInventory(rows, map, o)); else if (dest === "roHistory") run(planRoHistory(rows, cols, o)); // RC25 (imp) else if (dest === "partsMaster") run(planPartsMaster(rows, cols, o)); // RC25 (imp) else if (dest === "vehicleMaster") run(planVehicleMaster(rows, cols, o)); // RC25 (imp) else if (dest === "svcCustomers") run(planSvcCustomers(rows, cols, o)); // RC33 (svi) else if (dest === "arAging") run(planArAging(rows, cols, o)); // RC33 (svi) else if (dest === "warrantyClaims") run(planWarrantyClaims(rows, cols, o)); // RC33 (svi) else if (dest === "bodyWip") run(planBodyWip(rows, cols, o)); // RC33 (svi) else if (dest === "floorplanStmt") run(planFloorplanStmt(rows, cols, o)); // RC36 (fpd36) } const denied = !!(def && def.perm && D.can && !D.can(def.perm)); if (denied) items.forEach(it => { if (it.action !== "reject") { it.action = "reject"; it.reasons = [{ field: "role", reason: "your role can't import into " + def.short }, ...(it.reasons || [])]; } }); const counts = { added: 0, updated: 0, skipped: 0, rejected: 0 }; items.forEach(it => { counts[ACT2COUNT[it.action]]++; }); const errors = []; items.forEach(it => { (it.reasons || []).forEach(r => errors.push({ row: r.row || it.rows.join(" "), key: it.key, action: it.action, level: "rejected", field: r.field, reason: r.reason })); (it.warnings || []).forEach(r => errors.push({ row: r.row || it.rows.join(" "), key: it.key, action: it.action, level: "warning", field: r.field, reason: r.reason })); }); blockers.forEach(b => errors.push({ row: "", key: "", action: "blocked", level: "blocked", field: b.code, reason: [b.title + ".", b.detail, b.fix].filter(Boolean).join(" ") })); warnings.forEach(w => errors.push({ row: "", key: "", action: "", level: "warning", field: w.code, reason: [w.title + ".", w.detail].filter(Boolean).join(" ") })); const unmapped = headers.filter(h => map[h] === SKIP || !map[h]).map(h => { const values = []; rows.forEach((r, i) => { const v = txt(r[h]); if (v && values.length < MAX_KEEP) values.push({ row: i + 2, value: v }); }); const samples = []; values.forEach(x => { if (samples.length < 5 && !samples.includes(x.value)) samples.push(x.value); }); return { header: h, samples, values, truncated: values.length >= MAX_KEEP }; }); const blocked = blockers.length > 0; const sigExtra = blockers.map(b => b.code).join(",") + "|" + (summary ? [summary.asOf, summary.entry ? summary.entry.id + summary.entry.date + summary.entry.lines + summary.entry.debits : "", summary.total, summary.rows].join("|") : ""); const sig = items.map(it => `${it.action}:${it.key}:${Object.keys(it.changes || {}).sort().join("|")}`).join(";") + "#" + sigExtra; return { ok: !!def && !missing.length && !unresolved.length && !denied, denied, dest, destLabel: def ? def.label : dest, headers, mapping: map, opts: o, rows, items, counts, errors, unmapped, unresolved, missing, sig, lines: dest === "glJournal" || dest === "glTrial" ? rows.length : undefined, blockers, blocked, warnings, summary, entry: extra.entry || null, newAccounts: extra.newAccounts || [], asOf: extra.asOf || null, elapsed: extra.elapsed || 0, fileTotals: extra.fileTotals || undefined, /* RC36 (imp36): the deal file's Summary_by_Year */ toImport: blocked ? 0 : counts.added + counts.updated, at: new Date().toISOString() }; }; // ── commit: exactly the previewed plan ───────────────────────────────── // RC30 (imp2): NOTHING is written here any more — every import is STAGED on the server and waits for the owner's approval // (importStage below). The RC25 station-side commit is kept as importCommitLegacy for reference only; calling importCommit says so. const importCommit = (dest) => ({ ok: false, staged: true, msg: `${(destDef(dest) || { short: dest }).short}: imports are sent to the server and wait for the owner's approval (Accounting › Import review & approval) — nothing is written from this screen.` }); const importCommitLegacy = (dest, plan, meta) => { const m = meta || {}; const def = destDef(dest); if (!def || !plan || plan.dest !== dest) return { ok: false, msg: "Nothing to import — preview the file first." }; if (plan.unresolved && plan.unresolved.length) return { ok: false, msg: `Choose a field or Skip for: ${plan.unresolved.join(", ")}` }; if (plan.missing && plan.missing.length) return { ok: false, msg: `Map the required field(s): ${plan.missing.join("; ")}` }; if (plan.denied) return { ok: false, msg: `Your role can't import into ${def.short}.` }; // Re-run the dry run: if the records changed since the preview, stop. const fresh = importPlan(dest, plan.rows, plan.mapping, plan.opts); if (fresh.sig !== plan.sig) return { ok: false, stale: true, plan: fresh, msg: "The records changed since this preview — review the new preview and import again." }; if (fresh.blockers && fresh.blockers.length) { const b = fresh.blockers[0]; return { ok: false, blocked: true, plan: fresh, msg: [b.title + ".", b.fix].filter(Boolean).join(" ") }; } const affected = [], late = []; const act = fresh.items; const iso = new Date().toISOString(); const by = D.userId ? D.userId() : ""; const histId = D._uid("IMP"); let undo = null; if (dest === "customers") { D._mutate((state) => { state.customers = state.customers || {}; act.forEach(it => { if (it.householdAdd) return; // RC30 (cn) fix round: a second person on an account is never written as the account (importCommitCustomers / the staged approval adds the name) if (it.action === "add") { state.customers[it.id] = { ...it.record, saved: iso, importedAt: iso, importId: histId }; affected.push({ id: it.id, action: "added", label: it.label, rows: it.rows }); } else if (it.action === "update") { const cur = state.customers[it.id]; if (!cur) return; const patch = {}; Object.keys(it.changes).forEach(k => { patch[k] = it.changes[k].to; }); if (patch.postal !== undefined) patch.zip = patch.postal; // the CRM form reads zip state.customers[it.id] = { ...cur, ...patch, saved: iso, edits: [...(cur.edits || []), { at: iso, by, source: "import " + histId, changes: it.changes }] }; affected.push({ id: it.id, action: "updated", label: it.label, rows: it.rows, changes: it.changes }); } }); }); } else if (dest === "vendors") { D._mutate((state) => { state.vendors = state.vendors || []; act.forEach(it => { if (it.action === "add") { let id = it.record.id; while (state.vendors.some(v => v.id === id)) id = D._uid("V"); state.vendors.unshift({ ...it.record, id, importId: histId }); affected.push({ id, action: "added", label: it.label, rows: it.rows }); } else if (it.action === "update") { const cur = state.vendors.find(v => v.id === it.id); if (!cur) return; Object.keys(it.changes).forEach(k => { cur[k] = it.changes[k].to; }); cur.edits = [...(cur.edits || []), { at: iso, by, source: "import " + histId, changes: it.changes }]; affected.push({ id: it.id, action: "updated", label: it.label, rows: it.rows, changes: it.changes }); } }); }); } else if (dest === "checks") { D._mutate((state) => { state.checks = state.checks || []; act.forEach(it => { if (it.action === "add") { state.checks.unshift({ ...it.record, importId: histId }); affected.push({ id: it.id, action: "added", label: it.label, rows: it.rows }); } else if (it.action === "update") { const cur = state.checks.find(c => String(c.num).toUpperCase() === String(it.id).toUpperCase()); if (!cur) return; Object.keys(it.changes).forEach(k => { cur[k] = it.changes[k].to; }); cur.edits = [...(cur.edits || []), { at: iso, by, source: "import " + histId, changes: it.changes }]; affected.push({ id: it.id, action: "updated", label: it.label, rows: it.rows, changes: it.changes }); } }); }); } else if (dest === "glChart") { D._mutate((state) => { state.glAccounts = state.glAccounts || []; // An empty shared chart means Accounting is showing its working chart — // seed it first so importing a few accounts never hides the rest. if (!state.glAccounts.length && window.GL_ACCOUNTS && window.GL_ACCOUNTS.length) state.glAccounts = window.GL_ACCOUNTS.map(a => ({ ...a })); act.forEach(it => { if (it.action === "add") { state.glAccounts.push({ ...it.record, importId: histId }); affected.push({ id: it.id, action: "added", label: it.label, rows: it.rows }); } else if (it.action === "update") { const cur = state.glAccounts.find(a => acctKey(a.acct) === it.id); if (!cur) return; Object.keys(it.changes).forEach(k => { cur[k] = it.changes[k].to; }); affected.push({ id: it.id, action: "updated", label: it.label, rows: it.rows, changes: it.changes }); } }); }); } else if (dest === "glJournal") { act.forEach(it => { if (it.action !== "add") return; const r = D.postJournalEntry({ ...it.entry, src: "Import" }); if (r && r.ok) affected.push({ id: r.id, action: "added", label: `${it.key} · ${it.label}`, rows: it.rows }); else { it.action = "reject"; it.reasons.push({ field: "post", reason: (r && r.msg) || "not posted" }); late.push(it); } }); } else if (dest === "glTrial") { // ONE balanced opening-balances entry. If it cannot be posted nothing at all changes (accounts are added only after it posts). const e = fresh.entry, sum = fresh.summary; if (!sum.alreadyPosted) { if (!e || !e.lines.length) return { ok: false, msg: "Nothing to post — no account in the file has a balance." }; const r = D.postJournalEntry({ id: e.id, date: e.date, src: e.src, desc: e.desc, jrn: e.jrn, ref: e.ref, lines: e.lines, extra: { ...e.extra, importId: histId } }); if (!r || !r.ok) return { ok: false, denied: !!(r && r.denied), msg: (r && r.msg) || "The opening-balances entry could not be posted." }; if (fresh.newAccounts.length) D._mutate((state) => { state.glAccounts = state.glAccounts || []; if (!state.glAccounts.length && window.GL_ACCOUNTS && window.GL_ACCOUNTS.length) state.glAccounts = window.GL_ACCOUNTS.map(a => ({ ...a })); fresh.newAccounts.forEach(a => { if (!state.glAccounts.some(x => acctKey(x.acct) === a.acct)) state.glAccounts.push({ acct: a.acct, name: a.name, type: a.type, bal: 0, sched: false, imported: true, importId: histId }); }); }); affected.push({ id: r.id, action: "added", label: `Opening balances as of ${sum.asOf} · ${e.lines.length} accounts · debits ${$(sum.entry.debits)} = credits ${$(sum.entry.credits)}${fresh.newAccounts.length ? ` · ${fresh.newAccounts.length} account(s) added to the chart` : ""}`, rows: act.filter(it => it.action === "add").map(it => it.rows[0]) }); undo = { kind: "journal", id: r.id, how: "DMS.importUndo(historyId) reverses it (RV-" + r.id + ")" }; } } else if (dest === "schedule") { const asOf = fresh.asOf, key = ymd(asOf), aIso = isoNoon(asOf), elapsed = fresh.elapsed; D._mutate((state) => { state.arCustomers = state.arCustomers || []; state.factoryReceivables = state.factoryReceivables || []; act.forEach(it => { if (it.action !== "add") return; const r = it.record, id = `SCH${r.gl}-${key}-${r.control}-${r.days}-${cents(r.balance)}-${r.occ}`; if (r.kind === "factory") { state.factoryReceivables.unshift({ id, vin: "", deal: r.control, program: r.name || ("Schedule " + r.gl), claimed: r.balance, amount: r.balance, received: 0, age: r.days + elapsed, ageAsOf: r.days, asOf: aIso, asOfKey: key, imported: true, importedFrom: "Old DMS schedule " + r.gl, importId: histId, schedule: r.gl, glAcct: r.gl }); affected.push({ id, action: "added", label: `${r.control} · ${r.name || ""} · ${$(r.balance)} · ${r.days} days`, rows: it.rows }); } else { let c = state.arCustomers.find(a => String(a.acct) === r.control), made = false; if (!c) { c = { acct: r.control, name: r.name || r.control, type: "RTL", contact: "", phone: "", addr: "", city: "", terms: "Net 30", creditLimit: 0, taxCode: (state.arDefaults && state.arDefaults.defaultTaxCode) || "0100", opened: aIso, txns: [], imported: true, importId: histId }; state.arCustomers.unshift(c); made = true; } c.txns = c.txns || []; c.txns.push({ id, date: isoNoon(addDays(asOf, -r.days)), kind: r.balance > 0 ? "INV" : "ADJ", ref: `SCH${r.gl}-${key}`, desc: `Opening balance — schedule ${r.gl} as of ${mdy(asOf)} (${r.days} days)${r.name ? " · " + r.name : ""}`, charge: r.balance > 0 ? r.balance : 0, credit: r.balance < 0 ? -r.balance : 0, imported: true, importId: histId, schedule: r.gl, asOf: aIso, asOfKey: key, days: r.days }); affected.push({ id, action: "added", label: `${r.control} · ${r.name || ""} · ${$(r.balance)} · ${r.days} days${made ? " · new A/R account" : ""}`, rows: it.rows }); } }); }); undo = { kind: "records", how: "DMS.importUndo(historyId) removes the rows this import added" }; } else if (dest === "deals") { D._mutate((state) => { state.deals = state.deals || []; act.forEach(it => { if (it.action === "add") { const d = it.record; d.importId = histId; d.importedAt = iso; d.updatedAt = iso; state.deals.unshift(d); affected.push({ id: it.id, action: "added", label: `${it.label || ""}${d.stock ? " · stock " + d.stock : ""}`, rows: it.rows }); } else if (it.action === "update") { const cur = state.deals.find(x => String(x.id) === it.id); if (!cur || !cur.imported) return; applyDealPatch(cur, it.patch, histId, iso); affected.push({ id: it.id, action: "updated", label: it.label, rows: it.rows, changes: it.changes }); } }); }); undo = { kind: "records", how: "DMS.importUndo(historyId) removes the deals this import added (unedited ones)" }; } else if (dest === "inventory") { const prep = invPrep(plan.rows, plan.mapping, plan.opts); D.importInventory(prep.rows, { clearBlanks: !!plan.opts.clearBlanks }); act.forEach(it => { if (it.action === "add" || it.action === "update") affected.push({ id: it.id, action: it.action === "add" ? "added" : "updated", label: it.label, rows: it.rows, changes: it.action === "update" ? it.changes : undefined }); }); } else if (dest === "partsMaster") { // RC25 (imp): the Parts screen's own rules, plus the on-hand count when asked const toSend = act.filter(it => (it.action === "add" || it.action === "update") && it.send).map(it => it.send); if (toSend.length) D.importPartsMaster(toSend, { updatePrices: plan.opts.updatePrices !== false, applyOnHand: plan.opts.applyOnHand !== false, quiet: true }); act.forEach(it => { if (it.action === "add" || it.action === "update") affected.push({ id: it.id, action: it.action === "add" ? "added" : "updated", label: it.label, rows: it.rows, changes: it.action === "update" ? it.changes : undefined }); }); } else if (def.async) { return { ok: false, msg: `${def.short}: this import is sent to the server in batches — use the import button (DMS.importCommitAsync).` }; } const counts = { added: 0, updated: 0, skipped: 0, rejected: 0 }; act.forEach(it => { counts[ACT2COUNT[it.action]]++; }); const errors = []; act.forEach(it => { (it.reasons || []).forEach(r => errors.push({ row: r.row || it.rows.join(" "), key: it.key, action: it.action, level: "rejected", field: r.field, reason: r.reason })); (it.warnings || []).forEach(r => errors.push({ row: r.row || it.rows.join(" "), key: it.key, action: it.action, level: "warning", field: r.field, reason: r.reason })); }); (fresh.warnings || []).forEach(w => errors.push({ row: "", key: "", action: "", level: "warning", field: w.code, reason: [w.title + ".", w.detail].filter(Boolean).join(" ") })); const hist = { id: histId, at: iso, by, file: txt(m.file), sheet: txt(m.sheet), source: txt(m.source), dest, destLabel: def.label, template: m.template ? { id: m.template.id || "", name: m.template.name || "" } : null, mapping: { ...plan.mapping }, clearBlanks: !!plan.opts.clearBlanks, rowCount: plan.rows.length, counts, affected, errors: errors.slice(0, 5000), skippedRows: act.filter(it => it.action === "skip").map(it => ({ rows: it.rows, key: it.key, note: it.note || "" })).slice(0, 5000), unmapped: plan.unmapped.map(u => ({ header: u.header, samples: u.samples, values: u.values, truncated: u.truncated })), ...(fresh.summary ? { summary: dest === "glTrial" ? { ...fresh.summary, checks: undefined, breaks: undefined } : fresh.summary } : {}), ...(plan.opts && (plan.opts.asOf || plan.opts.periodMode || plan.opts.addMissingAccounts || plan.opts.glAcct || plan.opts.append) ? { options: { asOf: plan.opts.asOf instanceof Date ? mdy(plan.opts.asOf) : txt(plan.opts.asOf), periodMode: plan.opts.periodMode || "", addMissingAccounts: !!plan.opts.addMissingAccounts, glAcct: txt(plan.opts.glAcct), append: !!plan.opts.append } } : {}), ...(undo ? { undo } : {}), // mtd column of a trial balance: reference only, kept with the history ...(dest === "glTrial" && fresh.entry ? { reference: { mtd: fresh.entry.extra.mtdRef } } : {}), }; D._mutate((state) => { state.importHistory = state.importHistory || []; state.importHistory.unshift(hist); }); D._audit("Import", `${def.label} — ${hist.file || "file"}: ${counts.added} added · ${counts.updated} updated · ${counts.skipped} skipped · ${counts.rejected} rejected${hist.unmapped.length ? " · kept unmapped: " + hist.unmapped.map(u => u.header).join(", ") : ""} (${histId})`); return { ok: true, id: histId, history: hist, counts, late, summary: fresh.summary || null, msg: dest === "glTrial" && fresh.summary && fresh.summary.alreadyPosted ? `${def.short}: ${fresh.summary.alreadyPosted.id} was already posted — nothing was posted again.` : dest === "glTrial" && fresh.entry ? `${def.short}: ${fresh.entry.id} posted — ${counts.added} accounts, debits ${$(fresh.summary.entry.debits)} = credits ${$(fresh.summary.entry.credits)}.` : `${def.short}: ${counts.added} added · ${counts.updated} updated · ${counts.skipped} skipped · ${counts.rejected} rejected.` }; }; // ══════════════════════════════════════════════════════════════════════ // RC25 (imp): the SERVER-side imports (repair-order history, job lines, the VIN list) — sent in batches of 1,000 rows. // A run has an id; every batch has a number; the server stores the result of each batch, so a batch sent again (a dropped // connection, a retry) is answered from the store and nothing is counted twice. The run id and the next batch are kept in this // browser (localStorage) until the run finishes: if the page is reloaded mid-way, choosing the same file again continues from // the first batch the server has not seen. onProgress({ done, total, rows, rowsTotal, counts }) after every batch. // ══════════════════════════════════════════════════════════════════════ const RUN_KEY = "k2b-imp-run"; const runSaved = () => { try { return JSON.parse(localStorage.getItem(RUN_KEY) || "null"); } catch (e) { return null; } }; const runSave = (v) => { try { if (v) localStorage.setItem(RUN_KEY, JSON.stringify(v)); else localStorage.removeItem(RUN_KEY); } catch (e) {} }; const sleep = (ms) => new Promise(r => setTimeout(r, ms)); const isGateway = (e) => !e || !e.status || e.status === 502 || e.status === 503 || e.status === 504; async function sendBatch(path, body) { let last = null; for (let i = 0; i < 6; i++) { try { return await D._apiPost(path, body); } catch (e) { if (e && e.status && !isGateway(e) && !(e.status === 503 && e.body && e.body.code === "busy")) throw e; // the server refused it: no retry last = e; await sleep(700 * (i + 1)); } } const err = new Error("The connection to the server was lost while sending this batch. Nothing is lost: open the same file again and the import continues from where it stopped."); err.lost = true; err.cause = last; throw err; } const fileSigOf = (dest, meta, plan) => [dest, txt(meta && meta.file), txt(meta && meta.sheet), plan.rows.length, (plan.sig || "").length, (plan.sig || "").slice(0, 400)].join("|"); // ══════════════════════════════════════════════════════════════════════ // RC30 (imp2): EVERY import is STAGED — the planned rows go to the server in batches of 1,000 (POST /api/import/stage/start | // batch | finish) and the run waits there for the owner's approval. This page writes NOTHING to its records and posts NOTHING to // the general ledger (the opening-balances entry included): the server writes the records and posts the entries when the owner // approves (Accounting › Import review & approval), after the server re-checked every row and tied the lists to the ledger. // runOpts: { runId (an open run several sheets share — the whole-workbook flow), part (the sheet name), noFinish (the caller // finishes the run once every sheet is staged), origin ('text' | 'pdf-text' | 'ai-page'), pageRefs: [per-row page] } // ══════════════════════════════════════════════════════════════════════ const stageItem = (dest, it, origin, pageRefs, runId, iso) => { const o = { action: it.action, key: it.key, id: it.id, label: it.label, rows: it.rows, reasons: it.reasons || [], warnings: it.warnings || [], note: it.note || "", changes: it.changes && Object.keys(it.changes).length ? it.changes : undefined }; if (it.record) o.record = it.record; else if (it.send) o.record = it.send; else if (it.entry) o.record = it.entry; else if ((it.action === "reject" || dest === "glTrial") && it.raw && typeof it.raw === "object") { o.raw = {}; Object.keys(it.raw).forEach(k => { const v = it.raw[k]; if (v !== undefined && v !== null && v !== "" && typeof v !== "object") o.raw[k] = v; }); } // the file row of a rejected row (and of every trial-balance row: the server re-checks the netting from the sheet itself) — shown on the review, judged by the rule table, never written as a record // a re-imported deal: the station knows how its patch applies (applyDealPatch) — the server gets the patched copy and judges it as a deal save if (dest === "deals" && it.action === "update" && it.patch) { const cur = (D.deals() || []).find(x => String(x.id) === String(it.id)); if (cur) { const copy = JSON.parse(JSON.stringify(cur)); try { applyDealPatch(copy, it.patch, runId, iso); } catch (e) {} o.record = copy; } } // RC30 fix round (auditor F2): a Customers row carries the planner's intent to the approval — a second person on an account (householdAdd) // and "no phone: K2B issues the number" (needsNumber); the approval hands both to lane cn's rule (imp-stage.js approveCustomers → cust-store prepareImportCustomer / judgeWrite) if (dest === "customers") { if (it.householdAdd && typeof it.householdAdd === "object") o.householdAdd = { custNum: it.householdAdd.custNum, first: it.householdAdd.first, last: it.householdAdd.last }; if (it.needsNumber) o.needsNumber = true; } const ro = it.raw && it.raw.__origin; o.origin = (ro && ro.kind) || origin || "text"; const pr = Array.isArray(pageRefs) && it.rows && it.rows.length ? pageRefs[it.rows[0] - 2] : null; if (ro && (ro.page || ro.file)) o.page = [ro.file, ro.page ? "p." + ro.page : ""].filter(Boolean).join(" "); else if (pr) o.page = String(pr); if (it.raw && it.raw.__origin && o.record && o.record.__origin) { o.record = { ...o.record }; delete o.record.__origin; } return o; }; const stageSummary = (fresh) => (fresh.summary ? { kind: fresh.summary.kind, asOf: fresh.summary.asOfIso || fresh.summary.asOf || "", alreadyPosted: fresh.summary.alreadyPosted || null } : null); const importStageStart = async (o) => { const runId = o.runId || ("imp-" + D._uid("IMP")).replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 60); const r = await D._apiPost("/import/stage/start", { runId, kind: o.kind || "staged", fileName: txt(o.fileName), source: txt(o.source), parts: o.parts || [], opts: { asOf: o.asOf || "" }, tieOut: o.tieOut || undefined, totalRows: o.totalRows || 0 }); return Object.assign({ runId }, r); }; const importStageFinish = async (runId, counts) => D._apiPost("/import/stage/finish", { runId, counts: counts || {} }); const importStage = async (dest, plan, meta, onProgress, runOpts) => { const m = meta || {}, ro = runOpts || {}; const def = destDef(dest); if (!def || !plan || plan.dest !== dest) return { ok: false, msg: "Nothing to import — preview the file first." }; if (plan.unresolved && plan.unresolved.length) return { ok: false, msg: `Choose a field or Skip for: ${plan.unresolved.join(", ")}` }; if (plan.missing && plan.missing.length) return { ok: false, msg: `Map the required field(s): ${plan.missing.join("; ")}` }; if (plan.denied) return { ok: false, msg: `Your role can't import into ${def.short}.` }; if (typeof D._apiPost !== "function") return { ok: false, msg: "This DMS page is older than the server — reload it (Ctrl+F5)." }; const fresh = importPlan(dest, plan.rows, plan.mapping, plan.opts); if (!ro.allowBlocked && fresh.blockers && fresh.blockers.length) { const b = fresh.blockers[0]; return { ok: false, blocked: true, plan: fresh, msg: [b.title + ".", b.fix].filter(Boolean).join(" ") }; } const act = fresh.items; const iso = new Date().toISOString(), by = D.userId ? D.userId() : ""; // the part's name is spelled the shared way (ImpCalc.partName: letters, digits, space _ . ( ) -; "Repair orders — history" → "… - history") const partNameOf = (s) => IC() && IC().partName ? IC().partName(s) : String(s || "").replace(/[—–]/g, "-").replace(/[^A-Za-z0-9 _.()-]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 60); const part = partNameOf(txt(ro.part) || txt(m.sheet) || def.short) || def.key; const sig = fileSigOf(dest, m, fresh) + "|" + part; let runId = ro.runId || "", done = new Set(); let saved = ro.runId ? null : runSaved(); if (saved && saved.sig === sig && saved.runId) { runId = saved.runId; try { const r = await D._apiGet("/import/stage/runs/" + encodeURIComponent(runId)); if (r.run && r.run.status !== "open") { saved = null; runId = ""; } } catch (e) { if (e && e.status === 404) { saved = null; runId = ""; } else if (!isGateway(e)) return { ok: false, msg: (e.body && e.body.error) || e.message }; } } else saved = null; let started; try { started = await importStageStart({ runId: runId || undefined, fileName: m.file, source: m.source, parts: [{ part, dest, sheet: m.sheet || part }], asOf: fresh.summary && (fresh.summary.asOfIso || fresh.summary.asOf) ? impIsoOf(fresh.summary.asOfIso || fresh.summary.asOf) : (fresh.asOf ? impIsoOf(fresh.asOf) : ""), tieOut: m.tieOut, totalRows: act.length }); runId = started.runId || runId; (started.batches || []).forEach(b => { if (b.part === part) done.add(b.batchNo); }); } catch (e) { return { ok: false, msg: (e && e.body && e.body.error) || (e && e.message) || "The server refused to start the import run.", code: e && e.body && e.body.code }; } const origin = ro.origin || (m.source && /old reports/i.test(m.source) ? "ai-page" : "text"); const items = act.map(it => stageItem(dest, it, origin, ro.pageRefs, runId, iso)); const B = IMP_BATCH, batches = Math.max(1, Math.ceil(items.length / B)); const partMeta = { entry: fresh.entry || undefined, newAccounts: fresh.newAccounts && fresh.newAccounts.length ? fresh.newAccounts : undefined, asOf: fresh.asOf ? impIsoOf(fresh.asOf) : (fresh.summary && fresh.summary.asOfIso) || undefined, elapsed: fresh.elapsed || undefined, summary: stageSummary(fresh), blockers: fresh.blockers || [], warnings: fresh.warnings || [], opts: plan.opts ? { clearBlanks: !!plan.opts.clearBlanks, applyOnHand: plan.opts.applyOnHand !== false, updatePrices: plan.opts.updatePrices !== false, addMissingAccounts: !!plan.opts.addMissingAccounts, append: !!plan.opts.append, glAcct: txt(plan.opts.glAcct), periodMode: txt(plan.opts.periodMode) } : undefined, mapping: plan.mapping, sheet: txt(m.sheet) || part, file: txt(m.file), source: txt(m.source), fileTotals: fresh.fileTotals || undefined }; // RC36 (imp36): + the file's Summary_by_Year const progress = (b, counts) => { if (onProgress) try { onProgress({ done: b, total: batches, rows: Math.min(b * B, items.length), rowsTotal: items.length, counts, resumed: done.size > 0, staged: true }); } catch (e) {} }; progress(done.size ? Math.max(...Array.from(done)) : 0, {}); let lastRun = started.run || null; for (let b = 1; b <= batches; b++) { if (done.has(b)) continue; const body = { runId, part, dest, batchNo: b, items: items.slice((b - 1) * B, b * B), partMeta: b === 1 || !done.size ? partMeta : undefined }; let res; try { res = await sendBatch("/import/stage/batch", body); } catch (e) { if (!ro.runId) runSave({ sig, runId, dest, nextBatch: b, file: txt(m.file) }); return { ok: false, lost: !!e.lost, runId, msg: (e && e.body && e.body.error) || (e && e.message) || "The server refused this batch.", code: e && e.body && e.body.code, batchNo: b }; } lastRun = (res && res.run) || lastRun; if (!ro.runId) runSave({ sig, runId, dest, nextBatch: b + 1, file: txt(m.file) }); progress(b, (lastRun && lastRun.totals) || {}); } let fin = null; if (!ro.noFinish) { try { fin = await importStageFinish(runId, { stationRejected: act.filter(it => it.action === "reject").length }); } catch (e) { if (!ro.runId) runSave({ sig, runId, dest, nextBatch: batches + 1, file: txt(m.file) }); return { ok: false, runId, msg: (e && e.body && e.body.error) || (e && e.message) || "The last step (handing the run to the approval screen) did not reach the server — try the import again; nothing is counted twice." }; } lastRun = (fin && fin.run) || lastRun; } if (!ro.runId) runSave(null); const pc = ((lastRun && lastRun.parts) || []).find(p => p.part === part); const sc = (pc && pc.counts) || { ready: 0, problems: 0, skipped: 0, warnings: 0, rows: items.length }; const counts = { added: sc.ready || 0, updated: 0, skipped: sc.skipped || 0, rejected: sc.problems || 0, staged: sc.ready || 0, warnings: sc.warnings || 0 }; const errors = []; act.forEach(it => { (it.reasons || []).forEach(r => errors.push({ row: r.row || it.rows.join(" "), key: it.key, action: it.action, level: "rejected", field: r.field, reason: r.reason })); (it.warnings || []).forEach(r => errors.push({ row: r.row || it.rows.join(" "), key: it.key, action: it.action, level: "warning", field: r.field, reason: r.reason })); }); (fresh.warnings || []).forEach(w => errors.push({ row: "", key: "", action: "", level: "warning", field: w.code, reason: [w.title + ".", w.detail].filter(Boolean).join(" ") })); (fresh.blockers || []).forEach(b => errors.push({ row: "", key: "", action: "blocked", level: "blocked", field: b.code, reason: [b.title + ".", b.detail, b.fix].filter(Boolean).join(" ") })); // shown on the result screen only — the import-history record is written by the SERVER when the run is approved const hist = { id: runId, at: iso, by, file: txt(m.file), sheet: txt(m.sheet), source: txt(m.source), dest, destLabel: def.label, rowCount: plan.rows.length, counts, affected: [], errors: errors.slice(0, 5000), skippedRows: [], unmapped: plan.unmapped.map(u => ({ header: u.header, samples: u.samples, values: u.values, truncated: u.truncated })), staged: true, runId, summary: fresh.summary || null }; D._audit("Import staged", `${def.label} — ${hist.file || "file"}: run ${runId} (${part}): ${counts.staged} rows ready, ${counts.rejected} with problems, ${counts.skipped} skipped — waiting for approval`); const msg = `${def.short}: ${counts.staged} row${counts.staged === 1 ? "" : "s"} ready${counts.rejected ? ` · ${counts.rejected} with problems` : ""}${counts.skipped ? ` · ${counts.skipped} skipped` : ""} — waiting for the owner's approval (run ${runId}). Nothing is saved or posted until it is approved.`; return { ok: true, staged: true, id: runId, runId, history: hist, counts, late: [], summary: fresh.summary || null, run: lastRun, server: { runId, batches, staged: counts.staged, problems: counts.rejected }, msg }; }; const impIsoOf = (v) => { if (!v) return ""; if (v instanceof Date) return isNaN(v) ? "" : v.getFullYear() + "-" + String(v.getMonth() + 1).padStart(2, "0") + "-" + String(v.getDate()).padStart(2, "0"); const s = String(v); const m1 = s.match(/^(\d{4})-(\d{2})-(\d{2})/); if (m1) return m1[0]; const u = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2}|\d{4})$/); if (u) return (u[3].length === 2 ? "20" + u[3] : u[3]) + "-" + u[1].padStart(2, "0") + "-" + u[2].padStart(2, "0"); return ""; }; const importCommitAsync = (dest, plan, meta, onProgress, runOpts) => importStage(dest, plan, meta, onProgress, runOpts); // RC30 (imp2): the RC25 name — staged now const importCommitAsyncLegacy = async (dest, plan, meta, onProgress) => { const m = meta || {}; const def = destDef(dest); if (!def) return { ok: false, msg: "Nothing to import — preview the file first." }; if (!def.async) return importCommitLegacy(dest, plan, m); if (!plan || plan.dest !== dest) return { ok: false, msg: "Nothing to import — preview the file first." }; if (plan.unresolved && plan.unresolved.length) return { ok: false, msg: `Choose a field or Skip for: ${plan.unresolved.join(", ")}` }; if (plan.missing && plan.missing.length) return { ok: false, msg: `Map the required field(s): ${plan.missing.join("; ")}` }; if (plan.denied) return { ok: false, msg: `Your role can't import into ${def.short}.` }; if (typeof D._apiPost !== "function") return { ok: false, msg: "This DMS page is older than the server — reload it (Ctrl+F5)." }; const fresh = importPlan(dest, plan.rows, plan.mapping, plan.opts); if (fresh.blockers && fresh.blockers.length) { const b = fresh.blockers[0]; return { ok: false, blocked: true, plan: fresh, msg: [b.title + ".", b.fix].filter(Boolean).join(" ") }; } const act = fresh.items; const linesMode = !!(fresh.summary && fresh.summary.kind === "roLines"); const toSend = act.filter(it => it.action === "add" && it.send).map(it => it.send); const kind = dest === "vehicleMaster" ? "vehicles" : "ro-history"; const path = dest === "vehicleMaster" ? "/import/vehicles/batch" : linesMode ? "/import/ro-history/lines-batch" : "/import/ro-history/batch"; const B = linesMode ? 5000 : IMP_BATCH; const batches = Math.ceil(toSend.length / B); const iso = new Date().toISOString(), by = D.userId ? D.userId() : ""; const sig = fileSigOf(dest, m, fresh); let saved = runSaved(); let histId, runId, done = new Set(); if (saved && saved.sig === sig && saved.runId) { histId = saved.histId; runId = saved.runId; try { const r = await D._apiGet("/import/runs/" + encodeURIComponent(runId)); (r.run.batches || []).forEach(b => done.add(b.batchNo)); if (r.run.status === "done") done = new Set(Array.from({ length: batches }, (_, i) => i + 1)); } catch (e) { if (e && e.status === 404) { saved = null; } else if (!isGateway(e)) return { ok: false, msg: (e.body && e.body.error) || e.message }; } } if (!saved || saved.sig !== sig || !saved.runId) { histId = D._uid("IMP"); runId = ("imp-" + histId).replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 60); } let run; try { run = (await D._apiPost("/import/runs", { runId, kind, fileName: txt(m.file), sheetName: txt(m.sheet), totalRows: toSend.length })).run; } catch (e) { return { ok: false, msg: (e && e.body && e.body.error) || (e && e.message) || "The server refused to start the import.", code: e && e.body && e.body.code }; } const progress = (b, counts) => { if (onProgress) try { onProgress({ done: b, total: batches, rows: Math.min(b * B, toSend.length), rowsTotal: toSend.length, counts, resumed: done.size > 0 }); } catch (e) {} }; progress(done.size ? Math.max(...Array.from(done)) : 0, run.counts || {}); const noRo = []; let lastCounts = run.counts || {}; for (let b = 1; b <= batches; b++) { if (done.has(b)) continue; const body = { runId, batchNo: b }; body[linesMode ? "lines" : "rows"] = toSend.slice((b - 1) * B, b * B); let res; try { res = await sendBatch(path, body); } catch (e) { runSave({ sig, runId, histId, dest, nextBatch: b, file: txt(m.file) }); return { ok: false, lost: !!e.lost, runId, msg: (e && e.body && e.body.error) || (e && e.message) || "The server refused this batch.", code: e && e.body && e.body.code, batchNo: b }; } if (res && res.boardRecords && res.boardRecords.length && D._applyServerRecords) { try { D._applyServerRecords(res.boardRecords.map(x => ({ collection: x.collection, id: x.id, rev: x.rev, record: x.record }))); } catch (e) {} } if (res && res.noRo) res.noRo.forEach(x => { if (noRo.length < 100) noRo.push(x); }); lastCounts = (res && res.run && res.run.counts) || lastCounts; runSave({ sig, runId, histId, dest, nextBatch: b + 1, file: txt(m.file) }); progress(b, lastCounts); } let fin; try { fin = await D._apiPost("/import/runs/" + encodeURIComponent(runId) + "/finish", { counts: { stationRejected: act.filter(it => it.action === "reject").length } }); } catch (e) { runSave({ sig, runId, histId, dest, nextBatch: batches + 1, file: txt(m.file) }); return { ok: false, runId, msg: (e && e.body && e.body.error) || (e && e.message) || "The last step (closing the run) did not reach the server — try the import again; nothing is counted twice." }; } runSave(null); const sc = (fin.run && fin.run.counts) || lastCounts || {}; const stationRejected = act.filter(it => it.action === "reject").length; const counts = linesMode ? { added: sc.linesAttached || 0, updated: 0, skipped: sc.linesNoRo || 0, rejected: stationRejected } : { added: sc.added || 0, updated: sc.updated || 0, skipped: sc.unchanged || 0, rejected: (sc.rejected || 0) + stationRejected }; const errors = []; act.forEach(it => { (it.reasons || []).forEach(r => errors.push({ row: r.row || it.rows.join(" "), key: it.key, action: it.action, level: "rejected", field: r.field, reason: r.reason })); (it.warnings || []).forEach(r => errors.push({ row: r.row || it.rows.join(" "), key: it.key, action: it.action, level: "warning", field: r.field, reason: r.reason })); }); (fresh.warnings || []).forEach(w => errors.push({ row: "", key: "", action: "", level: "warning", field: w.code, reason: [w.title + ".", w.detail].filter(Boolean).join(" ") })); if (noRo.length) errors.push({ row: "", key: "", action: "skipped", level: "warning", field: "RO #", reason: `job lines for repair orders not imported yet (first ${noRo.length}): ${noRo.join(", ")}` }); const server = { runId, counts: sc, batches, boardOpened: sc.boardOpened || 0, boardUpdated: sc.boardUpdated || 0, boardUntouched: sc.boardUntouched || 0, boardRemoved: sc.boardRemoved || 0, voided: sc.voided || 0, open: sc.open || 0, closed: sc.closed || 0, held: sc.held || 0, linesAttached: sc.linesAttached || 0, linesNoRo: sc.linesNoRo || 0 }; const hist = { id: histId, at: iso, by, file: txt(m.file), sheet: txt(m.sheet), source: txt(m.source), dest, destLabel: def.label, template: m.template ? { id: m.template.id || "", name: m.template.name || "" } : null, mapping: { ...plan.mapping }, clearBlanks: false, rowCount: plan.rows.length, counts, affected: [], errors: errors.slice(0, 5000), skippedRows: [], unmapped: plan.unmapped.map(u => ({ header: u.header, samples: u.samples, values: u.values, truncated: u.truncated })), summary: Object.assign({}, fresh.summary || {}, { server }), server, runId, }; D._mutate((state) => { state.importHistory = state.importHistory || []; state.importHistory.unshift(hist); }); D._audit("Import", `${def.label} — ${hist.file || "file"}: run ${runId}, ${batches} batch${batches === 1 ? "" : "es"}: ${counts.added} added · ${counts.updated} updated · ${counts.skipped} unchanged · ${counts.rejected} rejected (${histId})`); const msg = linesMode ? `${def.short}: job lines attached to ${counts.added} repair order${counts.added === 1 ? "" : "s"}${counts.skipped ? ` · ${counts.skipped} not imported yet` : ""}${counts.rejected ? ` · ${counts.rejected} rejected` : ""}.` : `${def.short}: ${counts.added} added · ${counts.updated} updated · ${counts.skipped} unchanged · ${counts.rejected} rejected${server.boardOpened ? ` · ${server.boardOpened} open RO${server.boardOpened === 1 ? "" : "s"} on the board` : ""}${server.boardUntouched ? ` · ${server.boardUntouched} started in K2B, untouched` : ""}.`; return { ok: true, id: histId, history: hist, counts, late: [], summary: hist.summary, runId, server, msg }; }; const importRunPending = () => runSaved(); // ── undo (trial balance → reversal entry; schedule / deals → remove what that import added) ── // Safe by construction: only rows still carrying that import's id and not edited since are removed; // anything the office changed afterwards is left and reported. const importUndo = (histId) => { const h = (D.importHistory() || []).find(x => x && x.id === histId); if (!h) return { ok: false, msg: "That import was not found." }; const def = destDef(h.dest); if (h.undone) return { ok: false, msg: `That import was already undone (${impStampText(h.undone.at)}).` }; if (!["glTrial", "schedule", "deals"].includes(h.dest)) return { ok: false, msg: `${def ? def.short : h.dest} imports cannot be undone here — correct the records directly.` }; if (def && def.perm && D.can && !D.can(def.perm)) return { ok: false, msg: `Your role can't undo a ${def.short} import.` }; const iso = new Date().toISOString(), by = D.userId ? D.userId() : ""; let removed = 0, kept = 0, detail = ""; if (h.dest === "glTrial") { const a = (h.affected || [])[0]; if (!a) return { ok: false, msg: "This import posted nothing, so there is nothing to undo." }; const r = D.reverseJournalEntry(a.id, `Import ${histId} undone`); if (!r.ok) return { ok: false, msg: r.msg }; removed = 1; detail = `${a.id} reversed by ${r.id}`; } else { D._mutate((state) => { if (h.dest === "deals") { const ids = new Set((h.affected || []).filter(x => x.action === "added").map(x => String(x.id))); state.deals = (state.deals || []).filter(d => { if (!d || !ids.has(String(d.id))) return true; // still carrying THIS import's id and untouched since → remove; edited or re-imported since → leave it if (d.imported && d.importId === histId && d.updatedAt === d.importedAt) { removed++; return false; } kept++; return true; }); } else { state.factoryReceivables = (state.factoryReceivables || []).filter(f => { if (f && f.importId === histId && f.imported) { removed++; return false; } return true; }); (state.arCustomers || []).forEach(c => { const n0 = (c.txns || []).length; c.txns = (c.txns || []).filter(t => !(t && t.imported && t.importId === histId)); removed += n0 - c.txns.length; }); state.arCustomers = (state.arCustomers || []).filter(c => !(c && c.imported && c.importId === histId && !(c.txns || []).length)); } const rec = (state.importHistory || []).find(x => x && x.id === histId); if (rec) rec.undone = { at: iso, by, removed, kept }; }); detail = `${removed} record(s) removed${kept ? `, ${kept} left because they were edited after the import` : ""}`; } if (h.dest === "glTrial") D._mutate((state) => { const rec = (state.importHistory || []).find(x => x && x.id === histId); if (rec) rec.undone = { at: iso, by, removed, kept }; }); D._audit("Import Undone", `${def.label} — ${histId}: ${detail}`); return { ok: true, removed, kept, msg: `Import undone — ${detail}.` }; }; const impStampText = (iso) => { const d = new Date(iso); return isNaN(d) ? String(iso || "") : d.toLocaleString("en-US", { month: "2-digit", day: "2-digit", year: "numeric", hour: "numeric", minute: "2-digit" }); }; // ── error list CSV (plan or history record) ──────────────────────────── const csvQ = (v) => { const s = String(v == null ? "" : v); return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }; const importErrorsCsv = (p) => { const lines = [["row", "record", "result", "level", "field", "reason"].join(",")]; ((p && p.errors) || []).forEach(e => lines.push([e.row, e.key, e.action || "", e.level, e.field, e.reason].map(csvQ).join(","))); ((p && p.unmapped) || []).forEach(u => lines.push(["", "", "kept", "unmapped column", u.header, `not imported — ${(u.values || []).length} value(s) kept in import history (e.g. ${(u.samples || []).join(" | ")})`].map(csvQ).join(","))); return lines.join("\n"); }; const importUnmappedCsv = (h) => { const lines = [["row", "column", "value"].join(",")]; ((h && h.unmapped) || []).forEach(u => (u.values || []).forEach(x => lines.push([x.row, u.header, x.value].map(csvQ).join(",")))); return lines.join("\n"); }; // Current record for a history "affected" entry (drill-down). const importRecord = (dest, id) => { const k = String(id || ""); if (dest === "customers") return (D.customers() || {})[k] || null; if (dest === "vendors") return (D.vendors() || []).find(v => v.id === k) || null; if (dest === "checks") return (D.checks() || []).find(c => String(c.num) === k) || null; if (dest === "glChart") return chart().find(a => acctKey(a.acct) === acctKey(k)) || null; if (dest === "glJournal" || dest === "glTrial") return D.journalEntry ? D.journalEntry(k) : null; if (dest === "inventory") return D._unit ? D._unit(k) : null; if (dest === "deals") return (D.deals() || []).find(d => String(d.id) === k) || null; if (dest === "partsMaster") return (D.parts() || []).find(p => up(p.part) === up(k)) || null; // RC25 (imp) if (dest === "schedule") { const f = (D.factoryReceivables() || []).find(x => x && String(x.id) === k); if (f) return { schedule: f.schedule, control: f.deal, name: f.program, balance: f.claimed, daysAsOf: f.ageAsOf, daysToday: f.age, asOf: f.asOf, importId: f.importId }; for (const a of (D.arCustomers() || [])) { const t = (a.txns || []).find(x => x && String(x.id) === k); if (t) return { schedule: t.schedule, control: a.acct, name: a.name, balance: R2((+t.charge || 0) - (+t.credit || 0)), daysAsOf: t.days, date: t.date, asOf: t.asOf, importId: t.importId }; } return null; } return null; }; // Destination order + the card groups the picker shows. const GROUPS = { gl: { label: "General Ledger", blurb: "Chart of accounts, journal entries, a trial balance (opening balances) or aged schedules — journal entries and the trial balance post only after you confirm." }, inventory: { label: "Inventory", blurb: "Vehicles — matched by stock #, then VIN. Includes the old Vehicle Master report. Never posts to the ledger." }, deals: { label: "Deals", blurb: "Closed-deal history from old recaps — records only, nothing is posted and nobody is notified." }, customers: { label: "Customers", blurb: "Customer file — matched by Customer # (the phone without the area code, 7 digits), then email. A row without a phone gets a number K2B issues; two people on one phone are one account." }, vendors: { label: "Vendors", blurb: "Vendor list — matched by vendor #, then name." }, checks: { label: "Check register", blurb: "Checks already written — register records only, no GL posting." }, service: { label: "Service — repair orders", blurb: "Repair-order history from the old system (closed ROs as history, open ROs on the board) — matched by the old RO number; sent to the server in batches. No GL posting. RC33: the customer's name on the RO makes the customer (or fills the blank fields of the one on file); a service customer list, open warranty claims and body-shop jobs in progress import here too." }, // RC25 (imp) parts: { label: "Parts", blurb: "The parts list — master list and on-hand count, matched by part number (the Parts screen's own rules). No GL posting; tie it out on Reconcile inventory to GL." }, // RC25 (imp) }; const DEST_ORDER = ["glChart", "glJournal", "glTrial", "schedule", "arAging", "inventory", "floorplanStmt", "vehicleMaster", "deals", "roHistory", "svcCustomers", "warrantyClaims", "bodyWip", "partsMaster", "customers", "vendors", "checks"]; // RC25 (imp): + vehicleMaster, roHistory, partsMaster; RC33 (svi): + arAging, svcCustomers, warrantyClaims, bodyWip const importDests = () => DEST_ORDER.map(k => DESTS[k]); Object.assign(window.DMS, { IMPORT_SKIP: SKIP, IMPORT_THRESHOLD: THRESH, IMPORT_GROUPS: GROUPS, IMPORT_BATCH: IMP_BATCH, importDests, importDest: destDef, importGuessMapping, importTemplateFor, importApplyTemplate, importSaveTemplate, importPlan, importCommit, importCommitAsync, importStage, importStageStart, importStageFinish, importRunPending, importUndo, importErrorsCsv, importUnmappedCsv, importRecord, // RC30 (imp2): importStage / importStageStart / importStageFinish _importParse: { money, parseDate, phone10, dateInText, asOfInHeading, plainNum, custNumOf: (v) => (window.CustNum ? (window.CustNum.custNumFromPhone(plainNum(v)).num7 || "") : "") }, // RC30 (cn) }); })();