/** * The catalog. One entry per report. Adding a new report is one new entry * here — no new route, no new page, no new component. * * Filter behavior to keep in mind: * - Currency balances are NEVER summed across currencies (912 customers * carry both MXN and USD; the legacy data has no FX per row, so any * cross-currency total would be invented). Every report that touches * the ledger accepts a `currency` filter and reports per currency. * - Voided transactions must be excluded from totals (NOT_VOIDED). The * UI still shows them struck-through; the SQL drops them. * - The legacy `REPORTE DE EFECTIVO` covered cash receipts only. In the * new schema those are `Transaction` rows with `legacySourceTable` in * the EFECTIVO* set OR `checkNumber` null + amount > 0 (true cash). */ import { Prisma, type ServiceKind } from "@jorgecuadros/database"; import { BALANCE_FLOOR_JOIN, BALANCE_FORWARD_TYPE, balanceScopeSql, NOT_SUPERSEDED, notCashJournal, periodSourceTable, } from "../billing/billing.service"; import { UTILITIES_TABLE } from "../customers/numid.service"; import type { PrismaService } from "../prisma/prisma.service"; import { CONCEPT_BY_KIND } from "../statements/statements.service"; import { type ColumnDef, intParam, NOT_VOIDED, parseDate, type ReportDef, } from "./reports.types"; import { renewalLetterSelect, toRenewalLetterRow, } from "./renewal-letter"; /* ------------------------------------------------------------------ helpers */ function nameOf(c: { name: string; nameMissing: boolean }): string { return c.nameMissing ? "(sin nombre)" : c.name; } const isoDay = (d: Date | null | undefined) => d ? d.toISOString().slice(0, 10) : "—"; /** Every report leads with the customer's shelf folder number (2026-10 meeting). */ const FOLDER_COLUMN: ColumnDef = { key: "folder", label: "Carpeta", type: "text" }; /** Policy reports add the insurance file number beside it. */ const INSURANCE_NO_COLUMN: ColumnDef = { key: "insuranceNo", label: "No. seguros", type: "text", }; /** * Customer numbers for a page of report rows. The office files, captures and * talks in folder numbers, not platform ids. * * The insurance file number is the legacy insurance/DATGRAL id, a separate id * space under the same table name as the utilities NUMid; 16 customers hold * more than one. */ async function legacyNumbers(prisma: PrismaService, customerIds: string[]) { const ids = [...new Set(customerIds)]; const [customers, refs] = ids.length ? await Promise.all([ prisma.customer.findMany({ where: { id: { in: ids } }, select: { id: true, folderNumber: true }, }), prisma.customerLegacyRef.findMany({ where: { customerId: { in: ids }, sourceTable: UTILITIES_TABLE, sourceSystem: "insurance", }, select: { customerId: true, legacyId: true }, }), ]) : [[], []]; const folders = new Map(customers.map((c) => [c.id, c.folderNumber])); const insurance = new Map(); for (const r of refs) { insurance.set(r.customerId, [...(insurance.get(r.customerId) ?? []), r.legacyId]); } return { folder: (id: string) => String(folders.get(id) ?? "—"), insurance: (id: string) => insurance .get(id) ?.sort((a, b) => Number(a) - Number(b)) .join(", ") || "—", }; } /* ------------------------------------------------------------------ reports */ /** * LISTADO EN ROJO — overdue customers worklist. * * Legacy printed it from `ALFABETICO EN ROJO`: `SUM(CHARGECREDIT) < 0` over * DATOS2, by name. That sum was only honest because DATOS2 held one year plus * its BALANCE FORWARD. The platform keeps every year, so the balance here is * the statement's own — the floor, the cash journal and the archive window via * the same fragments `BillingService.balances()` uses. Summed raw, as this * report used to, 195 customers read in the red against 22 real ones: every * closed account whose history the opening balance had already settled. * * Ordered by the most recent real movement (Jorge, 2026-10): the oldest * debtors are the accounts that closed, the ones worth chasing are recent. * Inactive customers (`status = 0` — the office's own and internal accounts, * e.g. TRASPASOS PAYPAL at -12M) stay out unless asked for. */ const listadoEnRojo: ReportDef = { slug: "listado-en-rojo", title: "Clientes en rojo", description: "Cartera vencida: clientes con saldo deudor en la moneda seleccionada — " + "el mismo saldo de su estado de cuenta — ordenados por el movimiento " + "más reciente. Los clientes inactivos quedan fuera salvo que se pidan.", domain: "estado-cuenta", legacyName: "LISTADO EN ROJO", format: "tabular", params: [ { key: "currency", label: "Moneda", kind: "select", options: [ { value: "MXN", label: "MXN" }, { value: "USD", label: "USD" }, ], defaultValue: "MXN", }, { key: "domain", label: "Ramo", kind: "select", options: [ { value: "ALL", label: "Todos" }, { value: "UTILITY", label: "Servicios" }, { value: "INSURANCE", label: "Seguros" }, ], defaultValue: "ALL", }, { key: "since", label: "Con movimiento desde", kind: "date" }, { key: "minOwed", label: "Adeudo mínimo", kind: "number", placeholder: "0", }, { key: "inactive", label: "Clientes inactivos", kind: "select", options: [ { value: "no", label: "Excluir" }, { value: "yes", label: "Incluir" }, ], defaultValue: "no", }, { key: "sort", label: "Orden", kind: "select", options: [ { value: "recent", label: "Movimiento más reciente" }, { value: "debt", label: "Mayor adeudo" }, { value: "name", label: "Nombre" }, ], defaultValue: "recent", }, { key: "query", label: "Buscar (nombre o ciudad)", kind: "text", placeholder: "Ej. Pérez, Tijuana…", }, ], columns: [ FOLDER_COLUMN, { key: "name", label: "Cliente", type: "text" }, { key: "city", label: "Ciudad", type: "text" }, { key: "lastMovement", label: "Último movimiento", type: "date" }, { key: "lastPayment", label: "Último abono", type: "date" }, { key: "balance", label: "Saldo", type: "money", align: "right" }, ], async run(prisma, p) { const currency = (p.currency === "USD" ? "USD" : "MXN") as "MXN" | "USD"; const q = p.query?.trim(); const since = parseDate(p.since); const minOwed = Math.max(Number(p.minOwed) || 0, 0.005); const filters: Prisma.Sql[] = []; if (q) { filters.push(Prisma.sql`(c.name LIKE ${`%${q}%`} OR c.city LIKE ${`%${q}%`})`); } if (p.domain === "UTILITY" || p.domain === "INSURANCE") { filters.push(Prisma.sql`t.domain = ${p.domain}`); } if (p.inactive !== "yes") { filters.push(Prisma.sql`c.status = 1 AND c.archivedAt IS NULL`); } const where = filters.length ? Prisma.sql`AND ${Prisma.join(filters, " AND ")}` : Prisma.empty; const bal = currency === "USD" ? Prisma.sql`SUM(CASE WHEN t.currency = 'USD' THEN t.amount ELSE 0 END)` : Prisma.sql`SUM(CASE WHEN t.currency = 'MXN' THEN t.amount ELSE 0 END)`; // The Jan 1 BALANCE FORWARD is not a movement — every customer has one, so // counting it would date every closed account to this year. const real = Prisma.sql`(tt.nameEn IS NULL OR tt.nameEn <> ${BALANCE_FORWARD_TYPE})`; const lastMovement = Prisma.sql`MAX(CASE WHEN ${real} THEN t.transactionDate END)`; const orderBy = p.sort === "debt" ? Prisma.sql`ORDER BY balance ASC` : p.sort === "name" ? Prisma.sql`ORDER BY c.nameMissing ASC, c.name ASC` : Prisma.sql`ORDER BY lastMovement DESC, balance ASC`; const rows = await prisma.$queryRaw< Array<{ id: string; name: string; nameMissing: boolean; city: string | null; balance: Prisma.Decimal | null; lastMovement: Date | null; lastPayment: Date | null; }> >` SELECT c.id, c.name, c.nameMissing, c.city, ${bal} AS balance, ${lastMovement} AS lastMovement, MAX(CASE WHEN ${real} AND t.amount > 0 THEN t.transactionDate END) AS lastPayment FROM customers c JOIN transactions t ON t.customerId = c.id LEFT JOIN type_transactions tt ON tt.id = t.typeId ${BALANCE_FLOOR_JOIN} WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND ${NOT_SUPERSEDED} ${balanceScopeSql()} ${where} GROUP BY c.id, c.name, c.nameMissing, c.city HAVING ${bal} < ${-minOwed} ${since ? Prisma.sql`AND ${lastMovement} >= ${since}` : Prisma.empty} ${orderBy} `; const nums = await legacyNumbers(prisma, rows.map((r) => r.id)); let totalBalance = new Prisma.Decimal(0); const out = rows.map((r) => { const b = r.balance ?? new Prisma.Decimal(0); totalBalance = totalBalance.plus(b); return { folder: nums.folder(r.id), name: nameOf(r), city: r.city ?? "—", lastMovement: isoDay(r.lastMovement), lastPayment: isoDay(r.lastPayment), balance: b.toFixed(2), }; }); return { rows: out, totals: { customers: out.length, balance: totalBalance.toFixed(2), currency, }, subtitle: `Moneda: ${currency} · ${out.length} clientes con saldo deudor${ p.inactive === "yes" ? " · incluye inactivos" : "" }`, }; }, }; /** The services whose bill the office pays and books as a ledger concept. */ const SERVICE_OPTIONS: { value: ServiceKind; label: string }[] = [ { value: "WATER", label: "Agua" }, { value: "ELECTRIC", label: "Luz" }, { value: "TELEPHONE", label: "Teléfono" }, { value: "GAS", label: "Gas" }, { value: "PROPERTY_TAX", label: "Predial" }, { value: "FEDERAL_ZONE", label: "Zona federal" }, { value: "CABLE", label: "Cable" }, ]; const serviceLabel = (kind: string) => SERVICE_OPTIONS.find((o) => o.value === kind)?.label ?? kind; /** * PAGOS NO EFECTUADOS (AGUA / LUZ / TEL / …). * * Legacy `PAGO AGUA/LUZ/TEL NO`: every customer with the service, LEFT JOIN * `LOCALIZA PAGO *` — the rows of THAT service's concept dated in the period * the operator typed — where nothing was found. So: "whose water did we not * pay this month". The first port asked a different question ("no charge of * any kind in N days"), which is why customers who were up to date kept * showing: one IZZI or annual-fee row hid a missing water bill, and the * reverse. It also passed `ELECTRICITY`/`PHONE`, which are not ServiceKind * values, so Luz and Teléfono never returned a row at all. * * Matched per customer, as legacy did. A charge row carries no link to the * service it paid, and its reference only matches the stored account for * water and phone (813/822, 501/521) — CFE bills post under a reference that * is not the RPU (5 of 1109) — so matching on the reference would list every * electric customer as unpaid. * * A row captured "sin fondos" in the window is not a payment: the customer is * listed with that status, which is the follow-up the office actually owes. */ const pagosNoEfectuados: ReportDef = { slug: "pagos-no-efectuados", title: "Pagos no efectuados", description: "Clientes con el servicio contratado sin un pago de ese servicio " + "registrado en el periodo. Sin fechas, revisa un ciclo hacia atrás " + "desde hoy: 31 días, o 62 para luz (bimestral).", domain: "servicios", legacyName: "PAGOS NO EFECTUADOS AGUA/LUZ/TEL", format: "tabular", params: [ { key: "serviceKind", label: "Servicio", kind: "select", options: SERVICE_OPTIONS, defaultValue: "WATER", }, { key: "from", label: "Desde (por defecto, un ciclo atrás)", kind: "date" }, { key: "to", label: "Hasta (por defecto, hoy)", kind: "date", endOfDay: true }, { key: "inactive", label: "Clientes inactivos", kind: "select", options: [ { value: "no", label: "Excluir" }, { value: "yes", label: "Incluir" }, ], defaultValue: "no", }, ], columns: [ FOLDER_COLUMN, { key: "customerName", label: "Cliente", type: "text" }, { key: "propertyAddress", label: "Dirección", type: "text" }, { key: "accountNumber", label: "Cuenta", type: "text" }, { key: "status", label: "Estado", type: "text" }, { key: "lastPaid", label: "Último pago del servicio", type: "date" }, ], async run(prisma, p) { const kind = ( SERVICE_OPTIONS.some((o) => o.value === p.serviceKind) ? p.serviceKind : "WATER" ) as ServiceKind; const concept = CONCEPT_BY_KIND[kind]!; // No period given: one billing cycle back from the end date, so the // report means the same on the 2nd as on the 28th. "This month" listed // nearly everyone early in the month — the bills are captured ~20th. const to = parseDate(p.to, true) ?? new Date(); const cycleDays = kind === "ELECTRIC" ? 62 : 31; const from = parseDate(p.from) ?? new Date(to.getTime() - cycleDays * 86400000); const inactive = p.inactive === "yes" ? Prisma.empty : Prisma.sql`AND c.status = 1`; // One concept row for the customer, not voided, in the window. const conceptRow = (outstanding: 0 | 1) => Prisma.sql` SELECT 1 FROM transactions t JOIN type_transactions tt ON tt.id = t.typeId WHERE t.customerId = c.id AND t.voidedAt IS NULL AND t.outstanding = ${outstanding} AND tt.nameEn = ${concept} AND t.transactionDate >= ${from} AND t.transactionDate <= ${to}`; const rows = await prisma.$queryRaw< Array<{ customerId: string; customerName: string; nameMissing: boolean; addresses: string | null; accounts: string | null; unfunded: bigint | number; lastPaid: Date | null; }> >` SELECT c.id AS customerId, c.name AS customerName, c.nameMissing AS nameMissing, GROUP_CONCAT(DISTINCT pr.addressLine1 ORDER BY pr.addressLine1 SEPARATOR ' / ') AS addresses, GROUP_CONCAT(DISTINCT NULLIF(ps.accountNumber, '') SEPARATOR ', ') AS accounts, EXISTS(${conceptRow(1)}) AS unfunded, (SELECT MAX(t.transactionDate) FROM transactions t JOIN type_transactions tt ON tt.id = t.typeId WHERE t.customerId = c.id AND t.voidedAt IS NULL AND t.outstanding = 0 AND tt.nameEn = ${concept}) AS lastPaid FROM property_services ps JOIN properties pr ON pr.id = ps.propertyId AND pr.archivedAt IS NULL JOIN customers c ON c.id = pr.customerId AND c.archivedAt IS NULL WHERE ps.kind = ${kind} AND ps.active = 1 ${inactive} AND NOT EXISTS (${conceptRow(0)}) GROUP BY c.id, c.name, c.nameMissing ORDER BY c.nameMissing ASC, c.name ASC `; const nums = await legacyNumbers(prisma, rows.map((r) => r.customerId)); let unfunded = 0; const out = rows.map((r) => { if (Number(r.unfunded)) unfunded++; return { folder: nums.folder(r.customerId), customerName: nameOf({ name: r.customerName, nameMissing: r.nameMissing }), propertyAddress: r.addresses ?? "—", accountNumber: r.accounts ?? "—", status: Number(r.unfunded) ? "Sin fondos" : "Sin pago", lastPaid: isoDay(r.lastPaid), }; }); return { rows: out, totals: { clientes: out.length, "sin pago": out.length - unfunded, "sin fondos": unfunded, }, subtitle: `Servicio: ${serviceLabel(kind)} · ${isoDay(from)} a ${isoDay(to)} · ${out.length} clientes sin pago registrado`, }; }, }; /** * What each service must have on file to be paid and matched — the columns * legacy's FALTANTES DE AGUA/LUZ/TEL read (DATMEX.AGUA; MEDIDOR + RPU; * TELEFONO), in the field `scopedRefField` stores each one in. The first port * demanded account AND meter AND route of every kind; water and phone have no * route, so every one of them was flagged. */ const REQUIRED_FIELDS: Partial< Record> > = { WATER: [["accountNumber", "Sin número de cuenta"]], ELECTRIC: [ ["accountNumber", "Sin RPU"], ["meterNumber", "Sin medidor"], ], TELEPHONE: [["accountNumber", "Sin número de teléfono"]], }; /** * FALTANTES DE (AGUA / LUZ / TEL) — data quality: active services missing the * identifier the office needs to pay and match the bill. */ const faltantes: ReportDef = { slug: "faltantes", title: "Faltantes de datos por servicio", description: "Calidad de datos: servicios activos sin el dato que se necesita para " + "pagar el recibo — agua: número de cuenta; luz: RPU y medidor; " + "teléfono: número.", domain: "servicios", legacyName: "FALTANTES DE AGUA/LUZ/TEL", format: "tabular", params: [ { key: "serviceKind", label: "Servicio", kind: "select", options: SERVICE_OPTIONS.filter((o) => REQUIRED_FIELDS[o.value]), defaultValue: "WATER", }, ], columns: [ FOLDER_COLUMN, { key: "customerName", label: "Cliente", type: "text" }, { key: "propertyAddress", label: "Dirección", type: "text" }, { key: "missing", label: "Faltante", type: "text" }, ], async run(prisma, p) { const kind = ( p.serviceKind && REQUIRED_FIELDS[p.serviceKind as ServiceKind] ? p.serviceKind : "WATER" ) as ServiceKind; const required = REQUIRED_FIELDS[kind]!; // Column names come from the constant above, never from the request. const blank = (field: string) => Prisma.sql`(ps.${Prisma.raw(field)} IS NULL OR TRIM(ps.${Prisma.raw(field)}) = '')`; const rows = await prisma.$queryRaw< Array<{ customerId: string; customerName: string; nameMissing: boolean; propertyAddress: string | null; missing: string; }> >` SELECT c.id AS customerId, c.name AS customerName, c.nameMissing AS nameMissing, pr.addressLine1 AS propertyAddress, CONCAT_WS(', ', ${Prisma.join( required.map(([field, label]) => Prisma.sql`IF(${blank(field)}, ${label}, NULL)`), )}) AS missing FROM property_services ps JOIN properties pr ON pr.id = ps.propertyId AND pr.archivedAt IS NULL JOIN customers c ON c.id = pr.customerId AND c.archivedAt IS NULL WHERE ps.kind = ${kind} AND ps.active = 1 AND c.status = 1 AND (${Prisma.join(required.map(([field]) => blank(field)), " OR ")}) ORDER BY c.nameMissing ASC, c.name ASC, pr.addressLine1 ASC `; const nums = await legacyNumbers(prisma, rows.map((r) => r.customerId)); const out = rows.map((r) => ({ folder: nums.folder(r.customerId), customerName: nameOf({ name: r.customerName, nameMissing: r.nameMissing }), propertyAddress: r.propertyAddress ?? "—", missing: r.missing, })); return { rows: out, totals: { rows: out.length }, subtitle: `Servicio: ${serviceLabel(kind)} · ${out.length} servicios con datos faltantes`, }; }, }; /** * REPORTE DE EFECTIVO — cash reconciliation. * Credits in the EFECTIVO* legacy source tables OR with no cheque number * (true cash) within a date range. Excludes voided rows. Matches the * shape of the legacy REPORTE DE EFECTIVO report. */ const reporteDeEfectivo: ReportDef = { slug: "reporte-de-efectivo", title: "Reporte de efectivo", description: "Recibos de efectivo en el periodo seleccionado: los recibos EFE de la " + "plataforma, los de las tablas legacy EFECTIVO* y los créditos sin " + "número de cheque (efectivo real). El match del reporte original.", domain: "chequera", legacyName: "REPORTE DE EFECTIVO", format: "tabular", params: [ { key: "from", label: "Desde", kind: "date" }, { key: "to", label: "Hasta", kind: "date", endOfDay: true }, { key: "currency", label: "Moneda", kind: "select", options: [ { value: "MXN", label: "MXN" }, { value: "USD", label: "USD" }, ], defaultValue: "MXN", }, ], columns: [ { key: "date", label: "Fecha", type: "date" }, FOLDER_COLUMN, { key: "customerName", label: "Cliente", type: "text" }, { key: "concept", label: "Concepto", type: "text" }, { key: "source", label: "Origen", type: "text" }, { key: "amount", label: "Monto", type: "money", align: "right" }, ], async run(prisma, p) { const from = parseDate(p.from); const to = parseDate(p.to, true); const currency = (p.currency === "USD" ? "USD" : "MXN") as "MXN" | "USD"; const ands: Prisma.TransactionWhereInput[] = [ NOT_VOIDED, { amount: { gt: 0 } }, { currency }, // A credit opening balance has no check number but is not cash. { OR: [{ typeId: null }, { type: { nameEn: { not: BALANCE_FORWARD_TYPE } } }] }, { OR: [ // A platform receipt posts one row (its C credit, under a // CASH… pseudo check), so it is matched by the receipt, not the check. { cashReceipt: { isNot: null } }, { legacySourceTable: { in: ["EFECTIVO", "EFECTIVO_BACKUP"] } }, { AND: [ { checkNumber: null }, { legacySourceTable: { not: "CHEQUE FM3" } }, ], }, ], }, ]; if (from || to) { ands.push({ transactionDate: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}), }, }); } const rows = await prisma.transaction.findMany({ where: { AND: ands }, orderBy: { transactionDate: "asc" }, select: { transactionDate: true, amount: true, reference: true, checkNumber: true, message: true, legacySourceTable: true, cashReceipt: { select: { folio: true } }, type: { select: { nameEs: true, nameEn: true } }, customer: { select: { id: true, name: true, nameMissing: true } }, }, }); const nums = await legacyNumbers(prisma, rows.map((r) => r.customer.id)); let total = new Prisma.Decimal(0); const out = rows.map((r) => { total = total.plus(r.amount); return { date: r.transactionDate.toISOString().slice(0, 10), folder: nums.folder(r.customer.id), customerName: nameOf(r.customer), concept: r.message ?? r.type?.nameEs ?? r.type?.nameEn ?? "—", source: r.cashReceipt ? `EFE ${r.cashReceipt.folio}` : r.legacySourceTable ?? "—", amount: r.amount.toFixed(2), }; }); return { rows: out, totals: { rows: out.length, total: total.toFixed(2), currency, }, subtitle: `Efectivo · ${currency} · ${out.length} recibos${ from ? ` desde ${p.from}` : "" }${to ? ` hasta ${p.to}` : ""}`, }; }, }; /** * VIGENTE (LIC / INCEN / MULT / …) — policies up for renewal. * Wraps the policy listing with status=expiring and a policy-type filter, * sorted by soonest expiry. The legacy VIGENTE LIC / INCEN / MULT * reports are the same data; the new filter is a dropdown. */ const vigente: ReportDef = { slug: "vigente", title: "Pólizas por vencer", description: "Pólizas que vencen en los próximos N días, filtradas por ramo. " + "Equivalente a los reportes VIGENTE LIC / INCEN / MULT de la legacy.", domain: "polizas", legacyName: "VIGENTE LIC/INCEN/MULT", format: "tabular", params: [ { key: "typeName", label: "Ramo", kind: "select", options: [ { value: "LICENCIAS", label: "Licencias" }, { value: "INCENDIO", label: "Incendio" }, { value: "MULT", label: "Multirriesgo" }, { value: "MCA2", label: "MCA2 (auto)" }, { value: "ME", label: "ME" }, { value: "MF", label: "MF" }, { value: "RC", label: "RC" }, { value: "INCEN", label: "Incen" }, { value: "TAMPL", label: "TAMPL" }, { value: "FAMILIAR", label: "Familiar" }, ], defaultValue: "LICENCIAS", }, { key: "days", label: "Ventana (días)", kind: "number", defaultValue: "30", }, ], columns: [ { key: "policyNumber", label: "Póliza", type: "text" }, FOLDER_COLUMN, INSURANCE_NO_COLUMN, { key: "customerName", label: "Cliente", type: "text" }, { key: "provider", label: "Aseguradora", type: "text" }, { key: "agent", label: "Agente", type: "text" }, { key: "from", label: "Desde", type: "date" }, { key: "to", label: "Vence", type: "date" }, { key: "daysToExpire", label: "Días", type: "number", align: "right" }, { key: "premium", label: "Prima neta", type: "money", align: "right" }, ], async run(prisma, p) { const typeName = p.typeName ?? "LICENCIAS"; const days = intParam(p, "days", 30, 1, 365); const now = new Date(); const today = new Date( Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()), ); const soon = new Date(today.getTime() + days * 86400000); const rows = await prisma.policy.findMany({ where: { policyType: { name: typeName }, archivedAt: null, policyTo: { gte: today, lte: soon }, }, orderBy: { policyTo: "asc" }, select: { policyNumber: true, policyFrom: true, policyTo: true, netPremium: true, agentName: true, customer: { select: { id: true, name: true, nameMissing: true } }, insuranceProvider: { select: { name: true } }, }, }); const nums = await legacyNumbers(prisma, rows.map((r) => r.customer.id)); let totalPremium = new Prisma.Decimal(0); const out = rows.map((r) => { const daysTo = r.policyTo ? Math.round( (r.policyTo.getTime() - today.getTime()) / 86400000, ) : 0; if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium); return { policyNumber: r.policyNumber, folder: nums.folder(r.customer.id), insuranceNo: nums.insurance(r.customer.id), customerName: nameOf(r.customer), provider: r.insuranceProvider?.name ?? "—", agent: r.agentName ?? "—", from: r.policyFrom ? r.policyFrom.toISOString().slice(0, 10) : "—", to: r.policyTo ? r.policyTo.toISOString().slice(0, 10) : "—", daysToExpire: daysTo, premium: r.netPremium ? r.netPremium.toFixed(2) : "0.00", }; }); return { rows: out, totals: { rows: out.length, premium: totalPremium.toFixed(2), }, subtitle: `Ramo: ${typeName} · ${days} días · ${out.length} pólizas por vencer`, }; }, }; /** * AVISO DE RENOVACION — insurance renewal notice. * * Replaces ~40 legacy report clones (one per carrier per coverage tier — * `AMPL R RENEW X MES NEW ATLAS 13`, `... QUALITAS ...`, `LIC RENEW X * VENCE ATLAS 2013`, etc., see docs/RENEWAL_NOTICES.md) with one * parameterized report: pick the ramo, the expiry month/year, which * notice generation (1st/2nd/3rd, mirroring the legacy RENEW/RENEW2/ * RENEW3 escalation), and optionally a carrier filter. * * The legacy reports hardcoded per-policy figures (deductible, CSL limit, * premium) as static label text re-typed by hand for every new rate/ * carrier clone. Here they're read from real columns / `coveragesJson` * (see docs/RENEWAL_NOTICES.md's column-mapping table) so one template * covers every carrier and tier instead of a clone per combination. * * `sentStatus` is read from `RenewalNotice` (schema.prisma) — the * replacement for the legacy `CONTROL RENEW[2/3] X MES` paper log. */ const avisoRenovacion: ReportDef = { slug: "aviso-renovacion", title: "Aviso de renovación", description: "Cartas de aviso de renovación para pólizas por vencer en el mes y " + "año seleccionados, con la generación de aviso (1a/2a/3a) y filtro " + "opcional por aseguradora. Sustituye a los ~40 reportes clonados por " + "aseguradora/cobertura de la legacy (ver docs/RENEWAL_NOTICES.md).", domain: "polizas", legacyName: "AMPL R RENEW X MES NEW ATLAS 13 / RC RENEW X MES NEW ATLAS 13 / " + "LIC RENEW X VENCE ATLAS 2013 / RCR RENEW X MES NEWATLAS 2013 (y " + "sus clones por aseguradora y cobertura)", format: "letter", params: [ { key: "policyType", label: "Ramo", kind: "select", options: [ { value: "AUTO", label: "Auto" }, { value: "LICENCIAS", label: "Licencias" }, { value: "INCENDIO", label: "Incendio" }, { value: "MULT", label: "Multirriesgo" }, { value: "M_EMPR", label: "M Empresarial" }, ], defaultValue: "AUTO", }, { key: "month", label: "Mes de vencimiento (1-12)", kind: "number", defaultValue: String(new Date().getUTCMonth() + 1), }, { key: "year", label: "Año de vencimiento", kind: "number", defaultValue: String(new Date().getUTCFullYear()), }, { key: "generation", label: "Generación de aviso", kind: "select", options: [ { value: "1", label: "1er aviso" }, { value: "2", label: "2o aviso" }, { value: "3", label: "3er aviso" }, ], defaultValue: "1", }, { key: "provider", label: "Aseguradora (opcional)", kind: "text", placeholder: "Ej. ATLAS, QUALITAS…", }, ], // Flat columns so CSV/XLSX/generic PDF exports stay useful even though // the on-screen view renders each row as a full letter (LetterLayout in // ReportRunner.tsx) — same trade-off edoCuentaDatos makes for "statement". columns: [ { key: "policyNumber", label: "Póliza", type: "text" }, FOLDER_COLUMN, INSURANCE_NO_COLUMN, { key: "customerName", label: "Cliente", type: "text" }, { key: "provider", label: "Aseguradora", type: "text" }, { key: "policyTo", label: "Vence", type: "date" }, { key: "netPremium", label: "Prima neta", type: "money", align: "right" }, { key: "total", label: "Total", type: "money", align: "right" }, { key: "generation", label: "Generación", type: "number" }, { key: "sentAt", label: "Enviado", type: "date" }, ], async run(prisma, p) { const typeName = p.policyType ?? "AUTO"; const month = intParam(p, "month", new Date().getUTCMonth() + 1, 1, 12); const year = intParam(p, "year", new Date().getUTCFullYear(), 1990, 2100); const generation = intParam(p, "generation", 1, 1, 3); const provider = p.provider?.trim(); const from = new Date(Date.UTC(year, month - 1, 1)); const to = new Date(Date.UTC(year, month, 1)); const rows = await prisma.policy.findMany({ where: { policyType: { name: typeName }, archivedAt: null, policyTo: { gte: from, lt: to }, ...(provider ? { insuranceProvider: { name: { contains: provider } } } : {}), }, orderBy: { policyTo: "asc" }, select: renewalLetterSelect(generation), }); const nums = await legacyNumbers(prisma, rows.map((r) => r.customer.id)); let totalPremium = new Prisma.Decimal(0); let sentCount = 0; const out = rows.map((r) => { if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium); const letter = { ...toRenewalLetterRow(r, generation), folder: nums.folder(r.customer.id), insuranceNo: nums.insurance(r.customer.id), }; if (letter.sentAt) sentCount++; return letter; }); return { rows: out, totals: { cartas: out.length, enviadas: sentCount, pendientes: out.length - sentCount, primaTotal: totalPremium.toFixed(2), }, subtitle: `Ramo: ${typeName} · vencen ${String(month).padStart(2, "0")}/${year} · generación ${generation}${ provider ? ` · aseguradora: ${provider}` : "" } · ${out.length} avisos`, }; }, }; /** * EDO CUENTA DATOS — per-customer account statement. * Wraps the existing BillingService.statement() output. The full layout * (header, balance per currency, by-domain split, by-type breakdown, * full movement list with running balance) is rendered by the statement * page; this report is the same data with print/PDF/CSV/XLSX outputs. */ const edoCuentaDatos: ReportDef = { slug: "edo-cuenta-datos", title: "Estado de cuenta", description: "Estado de cuenta de un cliente: saldos por moneda, desglose por " + "ramo y concepto, y los movimientos del año en curso con saldo " + "corrido, abriendo con el saldo anterior. El reporte del cliente final.", domain: "estado-cuenta", legacyName: "EDO CUENTA DATOS", format: "statement", params: [ { key: "customerId", label: "Cliente", kind: "customer-picker" }, // Which period to print. Blank means the year in progress; an earlier year // prints from its imported archive, the same source the on-screen // statement reads. { key: "year", label: "Periodo (año)", kind: "number", placeholder: "año en curso", }, ], columns: [ // Statement rows carry synthetic `__kind` discriminators instead of // column keys; the runner renders the special cases inline. These // columns drive CSV/XLSX when the user wants a flat movement export. { key: "date", label: "Fecha", type: "date" }, { key: "concept", label: "Concepto", type: "text" }, { key: "reference", label: "Referencia", type: "text" }, { key: "amount", label: "Cargo / Abono", type: "money", align: "right" }, { key: "balanceAfter", label: "Saldo", type: "money", align: "right" }, ], async run(prisma, p) { const customerId = p.customerId; if (!customerId) { return { rows: [], subtitle: "Selecciona un cliente" }; } const customer = await prisma.customer.findUnique({ where: { id: customerId }, select: { id: true, name: true, nameMissing: true, addressLine1: true, city: true, state: true, email: true, phone: true, }, }); if (!customer) return { rows: [], subtitle: "Cliente no encontrado" }; const folder = (await legacyNumbers(prisma, [customer.id])).folder(customer.id); // The source-table exclusion, the balance floor and the year scope below // are BillingService.statement's, because this report and // /estado-cuenta/[id] are the same statement — one printable, one on // screen — and a customer holding both must not read two balances. const floor = await prisma.transaction.findFirst({ where: { customerId, voidedAt: null, type: { nameEn: BALANCE_FORWARD_TYPE }, }, orderBy: { transactionDate: "desc" }, select: { transactionDate: true }, }); // Which period to print. An earlier year comes from its imported archive, // tagged rather than dated, exactly as the on-screen statement reads it. const thisYear = new Date().getUTCFullYear(); const askedYear = Number(p.year); const requestedYear = Number.isInteger(askedYear) && askedYear > 0 ? askedYear : thisYear; const isArchive = requestedYear !== thisYear; const rows = await prisma.transaction.findMany({ where: { customerId, voidedAt: null, ...(isArchive ? // The archive is one period's ledger already, so the tag is the // whole filter and the balance floor must not apply — the floor // hides exactly the history this period is asking for. { legacySourceTable: periodSourceTable(requestedYear) } : { ...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}), // Archive rows count as history below the year start (that is // what `opening` is for, and for a customer floored by an archive // it is the only carry there is) and are dropped at or above it. // Same rule as the on-screen twin — see BillingService.statement. AND: [ { OR: [ { legacySourceTable: null }, { legacySourceTable: { not: { startsWith: "datos2@" } } }, { transactionDate: { lt: new Date(Date.UTC(requestedYear, 0, 1)), }, }, ], }, // The cash receipt book, which the ledger already carries as // its own `C` postings. Taken from the shared helper // rather than restated, so the printed statement and the screen // cannot drift apart — and so this keeps the database // qualifier that spares the insurance line's own EFECTIVO. notCashJournal(), ], }), }, orderBy: [{ transactionDate: "asc" }, { id: "asc" }], select: { id: true, transactionDate: true, domain: true, amount: true, currency: true, reference: true, period: true, checkNumber: true, message: true, legacySourceTable: true, type: { select: { nameEs: true, nameEn: true } }, }, }); // Scoped to the calendar year and listed oldest-first, the way the legacy // EDO CUENTA sheet reads. Rows from earlier years still move the running // balance — they are folded into `opening` and printed as a single "saldo // anterior" line, which is what a BALANCE FORWARD row is. // An archive needs no fold: it *is* the period, and its own Jan-1 BALANCE // FORWARD row is the carry, printed like legacy printed it. const yearStart = isArchive ? new Date(0) : new Date(Date.UTC(requestedYear, 0, 1)); const year = requestedYear; const running = new Map(); const opening = new Map(); const visible: typeof rows = []; const movements = rows.flatMap((r) => { const prev = running.get(r.currency) ?? new Prisma.Decimal(0); const next = prev.plus(r.amount); running.set(r.currency, next); if (r.transactionDate < yearStart) { opening.set(r.currency, next); return []; } visible.push(r); return { date: r.transactionDate.toISOString().slice(0, 10), domain: r.domain, currency: r.currency, reference: r.reference ?? "", period: r.period ?? "", checkNumber: r.checkNumber ?? "", concept: r.type?.nameEs ?? r.type?.nameEn ?? "—", amount: r.amount.toFixed(2), balanceAfter: next.toFixed(2), }; }); // The carried balance, printed as the statement's first line — same shape // as a movement row so it needs nothing special from the renderer. const carried = [...opening.entries()] .filter(([, amount]) => !amount.isZero()) .map(([currency, amount]) => ({ date: yearStart.toISOString().slice(0, 10), domain: "UTILITY", currency, reference: "", period: `Al cierre de ${year - 1}`, checkNumber: "", concept: "SALDO ANTERIOR", amount: amount.toFixed(2), balanceAfter: amount.toFixed(2), })); // Per-currency summary, seeded with the carried balance so it reconciles // against the last running balance printed below. const perCurrency = new Map< string, { currency: string; charges: Prisma.Decimal; credits: Prisma.Decimal; count: number } >(); for (const [currency, amount] of opening) { perCurrency.set(currency, { currency, charges: amount.lessThan(0) ? amount : new Prisma.Decimal(0), credits: amount.lessThan(0) ? new Prisma.Decimal(0) : amount, count: 0, }); } for (const r of visible) { const c = perCurrency.get(r.currency) ?? { currency: r.currency, charges: new Prisma.Decimal(0), credits: new Prisma.Decimal(0), count: 0, }; c.count += 1; if (r.amount.lessThan(0)) c.charges = c.charges.plus(r.amount); else c.credits = c.credits.plus(r.amount); perCurrency.set(r.currency, c); } return { rows: [ { __kind: "header", name: nameOf(customer), folder, address: customer.addressLine1 ?? "", city: [customer.city, customer.state].filter(Boolean).join(", "), phone: customer.phone ?? "", email: customer.email ?? "", }, ...[...perCurrency.values()].map((c) => ({ __kind: "summary", currency: c.currency, charges: c.charges.toFixed(2), credits: c.credits.toFixed(2), balance: c.charges.plus(c.credits).toFixed(2), count: c.count, })), { __kind: "movements-header" }, ...carried, ...movements, ], subtitle: `Carpeta ${folder} · ${nameOf(customer)} · ${year} · ${visible.length} movimientos`, }; }, }; /** * REPORTE CHEQUE COUNT — everything captured against one check. * * The reconciliation half of the batch-capture flow (docs/RECEIPT_CAPTURE_SPEC * §1.3): staff key many customers' receipts against one physical check, then * check that what was captured adds up to what the check was cut for. Replaces * `EDITA CHEQUE ALF/COUNT/NUM`, `REPORTE POR CHEQUE` and * `REPORTE POR CHEQUE PARA ALFA` — four legacy objects, one parameterized * report. * * Deliberately mirrors `BillingService.byCheck`'s rules rather than inventing * its own: voided rows are dropped entirely, and outstanding (NOPAGO) rows are * listed but excluded from the total, because the check never funded them. */ const chequeCount: ReportDef = { slug: "cheque-count", title: "Reporte por cheque", description: "Todos los movimientos capturados contra un mismo cheque, con el total " + "para conciliar contra el importe físico del cheque. Los movimientos " + "pendientes de pago (sin fondos) se listan pero no suman al total.", domain: "estado-cuenta", legacyName: "REPORTE CHEQUE COUNT / REPORTE POR CHEQUE / EDITA CHEQUE COUNT", format: "tabular", params: [ { key: "checkNumber", label: "Número de cheque", kind: "text", placeholder: "Ej. 10432", }, ], columns: [ FOLDER_COLUMN, { key: "customerName", label: "Cliente", type: "text" }, { key: "reference", label: "Referencia", type: "text" }, { key: "period", label: "Periodo", type: "text" }, { key: "concept", label: "Concepto", type: "text" }, { key: "transactionDate", label: "Fecha", type: "date" }, { key: "status", label: "Estado", type: "text" }, { key: "amount", label: "Importe", type: "money", align: "right" }, ], async run(prisma, p) { const checkNumber = p.checkNumber?.trim(); if (!checkNumber) { return { rows: [], totals: { movimientos: 0 }, subtitle: "Indique un número de cheque", }; } const rows = await prisma.transaction.findMany({ where: { checkNumber, ...NOT_VOIDED }, orderBy: [{ transactionDate: "asc" }, { id: "asc" }], select: { transactionDate: true, amount: true, currency: true, reference: true, period: true, outstanding: true, type: { select: { nameEn: true, nameEs: true } }, customer: { select: { id: true, name: true, nameMissing: true } }, }, }); // Per currency, and never collapsed — same rule as the rest of the ledger. const totals = new Map(); let outstandingCount = 0; for (const r of rows) { if (r.outstanding) { outstandingCount++; continue; } totals.set( r.currency, (totals.get(r.currency) ?? new Prisma.Decimal(0)).plus(r.amount), ); } const totalsOut: Record = { movimientos: rows.length, }; for (const [currency, sum] of totals) { totalsOut[`total ${currency}`] = sum.toFixed(2); } if (outstandingCount) totalsOut["sin fondos"] = outstandingCount; const nums = await legacyNumbers(prisma, rows.map((r) => r.customer.id)); return { rows: rows.map((r) => ({ folder: nums.folder(r.customer.id), customerName: nameOf(r.customer), reference: r.reference ?? "—", period: r.period ?? "—", concept: r.type?.nameEs || r.type?.nameEn || "Sin clasificar", transactionDate: r.transactionDate.toISOString().slice(0, 10), status: r.outstanding ? "Sin fondos" : "Pagado", amount: r.amount.toFixed(2), currency: r.currency, })), totals: totalsOut, subtitle: `Cheque ${checkNumber} · ${rows.length} movimientos`, }; }, }; /* ------------------------------------------------------------------ export */ export const REPORTS: ReportDef[] = [ listadoEnRojo, pagosNoEfectuados, faltantes, reporteDeEfectivo, vigente, avisoRenovacion, edoCuentaDatos, chequeCount, ]; export function findReport(slug: string): ReportDef | undefined { return REPORTS.find((r) => r.slug === slug); }