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.
1266 lines
60 KiB
TypeScript
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
|
|
})
|
|
}
|