feat(d20): P4 — full-APEX importer v2 (9 files, stage/commit, CLI)
parseWorkbookCsvs + stageApexFull (pure validation pass, zero writes) + commitApexFull (one transaction): clients w/ encrypted DB passwords + secretary contacts, YES-flags -> live client_module rows for the 5 modules, branches, bill_filed -> QT/PI/INV history with paisa-exact CGST/SGST split + payment/allocation rows + series seed, 4.5k tickets with status/date mapping, SMS/RTGS service data -> client_module provider/username/password_enc/details, project lists -> interaction notes with tolerant APEX date parsing. Plaintext passwords never stored or audited; missing HQ_SECRET_KEY is a blocking problem. CLI: npm run import:full -- --dir <folder> [--commit]. 7 new tests; suite 359 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/client-detail-redesign
parent
1d598e8a96
commit
b301272464
@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Full-APEX importer CLI (D20): `npm run import:full -- --dir <folder> [--commit]`
|
||||
* Reads the nine workbook CSVs from <folder> (all optional except clients.csv),
|
||||
* prints the per-section stage report, and — only with --commit and a clean
|
||||
* report — applies everything in one transaction. Without --commit it is a dry
|
||||
* run that writes nothing. DB path comes from HQ_DATA_DIR (run from the repo
|
||||
* root otherwise — the default resolves ./data from CWD); passwords in the
|
||||
* export need HQ_SECRET_KEY set or their rows are refused.
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { openDb } from '../src/db'
|
||||
import { seedIfEmpty } from '../src/seed'
|
||||
import { APEX_FILES, commitApexFull, stageApexFull } from '../src/import-apex-full'
|
||||
|
||||
void (async () => {
|
||||
const args = process.argv.slice(2)
|
||||
const dirIdx = args.indexOf('--dir')
|
||||
const dir = dirIdx >= 0 ? args[dirIdx + 1] : undefined
|
||||
if (dir === undefined) {
|
||||
console.error('Usage: npm run import:full -- --dir <folder> [--commit]')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const files: Record<string, string> = {}
|
||||
for (const name of APEX_FILES) {
|
||||
const p = path.join(dir, `${name}.csv`)
|
||||
if (fs.existsSync(p)) files[name] = fs.readFileSync(p, 'utf8')
|
||||
}
|
||||
if (files['clients'] === undefined) {
|
||||
console.error(`clients.csv not found in ${dir} — it is the one required file (SID_NO is the join key)`)
|
||||
process.exit(1)
|
||||
}
|
||||
const absent = APEX_FILES.filter((n) => files[n] === undefined)
|
||||
if (absent.length > 0) console.log(`Not present (skipped): ${absent.join(', ')}`)
|
||||
|
||||
const db = openDb(process.env['HQ_DATA_DIR'])
|
||||
await seedIfEmpty(db)
|
||||
const keyHex = process.env['HQ_SECRET_KEY'] ?? ''
|
||||
|
||||
const report = await stageApexFull(db, files, keyHex)
|
||||
let problemTotal = 0
|
||||
for (const section of APEX_FILES) {
|
||||
const r = report[section]
|
||||
if (r.staged === 0 && r.problems.length === 0) continue
|
||||
console.log(`${section}: ${r.staged} staged, ${r.problems.length} problem(s)`)
|
||||
for (const p of r.problems) console.log(` - ${p}`)
|
||||
problemTotal += r.problems.length
|
||||
}
|
||||
|
||||
if (!args.includes('--commit')) {
|
||||
console.log('Dry run — nothing written. Re-run with --commit to apply.')
|
||||
} else if (problemTotal > 0) {
|
||||
console.error(`Refusing to commit: ${problemTotal} problem row(s) above — fix the CSVs and re-run.`)
|
||||
process.exitCode = 1
|
||||
} else {
|
||||
const counts = await commitApexFull(db, 'system', files, keyHex)
|
||||
console.log('Committed:', JSON.stringify(counts, null, 2))
|
||||
}
|
||||
await db.close()
|
||||
})()
|
||||
@ -0,0 +1,794 @@
|
||||
import { fromRupees, fyOf, uuidv7 } from '@sims/domain'
|
||||
import { writeAudit } from './audit'
|
||||
import { encrypt } from './crypto'
|
||||
import type { DB } from './db'
|
||||
import { seedSeries } from './series'
|
||||
|
||||
/**
|
||||
* Full-APEX importer v2 (D20 — docs/superpowers/specs/2026-07-17-apex-parity-design.md §4).
|
||||
* Follows v1's discipline: stage → per-row problems → refuse commit while any problem
|
||||
* exists. Unlike v1 there are no staging tables — `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 (per-row
|
||||
* client/document audits are deliberately skipped — 5k+ rows would drown the log).
|
||||
* Rupee amounts convert to integer paise via `fromRupees` at this edge; plaintext
|
||||
* passwords (client DB + SMS portal) are AES-256-GCM encrypted or the row is refused.
|
||||
* v1 (`import-apex.ts`) stays untouched for the simple owner-only web flow.
|
||||
*/
|
||||
|
||||
// ---------- 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 seen in the APEX export:
|
||||
* ISO ('2025-08-18 00:00:00'), DD-MON-YYYY ('10-NOV-2023 10:57:18'), and
|
||||
* X-Y-ZZZZ / X/Y/ZZZZ where whichever of X/Y is >12 must be the day
|
||||
* ('08/29/2024' → MM/DD, '13-05-2024' → DD-MM); ambiguous → assume DD-MM
|
||||
* (the dominant APEX habit). 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[2]!)
|
||||
const [day, month] = a > 12 ? [a, b] : b > 12 ? [b, a] : [a, b]
|
||||
return validDate(Number(m[3]!), month!, day!)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------- report + plan ----------
|
||||
|
||||
export interface SectionReport { staged: number; problems: string[] }
|
||||
export type FullImportReport = Record<ApexFileName, SectionReport>
|
||||
|
||||
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
|
||||
provider: string | null; username: string | null; passwordEnc: string | null
|
||||
details: { label: string; value: string }[]
|
||||
remark: string | null
|
||||
/** True once sms/rtgs 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
|
||||
payment: { amount: number; receivedOn: string } | null
|
||||
}
|
||||
|
||||
interface PlannedTicket {
|
||||
id: string; clientId: string; moduleCode: string | null; kind: string; description: string
|
||||
status: 'open' | 'in_progress' | 'waiting' | 'closed' | 'dropped'
|
||||
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[]
|
||||
currentFy: string
|
||||
/** Max numeric tail among current-FY invoice numbers, for the series seed. */
|
||||
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 placeholder 'NO DATA' both mean "nothing here". */
|
||||
const noData = (v: string): boolean => v === '' || v.toUpperCase() === 'NO DATA'
|
||||
|
||||
const MODULE_NAMES: Record<string, string> = {
|
||||
SMS: 'Bulk SMS', RTGS: 'RTGS', WHATSAPP: 'WhatsApp', MOBILEAPP: 'Mobile App', ATM: 'ATM',
|
||||
}
|
||||
|
||||
const FLAG_MODULES: { col: 'SMS' | 'RTGS' | 'WHATSAPP' | 'MOBILE_APP' | 'ATM'; code: string }[] = [
|
||||
{ col: 'SMS', code: 'SMS' },
|
||||
{ col: 'RTGS', code: 'RTGS' },
|
||||
{ col: 'WHATSAPP', code: 'WHATSAPP' },
|
||||
{ col: 'MOBILE_APP', code: 'MOBILEAPP' },
|
||||
{ col: 'ATM', code: 'ATM' },
|
||||
]
|
||||
|
||||
function mapTicketStatus(status: string, dropped: string): PlannedTicket['status'] {
|
||||
const s = status.toUpperCase()
|
||||
if (s === 'CLOSED') return 'closed'
|
||||
if (s === 'OPEN') return 'open'
|
||||
if (s.includes('WAITING')) return 'waiting'
|
||||
if (dropped.toUpperCase() === 'Y') return 'dropped'
|
||||
return 'open'
|
||||
}
|
||||
|
||||
/** Set-or-replace one labeled detail (skip empty values, replace an existing label). */
|
||||
function setDetail(a: PlannedAssignment, label: string, value: string): void {
|
||||
if (value === '') return
|
||||
const existing = a.details.find((d) => d.label === label)
|
||||
if (existing !== undefined) existing.value = value
|
||||
else a.details.push({ label, value })
|
||||
}
|
||||
|
||||
/**
|
||||
* The single validation + mapping pass shared by stage and commit. Reads the DB
|
||||
* (existing codes/doc numbers/state codes) but NEVER writes. Rows with problems
|
||||
* are reported and excluded from the plan; commit refuses while any exist.
|
||||
*/
|
||||
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[] }]),
|
||||
) as FullImportReport
|
||||
const plan: Plan = {
|
||||
report, ourState, clients: [], assignments: [], branches: [], docs: [], tickets: [],
|
||||
interactions: [], 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)) existingByName.set(c.name, 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 assignmentByKey = new Map<string, PlannedAssignment>()
|
||||
const ensureAssignment = (clientId: string, moduleCode: string): PlannedAssignment => {
|
||||
const key = `${clientId}|${moduleCode}`
|
||||
let a = assignmentByKey.get(key)
|
||||
if (a === undefined) {
|
||||
a = {
|
||||
id: uuidv7(), clientId, moduleCode,
|
||||
provider: null, username: null, passwordEnc: null, details: [], remark: null,
|
||||
serviceTouched: false,
|
||||
}
|
||||
assignmentByKey.set(key, a)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// ----- clients (SID_NO is the cross-file join key → client.code) -----
|
||||
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 === '') {
|
||||
probs.push('DB_PASSWORD present but HQ_SECRET_KEY is not configured — refusing to import it unencrypted')
|
||||
} else dbPasswordEnc = encrypt(r.DB_PASSWORD, keyHex)
|
||||
}
|
||||
if (probs.length > 0) {
|
||||
report.clients.problems.push(...probs.map((p) => `row ${i + 1}: ${p}`))
|
||||
continue
|
||||
}
|
||||
const contacts: PlannedClient['contacts'] = []
|
||||
if (r.PHONE !== '' || r.MAIL !== '') {
|
||||
contacts.push({
|
||||
name: r.BANK,
|
||||
...(r.PHONE !== '' ? { phone: r.PHONE } : {}),
|
||||
...(r.MAIL !== '' ? { email: r.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: orNull(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)) planByName.set(pc.name, pc.id)
|
||||
plan.clients.push(pc)
|
||||
// YES/Y* service flags → live module assignments.
|
||||
for (const f of FLAG_MODULES) {
|
||||
if (r[f.col].toUpperCase().startsWith('Y')) ensureAssignment(pc.id, f.code)
|
||||
}
|
||||
}
|
||||
|
||||
// ----- branches (MID → client.code) -----
|
||||
for (let i = 0; i < wb.branches.length; i++) {
|
||||
const r = wb.branches[i]!
|
||||
const probs: string[] = []
|
||||
const c = r.MID === '' ? undefined : resolveClient(r.MID)
|
||||
if (r.MID === '') probs.push('missing MID')
|
||||
else if (c === undefined) probs.push(`unknown client MID=${r.MID}`)
|
||||
if (r.BRANCH === '') probs.push('missing BRANCH')
|
||||
if (probs.length > 0) {
|
||||
report.branches.problems.push(...probs.map((p) => `row ${i + 1}: ${p}`))
|
||||
continue
|
||||
}
|
||||
plan.branches.push({ id: uuidv7(), clientId: c!.id, name: r.BRANCH, code: orNull(r.BRCODE) })
|
||||
}
|
||||
|
||||
// ----- bills (documents + payments; legacy numbers kept, series seeded past them) -----
|
||||
const TYPE_MAP: Record<string, PlannedDoc['docType']> = {
|
||||
'INVOICE': 'INVOICE', 'PROFORMA INVOICE': 'PROFORMA', 'QUOTATION': 'QUOTATION',
|
||||
}
|
||||
const seenDocNos = new Set<string>()
|
||||
for (let i = 0; i < wb.bills.length; i++) {
|
||||
const r = wb.bills[i]!
|
||||
const probs: string[] = []
|
||||
const c = r.BANK_ID === '' ? undefined : resolveClient(r.BANK_ID)
|
||||
if (r.BANK_ID === '') probs.push('missing BANK_ID')
|
||||
else if (c === undefined) probs.push(`unknown client BANK_ID=${r.BANK_ID}`)
|
||||
const docType = TYPE_MAP[r.INVOICE_TYPE.toUpperCase()]
|
||||
if (docType === undefined) probs.push(`unknown INVOICE_TYPE: ${r.INVOICE_TYPE}`)
|
||||
const docNo = r.INVOICE_NO
|
||||
if (docNo === '') probs.push('missing INVOICE_NO')
|
||||
else if (seenDocNos.has(docNo) || existingDocNos.has(docNo)) probs.push(`duplicate doc_no: ${docNo}`)
|
||||
if (docNo !== '') seenDocNos.add(docNo)
|
||||
const docDate = parseApexDate(r.BILL_DATE)
|
||||
if (docDate === null) probs.push(`bad BILL_DATE: ${r.BILL_DATE}`)
|
||||
const taxable = rupeesToPaise(r.INVOICE_AMOUNT)
|
||||
if (taxable === null) probs.push(`bad INVOICE_AMOUNT: ${r.INVOICE_AMOUNT}`)
|
||||
const tax = r.GST_AMOUNT === '' ? 0 : rupeesToPaise(r.GST_AMOUNT)
|
||||
if (tax === null) probs.push(`bad GST_AMOUNT: ${r.GST_AMOUNT}`)
|
||||
let total: number | null = null
|
||||
if (taxable !== null && tax !== null) {
|
||||
total = r.TOTAL_AMOUNT === '' ? taxable + tax : rupeesToPaise(r.TOTAL_AMOUNT)
|
||||
if (total === null) probs.push(`bad TOTAL_AMOUNT: ${r.TOTAL_AMOUNT}`)
|
||||
}
|
||||
if (probs.length > 0) {
|
||||
report.bills.problems.push(...probs.map((p) => `row ${i + 1}: ${p}`))
|
||||
continue
|
||||
}
|
||||
// GST split by place of supply (v1 pattern): intra-state → CGST+SGST, else IGST.
|
||||
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 status = docType === 'INVOICE'
|
||||
? (r.PAYMENT_STATUS.toUpperCase() === 'PAID' ? 'paid' : 'sent')
|
||||
: 'sent'
|
||||
const payAmt = rupeesToPaise(r.PAYMENT_AMOUNT)
|
||||
const payDate = parseApexDate(r.PAYMENT_DATE)
|
||||
const payment = docType === 'INVOICE' && payAmt !== null && payAmt > 0 && payDate !== null
|
||||
? { amount: Math.min(payAmt, total!), receivedOn: payDate }
|
||||
: null
|
||||
plan.docs.push({
|
||||
id: uuidv7(), docType: docType!, docNo, fy: fyOf(docDate!), clientId: c!.id,
|
||||
docDate: docDate!, status,
|
||||
taxable: taxable!, cgst, sgst, igst, roundOff: total! - taxable! - tax!, total: total!,
|
||||
payment,
|
||||
})
|
||||
}
|
||||
// Series seed (v1 logic): continue INVOICE numbering after the last current-FY
|
||||
// legacy number — prefixes differ, so only the numeric tail matters.
|
||||
const tails = plan.docs
|
||||
.filter((d) => d.docType === 'INVOICE' && 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 (BANK_ID → code; blank BANK_ID falls back to exact BANK_NAME) -----
|
||||
for (let i = 0; i < wb.tickets.length; i++) {
|
||||
const r = wb.tickets[i]!
|
||||
const probs: string[] = []
|
||||
let clientId: string | undefined
|
||||
if (r.BANK_ID !== '') {
|
||||
const c = resolveClient(r.BANK_ID)
|
||||
if (c === undefined) probs.push(`unknown client BANK_ID=${r.BANK_ID}`)
|
||||
else clientId = c.id
|
||||
} else if (r.BANK_NAME !== '') {
|
||||
clientId = planByName.get(r.BANK_NAME) ?? existingByName.get(r.BANK_NAME)
|
||||
if (clientId === undefined) probs.push(`no client named '${r.BANK_NAME}'`)
|
||||
} else probs.push('missing BANK_ID and BANK_NAME')
|
||||
const openedRaw = r.ASSIGN_DATE !== '' ? r.ASSIGN_DATE : (r.DAY_DT !== '' ? r.DAY_DT : r.CREATED)
|
||||
const openedOn = parseApexDate(openedRaw)
|
||||
if (openedOn === null) probs.push(`no parseable open date (ASSIGN_DATE='${r.ASSIGN_DATE}', DAY_DT='${r.DAY_DT}', CREATED='${r.CREATED}')`)
|
||||
if (probs.length > 0) {
|
||||
report.tickets.problems.push(...probs.map((p) => `row ${i + 1}: ${p}`))
|
||||
continue
|
||||
}
|
||||
const status = mapTicketStatus(r.STATUS, r.TICKET_DROPED)
|
||||
const oo = r.ONLINE_OFFLINE.toUpperCase()
|
||||
plan.tickets.push({
|
||||
id: uuidv7(), clientId: clientId!,
|
||||
moduleCode: orNull(r.GROUP), kind: r.TYPE, description: r.DESCRIPTION, status,
|
||||
onlineOffline: oo === 'ONLINE' || oo === 'OFFLINE' ? oo.toLowerCase() : null,
|
||||
openedOn: openedOn!,
|
||||
closedOn: status === 'closed' || status === 'dropped' ? parseApexDate(r.UPDATED) : null,
|
||||
createdAt: r.CREATED !== '' ? r.CREATED : now,
|
||||
// BRID has no reliable branch join and APEX staff ids don't map to HQ
|
||||
// staff — branch_id and assigned_to stay NULL by design.
|
||||
})
|
||||
}
|
||||
|
||||
// ----- SMS service data (sms_clients: CID → code → the client's SMS module) -----
|
||||
const seenSmsCids = new Set<string>()
|
||||
for (let i = 0; i < wb.sms_clients.length; i++) {
|
||||
const r = wb.sms_clients[i]!
|
||||
const probs: string[] = []
|
||||
const c = r.CID === '' ? undefined : resolveClient(r.CID)
|
||||
if (r.CID === '') probs.push('missing CID')
|
||||
else if (c === undefined) probs.push(`unknown client CID=${r.CID}`)
|
||||
else if (seenSmsCids.has(r.CID)) probs.push(`duplicate CID: ${r.CID}`)
|
||||
if (r.CID !== '') seenSmsCids.add(r.CID)
|
||||
let passwordEnc: string | null = null
|
||||
if (r.PASSWORD !== '') {
|
||||
if (keyHex === '') {
|
||||
probs.push('PASSWORD present but HQ_SECRET_KEY is not configured — refusing to import it unencrypted')
|
||||
} else passwordEnc = encrypt(r.PASSWORD, keyHex)
|
||||
}
|
||||
if (probs.length > 0) {
|
||||
report.sms_clients.problems.push(...probs.map((p) => `row ${i + 1}: ${p}`))
|
||||
continue
|
||||
}
|
||||
// Flag said NO but this table has a row → data wins, create the assignment.
|
||||
const a = ensureAssignment(c!.id, 'SMS')
|
||||
a.provider = orNull(r.SMS_PROVIDER)
|
||||
a.username = orNull(r.USERNAME)
|
||||
a.passwordEnc = passwordEnc
|
||||
a.remark = orNull(r.REMARK)
|
||||
setDetail(a, 'SMS balance', r.SMS_BALANCE)
|
||||
setDetail(a, 'Reseller', r.RESELLER)
|
||||
setDetail(a, 'Features', r.ADDED_FEATURES)
|
||||
setDetail(a, 'Updation', r.UPDATION)
|
||||
setDetail(a, 'Payment', r.PAYMENT)
|
||||
setDetail(a, 'Phone', r.PHONE)
|
||||
setDetail(a, 'Installed', r.INSTALLATION_DATE)
|
||||
a.serviceTouched = true
|
||||
}
|
||||
|
||||
// ----- SMS alert types (sms_modules: group by SID_NO, HEAD where ACTIV='Y') -----
|
||||
const alertsByClient = new Map<string, string[]>()
|
||||
for (let i = 0; i < wb.sms_modules.length; i++) {
|
||||
const r = wb.sms_modules[i]!
|
||||
const c = r.SID_NO === '' ? undefined : resolveClient(r.SID_NO)
|
||||
if (c === undefined) {
|
||||
report.sms_modules.problems.push(`row ${i + 1}: ${r.SID_NO === '' ? 'missing SID_NO' : `unknown client SID_NO=${r.SID_NO}`}`)
|
||||
continue
|
||||
}
|
||||
if (r.ACTIV.toUpperCase() === 'Y' && r.HEAD !== '') {
|
||||
const list = alertsByClient.get(c.id) ?? []
|
||||
list.push(r.HEAD)
|
||||
alertsByClient.set(c.id, list)
|
||||
}
|
||||
}
|
||||
for (const [clientId, heads] of alertsByClient) {
|
||||
const a = ensureAssignment(clientId, 'SMS')
|
||||
setDetail(a, 'Active alerts', heads.join(', '))
|
||||
a.serviceTouched = true
|
||||
}
|
||||
|
||||
// ----- RTGS service data (rtgs_clients: SID_NO → code → the RTGS module) -----
|
||||
const seenRtgsSids = new Set<string>()
|
||||
for (let i = 0; i < wb.rtgs_clients.length; i++) {
|
||||
const r = wb.rtgs_clients[i]!
|
||||
const probs: string[] = []
|
||||
const c = r.SID_NO === '' ? undefined : resolveClient(r.SID_NO)
|
||||
if (r.SID_NO === '') probs.push('missing SID_NO')
|
||||
else if (c === undefined) probs.push(`unknown client SID_NO=${r.SID_NO}`)
|
||||
else if (seenRtgsSids.has(r.SID_NO)) probs.push(`duplicate SID_NO: ${r.SID_NO}`)
|
||||
if (r.SID_NO !== '') seenRtgsSids.add(r.SID_NO)
|
||||
if (probs.length > 0) {
|
||||
report.rtgs_clients.problems.push(...probs.map((p) => `row ${i + 1}: ${p}`))
|
||||
continue
|
||||
}
|
||||
const a = ensureAssignment(c!.id, 'RTGS')
|
||||
setDetail(a, 'IP', r.ADDRESS_IP)
|
||||
setDetail(a, 'IP code', r.IP_CODE)
|
||||
setDetail(a, 'E-collection code', r.E_COLLECTION_CODE)
|
||||
setDetail(a, 'ABB', r.ABB)
|
||||
setDetail(a, 'Partner bank', r.INTEGRATED_BANK_PARTNERS)
|
||||
setDetail(a, 'QR', r.GENERATE_QR)
|
||||
setDetail(a, 'SBI inward report', r.SBI_INWARD_REPORT)
|
||||
setDetail(a, 'Passbook program', r.PASSBOOK_PROGRAM)
|
||||
setDetail(a, 'IFSC 2025', r.IFSC_2025)
|
||||
setDetail(a, 'Notification', r.NOTIFICATION)
|
||||
setDetail(a, 'Misc RTGS outward', r.MISC_RTGS_OUTWARD)
|
||||
setDetail(a, 'Phone', r.PHONE_NO)
|
||||
setDetail(a, 'Installed', r.INSTALLED_ON !== '' ? r.INSTALLED_ON : r.INSTALLATION_DATE)
|
||||
setDetail(a, 'Payment', r.PAYMENT)
|
||||
a.remark = orNull(r.REMARK)
|
||||
a.serviceTouched = true
|
||||
}
|
||||
|
||||
// ----- project lists → interaction rows -----
|
||||
const planProjects = (
|
||||
rows: { CID: string; DT_INFO: string; TICKET_STATUS: string; DESCRIPTION: string }[],
|
||||
tag: string, section: 'sms_projects' | 'rtgs_projects',
|
||||
): void => {
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i]!
|
||||
const c = r.CID === '' ? undefined : resolveClient(r.CID)
|
||||
if (c === undefined) {
|
||||
report[section].problems.push(`row ${i + 1}: ${r.CID === '' ? 'missing CID' : `unknown client CID=${r.CID}`}`)
|
||||
continue
|
||||
}
|
||||
const parsed = parseApexDate(r.DT_INFO)
|
||||
const bits = [r.TICKET_STATUS, r.DESCRIPTION].filter((s) => s !== '')
|
||||
let notes = bits.length > 0 ? `${tag} ${bits.join(' — ')}` : tag
|
||||
if (parsed === null && r.DT_INFO !== '') notes += ` (DT_INFO unparsed: ${r.DT_INFO})`
|
||||
plan.interactions.push({
|
||||
id: uuidv7(), clientId: c.id, onDate: parsed ?? todayIso, notes, section,
|
||||
})
|
||||
}
|
||||
}
|
||||
planProjects(wb.sms_projects, '[SMS project]', 'sms_projects')
|
||||
planProjects(wb.rtgs_projects, '[RTGS project]', 'rtgs_projects')
|
||||
|
||||
plan.assignments = [...assignmentByKey.values()]
|
||||
return plan
|
||||
}
|
||||
|
||||
// ---------- stage (pure validation — writes nothing) ----------
|
||||
|
||||
/**
|
||||
* Validation pass over the whole workbook: per-section staged counts and per-row
|
||||
* problems. Reads the DB for join/duplicate checks but writes nothing permanent
|
||||
* (nothing at all, in fact — there are no v2 staging tables).
|
||||
*/
|
||||
export async function stageApexFull(
|
||||
db: DB, files: Record<string, string>, keyHex = '',
|
||||
): Promise<FullImportReport> {
|
||||
const plan = await buildPlan(db, parseWorkbookCsvs(files), keyHex)
|
||||
return plan.report
|
||||
}
|
||||
|
||||
// ---------- 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 row = await db.get<{ id: string }>(`SELECT id FROM module WHERE code=?`, code)
|
||||
if (row !== undefined) return row.id
|
||||
const id = uuidv7()
|
||||
await db.run(
|
||||
`INSERT INTO module (id, code, name, sac, allowed_kinds, multi_subscription, quote_content)
|
||||
VALUES (?, ?, ?, '998313', ?, 0, '[]')`,
|
||||
id, code, MODULE_NAMES[code] ?? code, ALL_KINDS_JSON,
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-run the validation and, only if EVERY section is clean, apply the whole
|
||||
* workbook in one transaction. Auditing is one summary row per section (the
|
||||
* deliberate volume call — 5k tickets would drown per-row audits).
|
||||
*/
|
||||
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 row(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,
|
||||
provider, username, password_enc, details, remark)
|
||||
VALUES (?, ?, ?, 'live', 'yearly', 'standard', 1, ?, ?, ?, ?, ?)`,
|
||||
a.id, a.clientId, moduleId,
|
||||
a.provider, a.username, a.passwordEnc, JSON.stringify(a.details), a.remark,
|
||||
)
|
||||
} 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=? WHERE id=?`,
|
||||
a.provider, a.username, a.passwordEnc, JSON.stringify(a.details), a.remark, 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) + 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,
|
||||
}
|
||||
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({ lines: [], totals }), userId, now,
|
||||
)
|
||||
if (d.payment !== null) {
|
||||
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, d.payment.receivedOn, d.payment.amount, userId, now,
|
||||
)
|
||||
await db.run(
|
||||
`INSERT INTO payment_allocation (id, payment_id, document_id, amount_paise)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
uuidv7(), paymentId, d.id, d.payment.amount,
|
||||
)
|
||||
payments++
|
||||
}
|
||||
}
|
||||
|
||||
// Series seed: continue INVOICE numbering after the last current-FY legacy number.
|
||||
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.
|
||||
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 lists → 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.interactions.filter((x) => x.section === 'sms_projects').length,
|
||||
rtgs_projects: plan.interactions.filter((x) => x.section === 'rtgs_projects').length,
|
||||
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
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,334 @@
|
||||
// apps/hq/test/import-apex-full.test.ts — D20 full-APEX importer v2 (stage → problems → one-txn commit)
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { fyOf } from '@sims/domain'
|
||||
import { openDb, type DB } from '../src/db'
|
||||
import { seedIfEmpty } from '../src/seed'
|
||||
import { decrypt } from '../src/crypto'
|
||||
import { commitApexFull, parseApexDate, stageApexFull } from '../src/import-apex-full'
|
||||
|
||||
const KEY = '22'.repeat(32) // 64 hex chars = 32 bytes
|
||||
const TODAY = new Date().toISOString().slice(0, 10)
|
||||
const FY = fyOf(TODAY)
|
||||
|
||||
// Build a CSV from sparse row objects — headers EXACTLY as the APEX export ships them.
|
||||
const csv = (headers: string, rows: Record<string, string>[]): string =>
|
||||
headers + '\n'
|
||||
+ rows.map((r) => headers.split(',').map((h) => r[h] ?? '').join(',')).join('\n') + '\n'
|
||||
|
||||
const CLIENT_HDR = 'SID_NO,BANK,ADDRESS,PHONE,MAIL,DISTRICT,SMS,RTGS,WHATSAPP,MOBILE_APP,DB_PASSWORD,ATM,ANYDESK,SECRETARY_CONTACT_DETAILS,OS,SECTOR'
|
||||
const BRANCH_HDR = 'ID,BRANCH,SMS,MID,BANK,BRCODE'
|
||||
const BILL_HDR = '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'
|
||||
const TICKET_HDR = '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'
|
||||
const SMS_CLIENT_HDR = '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'
|
||||
const SMS_MODULE_HDR = 'RNO,SID_NO,SMS_ID,HEAD,COLS,PKG,ACTIV,REMARK,IMP_BY,ON_DAY_DT'
|
||||
const RTGS_CLIENT_HDR = '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'
|
||||
const SMS_PROJECT_HDR = 'CID,BANK,DISTRICT,DT_INFO,RNO,SMS,PHONE,ASSIGNED_TO,TICKET_STATUS,DESCRIPTION,NEW_FORM,CREATED_BY,UPDATED_BY,TID'
|
||||
const RTGS_PROJECT_HDR = 'CID,BANK,DT_INFO,RNO,RTGS,PHONE,ASSIGNED_TO,TICKET_STATUS,DESCRIPTION,CREATED_BY,UPDATED_BY,TID'
|
||||
|
||||
/** A small but complete workbook exercising every section. */
|
||||
function fullFiles(): Record<string, string> {
|
||||
return {
|
||||
clients: csv(CLIENT_HDR, [
|
||||
{
|
||||
SID_NO: '101', BANK: 'Acme Bank', ADDRESS: 'Kochi HQ', PHONE: '9876543210',
|
||||
MAIL: 'acme@bank.in', DISTRICT: 'Ernakulam', SMS: 'YES', RTGS: 'NO', WHATSAPP: 'Y',
|
||||
MOBILE_APP: 'NO', DB_PASSWORD: 's3cret-db', ATM: 'NO', ANYDESK: '123 456 789',
|
||||
SECRETARY_CONTACT_DETAILS: 'Secretary John 9999', OS: 'Windows 10', SECTOR: 'Co-op',
|
||||
},
|
||||
{
|
||||
SID_NO: '102', BANK: 'Beta Bank', DISTRICT: 'Kollam', SMS: 'NO', RTGS: 'YES',
|
||||
WHATSAPP: 'NO', MOBILE_APP: 'NO', DB_PASSWORD: 'NO DATA', ATM: 'NO',
|
||||
ANYDESK: 'NO DATA', SECTOR: 'Service',
|
||||
},
|
||||
]),
|
||||
branches: csv(BRANCH_HDR, [
|
||||
{ ID: '1', BRANCH: 'Main Branch', MID: '101', BANK: 'Acme Bank', BRCODE: 'BR001' },
|
||||
]),
|
||||
bills: csv(BILL_HDR, [
|
||||
{ // paid current-FY invoice — rupees→paise, payment+allocation, series seed
|
||||
BILL_DATE: `${TODAY} 00:00:00`, BANK_ID: '101', INVOICE_NO: 'SMS/2026/0042',
|
||||
INVOICE_TYPE: 'INVOICE', INVOICE_AMOUNT: '1000', GST_AMOUNT: '180', TOTAL_AMOUNT: '1180',
|
||||
PAYMENT_STATUS: 'PAID', PAYMENT_DATE: `${TODAY} 00:00:00`, PAYMENT_AMOUNT: '1180',
|
||||
},
|
||||
{ // proforma with '' tax ('→0) and '' total ('→taxable+tax); never gets a payment
|
||||
BILL_DATE: '2025-08-18 00:00:00', BANK_ID: '102', INVOICE_NO: 'PI/2025/007',
|
||||
INVOICE_TYPE: 'PROFORMA INVOICE', INVOICE_AMOUNT: '500', GST_AMOUNT: '', TOTAL_AMOUNT: '',
|
||||
},
|
||||
]),
|
||||
tickets: csv(TICKET_HDR, [
|
||||
{
|
||||
ID: '1', BANK_ID: '101', TYPE: 'MODIFICATION', GROUP: 'SMS',
|
||||
ASSIGN_DATE: '2025-02-28 00:00:00', DESCRIPTION: 'Fix template', STATUS: 'CLOSED',
|
||||
CREATED: '2025-02-28 09:00:00', UPDATED: '2025-03-02 10:00:00', ONLINE_OFFLINE: 'ONLINE',
|
||||
},
|
||||
{ // blank BANK_ID → exact BANK_NAME match; DD-MON-YYYY open date; WAITING* status
|
||||
ID: '2', BANK_NAME: 'Acme Bank', DAY_DT: '01-MAR-2025',
|
||||
STATUS: 'WAITING FOR CONFIRMATION', DESCRIPTION: 'Waiting one',
|
||||
},
|
||||
{ // TICKET_DROPED='Y' with a non-terminal status → dropped, closed_on from UPDATED
|
||||
ID: '3', BANK_ID: '102', STATUS: '', TICKET_DROPED: 'Y', DESCRIPTION: 'Dropped one',
|
||||
CREATED: '2025-04-01 09:00:00', UPDATED: '2025-04-02 00:00:00',
|
||||
},
|
||||
]),
|
||||
sms_clients: csv(SMS_CLIENT_HDR, [
|
||||
{ // CID 102's SMS flag said NO — this row wins and creates the assignment
|
||||
RNO: '1', CID: '102', BANK: 'Beta Bank', USERNAME: 'betauser', PASSWORD: 'smsPass9',
|
||||
SMS_PROVIDER: 'AltSMS', PHONE: '0474222', SMS_BALANCE: '5000', REMARK: 'ok',
|
||||
RESELLER: 'RSL', PAYMENT: 'PAID 2025', INSTALLATION_DATE: '10-NOV-2023',
|
||||
},
|
||||
]),
|
||||
sms_modules: csv(SMS_MODULE_HDR, [
|
||||
{ RNO: '1', SID_NO: '102', HEAD: 'ATM ALERT', ACTIV: 'Y' },
|
||||
{ RNO: '2', SID_NO: '102', HEAD: 'BALANCE ALERT', ACTIV: 'Y' },
|
||||
{ RNO: '3', SID_NO: '102', HEAD: 'OLD ALERT', ACTIV: 'N' },
|
||||
]),
|
||||
rtgs_clients: csv(RTGS_CLIENT_HDR, [
|
||||
{
|
||||
SID_NO: '102', BANK_NAME: 'Beta Bank', ADDRESS_IP: '10.0.0.5', PHONE_NO: '333',
|
||||
IP_CODE: 'IPC1', ABB: 'BETA', INTEGRATED_BANK_PARTNERS: 'SBI',
|
||||
INSTALLED_ON: '2024-01-05 00:00:00', GENERATE_QR: 'YES', PAYMENT: 'PAID',
|
||||
},
|
||||
]),
|
||||
sms_projects: csv(SMS_PROJECT_HDR, [
|
||||
{ CID: '101', DT_INFO: '13-05-2024', TICKET_STATUS: 'COMPLETED', DESCRIPTION: 'Install SMS' },
|
||||
]),
|
||||
rtgs_projects: csv(RTGS_PROJECT_HDR, [
|
||||
{ CID: '102', DT_INFO: 'not-a-date', TICKET_STATUS: 'PENDING', DESCRIPTION: 'RTGS setup' },
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
async function freshDb(): Promise<DB> {
|
||||
const db = openDb(':memory:')
|
||||
await seedIfEmpty(db)
|
||||
return db
|
||||
}
|
||||
|
||||
describe('parseApexDate', () => {
|
||||
it('handles every format seen in the APEX export', () => {
|
||||
expect(parseApexDate('2025-08-18 00:00:00')).toBe('2025-08-18') // ISO datetime
|
||||
expect(parseApexDate('10-NOV-2023 10:57:18')).toBe('2023-11-10') // DD-MON-YYYY
|
||||
expect(parseApexDate('08/29/2024')).toBe('2024-08-29') // second >12 → MM/DD
|
||||
expect(parseApexDate('13-05-2024')).toBe('2024-05-13') // first >12 → DD-MM
|
||||
expect(parseApexDate('10-11-2023')).toBe('2023-11-10') // ambiguous → assume DD-MM
|
||||
expect(parseApexDate('')).toBeNull()
|
||||
expect(parseApexDate('not-a-date')).toBeNull()
|
||||
expect(parseApexDate('99-99-2024')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('APEX full importer v2', () => {
|
||||
it('stage: clean workbook → per-section counts, zero problems, and NO writes', async () => {
|
||||
const db = await freshDb()
|
||||
const report = await stageApexFull(db, fullFiles(), KEY)
|
||||
expect(report.clients).toEqual({ staged: 2, problems: [] })
|
||||
expect(report.branches).toEqual({ staged: 1, problems: [] })
|
||||
expect(report.bills).toEqual({ staged: 2, problems: [] })
|
||||
expect(report.tickets).toEqual({ staged: 3, problems: [] })
|
||||
expect(report.sms_clients).toEqual({ staged: 1, problems: [] })
|
||||
expect(report.sms_modules).toEqual({ staged: 3, problems: [] })
|
||||
expect(report.rtgs_clients).toEqual({ staged: 1, problems: [] })
|
||||
expect(report.sms_projects).toEqual({ staged: 1, problems: [] })
|
||||
expect(report.rtgs_projects).toEqual({ staged: 1, problems: [] })
|
||||
// Pure validation: no clients, no documents, no import audit rows.
|
||||
expect((await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM client`))!.n).toBe(0)
|
||||
expect((await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM document`))!.n).toBe(0)
|
||||
expect((await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM audit_log WHERE action='import'`))!.n).toBe(0)
|
||||
})
|
||||
|
||||
it('stage: unknown join keys are per-row problems with row numbers', async () => {
|
||||
const db = await freshDb()
|
||||
const report = await stageApexFull(db, {
|
||||
clients: csv(CLIENT_HDR, [{ SID_NO: '101', BANK: 'Acme Bank' }]),
|
||||
branches: csv(BRANCH_HDR, [{ ID: '9', BRANCH: 'Ghost', MID: '999' }]),
|
||||
tickets: csv(TICKET_HDR, [{ ID: '9', BANK_ID: '999', STATUS: 'OPEN', DAY_DT: '2025-01-01 00:00:00' }]),
|
||||
sms_projects: csv(SMS_PROJECT_HDR, [{ CID: '999', DT_INFO: '2025-01-01' }]),
|
||||
}, KEY)
|
||||
expect(report.branches.problems).toEqual(['row 1: unknown client MID=999'])
|
||||
expect(report.tickets.problems).toEqual(['row 1: unknown client BANK_ID=999'])
|
||||
expect(report.sms_projects.problems).toEqual(['row 1: unknown client CID=999'])
|
||||
})
|
||||
|
||||
it('commit: full happy path lands every section in one transaction', async () => {
|
||||
const db = await freshDb()
|
||||
const res = await commitApexFull(db, 'u-test', fullFiles(), KEY)
|
||||
expect(res.clients).toBe(2)
|
||||
expect(res.client_modules).toBe(4) // 101: SMS+WHATSAPP (flags); 102: RTGS (flag) + SMS (data wins)
|
||||
expect(res.branches).toBe(1)
|
||||
expect(res.bills).toBe(2)
|
||||
expect(res.payments).toBe(1)
|
||||
expect(res.tickets).toBe(3)
|
||||
expect(res.sms_projects).toBe(1)
|
||||
expect(res.rtgs_projects).toBe(1)
|
||||
expect(res.seeded).toEqual({ fy: FY, lastSeq: 42 })
|
||||
|
||||
// Clients: code = SID_NO, contacts carry bank + secretary, support fields ride along.
|
||||
const c101 = (await db.get<{
|
||||
id: string; name: string; address: string; district: string; sector: string
|
||||
os: string; anydesk: string; contacts: string; status: string; source: string
|
||||
state_code: string; db_password_enc: string
|
||||
}>(`SELECT * FROM client WHERE code='101'`))!
|
||||
expect(c101).toMatchObject({
|
||||
name: 'Acme Bank', address: 'Kochi HQ', district: 'Ernakulam', sector: 'Co-op',
|
||||
os: 'Windows 10', anydesk: '123 456 789', status: 'active', source: 'apex', state_code: '32',
|
||||
})
|
||||
expect(JSON.parse(c101.contacts)).toEqual([
|
||||
{ name: 'Acme Bank', phone: '9876543210', email: 'acme@bank.in' },
|
||||
{ name: 'Secretary John 9999', role: 'secretary' },
|
||||
])
|
||||
// DB password encrypted at rest, decryptable with the key.
|
||||
expect(decrypt(c101.db_password_enc, KEY)).toBe('s3cret-db')
|
||||
const c102 = (await db.get<{ anydesk: string | null; db_password_enc: string | null }>(
|
||||
`SELECT anydesk, db_password_enc FROM client WHERE code='102'`))!
|
||||
expect(c102).toEqual({ anydesk: null, db_password_enc: null }) // 'NO DATA' → nothing stored
|
||||
|
||||
// Flags → live yearly assignments on get-or-created modules.
|
||||
const modules = await db.all<{ code: string; name: string }>(
|
||||
`SELECT code, name FROM module WHERE code IN ('SMS','RTGS','WHATSAPP') ORDER BY code`)
|
||||
expect(modules).toEqual([
|
||||
{ code: 'RTGS', name: 'RTGS' }, { code: 'SMS', name: 'Bulk SMS' },
|
||||
{ code: 'WHATSAPP', name: 'WhatsApp' },
|
||||
])
|
||||
const codes101 = await db.all<{ code: string; status: string; kind: string }>(
|
||||
`SELECT m.code, cm.status, cm.kind FROM client_module cm
|
||||
JOIN module m ON m.id=cm.module_id JOIN client c ON c.id=cm.client_id
|
||||
WHERE c.code='101' ORDER BY m.code`)
|
||||
expect(codes101).toEqual([
|
||||
{ code: 'SMS', status: 'live', kind: 'yearly' },
|
||||
{ code: 'WHATSAPP', status: 'live', kind: 'yearly' },
|
||||
])
|
||||
|
||||
// Branch joined by MID.
|
||||
const branch = await db.get(`SELECT b.name, b.code, b.active FROM client_branch b
|
||||
JOIN client c ON c.id=b.client_id WHERE c.code='101'`)
|
||||
expect(branch).toEqual({ name: 'Main Branch', code: 'BR001', active: 1 })
|
||||
|
||||
// Bills: rupees→paise, intra-state split, payment + allocation, series seed.
|
||||
const inv = (await db.get<{
|
||||
id: string; doc_type: string; fy: string; status: string; taxable_paise: number
|
||||
cgst_paise: number; sgst_paise: number; igst_paise: number; round_off_paise: number
|
||||
payable_paise: number; source: string; payload: string
|
||||
}>(`SELECT * FROM document WHERE doc_no='SMS/2026/0042'`))!
|
||||
expect(inv).toMatchObject({
|
||||
doc_type: 'INVOICE', fy: FY, status: 'paid', taxable_paise: 100000,
|
||||
cgst_paise: 9000, sgst_paise: 9000, igst_paise: 0, round_off_paise: 0,
|
||||
payable_paise: 118000, source: 'apex',
|
||||
})
|
||||
expect(JSON.parse(inv.payload)).toMatchObject({ lines: [], totals: { payablePaise: 118000 } })
|
||||
const pi = await db.get(`SELECT doc_type, status, taxable_paise, payable_paise, cgst_paise
|
||||
FROM document WHERE doc_no='PI/2025/007'`)
|
||||
expect(pi).toEqual({
|
||||
doc_type: 'PROFORMA', status: 'sent', taxable_paise: 50000, payable_paise: 50000, cgst_paise: 0,
|
||||
})
|
||||
const pay = (await db.get<{ id: string; mode: string; reference: string; amount_paise: number; received_on: string }>(
|
||||
`SELECT * FROM payment`))!
|
||||
expect(pay).toMatchObject({
|
||||
mode: 'bank', reference: 'APEX import', amount_paise: 118000, received_on: TODAY,
|
||||
})
|
||||
const alloc = await db.get(`SELECT payment_id, document_id, amount_paise FROM payment_allocation`)
|
||||
expect(alloc).toEqual({ payment_id: pay.id, document_id: inv.id, amount_paise: 118000 })
|
||||
const series = await db.get(`SELECT next_seq FROM doc_series WHERE doc_type='INVOICE' AND fy=?`, FY)
|
||||
expect(series).toEqual({ next_seq: 43 }) // continues after the legacy 0042 tail
|
||||
|
||||
// Tickets: status mapping + join fallbacks.
|
||||
const t1 = await db.get(`SELECT status, opened_on, closed_on, online_offline, module_code, source
|
||||
FROM ticket WHERE description='Fix template'`)
|
||||
expect(t1).toEqual({
|
||||
status: 'closed', opened_on: '2025-02-28', closed_on: '2025-03-02',
|
||||
online_offline: 'online', module_code: 'SMS', source: 'apex',
|
||||
})
|
||||
const t2 = (await db.get<{ status: string; opened_on: string; client_id: string }>(
|
||||
`SELECT status, opened_on, client_id FROM ticket WHERE description='Waiting one'`))!
|
||||
expect(t2.status).toBe('waiting')
|
||||
expect(t2.opened_on).toBe('2025-03-01') // DD-MON-YYYY DAY_DT
|
||||
expect(t2.client_id).toBe(c101.id) // blank BANK_ID resolved by exact BANK_NAME
|
||||
const t3 = await db.get(`SELECT status, opened_on, closed_on FROM ticket WHERE description='Dropped one'`)
|
||||
expect(t3).toEqual({ status: 'dropped', opened_on: '2025-04-01', closed_on: '2025-04-02' })
|
||||
|
||||
// SMS service data landed on 102's (data-created) SMS module.
|
||||
const sms102 = (await db.get<{ provider: string; username: string; password_enc: string; details: string; remark: string }>(
|
||||
`SELECT cm.provider, cm.username, cm.password_enc, cm.details, cm.remark FROM client_module cm
|
||||
JOIN module m ON m.id=cm.module_id JOIN client c ON c.id=cm.client_id
|
||||
WHERE c.code='102' AND m.code='SMS'`))!
|
||||
expect(sms102.provider).toBe('AltSMS')
|
||||
expect(sms102.username).toBe('betauser')
|
||||
expect(decrypt(sms102.password_enc, KEY)).toBe('smsPass9')
|
||||
expect(sms102.remark).toBe('ok')
|
||||
const smsDetails = JSON.parse(sms102.details) as { label: string; value: string }[]
|
||||
expect(smsDetails).toContainEqual({ label: 'SMS balance', value: '5000' })
|
||||
expect(smsDetails).toContainEqual({ label: 'Installed', value: '10-NOV-2023' })
|
||||
expect(smsDetails).toContainEqual({ label: 'Active alerts', value: 'ATM ALERT, BALANCE ALERT' })
|
||||
|
||||
// RTGS service data on 102's RTGS module.
|
||||
const rtgs102 = (await db.get<{ details: string }>(
|
||||
`SELECT cm.details FROM client_module cm
|
||||
JOIN module m ON m.id=cm.module_id JOIN client c ON c.id=cm.client_id
|
||||
WHERE c.code='102' AND m.code='RTGS'`))!
|
||||
const rtgsDetails = JSON.parse(rtgs102.details) as { label: string; value: string }[]
|
||||
expect(rtgsDetails).toContainEqual({ label: 'IP', value: '10.0.0.5' })
|
||||
expect(rtgsDetails).toContainEqual({ label: 'Installed', value: '2024-01-05 00:00:00' })
|
||||
expect(rtgsDetails).toContainEqual({ label: 'QR', value: 'YES' })
|
||||
|
||||
// Projects → interaction rows; unparseable DT_INFO falls back to today + a note.
|
||||
const sp = (await db.get<{ on_date: string; notes: string; type_code: string }>(
|
||||
`SELECT on_date, notes, type_code FROM interaction WHERE notes LIKE '[SMS project]%'`))!
|
||||
expect(sp.type_code).toBe('project')
|
||||
expect(sp.on_date).toBe('2024-05-13')
|
||||
expect(sp.notes).toBe('[SMS project] COMPLETED — Install SMS')
|
||||
const rp = (await db.get<{ on_date: string; notes: string }>(
|
||||
`SELECT on_date, notes FROM interaction WHERE notes LIKE '[RTGS project]%'`))!
|
||||
expect(rp.on_date).toBe(TODAY)
|
||||
expect(rp.notes).toContain('DT_INFO unparsed: not-a-date')
|
||||
|
||||
// One summary audit per section (plus the series seed) — and never a plaintext secret.
|
||||
const auditEntities = (await db.all<{ entity: string }>(
|
||||
`SELECT entity FROM audit_log WHERE action='import'`)).map((r) => r.entity).sort()
|
||||
expect(auditEntities).toEqual(['bills', 'branches', 'clients', 'doc_series', 'rtgs_clients',
|
||||
'rtgs_projects', 'sms_clients', 'sms_modules', 'sms_projects', 'tickets'])
|
||||
const auditDump = JSON.stringify(await db.all(`SELECT * FROM audit_log`))
|
||||
expect(auditDump).not.toContain('s3cret-db')
|
||||
expect(auditDump).not.toContain('smsPass9')
|
||||
})
|
||||
|
||||
it('refuses plaintext passwords loudly when no encryption key is configured', async () => {
|
||||
const db = await freshDb()
|
||||
const files = fullFiles()
|
||||
const report = await stageApexFull(db, files, '')
|
||||
expect(report.clients.problems).toEqual([
|
||||
'row 1: DB_PASSWORD present but HQ_SECRET_KEY is not configured — refusing to import it unencrypted',
|
||||
])
|
||||
expect(report.sms_clients.problems).toEqual([
|
||||
'row 1: PASSWORD present but HQ_SECRET_KEY is not configured — refusing to import it unencrypted',
|
||||
])
|
||||
await expect(commitApexFull(db, 'u-test', files, '')).rejects.toThrow(/HQ_SECRET_KEY/)
|
||||
// Refused before the transaction — nothing landed.
|
||||
expect((await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM client`))!.n).toBe(0)
|
||||
})
|
||||
|
||||
it('duplicate doc_no is a problem that blocks the whole commit', async () => {
|
||||
const db = await freshDb()
|
||||
const files = {
|
||||
clients: csv(CLIENT_HDR, [{ SID_NO: '101', BANK: 'Acme Bank' }]),
|
||||
bills: csv(BILL_HDR, [
|
||||
{ BILL_DATE: '2025-05-01 00:00:00', BANK_ID: '101', INVOICE_NO: 'INV-1', INVOICE_TYPE: 'INVOICE', INVOICE_AMOUNT: '100' },
|
||||
{ BILL_DATE: '2025-05-02 00:00:00', BANK_ID: '101', INVOICE_NO: 'INV-1', INVOICE_TYPE: 'INVOICE', INVOICE_AMOUNT: '200' },
|
||||
]),
|
||||
}
|
||||
const report = await stageApexFull(db, files, KEY)
|
||||
expect(report.bills.problems).toEqual(['row 2: duplicate doc_no: INV-1'])
|
||||
await expect(commitApexFull(db, 'u-test', files, KEY)).rejects.toThrow(/duplicate doc_no/)
|
||||
expect((await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM document`))!.n).toBe(0)
|
||||
expect((await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM client`))!.n).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects an unknown INVOICE_TYPE instead of guessing', async () => {
|
||||
const db = await freshDb()
|
||||
const report = await stageApexFull(db, {
|
||||
clients: csv(CLIENT_HDR, [{ SID_NO: '101', BANK: 'Acme Bank' }]),
|
||||
bills: csv(BILL_HDR, [
|
||||
{ BILL_DATE: '2025-05-01 00:00:00', BANK_ID: '101', INVOICE_NO: 'X-1', INVOICE_TYPE: 'RECEIPT', INVOICE_AMOUNT: '100' },
|
||||
]),
|
||||
}, KEY)
|
||||
expect(report.bills.problems).toEqual(['row 1: unknown INVOICE_TYPE: RECEIPT'])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue