fix(reports): balances the statement agrees with, NUMid on every report
From the 2026-10-08 meeting. - Clientes en rojo summed every row a customer ever had, so the opening balance's history counted twice, cash receipts twice and the 2025 archive on top: 195 customers in the red against 22 real ones. It now takes the balance scope balances() uses (extracted as balanceScopeSql), sorts by most recent real movement, and filters by ramo, date, minimum owed and inactive customers (status = 0, the internal accounts). - Pagos no efectuados asked "no charge of any kind in N days"; legacy asks "no row of THAT service's concept in the period". Rewritten to legacy's question, per customer, defaulting to one billing cycle back. It also passed ELECTRICITY/PHONE, which are not ServiceKind values, so Luz and Telefono never returned a row. Sin fondos rows show as their own status. - Faltantes demanded account, meter AND route of every kind; water and phone have no route. Now the legacy fields per kind. - Reporte de efectivo counted every credit BALANCE FORWARD as cash (958 of 1093 rows on dev). - Every report carries the legacy NUMid; policy reports add the insurance file number. The statement header shows it too. - Printed headers showed placeholder company details: prod sets no COMPANY_* variables. Defaults are now the office's real locator, plus a US mailing address line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2cf07ed5ee
commit
031b13f8c8
7 files changed
+364
-176
No files matched your search
+2
-1
@@ -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=
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, { numid?: string; insurance: string[] }>();
|
||||
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
|
||||
JOIN type_transactions tt ON tt.id = t.typeId
|
||||
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
|
||||
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<ServiceKind, Array<["accountNumber" | "meterNumber", string]>>
|
||||
> = {
|
||||
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,37 +476,34 @@ 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;
|
||||
}>
|
||||
>`
|
||||
@@ -348,44 +511,30 @@ const faltantes: ReportDef = {
|
||||
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
|
||||
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 ?? "—",
|
||||
|
||||
@@ -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<Record<ServiceKind, string>> = {
|
||||
export const CONCEPT_BY_KIND: Partial<Record<ServiceKind, string>> = {
|
||||
ELECTRIC: "ELECTRIC",
|
||||
WATER: "WATER",
|
||||
TELEPHONE: "TELEPHONE",
|
||||
|
||||
@@ -390,6 +390,9 @@ function StatementLayout({ result }: { result: ReportRunResult }) {
|
||||
<div className="statement">
|
||||
<header className="statement-head">
|
||||
<h2 className="statement-name">{String(header.name ?? "—")}</h2>
|
||||
{header.numid && header.numid !== "—" ? (
|
||||
<p className="muted">NUMid {String(header.numid)}</p>
|
||||
) : null}
|
||||
{Boolean(header.address) && (
|
||||
<p className="muted">{String(header.address)}</p>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user