import { fromRupees, fyOf, uuidv7 } from '@sims/domain' import { writeAudit } from './audit' import { encrypt } from './crypto' import type { DB } from './db' import { validateFieldSpec, type FieldDef } from './repos-modules' import { seedSeries } from './series' /** * Full-APEX importer v2 (D20 — docs/superpowers/specs/2026-07-17-apex-parity-design.md §4), * reworked against the profiled REAL export (46-agent audit, 2026-07-17). Follows v1's * discipline: stage → per-row problems → refuse commit while problems exist — with one * addition: deliberate, founder-approved auto-resolutions (test-client skips, orphan refs, * duplicate-row merges, chain collapses) are counted `notes`, NOT commit-blocking problems, * because the real export contains them and the import must still land. * * The big structural facts this version encodes (from the audit): * - bills.csv is LINE grain: 269 rows = 151 documents; GST rides as pseudo-lines * (DESCRIPTION='GST' / PER_QTY '18%') or in GST_AMOUNT columns depending on era. * - tickets.csv is a day-workbench: 4,567 rows = ~1,857 threads chained by OLD_ID; * superseded carry-forward copies must collapse or the open queue is ~13x inflated. * - sms/rtgs service files carry pipeline-status vocabulary (GENERAL ENQUIRY / NEW * PROJECT / WAITING…), not YES/NO flags. * * `stageApexFull` is a pure validation pass that writes nothing; `commitApexFull` * re-runs the same validation and applies everything inside ONE transaction, auditing * one summary row per section. Rupees→paise via `fromRupees` at this edge; plaintext * passwords are AES-256-GCM encrypted or the commit refuses. v1 stays untouched. * * Explicit skips (audit verdicts, recorded here as the deliberate calls): * - tickets.MAIL_BLOB: exported as '[unsupported data type]' — content lost in the * export; MAIL='Y' is preserved as a description marker so the fact survives. * - bills TDS split: short-payments (e.g. the 1% TDS on SMS/2026/10211) land as a * partial allocation with tds_paise=0 — the true TDS split is not derivable. * - bills QTY/PER_QTY: not used for amounts (transposed on 4 rows; AMT is reliable). * - bills INVOICE_AMOUNT: not used — its meaning flips base→gross in the 2026 era. * - Cross-bill lump-sum payments (3 events stamped on 2 bills each): accepted; * per-doc allocation is clamped to that doc's payable, which caps the damage. * - sms_clients.ERR (ORA-29273 noise) and TID; tickets ASSIGNED_TO (numeric APEX * staff ids with no HQ mapping) and BRID (blank on all rows); projects NEW_FORM * (APEX form flag) and CREATED_BY/UPDATED_BY (provenance survives only via the * '[assigned: …]' note folded from ASSIGNED_TO). */ // ---------- tiny CSV parser (copied from v1, which keeps it private) ---------- function parseCsv(text: string): Record[] { const rows: string[][] = [] let field = '' let row: string[] = [] let inQuotes = false const endField = (): void => { row.push(field); field = '' } const endRow = (): void => { endField(); rows.push(row); row = [] } for (let i = 0; i < text.length; i++) { const ch = text[i]! if (inQuotes) { if (ch === '"') { if (text[i + 1] === '"') { field += '"'; i++ } else inQuotes = false } else field += ch } else if (ch === '"') inQuotes = true else if (ch === ',') endField() else if (ch === '\n') endRow() else if (ch !== '\r') field += ch } if (field !== '' || row.length > 0) endRow() const header = (rows.shift() ?? []).map((h) => h.trim()) return rows .filter((r) => r.some((c) => c.trim() !== '')) .map((r) => Object.fromEntries(header.map((h, i) => [h, (r[i] ?? '').trim()]))) } // ---------- typed workbook rows ---------- /** The nine CSVs of the APEX export (clients required, the rest optional). */ export const APEX_FILES = [ 'clients', 'branches', 'bills', 'tickets', 'sms_clients', 'sms_modules', 'rtgs_clients', 'sms_projects', 'rtgs_projects', ] as const export type ApexFileName = (typeof APEX_FILES)[number] type RowOf = { [K in H[number]]: string } const CLIENT_H = ['SID_NO', 'BANK', 'ADDRESS', 'PHONE', 'MAIL', 'DISTRICT', 'SMS', 'RTGS', 'WHATSAPP', 'MOBILE_APP', 'DB_PASSWORD', 'ATM', 'ANYDESK', 'SECRETARY_CONTACT_DETAILS', 'OS', 'SECTOR'] as const const BRANCH_H = ['ID', 'BRANCH', 'SMS', 'MID', 'BANK', 'BRCODE'] as const const BILL_H = ['DAY_DT', 'BILL_TYP', 'BILL_DATE', 'BILL_NO', 'BANK_ID', 'BANK_NAME', 'ADDRESS', 'MOBILE', 'MAIL', 'REMARK', 'USR', 'INVOICE_NO', 'QTY', 'PER_QTY', 'DESCRIPTION', 'INVOICE_TYPE', 'INVOICE_AMOUNT', 'PAYMENT_STATUS', 'PAYMENT_DATE', 'PAYMENT_AMOUNT', 'NOTES', 'AMT', 'SRNO', 'GST_RATE', 'GST_AMOUNT', 'TOTAL_AMOUNT', 'CONVERTED_FROM_PROFORMA_ID', 'GST_APPLICABLE', 'INVOICE_ID', 'SCROLL'] as const const TICKET_H = ['ID', 'DAY_DT', 'BANK_NAME', 'BANK_ID', 'BRID', 'TYPE', 'GROUP', 'ASSIGN_DATE', 'MOBILE_NO', 'ASSIGNED_TO', 'DESCRIPTION', 'STATUS', 'CREATED', 'CREATED_BY', 'UPDATED', 'UPDATED_BY', 'ONLINE_OFFLINE', 'MAIL_BLOB', 'MAIL', 'TRNS_ID', 'TRNS_FRM', 'DAY_ID', 'ENAME', 'TRANS', 'TICKET_DROPED', 'DAY_CLS', 'OLD_ID', 'SCROLL'] as const const SMS_CLIENT_H = ['RNO', 'CID', 'BANK', 'DISTRICT', 'SMS', 'USERNAME', 'PASSWORD', 'PAYMENT', 'SMS_PROVIDER', 'ADDED_FEATURES', 'CREATED_BY', 'UPDATED_BY', 'PHONE', 'TID', 'SMS_BALANCE', 'ERR', 'REMARK', 'UPDATION', 'RESELLER', 'PAYMENT_DATE', 'INSTALLATION_DATE'] as const const SMS_MODULE_H = ['RNO', 'SID_NO', 'SMS_ID', 'HEAD', 'COLS', 'PKG', 'ACTIV', 'REMARK', 'IMP_BY', 'ON_DAY_DT'] as const const RTGS_CLIENT_H = ['SID_NO', 'BANK_NAME', 'ADDRESS_IP', 'PHONE_NO', 'RTGS', 'IP_CODE', 'E_COLLECTION_CODE', 'RID', 'TID', 'ABB', 'INTEGRATED_BANK_PARTNERS', 'REMARK', 'PAYMENT', 'INSTALLATION_DATE', 'INSTALLED_ON', 'GENERATE_QR', 'SBI_INWARD_REPORT', 'PASSBOOK_PROGRAM', 'IFSC_2025', 'NOTIFICATION', 'MISC_RTGS_OUTWARD', 'PAYMENT_DATE'] as const const SMS_PROJECT_H = ['CID', 'BANK', 'DISTRICT', 'DT_INFO', 'RNO', 'SMS', 'PHONE', 'ASSIGNED_TO', 'TICKET_STATUS', 'DESCRIPTION', 'NEW_FORM', 'CREATED_BY', 'UPDATED_BY', 'TID'] as const const RTGS_PROJECT_H = ['CID', 'BANK', 'DT_INFO', 'RNO', 'RTGS', 'PHONE', 'ASSIGNED_TO', 'TICKET_STATUS', 'DESCRIPTION', 'CREATED_BY', 'UPDATED_BY', 'TID'] as const export type ApexClientRow = RowOf export type ApexBranchRow = RowOf export type ApexBillRow = RowOf export type ApexTicketRow = RowOf export type ApexSmsClientRow = RowOf export type ApexSmsModuleRow = RowOf export type ApexRtgsClientRow = RowOf export type ApexSmsProjectRow = RowOf export type ApexRtgsProjectRow = RowOf export interface ApexWorkbook { clients: ApexClientRow[] branches: ApexBranchRow[] bills: ApexBillRow[] tickets: ApexTicketRow[] sms_clients: ApexSmsClientRow[] sms_modules: ApexSmsModuleRow[] rtgs_clients: ApexRtgsClientRow[] sms_projects: ApexSmsProjectRow[] rtgs_projects: ApexRtgsProjectRow[] } /** Oracle CSV exports embed carriage returns as the literal token `_x000D_`. */ const clean = (s: string): string => s.replace(/_x000D_/g, '\n').replace(/\r/g, '').trim() const orNull = (v: string): string | null => (v === '' ? null : v) function typed(records: Record[], headers: H): RowOf[] { return records.map((r) => Object.fromEntries(headers.map((h) => [h, clean(r[h] ?? '')])) as RowOf) } /** Parse the csv-name→csv-text map (keys with or without `.csv`) into typed row arrays. */ export function parseWorkbookCsvs(files: Record): ApexWorkbook { const byName = new Map() for (const [k, v] of Object.entries(files)) byName.set(k.toLowerCase().replace(/\.csv$/, ''), v) const rows = (name: ApexFileName): Record[] => { const text = byName.get(name) return text === undefined ? [] : parseCsv(text) } return { clients: typed(rows('clients'), CLIENT_H), branches: typed(rows('branches'), BRANCH_H), bills: typed(rows('bills'), BILL_H), tickets: typed(rows('tickets'), TICKET_H), sms_clients: typed(rows('sms_clients'), SMS_CLIENT_H), sms_modules: typed(rows('sms_modules'), SMS_MODULE_H), rtgs_clients: typed(rows('rtgs_clients'), RTGS_CLIENT_H), sms_projects: typed(rows('sms_projects'), SMS_PROJECT_H), rtgs_projects: typed(rows('rtgs_projects'), RTGS_PROJECT_H), } } // ---------- tolerant APEX date parser ---------- const MONTHS: Record = { JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6, JUL: 7, AUG: 8, SEP: 9, OCT: 10, NOV: 11, DEC: 12, } function validDate(y: number, month: number, day: number): string | null { if (month < 1 || month > 12 || day < 1 || day > 31) return null const iso = `${y}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}` return Number.isNaN(Date.parse(iso)) ? null : iso } /** * Best-effort date-part extraction for the formats profiled in the APEX export: * ISO ('2025-08-18 00:00:00'), DD-MON-YYYY ('10-NOV-2023 10:57:18'), and X-Y-ZZZZ. * Profiling proved the SEPARATOR decides day/month order in the project files * (dash = day-first '16-11-2023', slash = month-first '09/19/2024', with a handful * of contradictions like '12-13-2023') — so: dash tries DD-MM then falls back to * MM-DD, slash tries MM/DD then falls back to DD/MM. Returns 'YYYY-MM-DD' or null. */ export function parseApexDate(raw: string): string | null { const s = raw.trim() if (s === '') return null let m = /^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(s) if (m !== null) return validDate(Number(m[1]!), Number(m[2]!), Number(m[3]!)) m = /^(\d{1,2})-([A-Za-z]{3})-(\d{4})/.exec(s) if (m !== null) { const month = MONTHS[m[2]!.toUpperCase()] return month === undefined ? null : validDate(Number(m[3]!), month, Number(m[1]!)) } m = /^(\d{1,2})([/-])(\d{1,2})[/-](\d{4})/.exec(s) if (m !== null) { const a = Number(m[1]!) const b = Number(m[3]!) const y = Number(m[4]!) return m[2] === '/' ? (validDate(y, a, b) ?? validDate(y, b, a)) // slash: MM/DD, fall back DD/MM : (validDate(y, b, a) ?? validDate(y, a, b)) // dash: DD-MM, fall back MM-DD } return null } // ---------- report + plan ---------- /** * `problems` block commit (malformed data an operator must fix). `notes` are the * counted, deliberate auto-resolutions (test-client skips, orphan refs, merges, * chain collapses) — the real export contains them, so they must not block. */ export interface SectionReport { staged: number; problems: string[]; notes: string[] } export type FullImportReport = Record export interface StageSummary { clients: number; client_modules: number; branches: number documents: number; totalPayablePaise: number; payments: number tickets: number; interactions: number currentFy: string /** Max numeric tail among current-FY docs of ANY type (QT/PI/INV share one APEX space). */ seedTail: number | null } export interface FullStageResult { sections: FullImportReport; summary: StageSummary } type AssignStatus = 'quoted' | 'installing' | 'live' const STATUS_RANK: Record = { quoted: 0, installing: 1, live: 2 } interface PlannedClient { id: string; code: string; name: string; address: string district: string | null; sector: string | null; os: string | null; anydesk: string | null contacts: { name: string; phone?: string; email?: string; role?: string }[] dbPasswordEnc: string | null } interface PlannedAssignment { id: string; clientId: string; moduleCode: string status: AssignStatus provider: string | null; username: string | null; passwordEnc: string | null /** D21: module-declared field values, keyed to the module's field_spec keys. */ fieldValues: Record /** Values with no matching declared field key — kept so nothing is lost. */ details: { label: string; value: string }[] remark: string | null installedOn: string | null /** True once service data landed — gates updates onto pre-existing rows. */ serviceTouched: boolean } interface PlannedBranch { id: string; clientId: string; name: string; code: string | null } interface PlannedDoc { id: string; docType: 'INVOICE' | 'PROFORMA' | 'QUOTATION'; docNo: string; fy: string clientId: string; docDate: string; status: string taxable: number; cgst: number; sgst: number; igst: number; roundOff: number; total: number payments: { amount: number; receivedOn: string }[] /** Joined real line DESCRIPTIONs + NOTES memos (policy 1: land in payload.notes). */ notes: string | null /** As-issued bill-to snapshot — name/address/mail differ from today's client on some bills. */ billTo: { name?: string; address?: string; email?: string } | null } type TicketStatus = 'open' | 'in_progress' | 'waiting' | 'closed' | 'dropped' interface PlannedTicket { id: string; clientId: string; moduleCode: string | null; kind: string; description: string status: TicketStatus onlineOffline: string | null; openedOn: string; closedOn: string | null; createdAt: string } interface PlannedInteraction { id: string; clientId: string; onDate: string; notes: string section: 'sms_projects' | 'rtgs_projects' } interface Plan { report: FullImportReport ourState: string clients: PlannedClient[] assignments: PlannedAssignment[] branches: PlannedBranch[] docs: PlannedDoc[] tickets: PlannedTicket[] interactions: PlannedInteraction[] /** Applied-row counts per project section (interactions + tickets created from it). */ projectCounts: { sms_projects: number; rtgs_projects: number } currentFy: string seedTail: number | null } async function companyStateCode(db: DB): Promise { const row = await db.get<{ value: string }>(`SELECT value FROM setting WHERE key='company.state_code'`) // Silent defaults on place-of-supply are how wrong GST reaches the portal — fail loudly. if (row === undefined) throw new Error(`Setting 'company.state_code' is not configured`) return row.value } function rupeesToPaise(raw: string): number | null { if (raw === '') return null const n = Number(raw) return Number.isFinite(n) ? fromRupees(n) : null } /** '' and the APEX placeholders ('NO DATA', 'No Data', 'NO DATA FOUND') mean "nothing here". */ const noData = (v: string): boolean => v === '' || v.toUpperCase() === 'NO DATA' || v.toUpperCase() === 'NO DATA FOUND' /** Junk placeholder values that must not become labeled details. */ const junkDetail = (v: string): boolean => noData(v) || ['N/A', 'NA', 'NIL'].includes(v.toUpperCase()) /** Payment normalize (policy 8): PAID any case → paid; N/UNPAID/PENDING/Nil/NA/blank → not. */ const isPaid = (v: string): boolean => v.toUpperCase() === 'PAID' /** DISTRICT normalize (policy 8): UPPER trimmed + the known misspelling. */ function normDistrict(v: string): string | null { const d = v.toUpperCase() if (d === '') return null return d === 'PATHANAMTHITA' ? 'PATHANAMTHITTA' : d } /** APEX-internal test client (SID 329 = 'WQEQE'/'SIMS') — skipped everywhere, never landed. */ const TEST_SIDS = new Set(['329']) const MODULE_NAMES: Record = { SMS: 'Bulk SMS', RTGS: 'RTGS', WHATSAPP: 'WhatsApp', MOBILEAPP: 'Mobile App', ATM: 'ATM', } /** * D21 module-defined field schema. When the importer get-or-creates each of the five * modules it seeds this field_spec, declaring ONLY the module-specific extra fields — * provider / username / portal-password stay in the D20 client_module columns and are * NOT declared here. The per-service values then land in client_module.field_values * keyed to these keys (lower_snake_case, unique — validated at load below), instead of * the free-form details list. Anything with no matching key still goes to details so no * data is lost. */ const FIELD_SPECS: Record = { SMS: [ { key: 'sms_balance', label: 'SMS balance', type: 'text' }, { key: 'reseller', label: 'Reseller', type: 'text' }, { key: 'added_features', label: 'Added features', type: 'text' }, { key: 'active_alerts', label: 'Active alerts', type: 'text' }, { key: 'phone', label: 'Phone', type: 'text' }, { key: 'installation_date', label: 'Installation date', type: 'text' }, ], RTGS: [ { key: 'ip', label: 'IP', type: 'text' }, { key: 'ip_code', label: 'IP code', type: 'text' }, { key: 'e_collection_code', label: 'E-collection code', type: 'text' }, { key: 'abb', label: 'ABB', type: 'text' }, { key: 'partner_bank', label: 'Partner bank', type: 'text' }, { key: 'ifsc_2025', label: 'IFSC 2025', type: 'text' }, { key: 'generate_qr', label: 'Generate QR', type: 'text' }, { key: 'sbi_inward_report', label: 'SBI inward report', type: 'text' }, { key: 'passbook_program', label: 'Passbook program', type: 'text' }, { key: 'notification', label: 'Notification', type: 'text' }, { key: 'misc_rtgs_outward', label: 'Misc RTGS outward', type: 'text' }, { key: 'phone', label: 'Phone', type: 'text' }, { key: 'installed_on_note', label: 'Installed on note', type: 'text' }, ], ATM: [], // provider stays in the D20 column — no extra fields WHATSAPP: [], // flag-only in the export — no per-service field data MOBILEAPP: [{ key: 'contact', label: 'Contact', type: 'text' }], } // Fail fast at load if any spec is malformed (unique lower_snake_case keys, known types). for (const spec of Object.values(FIELD_SPECS)) validateFieldSpec(spec) const yesIsh = (v: string): boolean => v.toUpperCase().startsWith('Y') const looksLikePhone = (v: string): boolean => /^[\d\s+()-]{7,}$/.test(v) /** * Pipeline-status vocabulary carried by sms_clients.SMS / rtgs_clients.RTGS (policy 5): * PAID/YES/COMPLETED → live; NEW PROJECT → installing; WAITING…/GENERAL ENQUIRY → quoted; * CANCELLED/NO/blank/anything else → no module from this signal. */ function moduleSignal(raw: string): AssignStatus | null { const v = raw.toUpperCase() if (v === '') return null if (yesIsh(v) || v === 'PAID' || v === 'COMPLETED') return 'live' if (v === 'NEW PROJECT') return 'installing' if (v.startsWith('WAITING') || v === 'GENERAL ENQUIRY' || v === 'GENERAL ENQUIERY') return 'quoted' return null } /** Project rows may only UPGRADE an existing assignment (policy 5, second clause). */ function projectUpgrade(raw: string): AssignStatus | null { const v = raw.toUpperCase() if (v === 'COMPLETED') return 'live' if (v === 'NEW PROJECT' || v.startsWith('WAITING')) return 'installing' return null } /** * Ticket status pattern mapping over the 46 raw APEX values (policy 2 — IN THIS ORDER): * contains CLOSED → closed; contains DROP or TICKET_DROPED='Y' → dropped; contains * WAITING → waiting; contains TRANSFER (PENDING_TRANSfERED, 2,836 rows) → in_progress; * contains PENDING → open; else open. */ function mapTicketStatus(status: string, dropped: string): TicketStatus { const s = status.toUpperCase() if (s.includes('CLOSED')) return 'closed' if (s.includes('DROP') || dropped.toUpperCase() === 'Y') return 'dropped' if (s.includes('WAITING')) return 'waiting' if (s.includes('TRANSFER')) return 'in_progress' if (s.includes('PENDING')) return 'open' return 'open' } /** Set-or-replace one labeled detail (skip junk/empty values, replace an existing label). */ function setDetail(a: PlannedAssignment, label: string, value: string): void { if (junkDetail(value)) return const existing = a.details.find((d) => d.label === label) if (existing !== undefined) existing.value = value else a.details.push({ label, value }) } /** Set one module-declared field value (D21) — skip junk/empty; key is a field_spec key. */ function setField(a: PlannedAssignment, key: string, value: string): void { if (junkDetail(value)) return a.fieldValues[key] = value.trim() } /** Count of non-empty cells — "richer row wins" for duplicate dimension rows (policy 9). */ function richness(r: Record): number { return Object.values(r).filter((v) => v !== '').length } /** * The single validation + mapping pass shared by stage and commit. Reads the DB * (existing codes/doc numbers/state codes) but NEVER writes. */ async function buildPlan(db: DB, wb: ApexWorkbook, keyHex: string): Promise { const ourState = await companyStateCode(db) const report = Object.fromEntries( APEX_FILES.map((s) => [s, { staged: wb[s].length, problems: [] as string[], notes: [] as string[] }]), ) as FullImportReport const plan: Plan = { report, ourState, clients: [], assignments: [], branches: [], docs: [], tickets: [], interactions: [], projectCounts: { sms_projects: 0, rtgs_projects: 0 }, currentFy: fyOf(new Date().toISOString().slice(0, 10)), seedTail: null, } const todayIso = new Date().toISOString().slice(0, 10) const now = new Date().toISOString() const existing = await db.all<{ id: string; code: string; name: string; state_code: string }>( `SELECT id, code, name, state_code FROM client`, ) const existingByCode = new Map(existing.map((c) => [c.code, c])) const existingByName = new Map() for (const c of existing) if (!existingByName.has(c.name.toUpperCase())) existingByName.set(c.name.toUpperCase(), c.id) const existingDocNos = new Set( (await db.all<{ doc_no: string }>(`SELECT doc_no FROM document WHERE doc_no IS NOT NULL`)) .map((r) => r.doc_no), ) const planByCode = new Map() const planByName = new Map() const resolveClient = (code: string): { id: string; state: string } | undefined => { const p = planByCode.get(code) if (p !== undefined) return { id: p.id, state: ourState } // imported clients take our state const e = existingByCode.get(code) return e === undefined ? undefined : { id: e.id, state: e.state_code } } const resolveByName = (name: string): string | undefined => planByName.get(name.toUpperCase()) ?? existingByName.get(name.toUpperCase()) /** Skip-note text for an unresolvable/test client ref (policy 7: skip, never stub). */ const refSkip = (col: string, code: string): string => TEST_SIDS.has(code) ? `${col}=${code} is the APEX test client — skipped` : `unknown client ${col}=${code} — skipped (never stubbed)` const assignmentByKey = new Map() const ensureAssignment = (clientId: string, moduleCode: string, status: AssignStatus): PlannedAssignment => { const key = `${clientId}|${moduleCode}` let a = assignmentByKey.get(key) if (a === undefined) { a = { id: uuidv7(), clientId, moduleCode, status, provider: null, username: null, passwordEnc: null, fieldValues: {}, details: [], remark: null, installedOn: null, serviceTouched: false, } assignmentByKey.set(key, a) } else if (STATUS_RANK[status] > STATUS_RANK[a.status]) { a.status = status // signals only ever upgrade, never downgrade } return a } const findAssignment = (clientId: string, moduleCode: string): PlannedAssignment | undefined => assignmentByKey.get(`${clientId}|${moduleCode}`) // ----- clients (SID_NO is the cross-file join key → client.code) ----- let pwNoKey = 0 for (let i = 0; i < wb.clients.length; i++) { const r = wb.clients[i]! const probs: string[] = [] const code = r.SID_NO if (code === '') probs.push('missing SID_NO') else if (planByCode.has(code) || existingByCode.has(code)) probs.push(`duplicate SID_NO: ${code}`) if (r.BANK === '') probs.push('missing BANK') let dbPasswordEnc: string | null = null if (!noData(r.DB_PASSWORD)) { if (keyHex === '') pwNoKey++ // aggregated below — a dry run needs no key else dbPasswordEnc = encrypt(r.DB_PASSWORD, keyHex) } if (probs.length > 0) { report.clients.problems.push(...probs.map((p) => `row ${i + 1}: ${p}`)) continue } const phone = noData(r.PHONE) ? '' : r.PHONE const mail = noData(r.MAIL) ? '' : r.MAIL const contacts: PlannedClient['contacts'] = [] if (phone !== '' || mail !== '') { contacts.push({ name: r.BANK, ...(phone !== '' ? { phone } : {}), ...(mail !== '' ? { email: mail } : {}), }) } if (r.SECRETARY_CONTACT_DETAILS !== '') { contacts.push({ name: r.SECRETARY_CONTACT_DETAILS, role: 'secretary' }) } const pc: PlannedClient = { id: uuidv7(), code, name: r.BANK, address: r.ADDRESS, district: normDistrict(r.DISTRICT), sector: orNull(r.SECTOR), os: orNull(r.OS), anydesk: noData(r.ANYDESK) ? null : r.ANYDESK, contacts, dbPasswordEnc, } planByCode.set(code, pc) if (!planByName.has(pc.name.toUpperCase())) planByName.set(pc.name.toUpperCase(), pc.id) plan.clients.push(pc) // Service flags (policy 4): SMS/RTGS/WHATSAPP only on YES-ish ('GENERAL ENQUIRY' // and blank are NOT yes). ATM holds provider names (EWIRE/ACEMONEY/OTHERS) — // any non-empty non-NO assigns ATM with that value as provider. MOBILE_APP: // non-empty non-NO assigns; a phone-number value lands as a 'Contact' detail. for (const col of ['SMS', 'RTGS', 'WHATSAPP'] as const) { if (yesIsh(r[col])) ensureAssignment(pc.id, col, 'live') } if (r.ATM !== '' && r.ATM.toUpperCase() !== 'NO') { const a = ensureAssignment(pc.id, 'ATM', 'live') if (!yesIsh(r.ATM)) a.provider = r.ATM } if (r.MOBILE_APP !== '' && r.MOBILE_APP.toUpperCase() !== 'NO') { const a = ensureAssignment(pc.id, 'MOBILEAPP', 'live') if (!yesIsh(r.MOBILE_APP) && looksLikePhone(r.MOBILE_APP)) setField(a, 'contact', r.MOBILE_APP) } } if (pwNoKey > 0) { report.clients.problems.push( `${pwNoKey} row(s) carry a plaintext DB_PASSWORD but HQ_SECRET_KEY is not set — commit refuses until the key is provided (values are never stored unencrypted)`, ) } // ----- branches (MID → client.code; byte-identical duplicate rows deduped) ----- const seenBranchKeys = new Set() for (let i = 0; i < wb.branches.length; i++) { const r = wb.branches[i]! const c = r.MID === '' ? undefined : resolveClient(r.MID) if (r.MID === '' || c === undefined) { report.branches.notes.push(`row ${i + 1}: ${r.MID === '' ? 'blank MID — skipped' : refSkip('MID', r.MID)}`) continue } if (r.BRANCH === '') { report.branches.problems.push(`row ${i + 1}: missing BRANCH`); continue } const key = `${r.MID}|${r.BRANCH}|${r.BRCODE}` if (seenBranchKeys.has(key)) { report.branches.notes.push(`row ${i + 1}: duplicate branch row for MID=${r.MID} ('${r.BRANCH}') — deduped`) continue } seenBranchKeys.add(key) plan.branches.push({ id: uuidv7(), clientId: c.id, name: r.BRANCH, code: orNull(r.BRCODE) }) } // ----- bills: LINE grain → group by INVOICE_NO (269 rows = 151 documents) ----- // GST pseudo-lines (DESCRIPTION='GST' or PER_QTY like '18%') carry the tax as AMT. // Three eras (policy 1): (a) GST line present → tax = GST-line AMT sum; (b) no GST // line but GST_AMOUNT populated (2026 era, where INVOICE_AMOUNT flips meaning to // gross — never trust it; AMT is the base) → tax = GST_AMOUNT, total = TOTAL_AMOUNT; // (c) no GST anywhere → tax 0 (pre-GST/DLT history; history docs bypass computeBill // by design). total fallback = taxable + tax. const TYPE_MAP: Record = { 'INVOICE': 'INVOICE', 'PROFORMA INVOICE': 'PROFORMA', 'QUOTATION': 'QUOTATION', } interface BillLine { row: number; r: ApexBillRow; isGst: boolean } const billGroups = new Map() for (let i = 0; i < wb.bills.length; i++) { const r = wb.bills[i]! const rowNo = i + 1 if (r.BANK_ID !== '' && TEST_SIDS.has(r.BANK_ID)) { report.bills.notes.push(`row ${rowNo}: ${refSkip('BANK_ID', r.BANK_ID)}${r.INVOICE_NO !== '' ? ` (doc ${r.INVOICE_NO})` : ''}`) continue } if (r.INVOICE_NO === '') { report.bills.problems.push(`row ${rowNo}: missing INVOICE_NO`); continue } const isGst = r.DESCRIPTION.toUpperCase() === 'GST' || r.PER_QTY.includes('%') const group = billGroups.get(r.INVOICE_NO) ?? [] group.push({ row: rowNo, r, isGst }) billGroups.set(r.INVOICE_NO, group) } for (const [docNo, lines] of billGroups) { const probs: string[] = [] const first = lines[0]!.r const rowsRef = lines.map((l) => l.row).join(',') // Cross-line consistency: BANK_ID / type / date must agree within a document. for (const [label, vals] of [ ['BANK_ID', new Set(lines.map((l) => l.r.BANK_ID))], ['INVOICE_TYPE', new Set(lines.map((l) => l.r.INVOICE_TYPE.toUpperCase()))], ['BILL_DATE', new Set(lines.map((l) => l.r.BILL_DATE.slice(0, 10)))], ] as const) { if (vals.size > 1) probs.push(`inconsistent ${label} across lines`) } const c = first.BANK_ID === '' ? undefined : resolveClient(first.BANK_ID) if (first.BANK_ID === '' || c === undefined) { report.bills.notes.push(`doc ${docNo} (rows ${rowsRef}): ${first.BANK_ID === '' ? 'blank BANK_ID — skipped' : refSkip('BANK_ID', first.BANK_ID)}`) continue } const docType = TYPE_MAP[first.INVOICE_TYPE.toUpperCase()] if (docType === undefined) probs.push(`unknown INVOICE_TYPE: ${first.INVOICE_TYPE}`) if (existingDocNos.has(docNo)) probs.push(`doc_no already exists in HQ: ${docNo}`) const docDate = parseApexDate(first.BILL_DATE) if (docDate === null) probs.push(`bad BILL_DATE: ${first.BILL_DATE}`) // Base lines, with the exact-duplicate-line dedupe (SMS/2026/10208 pattern: // same SRNO + same AMT, blank/identical description → drop one, note it). const baseLines: BillLine[] = [] for (const l of lines.filter((x) => !x.isGst)) { const dup = baseLines.find((k) => k.r.SRNO === l.r.SRNO && k.r.AMT === l.r.AMT && (l.r.DESCRIPTION === '' || l.r.DESCRIPTION === k.r.DESCRIPTION)) if (dup !== undefined) { report.bills.notes.push(`doc ${docNo}: exact-duplicate line (row ${l.row}, SRNO ${l.r.SRNO || '?'}) dropped`) continue } baseLines.push(l) } if (baseLines.length === 0) probs.push('no base (non-GST) line') let taxable = 0 for (const l of baseLines) { const amt = rupeesToPaise(l.r.AMT) if (amt === null) { probs.push(`row ${l.row}: bad AMT: ${l.r.AMT}`); continue } taxable += amt } let tax = 0 const gstLines = lines.filter((x) => x.isGst) if (gstLines.length > 0) { for (const l of gstLines) { const amt = rupeesToPaise(l.r.AMT) if (amt === null) { probs.push(`row ${l.row}: bad GST-line AMT: ${l.r.AMT}`); continue } tax += amt } } else { const gstCol = lines.map((l) => l.r.GST_AMOUNT).find((v) => v !== '') if (gstCol !== undefined) { const t = rupeesToPaise(gstCol) if (t === null) probs.push(`bad GST_AMOUNT: ${gstCol}`) else tax = t } } const totalCol = lines.map((l) => l.r.TOTAL_AMOUNT).find((v) => v !== '') let total = taxable + tax if (totalCol !== undefined) { const t = rupeesToPaise(totalCol) if (t === null) probs.push(`bad TOTAL_AMOUNT: ${totalCol}`) else total = t } if (probs.length > 0) { report.bills.problems.push(...probs.map((p) => `doc ${docNo} (rows ${rowsRef}): ${p}`)) continue } // GST split by place of supply (v1 pattern). APEX has no client state column, so // every imported client carries the company state → all docs split CGST+SGST. const intra = c.state === ourState const cgst = intra ? Math.floor(tax / 2) : 0 const sgst = intra ? tax - cgst : 0 const igst = intra ? 0 : tax const paid = lines.some((l) => isPaid(l.r.PAYMENT_STATUS)) const status = docType === 'INVOICE' ? (paid ? 'paid' : 'sent') : 'sent' // Payments: distinct (date, amount) pairs across the doc's lines — header fields // repeat per line (same pair twice = ONE payment), while genuinely different // pairs are real split payments (SMS/2025/10133: 17,500 + 3,150). Allocation // clamps to the remaining payable (policy 8) so overpayments and cross-bill // lump-sum stamps cannot inflate a document past settled. const payments: PlannedDoc['payments'] = [] if (docType === 'INVOICE') { const pairs = new Map() for (const l of lines) { const amt = rupeesToPaise(l.r.PAYMENT_AMOUNT) const date = parseApexDate(l.r.PAYMENT_DATE) if (amt !== null && amt > 0 && date !== null) pairs.set(`${date}|${amt}`, { amount: amt, receivedOn: date }) } let remaining = total for (const p of [...pairs.values()].sort((x, y) => x.receivedOn.localeCompare(y.receivedOn))) { const alloc = Math.min(p.amount, remaining) if (alloc <= 0) { report.bills.notes.push(`doc ${docNo}: extra payment pair on ${p.receivedOn} exceeds payable — skipped`) continue } payments.push({ amount: alloc, receivedOn: p.receivedOn }) remaining -= alloc } if (paid && payments.length === 0) { report.bills.notes.push(`doc ${docNo}: marked PAID with no payment date/amount — status 'paid', no payment row`) } } // Real line descriptions + NOTES memos survive in payload.notes (audit: 67 rows // carry reconciliation memos with no other destination). const noteBits: string[] = [] for (const l of baseLines) if (l.r.DESCRIPTION !== '' && !noteBits.includes(l.r.DESCRIPTION)) noteBits.push(l.r.DESCRIPTION) for (const l of lines) if (l.r.NOTES !== '' && !noteBits.includes(l.r.NOTES)) noteBits.push(l.r.NOTES) const billTo: PlannedDoc['billTo'] = first.BANK_NAME !== '' || first.ADDRESS !== '' || !noData(first.MAIL) ? { ...(first.BANK_NAME !== '' ? { name: first.BANK_NAME } : {}), ...(first.ADDRESS !== '' ? { address: first.ADDRESS } : {}), ...(!noData(first.MAIL) ? { email: first.MAIL } : {}), } : null plan.docs.push({ id: uuidv7(), docType: docType!, docNo, fy: fyOf(docDate!), clientId: c.id, docDate: docDate!, status, taxable, cgst, sgst, igst, roundOff: total - taxable - tax, total, payments, notes: noteBits.length > 0 ? noteBits.join('\n') : null, billTo, }) } if (plan.docs.length > 0) { report.bills.notes.push( `${plan.docs.length} document(s) split GST as CGST+SGST — APEX has no client state; company state ${ourState} assumed for all`, ) } // Series seed (policy 10): QT/PI/INV share ONE 'SMS/YYYY/NNNNN' space in APEX, so // the INVOICE series continues after the max numeric tail across ALL current-FY docs. const tails = plan.docs .filter((d) => d.fy === plan.currentFy) .map((d) => /(\d+)$/.exec(d.docNo)?.[1]) .filter((t): t is string => t !== undefined) .map((t) => Number(t)) plan.seedTail = tails.length > 0 ? Math.max(...tails) : null // ----- tickets: collapse OLD_ID chains (4,567 rows = ~1,857 threads) ----- // APEX's day-workbench re-created pending tickets daily; superseded carry-forward // copies chain via OLD_ID (policy 3). One ticket per chain: terminal row wins // status, earliest CREATED wins opened_on. Per-row import would inflate the open // queue ~13x (2,909 phantom open tickets vs ~219 real). { const byId = new Map() for (const r of wb.tickets) if (r.ID !== '') byId.set(r.ID, r) const chainKeyOf = (r: ApexTicketRow): string => { let cur = r const seen = new Set([cur.ID]) while (cur.OLD_ID !== '' && cur.OLD_ID !== cur.ID) { if (seen.has(cur.OLD_ID)) return cur.OLD_ID const next = byId.get(cur.OLD_ID) if (next === undefined) return cur.OLD_ID // dangling root — key by the referenced id seen.add(cur.OLD_ID) cur = next } return cur.ID } const chains = new Map() for (let i = 0; i < wb.tickets.length; i++) { const r = wb.tickets[i]! const key = r.ID === '' ? `blank#${i}` : chainKeyOf(r) const members = chains.get(key) ?? [] members.push({ row: i + 1, r }) chains.set(key, members) } let superseded = 0 for (const members of chains.values()) { members.sort((a, b) => a.r.CREATED.localeCompare(b.r.CREATED)) superseded += members.length - 1 const terminals = members.filter((m) => { const s = mapTicketStatus(m.r.STATUS, m.r.TICKET_DROPED) return s === 'closed' || s === 'dropped' }) const rep = terminals.length > 0 ? terminals[terminals.length - 1]! : members[members.length - 1]! const firstM = members[0]! // Client: first chain member with a resolvable BANK_ID, else exact BANK_NAME. let clientId: string | undefined let skipNote: string | undefined for (const m of members) { if (m.r.BANK_ID !== '') { const c = resolveClient(m.r.BANK_ID) if (c !== undefined) { clientId = c.id; break } skipNote = refSkip('BANK_ID', m.r.BANK_ID) } else if (m.r.BANK_NAME !== '') { const byName = resolveByName(m.r.BANK_NAME) if (byName !== undefined) { clientId = byName; break } skipNote = `no client named '${m.r.BANK_NAME}' — skipped` } } if (clientId === undefined) { report.tickets.notes.push(`rows ${members.map((m) => m.row).join(',')}: ${skipNote ?? 'no BANK_ID/BANK_NAME on any chain row — skipped'}`) continue } const openedRaw = firstM.r.CREATED !== '' ? firstM.r.CREATED : (firstM.r.ASSIGN_DATE !== '' ? firstM.r.ASSIGN_DATE : firstM.r.DAY_DT) const openedOn = parseApexDate(openedRaw) if (openedOn === null) { report.tickets.problems.push(`row ${firstM.row}: no parseable open date (CREATED='${firstM.r.CREATED}', ASSIGN_DATE='${firstM.r.ASSIGN_DATE}', DAY_DT='${firstM.r.DAY_DT}')`) continue } const status = mapTicketStatus(rep.r.STATUS, rep.r.TICKET_DROPED) // MOBILE_NO is the caller callback number (the ticket table has no phone column // — policy 3 appends it to the description); MAIL='Y' rows had an attached mail // whose BLOB the export lost — the marker preserves that it existed (in APEX). let description = rep.r.DESCRIPTION const mobile = members.map((m) => m.r.MOBILE_NO).find((v) => v !== '') if (mobile !== undefined) description += `\n[contact: ${mobile}]` if (members.some((m) => m.r.MAIL.toUpperCase() === 'Y')) description += '\n[mail attachment retained in APEX]' const oo = rep.r.ONLINE_OFFLINE.toUpperCase() plan.tickets.push({ id: uuidv7(), clientId, moduleCode: orNull(rep.r.GROUP), kind: rep.r.TYPE, description: description.trim(), status, onlineOffline: oo === 'ONLINE' || oo === 'OFFLINE' ? oo.toLowerCase() : null, openedOn, closedOn: status === 'closed' || status === 'dropped' ? parseApexDate(rep.r.UPDATED) : null, createdAt: firstM.r.CREATED !== '' ? firstM.r.CREATED : now, // BRID is blank on all 4,567 rows and APEX numeric staff ids have no HQ // mapping — branch_id and assigned_to stay NULL by design. }) } if (superseded > 0) { report.tickets.notes.push(`collapsed ${superseded} superseded OLD_ID chain rows into ${plan.tickets.length} ticket thread(s)`) } } // ----- SMS service data (sms_clients: CID → code → the client's SMS module) ----- // Duplicate CID rows: richer row wins, junk twin dropped (policy 9 — CID 33's // 'WHATSAPP' enquiry row). The SMS column carries pipeline vocabulary (policy 5). { const byCid = new Map() let pwRowsNoKey = 0 for (let i = 0; i < wb.sms_clients.length; i++) { const r = wb.sms_clients[i]! if (r.CID === '') { report.sms_clients.problems.push(`row ${i + 1}: missing CID`); continue } const list = byCid.get(r.CID) ?? [] list.push({ row: i + 1, r }) byCid.set(r.CID, list) } for (const [cid, rows] of byCid) { rows.sort((a, b) => richness(b.r) - richness(a.r)) const kept = rows[0]! for (const dropped of rows.slice(1)) { report.sms_clients.notes.push(`row ${dropped.row}: duplicate CID=${cid} — dropped in favour of richer row ${kept.row}`) } const r = kept.r const c = TEST_SIDS.has(cid) ? undefined : resolveClient(cid) if (c === undefined) { report.sms_clients.notes.push(`row ${kept.row}: ${refSkip('CID', cid)}`); continue } let passwordEnc: string | null = null if (r.PASSWORD !== '') { if (keyHex === '') pwRowsNoKey++ else passwordEnc = encrypt(r.PASSWORD, keyHex) } const signal = moduleSignal(r.SMS) let a = findAssignment(c.id, 'SMS') if (signal !== null) a = ensureAssignment(c.id, 'SMS', signal) if (a === undefined) { report.sms_clients.notes.push(`row ${kept.row}: SMS='${r.SMS}' gives no module signal and no assignment exists — row skipped`) continue } a.provider = orNull(r.SMS_PROVIDER) a.username = orNull(r.USERNAME) a.passwordEnc = passwordEnc if (!noData(r.REMARK)) a.remark = r.REMARK // D21: SMS module-specific values land under the SMS field_spec keys (field_values); // provider/username/portal password keep the D20 columns/password_enc path. const smsInstall = parseApexDate(r.INSTALLATION_DATE) a.installedOn = smsInstall ?? a.installedOn // D20 typed go-live date (roster/card) if (smsInstall !== null) setField(a, 'installation_date', smsInstall) // SMS_BALANCE was repurposed as a flag ('YES' on 74 rows) — only numbers land. if (/^\d+(\.\d+)?$/.test(r.SMS_BALANCE)) setField(a, 'sms_balance', r.SMS_BALANCE) setField(a, 'reseller', r.RESELLER) setField(a, 'added_features', r.ADDED_FEATURES) setField(a, 'phone', r.PHONE) // No declared SMS field for these — keep them in details so nothing is lost. setDetail(a, 'Updation', r.UPDATION) if (isPaid(r.PAYMENT)) setDetail(a, 'Payment', 'paid') a.serviceTouched = true } if (pwRowsNoKey > 0) { report.sms_clients.problems.push( `${pwRowsNoKey} row(s) carry a plaintext portal PASSWORD but HQ_SECRET_KEY is not set — commit refuses until the key is provided (values are never stored unencrypted)`, ) } } // ----- SMS alert types (sms_modules: HEAD where ACTIV='Y', + the PKG codepath and // per-alert REMARK deviation notes — policy 6 says neither may be dropped) ----- { const alertsByClient = new Map() for (let i = 0; i < wb.sms_modules.length; i++) { const r = wb.sms_modules[i]! const c = r.SID_NO === '' || TEST_SIDS.has(r.SID_NO) ? undefined : resolveClient(r.SID_NO) if (c === undefined) { report.sms_modules.notes.push(`row ${i + 1}: ${r.SID_NO === '' ? 'blank SID_NO — skipped' : refSkip('SID_NO', r.SID_NO)}`) continue } if (r.ACTIV.toUpperCase() !== 'Y' || r.HEAD === '') continue const entry = alertsByClient.get(c.id) ?? { heads: [], notes: [] } entry.heads.push(r.PKG !== '' ? `${r.HEAD} (${r.PKG})` : r.HEAD) if (r.REMARK !== '') entry.notes.push(`${r.HEAD}: ${r.REMARK}`) alertsByClient.set(c.id, entry) } for (const [clientId, entry] of alertsByClient) { // Live alert config proves a live install even where the clients-list flag said NO. const a = ensureAssignment(clientId, 'SMS', 'live') setField(a, 'active_alerts', entry.heads.join(', ')) // Per-alert deviation notes have no declared SMS field — keep them in details. if (entry.notes.length > 0) setDetail(a, 'Alert notes', entry.notes.join('; ')) a.serviceTouched = true } } // ----- RTGS service data (rtgs_clients: duplicate SID_NO engagements merged) ----- { const bySid = new Map() for (let i = 0; i < wb.rtgs_clients.length; i++) { const r = wb.rtgs_clients[i]! if (r.SID_NO === '') { report.rtgs_clients.problems.push(`row ${i + 1}: missing SID_NO`); continue } const list = bySid.get(r.SID_NO) ?? [] list.push({ row: i + 1, r }) bySid.set(r.SID_NO, list) } for (const [sid, rows] of bySid) { rows.sort((a, b) => richness(b.r) - richness(a.r)) let r = rows[0]!.r if (rows.length > 1) { // Merge duplicate engagements field-wise, richer row preferred (policy 9). const merged = { ...r } for (const other of rows.slice(1)) { for (const k of RTGS_CLIENT_H) if (merged[k] === '') merged[k] = other.r[k] } r = merged report.rtgs_clients.notes.push( `rows ${rows.map((x) => x.row).join(',')}: duplicate SID_NO=${sid} merged (non-empty fields, richer row preferred)`, ) } const c = TEST_SIDS.has(sid) ? undefined : resolveClient(sid) if (c === undefined) { report.rtgs_clients.notes.push(`row ${rows[0]!.row}: ${refSkip('SID_NO', sid)}`); continue } const signal = moduleSignal(r.RTGS) let a = findAssignment(c.id, 'RTGS') if (signal !== null) a = ensureAssignment(c.id, 'RTGS', signal) if (a === undefined) { report.rtgs_clients.notes.push(`row ${rows[0]!.row}: RTGS='${r.RTGS}' gives no module signal and no assignment exists — row skipped`) continue } // D21: RTGS module-specific values land under the RTGS field_spec keys. setField(a, 'ip', r.ADDRESS_IP) setField(a, 'ip_code', r.IP_CODE) setField(a, 'e_collection_code', r.E_COLLECTION_CODE) setField(a, 'abb', r.ABB) setField(a, 'partner_bank', r.INTEGRATED_BANK_PARTNERS) setField(a, 'generate_qr', r.GENERATE_QR) setField(a, 'sbi_inward_report', r.SBI_INWARD_REPORT) setField(a, 'passbook_program', r.PASSBOOK_PROGRAM) setField(a, 'ifsc_2025', r.IFSC_2025) setField(a, 'notification', r.NOTIFICATION) setField(a, 'misc_rtgs_outward', r.MISC_RTGS_OUTWARD) setField(a, 'phone', r.PHONE_NO) // INSTALLATION_DATE is the go-live date → the typed installed_on column (D20 // verdict); INSTALLED_ON is server-location text ('Database Server') → its own field. a.installedOn = parseApexDate(r.INSTALLATION_DATE) ?? a.installedOn setField(a, 'installed_on_note', r.INSTALLED_ON) // No declared RTGS field for payment — keep it in details so nothing is lost. if (isPaid(r.PAYMENT)) setDetail(a, 'Payment', 'paid') if (!noData(r.REMARK)) a.remark = r.REMARK a.serviceTouched = true } } // ----- project lists → interactions (history) + tickets (live work) ----- // Audit verdict (projects TICKET_STATUS, major): non-closed rows WITHOUT a TID are // the open SMS/RTGS work queue and are NOT recovered by the ticket import — they // land as ticket rows; everything else lands as interaction history (rows WITH a // TID echo tickets the ticket import already lands). The latest project row per // client+module may upgrade the assignment status (policy 5). { const upgrades = new Map() const planProjects = ( rows: { CID: string; DT_INFO: string; TICKET_STATUS: string; DESCRIPTION: string; PHONE: string; ASSIGNED_TO: string; TID: string; RNO: string; moduleValue: string }[], moduleCode: 'SMS' | 'RTGS', section: 'sms_projects' | 'rtgs_projects', ): void => { const seenRows = new Set() for (let i = 0; i < rows.length; i++) { const r = rows[i]! const rowNo = i + 1 if (r.CID === '') { report[section].notes.push(`row ${rowNo}: blank CID — skipped`); continue } const c = TEST_SIDS.has(r.CID) ? undefined : resolveClient(r.CID) if (c === undefined) { report[section].notes.push(`row ${rowNo}: ${refSkip('CID', r.CID)}`); continue } const dupKey = [r.CID, r.RNO, r.DT_INFO, r.TICKET_STATUS, r.DESCRIPTION].join('|') if (seenRows.has(dupKey)) { report[section].notes.push(`row ${rowNo}: byte-identical duplicate project row — deduped`) continue } seenRows.add(dupKey) const parsed = parseApexDate(r.DT_INFO) const onDate = parsed ?? todayIso const up = projectUpgrade(r.moduleValue) if (up !== null) { const key = `${c.id}|${moduleCode}` const prev = upgrades.get(key) if (prev === undefined || onDate.localeCompare(prev.onDate) >= 0) upgrades.set(key, { onDate, status: up }) } const markers: string[] = [] if (r.ASSIGNED_TO !== '' && r.ASSIGNED_TO !== '1') markers.push(`[assigned: ${r.ASSIGNED_TO}]`) if (r.PHONE !== '') markers.push(`[contact: ${r.PHONE}]`) if (parsed === null && r.DT_INFO !== '') markers.push(`(DT_INFO unparsed: ${r.DT_INFO})`) const status = mapTicketStatus(r.TICKET_STATUS, '') if (r.TID === '' && status !== 'closed' && status !== 'dropped') { // Open project work with no APEX ticket behind it — lands in the live queue. plan.tickets.push({ id: uuidv7(), clientId: c.id, moduleCode, kind: 'PROJECT', description: [r.DESCRIPTION, ...markers].filter((s) => s !== '').join('\n').trim(), status, onlineOffline: null, openedOn: onDate, closedOn: null, createdAt: now, }) plan.projectCounts[section]++ continue } const bits = [r.TICKET_STATUS, r.DESCRIPTION].filter((s) => s !== '') const tag = `[${moduleCode} project]` let notes = bits.length > 0 ? `${tag} ${bits.join(' — ')}` : tag if (markers.length > 0) notes += ` ${markers.join(' ')}` plan.interactions.push({ id: uuidv7(), clientId: c.id, onDate, notes, section }) plan.projectCounts[section]++ } } planProjects(wb.sms_projects.map((r) => ({ ...r, moduleValue: r.SMS })), 'SMS', 'sms_projects') planProjects(wb.rtgs_projects.map((r) => ({ ...r, moduleValue: r.RTGS })), 'RTGS', 'rtgs_projects') for (const [key, u] of upgrades) { const a = assignmentByKey.get(key) if (a !== undefined && STATUS_RANK[u.status] > STATUS_RANK[a.status]) a.status = u.status } } plan.assignments = [...assignmentByKey.values()] return plan } function summarize(plan: Plan): StageSummary { return { clients: plan.clients.length, client_modules: plan.assignments.length, branches: plan.branches.length, documents: plan.docs.length, totalPayablePaise: plan.docs.reduce((s, d) => s + d.total, 0), payments: plan.docs.reduce((s, d) => s + d.payments.length, 0), tickets: plan.tickets.length, interactions: plan.interactions.length, currentFy: plan.currentFy, seedTail: plan.seedTail, } } // ---------- stage (pure validation — writes nothing) ---------- /** * Validation pass over the whole workbook: per-section staged counts, blocking * problems, deliberate skip/merge notes, and a plan summary. Reads the DB for * join/duplicate checks but writes nothing. A dry run needs no keyHex — plaintext * passwords then surface as an aggregated blocking problem (commit needs the key). */ export async function stageApexFull( db: DB, files: Record, keyHex = '', ): Promise { const plan = await buildPlan(db, parseWorkbookCsvs(files), keyHex) return { sections: plan.report, summary: summarize(plan) } } // ---------- commit (same validation, ONE transaction) ---------- export interface FullCommitResult { clients: number; client_modules: number; branches: number bills: number; payments: number; tickets: number sms_clients: number; sms_modules: number; rtgs_clients: number sms_projects: number; rtgs_projects: number seeded: { fy: string; lastSeq: number } | null } const ALL_KINDS_JSON = JSON.stringify(['one_time', 'monthly', 'yearly', 'usage']) async function ensureModule(db: DB, code: string): Promise { const spec = JSON.stringify(FIELD_SPECS[code] ?? []) const row = await db.get<{ id: string; field_spec: string }>( `SELECT id, field_spec FROM module WHERE code=?`, code) if (row !== undefined) { // Additive backfill: seed our field_spec only if the module has none yet — // never clobber a spec an owner has since customized. if ((row.field_spec ?? '[]') === '[]' && spec !== '[]') { await db.run(`UPDATE module SET field_spec=? WHERE id=?`, spec, row.id) } return row.id } const id = uuidv7() await db.run( `INSERT INTO module (id, code, name, sac, allowed_kinds, multi_subscription, quote_content, field_spec) VALUES (?, ?, ?, '998313', ?, 0, '[]', ?)`, id, code, MODULE_NAMES[code] ?? code, ALL_KINDS_JSON, spec, ) return id } /** * Re-run the validation and, only if EVERY section is problem-free (notes are the * deliberate skips and do not block), apply the whole workbook in one transaction. * Auditing is one summary row per section — 4.5k per-row audits would drown the log. */ export async function commitApexFull( db: DB, userId: string, files: Record, keyHex = '', ): Promise { const wb = parseWorkbookCsvs(files) const plan = await buildPlan(db, wb, keyHex) const allProblems = APEX_FILES.flatMap((s) => plan.report[s].problems.map((p) => `${s} ${p}`)) if (allProblems.length > 0) { throw new Error(`${allProblems.length} problem(s) block the import — fix the CSVs and re-stage:\n${allProblems.join('\n')}`) } const now = new Date().toISOString() return db.transaction(async (): Promise => { // Clients — source 'apex' marks the cutover rows; code IS the APEX SID_NO. for (const c of plan.clients) { await db.run( `INSERT INTO client (id, code, name, gstin, state_code, address, contacts, status, anydesk, os, district, sector, db_password_enc, source, created_at) VALUES (?, ?, ?, NULL, ?, ?, ?, 'active', ?, ?, ?, ?, ?, 'apex', ?)`, c.id, c.code, c.name, plan.ourState, c.address, JSON.stringify(c.contacts), c.anydesk, c.os, c.district, c.sector, c.dbPasswordEnc, now, ) } // Module assignments — direct inserts, NOT assignModule (which audits per row). const moduleIds = new Map() for (const a of plan.assignments) { let moduleId = moduleIds.get(a.moduleCode) if (moduleId === undefined) { moduleId = await ensureModule(db, a.moduleCode) moduleIds.set(a.moduleCode, moduleId) } const existingCm = await db.get<{ id: string }>( `SELECT id FROM client_module WHERE client_id=? AND module_id=? AND active=1`, a.clientId, moduleId, ) if (existingCm === undefined) { await db.run( `INSERT INTO client_module (id, client_id, module_id, status, kind, edition, active, installed_on, provider, username, password_enc, details, remark, field_values) VALUES (?, ?, ?, ?, 'yearly', 'standard', 1, ?, ?, ?, ?, ?, ?, ?)`, a.id, a.clientId, moduleId, a.status, a.installedOn, a.provider, a.username, a.passwordEnc, JSON.stringify(a.details), a.remark, JSON.stringify(a.fieldValues), ) } else if (a.serviceTouched) { // Client pre-dated this import with a live assignment: service data wins. await db.run( `UPDATE client_module SET provider=?, username=?, password_enc=?, details=?, remark=?, installed_on=?, field_values=? WHERE id=?`, a.provider, a.username, a.passwordEnc, JSON.stringify(a.details), a.remark, a.installedOn, JSON.stringify(a.fieldValues), existingCm.id, ) } } // Branches. for (const b of plan.branches) { await db.run( `INSERT INTO client_branch (id, client_id, name, code, active) VALUES (?, ?, ?, ?, 1)`, b.id, b.clientId, b.name, b.code, ) } // Documents (history-only: empty payload lines, legacy numbers; payload carries // the line/NOTES memos and the as-issued bill-to snapshot) + payments. let payments = 0 for (const d of plan.docs) { const totals = { grossPaise: d.taxable, discountPaise: 0, taxablePaise: d.taxable, cgstPaise: d.cgst, sgstPaise: d.sgst, igstPaise: d.igst, cessPaise: 0, roundOffPaise: d.roundOff, payablePaise: d.total, savingsVsMrpPaise: 0, } const payload = { lines: [], totals, ...(d.notes !== null ? { notes: d.notes } : {}), ...(d.billTo !== null ? { billTo: d.billTo } : {}), } await db.run( `INSERT INTO document (id, doc_type, doc_no, fy, client_id, doc_date, status, ref_doc_id, taxable_paise, cgst_paise, sgst_paise, igst_paise, round_off_paise, payable_paise, payload, source, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, 'apex', ?, ?)`, d.id, d.docType, d.docNo, d.fy, d.clientId, d.docDate, d.status, d.taxable, d.cgst, d.sgst, d.igst, d.roundOff, d.total, JSON.stringify(payload), userId, now, ) for (const p of d.payments) { const paymentId = uuidv7() await db.run( `INSERT INTO payment (id, client_id, received_on, mode, reference, amount_paise, tds_paise, created_by, created_at) VALUES (?, ?, ?, 'bank', 'APEX import', ?, 0, ?, ?)`, paymentId, d.clientId, p.receivedOn, p.amount, userId, now, ) await db.run( `INSERT INTO payment_allocation (id, payment_id, document_id, amount_paise) VALUES (?, ?, ?, ?)`, uuidv7(), paymentId, d.id, p.amount, ) payments++ } } // Series seed: QT/PI/INV shared one APEX number space — continue the INVOICE // series after the max current-FY tail across ALL imported docs (policy 10). let seeded: FullCommitResult['seeded'] = null if (plan.seedTail !== null) { await seedSeries(db, 'INVOICE', plan.currentFy, plan.seedTail) seeded = { fy: plan.currentFy, lastSeq: plan.seedTail } await writeAudit(db, userId, 'import', 'doc_series', `INVOICE/${plan.currentFy}`, undefined, { lastSeq: plan.seedTail }) } // Tickets (collapsed chains + open project work). for (const t of plan.tickets) { await db.run( `INSERT INTO ticket (id, client_id, branch_id, module_code, kind, description, status, assigned_to, online_offline, opened_on, closed_on, created_by, created_at, source) VALUES (?, ?, NULL, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, 'apex')`, t.id, t.clientId, t.moduleCode, t.kind, t.description, t.status, t.onlineOffline, t.openedOn, t.closedOn, userId, t.createdAt, ) } // Project history → interaction rows under a get-or-create 'project' type. if (plan.interactions.length > 0) { // Portability quirk: ON CONFLICT upsert is SQLite/Postgres dialect (standard SQL has MERGE). await db.run( `INSERT INTO interaction_type (code, label) VALUES ('project', 'Project note') ON CONFLICT (code) DO NOTHING`, ) for (const x of plan.interactions) { await db.run( `INSERT INTO interaction (id, client_id, type_code, on_date, staff_id, notes, outcome, follow_up_on, created_at) VALUES (?, ?, 'project', ?, ?, ?, NULL, NULL, ?)`, x.id, x.clientId, x.onDate, userId, x.notes, now, ) } } const counts: FullCommitResult = { clients: plan.clients.length, client_modules: plan.assignments.length, branches: plan.branches.length, bills: plan.docs.length, payments, tickets: plan.tickets.length, sms_clients: plan.report.sms_clients.staged, sms_modules: plan.report.sms_modules.staged, rtgs_clients: plan.report.rtgs_clients.staged, sms_projects: plan.projectCounts.sms_projects, rtgs_projects: plan.projectCounts.rtgs_projects, seeded, } // One summary audit row per section that carried data — same txn as the writes. for (const s of APEX_FILES) { const count = counts[s] if (count > 0) await writeAudit(db, userId, 'import', s, 'apex', undefined, { count }) } return counts }) }