You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq/src/import-apex-full.ts

1266 lines
60 KiB
TypeScript

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<string, string>[] {
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<H extends readonly string[]> = { [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<typeof CLIENT_H>
export type ApexBranchRow = RowOf<typeof BRANCH_H>
export type ApexBillRow = RowOf<typeof BILL_H>
export type ApexTicketRow = RowOf<typeof TICKET_H>
export type ApexSmsClientRow = RowOf<typeof SMS_CLIENT_H>
export type ApexSmsModuleRow = RowOf<typeof SMS_MODULE_H>
export type ApexRtgsClientRow = RowOf<typeof RTGS_CLIENT_H>
export type ApexSmsProjectRow = RowOf<typeof SMS_PROJECT_H>
export type ApexRtgsProjectRow = RowOf<typeof RTGS_PROJECT_H>
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<H extends readonly string[]>(records: Record<string, string>[], headers: H): RowOf<H>[] {
return records.map((r) =>
Object.fromEntries(headers.map((h) => [h, clean(r[h] ?? '')])) as RowOf<H>)
}
/** Parse the csv-name→csv-text map (keys with or without `.csv`) into typed row arrays. */
export function parseWorkbookCsvs(files: Record<string, string>): ApexWorkbook {
const byName = new Map<string, string>()
for (const [k, v] of Object.entries(files)) byName.set(k.toLowerCase().replace(/\.csv$/, ''), v)
const rows = (name: ApexFileName): Record<string, string>[] => {
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<string, number> = {
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<ApexFileName, SectionReport>
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<AssignStatus, number> = { 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<string, string>
/** 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<string> {
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<string, string> = {
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<string, FieldDef[]> = {
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<string, string>): 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<Plan> {
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<string, string>()
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<string, PlannedClient>()
const planByName = new Map<string, string>()
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<string, PlannedAssignment>()
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<string>()
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<string, PlannedDoc['docType']> = {
'INVOICE': 'INVOICE', 'PROFORMA INVOICE': 'PROFORMA', 'QUOTATION': 'QUOTATION',
}
interface BillLine { row: number; r: ApexBillRow; isGst: boolean }
const billGroups = new Map<string, BillLine[]>()
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<string, { amount: number; receivedOn: string }>()
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<string, ApexTicketRow>()
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<string>([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<string, { row: number; r: ApexTicketRow }[]>()
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<string, { row: number; r: ApexSmsClientRow }[]>()
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<string, { heads: string[]; notes: string[] }>()
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<string, { row: number; r: ApexRtgsClientRow }[]>()
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<string, { onDate: string; status: AssignStatus }>()
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<string>()
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<string, string>, keyHex = '',
): Promise<FullStageResult> {
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<string> {
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<string, string>, keyHex = '',
): Promise<FullCommitResult> {
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<FullCommitResult> => {
// 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<string, string>()
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
})
}