diff --git a/.env.example b/.env.example index fd1dd01..e1f5fab 100644 --- a/.env.example +++ b/.env.example @@ -22,12 +22,13 @@ OPS_DB_ADMIN_USER= OPS_DB_ADMIN_PASSWORD= # Company info — printed in the header of every report (PDF + browser -# print). Leave blank to use the placeholders. COMPANY_LOGO_PATH is +# print). Leave blank to use the defaults in company.ts. COMPANY_LOGO_PATH is # optional; when unset the API falls back to apps/api/assets/company_logo.png. COMPANY_NAME=Jorge Cuadros & Asociados COMPANY_ADDRESS_LINE1= COMPANY_ADDRESS_LINE2= COMPANY_CITY_STATE= +COMPANY_MAILING_ADDRESS= COMPANY_PHONE= COMPANY_EMAIL= COMPANY_TAX_ID= diff --git a/apps/api/src/billing/billing.service.ts b/apps/api/src/billing/billing.service.ts index e78be97..acdecb8 100644 --- a/apps/api/src/billing/billing.service.ts +++ b/apps/api/src/billing/billing.service.ts @@ -305,6 +305,15 @@ export const archiveIsHistorySql = (yearStart: Date) => Prisma.sql`( export const currentYearStart = () => new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1)); +/** + * What a balance sums over beyond "not voided": the cash journal out, the + * archives kept to history. Shared by `balances()`, `stats()` and the reports, + * so a customer's balance reads the same on every screen that quotes one. + * Pair it with BALANCE_FLOOR_JOIN + NOT_SUPERSEDED. + */ +export const balanceScopeSql = () => + Prisma.sql`AND ${NOT_CASH_JOURNAL} AND ${archiveIsHistorySql(currentYearStart())}`; + @Injectable() export class BillingService { constructor(private readonly prisma: PrismaService) {} @@ -480,9 +489,7 @@ export class BillingService { // customer's own statement — NUMid 295 read 14,377.46 against a statement // of 4,377.46, and NUMid 10 read 2,362.20 against -1,137.80, the gap in // each case being a cash receipt already posted to the ledger. - const scope = Prisma.sql`AND ${NOT_CASH_JOURNAL} AND ${archiveIsHistorySql( - currentYearStart(), - )}`; + const scope = balanceScopeSql(); const filters: Prisma.Sql[] = []; if (domain) filters.push(Prisma.sql`t.domain = ${domain}`); @@ -621,9 +628,7 @@ export class BillingService { * which is the client's call and not settled yet. */ async stats() { - const scope = Prisma.sql`AND ${NOT_CASH_JOURNAL} AND ${archiveIsHistorySql( - currentYearStart(), - )}`; + const scope = balanceScopeSql(); const [movements, ledgerCustomers] = await Promise.all([ this.prisma.transaction.count({ where: NOT_VOIDED }), diff --git a/apps/api/src/reports/company.ts b/apps/api/src/reports/company.ts index 6bfc0ac..17453a4 100644 --- a/apps/api/src/reports/company.ts +++ b/apps/api/src/reports/company.ts @@ -21,6 +21,8 @@ export interface CompanyInfo { addressLine2: string; /** "City, State, ZIP, Country" — single line. */ cityState: string; + /** US postal address, printed after the office address. Optional. */ + mailingAddress: string; phone: string; email: string; /** Mexican tax ID ("RFC"). Optional. */ @@ -83,19 +85,23 @@ export function getCompanyInfo(): CompanyInfo { } cached = { name: envOr("COMPANY_NAME", "Jorge Cuadros & Asociados"), - addressLine1: envOr( - "COMPANY_ADDRESS_LINE1", - "Av. Revolución 1234, Int. 5", - ), + // The office's real locator, as published on jorgecuadros.com. Prod sets + // no COMPANY_* variables, so these defaults ARE what every printed + // statement shows — they were placeholders until 2026-10. + addressLine1: envOr("COMPANY_ADDRESS_LINE1", "Quinta Plaza Ste 3-B"), addressLine2: envOr("COMPANY_ADDRESS_LINE2", ""), cityState: envOr( "COMPANY_CITY_STATE", - "Tijuana, Baja California 22000, México", + "Rosarito, Baja California, México", ), - phone: envOr("COMPANY_PHONE", "(664) 000-0000"), - email: envOr("COMPANY_EMAIL", "contacto@jorgecuadros.local"), + mailingAddress: envOr( + "COMPANY_MAILING_ADDRESS", + "US: 416 W. San Ysidro Blvd L #3108, San Ysidro, CA 92173", + ), + phone: envOr("COMPANY_PHONE", "011-52 (661) 612-1295"), + email: envOr("COMPANY_EMAIL", "info@jorgecuadros.com"), taxId: envOr("COMPANY_TAX_ID", ""), - website: envOr("COMPANY_WEBSITE", "jorgecuadros.local"), + website: envOr("COMPANY_WEBSITE", "jorgecuadros.com"), logoPath, logo, }; diff --git a/apps/api/src/reports/outputs.ts b/apps/api/src/reports/outputs.ts index 7c6dad9..aa49c63 100644 --- a/apps/api/src/reports/outputs.ts +++ b/apps/api/src/reports/outputs.ts @@ -199,6 +199,7 @@ export function renderPdf( company.addressLine1, company.addressLine2, [company.cityState].filter(Boolean).join(" · "), + company.mailingAddress, [company.phone, company.email].filter(Boolean).join(" · "), company.taxId ? `RFC: ${company.taxId}` : "", ].filter(Boolean); @@ -363,6 +364,7 @@ export function renderPrintHtml( company.addressLine1, company.addressLine2, company.cityState, + company.mailingAddress, [company.phone, company.email].filter(Boolean).join(" · "), company.taxId ? `RFC: ${company.taxId}` : "", ].filter(Boolean); diff --git a/apps/api/src/reports/reports.registry.ts b/apps/api/src/reports/reports.registry.ts index cc6c1b7..54cc583 100644 --- a/apps/api/src/reports/reports.registry.ts +++ b/apps/api/src/reports/reports.registry.ts @@ -14,13 +14,23 @@ * the EFECTIVO* set OR `checkNumber` null + amount > 0 (true cash). */ -import { Prisma } from "@jorgecuadros/database"; +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_SYSTEM, + 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, @@ -37,19 +47,80 @@ 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 legacy customer number (2026-10 meeting). */ +const NUMID_COLUMN: ColumnDef = { key: "numid", label: "NUMid", type: "text" }; +/** Policy reports add the insurance file number beside it. */ +const INSURANCE_NO_COLUMN: ColumnDef = { + key: "insuranceNo", + label: "No. seguros", + type: "text", +}; + +/** + * Legacy customer numbers for a page of report rows. The office files, + * captures and talks in NUMids, not platform ids. + * + * The utilities NUMid is the portal number, one per customer. insurance/DATGRAL + * is a separate id space under the same table name, so it is read on its own + * and never shown as a NUMid; 16 customers hold more than one. + */ +async function legacyNumbers(prisma: PrismaService, customerIds: string[]) { + const ids = [...new Set(customerIds)]; + const refs = ids.length + ? await prisma.customerLegacyRef.findMany({ + where: { + customerId: { in: ids }, + sourceTable: UTILITIES_TABLE, + sourceSystem: { in: [UTILITIES_SYSTEM, "insurance"] }, + }, + select: { customerId: true, sourceSystem: true, legacyId: true }, + }) + : []; + const byCustomer = new Map(); + for (const r of refs) { + const e = byCustomer.get(r.customerId) ?? { insurance: [] }; + if (r.sourceSystem === UTILITIES_SYSTEM) e.numid = r.legacyId; + else e.insurance.push(r.legacyId); + byCustomer.set(r.customerId, e); + } + return { + numid: (id: string) => byCustomer.get(id)?.numid ?? "—", + insurance: (id: string) => + byCustomer + .get(id) + ?.insurance.sort((a, b) => Number(a) - Number(b)) + .join(", ") || "—", + }; +} + /* ------------------------------------------------------------------ reports */ /** * LISTADO EN ROJO — overdue customers worklist. - * Same data as the receivables worklist with balance=owing, but presented - * as a printable report rather than a paginated browser. + * + * 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, " + - "ordenados del más antiguo al más reciente.", + "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", @@ -64,6 +135,45 @@ const listadoEnRojo: ReportDef = { ], 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)", @@ -72,24 +182,48 @@ const listadoEnRojo: ReportDef = { }, ], columns: [ - { key: "id", label: "#", type: "text" }, + NUMID_COLUMN, { key: "name", label: "Cliente", type: "text" }, { key: "city", label: "Ciudad", type: "text" }, - { key: "movements", label: "Movs.", type: "number", align: "right" }, - { key: "balance", label: "Saldo", type: "money", align: "right" }, { 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 nameFilter = q - ? Prisma.sql`AND (c.name LIKE ${`%${q}%`} OR c.city LIKE ${`%${q}%`})` + 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<{ @@ -97,38 +231,39 @@ const listadoEnRojo: ReportDef = { name: string; nameMissing: boolean; city: string | null; - movements: bigint | number | string; balance: Prisma.Decimal | null; lastMovement: Date | null; + lastPayment: Date | null; }> >` SELECT c.id, c.name, c.nameMissing, c.city, - COUNT(*) AS movements, ${bal} AS balance, - MAX(t.transactionDate) AS lastMovement + ${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 - WHERE t.voidedAt IS NULL ${nameFilter} + 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} < -0.005 - ORDER BY MAX(t.transactionDate) ASC, c.nameMissing ASC, c.name ASC + 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); - let totalMovs = 0; const out = rows.map((r) => { const b = r.balance ?? new Prisma.Decimal(0); totalBalance = totalBalance.plus(b); - totalMovs += Number(r.movements); return { - id: r.id.slice(0, 8), + numid: nums.numid(r.id), name: nameOf(r), city: r.city ?? "—", - movements: Number(r.movements), + lastMovement: isoDay(r.lastMovement), + lastPayment: isoDay(r.lastPayment), balance: b.toFixed(2), - lastMovement: r.lastMovement - ? r.lastMovement.toISOString().slice(0, 10) - : "—", }; }); @@ -136,31 +271,58 @@ const listadoEnRojo: ReportDef = { rows: out, totals: { customers: out.length, - movements: totalMovs, balance: totalBalance.toFixed(2), currency, }, - subtitle: `Moneda: ${currency} · ${out.length} clientes con saldo deudor`, + 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). - * Customers enrolled in a service (by PropertyService.kind) with no - * related ledger charge in the last N days. Heuristic: any non-voided - * charge-type transaction in the window counts as "they paid". The - * report groups by property so the same customer with two water meters - * appears once per property. + * 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 un servicio contratado (agua, luz o teléfono) sin " + - "movimientos de cargo en los últimos N días. Heurística basada en el " + - "servicio registrado en la propiedad y la ausencia de cargos en el " + - "periodo seleccionado.", + "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", @@ -169,139 +331,143 @@ const pagosNoEfectuados: ReportDef = { key: "serviceKind", label: "Servicio", kind: "select", - options: [ - { value: "WATER", label: "Agua" }, - { value: "ELECTRICITY", label: "Luz" }, - { value: "PHONE", label: "Teléfono" }, - ], + 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: "days", - label: "Días sin movimiento", - kind: "number", - defaultValue: "60", - }, - { - key: "currency", - label: "Moneda", + key: "inactive", + label: "Clientes inactivos", kind: "select", options: [ - { value: "MXN", label: "MXN" }, - { value: "USD", label: "USD" }, + { value: "no", label: "Excluir" }, + { value: "yes", label: "Incluir" }, ], - defaultValue: "MXN", + defaultValue: "no", }, ], columns: [ - { key: "customerId", label: "Cliente #", type: "text" }, + NUMID_COLUMN, { key: "customerName", label: "Cliente", type: "text" }, { key: "propertyAddress", label: "Dirección", type: "text" }, - { key: "accountNumber", label: "Cuenta / Medidor", type: "text" }, - { key: "lastCharge", label: "Último cargo", type: "date" }, - { key: "balance", label: "Saldo", type: "money", align: "right" }, + { 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 = (p.serviceKind ?? "WATER") as - | "WATER" - | "ELECTRICITY" - | "PHONE"; - const days = intParam(p, "days", 60, 1, 365); - const currency = (p.currency === "USD" ? "USD" : "MXN") as "MXN" | "USD"; - const cutoff = new Date(Date.now() - days * 86400000); + 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}`; - // Customers enrolled in the service on a non-archived property, with no - // charge-type transaction in the window. The subquery picks up - // *anything* the customer paid (any domain, any type) — close enough - // for the staff's "who's overdue" view; the precise per-service match - // would need a per-service typeId taxonomy that doesn't exist in the - // legacy data. const rows = await prisma.$queryRaw< Array<{ customerId: string; customerName: string; nameMissing: boolean; - propertyId: string; - propertyAddress: string | null; - accountNumber: string | null; - lastCharge: Date | null; - balance: Prisma.Decimal | null; + 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, - pr.id AS propertyId, - pr.addressLine1 AS propertyAddress, - ps.accountNumber AS accountNumber, + 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 - WHERE t.customerId = c.id AND t.voidedAt IS NULL - AND t.amount < 0 - AND t.transactionDate >= ${cutoff}) AS lastCharge, - (SELECT SUM(t.amount) FROM transactions t - WHERE t.customerId = c.id AND t.voidedAt IS NULL - AND t.currency = ${currency}) AS balance + 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 - AND NOT EXISTS ( - SELECT 1 FROM transactions t - WHERE t.customerId = c.id AND t.voidedAt IS NULL - AND t.amount < 0 AND t.transactionDate >= ${cutoff} - ) - ORDER BY c.nameMissing ASC, c.name ASC, pr.addressLine1 ASC + 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 `; - let totalBalance = new Prisma.Decimal(0); + const nums = await legacyNumbers(prisma, rows.map((r) => r.customerId)); + let unfunded = 0; const out = rows.map((r) => { - const b = r.balance ?? new Prisma.Decimal(0); - totalBalance = totalBalance.plus(b); + if (Number(r.unfunded)) unfunded++; return { - customerId: r.customerId.slice(0, 8), - customerName: nameOf({ - name: r.customerName, - nameMissing: r.nameMissing, - }), - propertyAddress: r.propertyAddress ?? "—", - accountNumber: r.accountNumber ?? "—", - lastCharge: r.lastCharge - ? r.lastCharge.toISOString().slice(0, 10) - : "—", - balance: b.toFixed(2), + numid: nums.numid(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: { - rows: out.length, - balance: totalBalance.toFixed(2), - currency, + clientes: out.length, + "sin pago": out.length - unfunded, + "sin fondos": unfunded, }, - subtitle: `Servicio: ${ - kind === "WATER" ? "Agua" : kind === "ELECTRICITY" ? "Luz" : "Teléfono" - } · ${days} días · ${out.length} propiedades sin cargo reciente`, + subtitle: `Servicio: ${serviceLabel(kind)} · ${isoDay(from)} a ${isoDay(to)} · ${out.length} clientes sin pago registrado`, }; }, }; /** - * FALTANTES DE (AGUA / LUZ / TEL). - * Data-quality report: properties enrolled in a service that are missing - * the key identifier the legacy system required (account/meter/route). - * Different `faltante` per service kind in the legacy because the - * service's identifier fields differ; here we flag any of the three - * common identifiers being blank. + * 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: propiedades con un servicio contratado que no " + - "tienen número de cuenta, medidor o ruta registrado. El reporte que " + - "en la legacy corría como FALTANTES DE AGUA / LUZ / TEL.", + "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", @@ -310,82 +476,65 @@ const faltantes: ReportDef = { key: "serviceKind", label: "Servicio", kind: "select", - options: [ - { value: "WATER", label: "Agua" }, - { value: "ELECTRICITY", label: "Luz" }, - { value: "PHONE", label: "Teléfono" }, - ], + options: SERVICE_OPTIONS.filter((o) => REQUIRED_FIELDS[o.value]), defaultValue: "WATER", }, ], columns: [ - { key: "customerId", label: "Cliente #", type: "text" }, + NUMID_COLUMN, { key: "customerName", label: "Cliente", type: "text" }, { key: "propertyAddress", label: "Dirección", type: "text" }, { key: "missing", label: "Faltante", type: "text" }, - { key: "dueDay", label: "Día de vencimiento", type: "text" }, ], async run(prisma, p) { - const kind = (p.serviceKind ?? "WATER") as - | "WATER" - | "ELECTRICITY" - | "PHONE"; + 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)}) = '')`; - // A row per (property, missing field). The "missing" string describes - // what's blank so the report is self-explanatory when printed. const rows = await prisma.$queryRaw< Array<{ customerId: string; customerName: string; nameMissing: boolean; - propertyId: string; propertyAddress: string | null; - dueDay: string | null; missing: string; }> >` SELECT - c.id AS customerId, - c.name AS customerName, - c.nameMissing AS nameMissing, - pr.id AS propertyId, + c.id AS customerId, + c.name AS customerName, + c.nameMissing AS nameMissing, pr.addressLine1 AS propertyAddress, - ps.dueDay AS dueDay, - CASE - WHEN ps.accountNumber IS NULL OR ps.accountNumber = '' THEN 'Sin número de cuenta' - WHEN ps.meterNumber IS NULL OR ps.meterNumber = '' THEN 'Sin número de medidor' - WHEN ps.route IS NULL OR ps.route = '' THEN 'Sin ruta' - ELSE '' - END AS missing + 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 ( - ps.accountNumber IS NULL OR ps.accountNumber = '' - OR ps.meterNumber IS NULL OR ps.meterNumber = '' - OR ps.route IS NULL OR ps.route = '' - ) + 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) => ({ - customerId: r.customerId.slice(0, 8), - customerName: nameOf({ - name: r.customerName, - nameMissing: r.nameMissing, - }), + numid: nums.numid(r.customerId), + customerName: nameOf({ name: r.customerName, nameMissing: r.nameMissing }), propertyAddress: r.propertyAddress ?? "—", missing: r.missing, - dueDay: r.dueDay ?? "—", })); return { rows: out, totals: { rows: out.length }, - subtitle: `Servicio: ${ - kind === "WATER" ? "Agua" : kind === "ELECTRICITY" ? "Luz" : "Teléfono" - } · ${out.length} propiedades con datos faltantes`, + subtitle: `Servicio: ${serviceLabel(kind)} · ${out.length} servicios con datos faltantes`, }; }, }; @@ -422,6 +571,7 @@ const reporteDeEfectivo: ReportDef = { ], columns: [ { key: "date", label: "Fecha", type: "date" }, + NUMID_COLUMN, { key: "customerName", label: "Cliente", type: "text" }, { key: "concept", label: "Concepto", type: "text" }, { key: "source", label: "Origen", type: "text" }, @@ -436,6 +586,8 @@ const reporteDeEfectivo: ReportDef = { 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: [ { legacySourceTable: { in: ["EFECTIVO", "EFECTIVO_BACKUP"] } }, @@ -468,15 +620,17 @@ const reporteDeEfectivo: ReportDef = { message: true, legacySourceTable: true, type: { select: { nameEs: true, nameEn: true } }, - customer: { select: { name: true, nameMissing: 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), + numid: nums.numid(r.customer.id), customerName: nameOf(r.customer), concept: r.message ?? r.type?.nameEs ?? r.type?.nameEn ?? "—", source: r.legacySourceTable ?? "—", @@ -541,6 +695,8 @@ const vigente: ReportDef = { ], columns: [ { key: "policyNumber", label: "Póliza", type: "text" }, + NUMID_COLUMN, + INSURANCE_NO_COLUMN, { key: "customerName", label: "Cliente", type: "text" }, { key: "provider", label: "Aseguradora", type: "text" }, { key: "agent", label: "Agente", type: "text" }, @@ -571,11 +727,12 @@ const vigente: ReportDef = { policyTo: true, netPremium: true, agentName: true, - customer: { select: { name: true, nameMissing: 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 @@ -586,6 +743,8 @@ const vigente: ReportDef = { if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium); return { policyNumber: r.policyNumber, + numid: nums.numid(r.customer.id), + insuranceNo: nums.insurance(r.customer.id), customerName: nameOf(r.customer), provider: r.insuranceProvider?.name ?? "—", agent: r.agentName ?? "—", @@ -689,6 +848,8 @@ const avisoRenovacion: ReportDef = { // ReportRunner.tsx) — same trade-off edoCuentaDatos makes for "statement". columns: [ { key: "policyNumber", label: "Póliza", type: "text" }, + NUMID_COLUMN, + INSURANCE_NO_COLUMN, { key: "customerName", label: "Cliente", type: "text" }, { key: "provider", label: "Aseguradora", type: "text" }, { key: "policyTo", label: "Vence", type: "date" }, @@ -720,11 +881,16 @@ const avisoRenovacion: ReportDef = { 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); + const letter = { + ...toRenewalLetterRow(r, generation), + numid: nums.numid(r.customer.id), + insuranceNo: nums.insurance(r.customer.id), + }; if (letter.sentAt) sentCount++; return letter; }); @@ -802,6 +968,7 @@ const edoCuentaDatos: ReportDef = { }, }); if (!customer) return { rows: [], subtitle: "Cliente no encontrado" }; + const numid = (await legacyNumbers(prisma, [customer.id])).numid(customer.id); // The source-table exclusion, the balance floor and the year scope below // are BillingService.statement's, because this report and @@ -966,6 +1133,7 @@ const edoCuentaDatos: ReportDef = { { __kind: "header", name: nameOf(customer), + numid, address: customer.addressLine1 ?? "", city: [customer.city, customer.state].filter(Boolean).join(", "), phone: customer.phone ?? "", @@ -983,7 +1151,7 @@ const edoCuentaDatos: ReportDef = { ...carried, ...movements, ], - subtitle: `${nameOf(customer)} · ${year} · ${visible.length} movimientos`, + subtitle: `NUMid ${numid} · ${nameOf(customer)} · ${year} · ${visible.length} movimientos`, }; }, }; @@ -1021,6 +1189,7 @@ const chequeCount: ReportDef = { }, ], columns: [ + NUMID_COLUMN, { key: "customerName", label: "Cliente", type: "text" }, { key: "reference", label: "Referencia", type: "text" }, { key: "period", label: "Periodo", type: "text" }, @@ -1050,7 +1219,7 @@ const chequeCount: ReportDef = { period: true, outstanding: true, type: { select: { nameEn: true, nameEs: true } }, - customer: { select: { name: true, nameMissing: true } }, + customer: { select: { id: true, name: true, nameMissing: true } }, }, }); @@ -1076,8 +1245,10 @@ const chequeCount: ReportDef = { } if (outstandingCount) totalsOut["sin fondos"] = outstandingCount; + const nums = await legacyNumbers(prisma, rows.map((r) => r.customer.id)); return { rows: rows.map((r) => ({ + numid: nums.numid(r.customer.id), customerName: nameOf(r.customer), reference: r.reference ?? "—", period: r.period ?? "—", diff --git a/apps/api/src/statements/statements.service.ts b/apps/api/src/statements/statements.service.ts index 865d543..4b361a8 100644 --- a/apps/api/src/statements/statements.service.ts +++ b/apps/api/src/statements/statements.service.ts @@ -24,7 +24,7 @@ import type { ConfirmBatchDto, ReviewDocumentDto } from "./statement.dto"; * `TYPE OF TRX` values already in `type_transactions`, resolved by name once * per confirm rather than hard-coded as ids, which differ per environment. */ -const CONCEPT_BY_KIND: Partial> = { +export const CONCEPT_BY_KIND: Partial> = { ELECTRIC: "ELECTRIC", WATER: "WATER", TELEPHONE: "TELEPHONE", diff --git a/apps/web/src/components/ReportRunner.tsx b/apps/web/src/components/ReportRunner.tsx index cf02d95..fc8d4c2 100644 --- a/apps/web/src/components/ReportRunner.tsx +++ b/apps/web/src/components/ReportRunner.tsx @@ -390,6 +390,9 @@ function StatementLayout({ result }: { result: ReportRunResult }) {

{String(header.name ?? "—")}

+ {header.numid && header.numid !== "—" ? ( +

NUMid {String(header.numid)}

+ ) : null} {Boolean(header.address) && (

{String(header.address)}

)}