feat(reports): reports module + /inicio + edo-cuenta-datos prefill
- New reports backend (registry, service, controller, outputs, types) with catalog endpoint + slug/CSV/XLSX/PDF/print outputs. - /reportes catalog + /reportes/[slug] runner; ReportRunner + ContextReports components wire pre-filtered links from domain pages. - Fix: /reportes/[slug] now reads searchParams and forwards initialParams to ReportRunner so /reportes/edo-cuenta-datos?customerId=... auto-runs instead of dropping the id and forcing a manual customer search. - /inicio landing page; root + login redirect to /inicio. - Company header env vars + logo asset for PDF/print rendering. - exceljs + pdfkit deps.
This commit is contained in:
@@ -0,0 +1,763 @@
|
||||
/**
|
||||
* 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 } from "@jorgecuadros/database";
|
||||
import {
|
||||
intParam,
|
||||
NOT_VOIDED,
|
||||
parseDate,
|
||||
type ReportDef,
|
||||
} from "./reports.types";
|
||||
|
||||
/* ------------------------------------------------------------------ helpers */
|
||||
|
||||
function nameOf(c: { name: string; nameMissing: boolean }): string {
|
||||
return c.nameMissing ? "(sin nombre)" : c.name;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 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.
|
||||
*/
|
||||
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.",
|
||||
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: "query",
|
||||
label: "Buscar (nombre o ciudad)",
|
||||
kind: "text",
|
||||
placeholder: "Ej. Pérez, Tijuana…",
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
{ key: "id", label: "#", type: "text" },
|
||||
{ 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" },
|
||||
],
|
||||
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}%`})`
|
||||
: 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)`;
|
||||
|
||||
const rows = await prisma.$queryRaw<
|
||||
Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
nameMissing: boolean;
|
||||
city: string | null;
|
||||
movements: bigint | number | string;
|
||||
balance: Prisma.Decimal | null;
|
||||
lastMovement: Date | null;
|
||||
}>
|
||||
>`
|
||||
SELECT c.id, c.name, c.nameMissing, c.city,
|
||||
COUNT(*) AS movements,
|
||||
${bal} AS balance,
|
||||
MAX(t.transactionDate) AS lastMovement
|
||||
FROM customers c
|
||||
JOIN transactions t ON t.customerId = c.id
|
||||
WHERE t.voidedAt IS NULL ${nameFilter}
|
||||
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
|
||||
`;
|
||||
|
||||
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),
|
||||
name: nameOf(r),
|
||||
city: r.city ?? "—",
|
||||
movements: Number(r.movements),
|
||||
balance: b.toFixed(2),
|
||||
lastMovement: r.lastMovement
|
||||
? r.lastMovement.toISOString().slice(0, 10)
|
||||
: "—",
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
rows: out,
|
||||
totals: {
|
||||
customers: out.length,
|
||||
movements: totalMovs,
|
||||
balance: totalBalance.toFixed(2),
|
||||
currency,
|
||||
},
|
||||
subtitle: `Moneda: ${currency} · ${out.length} clientes con saldo deudor`,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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.",
|
||||
domain: "servicios",
|
||||
legacyName: "PAGOS NO EFECTUADOS AGUA/LUZ/TEL",
|
||||
format: "tabular",
|
||||
params: [
|
||||
{
|
||||
key: "serviceKind",
|
||||
label: "Servicio",
|
||||
kind: "select",
|
||||
options: [
|
||||
{ value: "WATER", label: "Agua" },
|
||||
{ value: "ELECTRICITY", label: "Luz" },
|
||||
{ value: "PHONE", label: "Teléfono" },
|
||||
],
|
||||
defaultValue: "WATER",
|
||||
},
|
||||
{
|
||||
key: "days",
|
||||
label: "Días sin movimiento",
|
||||
kind: "number",
|
||||
defaultValue: "60",
|
||||
},
|
||||
{
|
||||
key: "currency",
|
||||
label: "Moneda",
|
||||
kind: "select",
|
||||
options: [
|
||||
{ value: "MXN", label: "MXN" },
|
||||
{ value: "USD", label: "USD" },
|
||||
],
|
||||
defaultValue: "MXN",
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
{ key: "customerId", label: "Cliente #", type: "text" },
|
||||
{ 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" },
|
||||
],
|
||||
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);
|
||||
|
||||
// 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;
|
||||
}>
|
||||
>`
|
||||
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,
|
||||
(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
|
||||
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
|
||||
`;
|
||||
|
||||
let totalBalance = new Prisma.Decimal(0);
|
||||
const out = rows.map((r) => {
|
||||
const b = r.balance ?? new Prisma.Decimal(0);
|
||||
totalBalance = totalBalance.plus(b);
|
||||
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),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
rows: out,
|
||||
totals: {
|
||||
rows: out.length,
|
||||
balance: totalBalance.toFixed(2),
|
||||
currency,
|
||||
},
|
||||
subtitle: `Servicio: ${
|
||||
kind === "WATER" ? "Agua" : kind === "ELECTRICITY" ? "Luz" : "Teléfono"
|
||||
} · ${days} días · ${out.length} propiedades sin cargo reciente`,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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.",
|
||||
domain: "servicios",
|
||||
legacyName: "FALTANTES DE AGUA/LUZ/TEL",
|
||||
format: "tabular",
|
||||
params: [
|
||||
{
|
||||
key: "serviceKind",
|
||||
label: "Servicio",
|
||||
kind: "select",
|
||||
options: [
|
||||
{ value: "WATER", label: "Agua" },
|
||||
{ value: "ELECTRICITY", label: "Luz" },
|
||||
{ value: "PHONE", label: "Teléfono" },
|
||||
],
|
||||
defaultValue: "WATER",
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
{ key: "customerId", label: "Cliente #", type: "text" },
|
||||
{ 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";
|
||||
|
||||
// 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,
|
||||
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
|
||||
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 = ''
|
||||
)
|
||||
ORDER BY c.nameMissing ASC, c.name ASC, pr.addressLine1 ASC
|
||||
`;
|
||||
|
||||
const out = rows.map((r) => ({
|
||||
customerId: r.customerId.slice(0, 8),
|
||||
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`,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 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. Cubre los abonos " +
|
||||
"provenientes 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" },
|
||||
{ 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 },
|
||||
{
|
||||
OR: [
|
||||
{ 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,
|
||||
type: { select: { nameEs: true, nameEn: true } },
|
||||
customer: { select: { name: true, nameMissing: true } },
|
||||
},
|
||||
});
|
||||
|
||||
let total = new Prisma.Decimal(0);
|
||||
const out = rows.map((r) => {
|
||||
total = total.plus(r.amount);
|
||||
return {
|
||||
date: r.transactionDate.toISOString().slice(0, 10),
|
||||
customerName: nameOf(r.customer),
|
||||
concept: r.message ?? r.type?.nameEs ?? r.type?.nameEn ?? "—",
|
||||
source: 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" },
|
||||
{ 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: { name: true, nameMissing: true } },
|
||||
insuranceProvider: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
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`,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 el historial completo de movimientos con saldo " +
|
||||
"corrido. El reporte del cliente final.",
|
||||
domain: "estado-cuenta",
|
||||
legacyName: "EDO CUENTA DATOS",
|
||||
format: "statement",
|
||||
params: [
|
||||
{ key: "customerId", label: "Cliente", kind: "customer-picker" },
|
||||
],
|
||||
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" };
|
||||
|
||||
// Reuse the same NOT_VOIDED + STATEMENT_EXCLUDED_SOURCE_TABLES filter
|
||||
// as BillingService.statement so the numbers match what the customer
|
||||
// already sees in /estado-cuenta/[id].
|
||||
const rows = await prisma.transaction.findMany({
|
||||
where: {
|
||||
customerId,
|
||||
voidedAt: null,
|
||||
legacySourceTable: {
|
||||
notIn: [
|
||||
"EFECTIVO",
|
||||
"EFECTIVO_BACKUP",
|
||||
"EFECTIVO FM3",
|
||||
"CHEQUE FM3",
|
||||
"IVA 2015",
|
||||
],
|
||||
},
|
||||
},
|
||||
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 } },
|
||||
},
|
||||
});
|
||||
|
||||
// Compute running balance per currency, then return newest-first.
|
||||
const running = new Map<string, Prisma.Decimal>();
|
||||
const movements = rows.map((r) => {
|
||||
const prev = running.get(r.currency) ?? new Prisma.Decimal(0);
|
||||
const next = prev.plus(r.amount);
|
||||
running.set(r.currency, next);
|
||||
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),
|
||||
};
|
||||
});
|
||||
movements.reverse();
|
||||
|
||||
// Per-currency summary + per-domain breakdown.
|
||||
const perCurrency = new Map<
|
||||
string,
|
||||
{ currency: string; charges: Prisma.Decimal; credits: Prisma.Decimal; count: number }
|
||||
>();
|
||||
for (const r of rows) {
|
||||
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),
|
||||
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" },
|
||||
...movements,
|
||||
],
|
||||
subtitle: `${nameOf(customer)} · ${rows.length} movimientos`,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ export */
|
||||
|
||||
export const REPORTS: ReportDef[] = [
|
||||
listadoEnRojo,
|
||||
pagosNoEfectuados,
|
||||
faltantes,
|
||||
reporteDeEfectivo,
|
||||
vigente,
|
||||
edoCuentaDatos,
|
||||
];
|
||||
|
||||
export function findReport(slug: string): ReportDef | undefined {
|
||||
return REPORTS.find((r) => r.slug === slug);
|
||||
}
|
||||
Reference in New Issue
Block a user