Compare commits

...
2 Commits
Author SHA1 Message Date
rmancinasandClaude Opus 5.5 031b13f8c8 fix(reports): balances the statement agrees with, NUMid on every report
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m44s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m16s
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>
2026-10-11 23:20:44 -07:00
rmancinasandClaude Opus 5.5 2cf07ed5ee fix(migration): the CFE meter and route belong to the electric service
DATMEX.MEDIDOR/RUTA were unpivoted onto the WATER row, so every electric
service arrived without a meter and water carried CFE's. They are filled
on exactly the 1113 rows that carry an RPU (100% overlap), the meters are
CFE's alphanumeric form (53MT58), and every legacy query that reads them
is a LUZ one. medidor2/ruta2 pair with rpu2 the same way.

Takes effect on the next reimport.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:20:44 -07:00
8 changed files with 382 additions and 191 deletions

No files matched your search

+2 -1
View File
@@ -22,12 +22,13 @@ OPS_DB_ADMIN_USER=
OPS_DB_ADMIN_PASSWORD= OPS_DB_ADMIN_PASSWORD=
# Company info — printed in the header of every report (PDF + browser # 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. # optional; when unset the API falls back to apps/api/assets/company_logo.png.
COMPANY_NAME=Jorge Cuadros & Asociados COMPANY_NAME=Jorge Cuadros & Asociados
COMPANY_ADDRESS_LINE1= COMPANY_ADDRESS_LINE1=
COMPANY_ADDRESS_LINE2= COMPANY_ADDRESS_LINE2=
COMPANY_CITY_STATE= COMPANY_CITY_STATE=
COMPANY_MAILING_ADDRESS=
COMPANY_PHONE= COMPANY_PHONE=
COMPANY_EMAIL= COMPANY_EMAIL=
COMPANY_TAX_ID= COMPANY_TAX_ID=
+11 -6
View File
@@ -305,6 +305,15 @@ export const archiveIsHistorySql = (yearStart: Date) => Prisma.sql`(
export const currentYearStart = () => export const currentYearStart = () =>
new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1)); 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() @Injectable()
export class BillingService { export class BillingService {
constructor(private readonly prisma: PrismaService) {} 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 // 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 // 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. // each case being a cash receipt already posted to the ledger.
const scope = Prisma.sql`AND ${NOT_CASH_JOURNAL} AND ${archiveIsHistorySql( const scope = balanceScopeSql();
currentYearStart(),
)}`;
const filters: Prisma.Sql[] = []; const filters: Prisma.Sql[] = [];
if (domain) filters.push(Prisma.sql`t.domain = ${domain}`); 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. * which is the client's call and not settled yet.
*/ */
async stats() { async stats() {
const scope = Prisma.sql`AND ${NOT_CASH_JOURNAL} AND ${archiveIsHistorySql( const scope = balanceScopeSql();
currentYearStart(),
)}`;
const [movements, ledgerCustomers] = await Promise.all([ const [movements, ledgerCustomers] = await Promise.all([
this.prisma.transaction.count({ where: NOT_VOIDED }), this.prisma.transaction.count({ where: NOT_VOIDED }),
+14 -8
View File
@@ -21,6 +21,8 @@ export interface CompanyInfo {
addressLine2: string; addressLine2: string;
/** "City, State, ZIP, Country" — single line. */ /** "City, State, ZIP, Country" — single line. */
cityState: string; cityState: string;
/** US postal address, printed after the office address. Optional. */
mailingAddress: string;
phone: string; phone: string;
email: string; email: string;
/** Mexican tax ID ("RFC"). Optional. */ /** Mexican tax ID ("RFC"). Optional. */
@@ -83,19 +85,23 @@ export function getCompanyInfo(): CompanyInfo {
} }
cached = { cached = {
name: envOr("COMPANY_NAME", "Jorge Cuadros & Asociados"), name: envOr("COMPANY_NAME", "Jorge Cuadros & Asociados"),
addressLine1: envOr( // The office's real locator, as published on jorgecuadros.com. Prod sets
"COMPANY_ADDRESS_LINE1", // no COMPANY_* variables, so these defaults ARE what every printed
"Av. Revolución 1234, Int. 5", // statement shows — they were placeholders until 2026-10.
), addressLine1: envOr("COMPANY_ADDRESS_LINE1", "Quinta Plaza Ste 3-B"),
addressLine2: envOr("COMPANY_ADDRESS_LINE2", ""), addressLine2: envOr("COMPANY_ADDRESS_LINE2", ""),
cityState: envOr( cityState: envOr(
"COMPANY_CITY_STATE", "COMPANY_CITY_STATE",
"Tijuana, Baja California 22000, México", "Rosarito, Baja California, México",
), ),
phone: envOr("COMPANY_PHONE", "(664) 000-0000"), mailingAddress: envOr(
email: envOr("COMPANY_EMAIL", "contacto@jorgecuadros.local"), "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", ""), taxId: envOr("COMPANY_TAX_ID", ""),
website: envOr("COMPANY_WEBSITE", "jorgecuadros.local"), website: envOr("COMPANY_WEBSITE", "jorgecuadros.com"),
logoPath, logoPath,
logo, logo,
}; };
+2
View File
@@ -199,6 +199,7 @@ export function renderPdf(
company.addressLine1, company.addressLine1,
company.addressLine2, company.addressLine2,
[company.cityState].filter(Boolean).join(" · "), [company.cityState].filter(Boolean).join(" · "),
company.mailingAddress,
[company.phone, company.email].filter(Boolean).join(" · "), [company.phone, company.email].filter(Boolean).join(" · "),
company.taxId ? `RFC: ${company.taxId}` : "", company.taxId ? `RFC: ${company.taxId}` : "",
].filter(Boolean); ].filter(Boolean);
@@ -363,6 +364,7 @@ export function renderPrintHtml(
company.addressLine1, company.addressLine1,
company.addressLine2, company.addressLine2,
company.cityState, company.cityState,
company.mailingAddress,
[company.phone, company.email].filter(Boolean).join(" · "), [company.phone, company.email].filter(Boolean).join(" · "),
company.taxId ? `RFC: ${company.taxId}` : "", company.taxId ? `RFC: ${company.taxId}` : "",
].filter(Boolean); ].filter(Boolean);
+335 -164
View File
@@ -14,13 +14,23 @@
* the EFECTIVO* set OR `checkNumber` null + amount > 0 (true cash). * the EFECTIVO* set OR `checkNumber` null + amount > 0 (true cash).
*/ */
import { Prisma } from "@jorgecuadros/database"; import { Prisma, type ServiceKind } from "@jorgecuadros/database";
import { import {
BALANCE_FLOOR_JOIN,
BALANCE_FORWARD_TYPE, BALANCE_FORWARD_TYPE,
balanceScopeSql,
NOT_SUPERSEDED,
notCashJournal, notCashJournal,
periodSourceTable, periodSourceTable,
} from "../billing/billing.service"; } from "../billing/billing.service";
import { 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, intParam,
NOT_VOIDED, NOT_VOIDED,
parseDate, parseDate,
@@ -37,19 +47,80 @@ function nameOf(c: { name: string; nameMissing: boolean }): string {
return c.nameMissing ? "(sin nombre)" : c.name; 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 */ /* ------------------------------------------------------------------ reports */
/** /**
* LISTADO EN ROJO — overdue customers worklist. * 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 = { const listadoEnRojo: ReportDef = {
slug: "listado-en-rojo", slug: "listado-en-rojo",
title: "Clientes en rojo", title: "Clientes en rojo",
description: description:
"Cartera vencida: clientes con saldo deudor en la moneda seleccionada, " + "Cartera vencida: clientes con saldo deudor en la moneda seleccionada — " +
"ordenados del más antiguo al más reciente.", "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", domain: "estado-cuenta",
legacyName: "LISTADO EN ROJO", legacyName: "LISTADO EN ROJO",
format: "tabular", format: "tabular",
@@ -64,6 +135,45 @@ const listadoEnRojo: ReportDef = {
], ],
defaultValue: "MXN", 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", key: "query",
label: "Buscar (nombre o ciudad)", label: "Buscar (nombre o ciudad)",
@@ -72,24 +182,48 @@ const listadoEnRojo: ReportDef = {
}, },
], ],
columns: [ columns: [
{ key: "id", label: "#", type: "text" }, NUMID_COLUMN,
{ key: "name", label: "Cliente", type: "text" }, { key: "name", label: "Cliente", type: "text" },
{ key: "city", label: "Ciudad", 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: "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) { async run(prisma, p) {
const currency = (p.currency === "USD" ? "USD" : "MXN") as "MXN" | "USD"; const currency = (p.currency === "USD" ? "USD" : "MXN") as "MXN" | "USD";
const q = p.query?.trim(); const q = p.query?.trim();
const nameFilter = q const since = parseDate(p.since);
? Prisma.sql`AND (c.name LIKE ${`%${q}%`} OR c.city LIKE ${`%${q}%`})` 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; : Prisma.empty;
const bal = const bal =
currency === "USD" currency === "USD"
? Prisma.sql`SUM(CASE WHEN t.currency = 'USD' THEN t.amount ELSE 0 END)` ? 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)`; : 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< const rows = await prisma.$queryRaw<
Array<{ Array<{
@@ -97,38 +231,39 @@ const listadoEnRojo: ReportDef = {
name: string; name: string;
nameMissing: boolean; nameMissing: boolean;
city: string | null; city: string | null;
movements: bigint | number | string;
balance: Prisma.Decimal | null; balance: Prisma.Decimal | null;
lastMovement: Date | null; lastMovement: Date | null;
lastPayment: Date | null;
}> }>
>` >`
SELECT c.id, c.name, c.nameMissing, c.city, SELECT c.id, c.name, c.nameMissing, c.city,
COUNT(*) AS movements,
${bal} AS balance, ${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 FROM customers c
JOIN transactions t ON t.customerId = c.id 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 GROUP BY c.id, c.name, c.nameMissing, c.city
HAVING ${bal} < -0.005 HAVING ${bal} < ${-minOwed}
ORDER BY MAX(t.transactionDate) ASC, c.nameMissing ASC, c.name ASC ${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 totalBalance = new Prisma.Decimal(0);
let totalMovs = 0;
const out = rows.map((r) => { const out = rows.map((r) => {
const b = r.balance ?? new Prisma.Decimal(0); const b = r.balance ?? new Prisma.Decimal(0);
totalBalance = totalBalance.plus(b); totalBalance = totalBalance.plus(b);
totalMovs += Number(r.movements);
return { return {
id: r.id.slice(0, 8), numid: nums.numid(r.id),
name: nameOf(r), name: nameOf(r),
city: r.city ?? "—", city: r.city ?? "—",
movements: Number(r.movements), lastMovement: isoDay(r.lastMovement),
lastPayment: isoDay(r.lastPayment),
balance: b.toFixed(2), balance: b.toFixed(2),
lastMovement: r.lastMovement
? r.lastMovement.toISOString().slice(0, 10)
: "—",
}; };
}); });
@@ -136,31 +271,58 @@ const listadoEnRojo: ReportDef = {
rows: out, rows: out,
totals: { totals: {
customers: out.length, customers: out.length,
movements: totalMovs,
balance: totalBalance.toFixed(2), balance: totalBalance.toFixed(2),
currency, 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). * 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 * Legacy `PAGO AGUA/LUZ/TEL NO`: every customer with the service, LEFT JOIN
* charge-type transaction in the window counts as "they paid". The * `LOCALIZA PAGO *` — the rows of THAT service's concept dated in the period
* report groups by property so the same customer with two water meters * the operator typed — where nothing was found. So: "whose water did we not
* appears once per property. * 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 = { const pagosNoEfectuados: ReportDef = {
slug: "pagos-no-efectuados", slug: "pagos-no-efectuados",
title: "Pagos no efectuados", title: "Pagos no efectuados",
description: description:
"Clientes con un servicio contratado (agua, luz o teléfono) sin " + "Clientes con el servicio contratado sin un pago de ese servicio " +
"movimientos de cargo en los últimos N días. Heurística basada en el " + "registrado en el periodo. Sin fechas, revisa un ciclo hacia atrás " +
"servicio registrado en la propiedad y la ausencia de cargos en el " + "desde hoy: 31 días, o 62 para luz (bimestral).",
"periodo seleccionado.",
domain: "servicios", domain: "servicios",
legacyName: "PAGOS NO EFECTUADOS AGUA/LUZ/TEL", legacyName: "PAGOS NO EFECTUADOS AGUA/LUZ/TEL",
format: "tabular", format: "tabular",
@@ -169,139 +331,143 @@ const pagosNoEfectuados: ReportDef = {
key: "serviceKind", key: "serviceKind",
label: "Servicio", label: "Servicio",
kind: "select", kind: "select",
options: [ options: SERVICE_OPTIONS,
{ value: "WATER", label: "Agua" },
{ value: "ELECTRICITY", label: "Luz" },
{ value: "PHONE", label: "Teléfono" },
],
defaultValue: "WATER", 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", key: "inactive",
label: "Días sin movimiento", label: "Clientes inactivos",
kind: "number",
defaultValue: "60",
},
{
key: "currency",
label: "Moneda",
kind: "select", kind: "select",
options: [ options: [
{ value: "MXN", label: "MXN" }, { value: "no", label: "Excluir" },
{ value: "USD", label: "USD" }, { value: "yes", label: "Incluir" },
], ],
defaultValue: "MXN", defaultValue: "no",
}, },
], ],
columns: [ columns: [
{ key: "customerId", label: "Cliente #", type: "text" }, NUMID_COLUMN,
{ key: "customerName", label: "Cliente", type: "text" }, { key: "customerName", label: "Cliente", type: "text" },
{ key: "propertyAddress", label: "Dirección", type: "text" }, { key: "propertyAddress", label: "Dirección", type: "text" },
{ key: "accountNumber", label: "Cuenta / Medidor", type: "text" }, { key: "accountNumber", label: "Cuenta", type: "text" },
{ key: "lastCharge", label: "Último cargo", type: "date" }, { key: "status", label: "Estado", type: "text" },
{ key: "balance", label: "Saldo", type: "money", align: "right" }, { key: "lastPaid", label: "Último pago del servicio", type: "date" },
], ],
async run(prisma, p) { async run(prisma, p) {
const kind = (p.serviceKind ?? "WATER") as const kind = (
| "WATER" SERVICE_OPTIONS.some((o) => o.value === p.serviceKind)
| "ELECTRICITY" ? p.serviceKind
| "PHONE"; : "WATER"
const days = intParam(p, "days", 60, 1, 365); ) as ServiceKind;
const currency = (p.currency === "USD" ? "USD" : "MXN") as "MXN" | "USD"; const concept = CONCEPT_BY_KIND[kind]!;
const cutoff = new Date(Date.now() - days * 86400000); // 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< const rows = await prisma.$queryRaw<
Array<{ Array<{
customerId: string; customerId: string;
customerName: string; customerName: string;
nameMissing: boolean; nameMissing: boolean;
propertyId: string; addresses: string | null;
propertyAddress: string | null; accounts: string | null;
accountNumber: string | null; unfunded: bigint | number;
lastCharge: Date | null; lastPaid: Date | null;
balance: Prisma.Decimal | null;
}> }>
>` >`
SELECT SELECT
c.id AS customerId, c.id AS customerId,
c.name AS customerName, c.name AS customerName,
c.nameMissing AS nameMissing, c.nameMissing AS nameMissing,
pr.id AS propertyId, GROUP_CONCAT(DISTINCT pr.addressLine1 ORDER BY pr.addressLine1 SEPARATOR ' / ') AS addresses,
pr.addressLine1 AS propertyAddress, GROUP_CONCAT(DISTINCT NULLIF(ps.accountNumber, '') SEPARATOR ', ') AS accounts,
ps.accountNumber AS accountNumber, EXISTS(${conceptRow(1)}) AS unfunded,
(SELECT MAX(t.transactionDate) FROM transactions t (SELECT MAX(t.transactionDate) FROM transactions t
WHERE t.customerId = c.id AND t.voidedAt IS NULL JOIN type_transactions tt ON tt.id = t.typeId
AND t.amount < 0 WHERE t.customerId = c.id AND t.voidedAt IS NULL
AND t.transactionDate >= ${cutoff}) AS lastCharge, AND t.outstanding = 0 AND tt.nameEn = ${concept}) AS lastPaid
(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 FROM property_services ps
JOIN properties pr ON pr.id = ps.propertyId AND pr.archivedAt IS NULL 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 JOIN customers c ON c.id = pr.customerId AND c.archivedAt IS NULL
WHERE ps.kind = ${kind} AND ps.active = 1 WHERE ps.kind = ${kind} AND ps.active = 1 ${inactive}
AND NOT EXISTS ( AND NOT EXISTS (${conceptRow(0)})
SELECT 1 FROM transactions t GROUP BY c.id, c.name, c.nameMissing
WHERE t.customerId = c.id AND t.voidedAt IS NULL ORDER BY c.nameMissing ASC, c.name ASC
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 nums = await legacyNumbers(prisma, rows.map((r) => r.customerId));
let unfunded = 0;
const out = rows.map((r) => { const out = rows.map((r) => {
const b = r.balance ?? new Prisma.Decimal(0); if (Number(r.unfunded)) unfunded++;
totalBalance = totalBalance.plus(b);
return { return {
customerId: r.customerId.slice(0, 8), numid: nums.numid(r.customerId),
customerName: nameOf({ customerName: nameOf({ name: r.customerName, nameMissing: r.nameMissing }),
name: r.customerName, propertyAddress: r.addresses ?? "—",
nameMissing: r.nameMissing, accountNumber: r.accounts ?? "—",
}), status: Number(r.unfunded) ? "Sin fondos" : "Sin pago",
propertyAddress: r.propertyAddress ?? "—", lastPaid: isoDay(r.lastPaid),
accountNumber: r.accountNumber ?? "—",
lastCharge: r.lastCharge
? r.lastCharge.toISOString().slice(0, 10)
: "—",
balance: b.toFixed(2),
}; };
}); });
return { return {
rows: out, rows: out,
totals: { totals: {
rows: out.length, clientes: out.length,
balance: totalBalance.toFixed(2), "sin pago": out.length - unfunded,
currency, "sin fondos": unfunded,
}, },
subtitle: `Servicio: ${ subtitle: `Servicio: ${serviceLabel(kind)} · ${isoDay(from)} a ${isoDay(to)} · ${out.length} clientes sin pago registrado`,
kind === "WATER" ? "Agua" : kind === "ELECTRICITY" ? "Luz" : "Teléfono"
} · ${days} días · ${out.length} propiedades sin cargo reciente`,
}; };
}, },
}; };
/** /**
* FALTANTES DE (AGUA / LUZ / TEL). * What each service must have on file to be paid and matched — the columns
* Data-quality report: properties enrolled in a service that are missing * legacy's FALTANTES DE AGUA/LUZ/TEL read (DATMEX.AGUA; MEDIDOR + RPU;
* the key identifier the legacy system required (account/meter/route). * TELEFONO), in the field `scopedRefField` stores each one in. The first port
* Different `faltante` per service kind in the legacy because the * demanded account AND meter AND route of every kind; water and phone have no
* service's identifier fields differ; here we flag any of the three * route, so every one of them was flagged.
* common identifiers being blank. */
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 = { const faltantes: ReportDef = {
slug: "faltantes", slug: "faltantes",
title: "Faltantes de datos por servicio", title: "Faltantes de datos por servicio",
description: description:
"Calidad de datos: propiedades con un servicio contratado que no " + "Calidad de datos: servicios activos sin el dato que se necesita para " +
"tienen número de cuenta, medidor o ruta registrado. El reporte que " + "pagar el recibo — agua: número de cuenta; luz: RPU y medidor; " +
"en la legacy corría como FALTANTES DE AGUA / LUZ / TEL.", "teléfono: número.",
domain: "servicios", domain: "servicios",
legacyName: "FALTANTES DE AGUA/LUZ/TEL", legacyName: "FALTANTES DE AGUA/LUZ/TEL",
format: "tabular", format: "tabular",
@@ -310,82 +476,65 @@ const faltantes: ReportDef = {
key: "serviceKind", key: "serviceKind",
label: "Servicio", label: "Servicio",
kind: "select", kind: "select",
options: [ options: SERVICE_OPTIONS.filter((o) => REQUIRED_FIELDS[o.value]),
{ value: "WATER", label: "Agua" },
{ value: "ELECTRICITY", label: "Luz" },
{ value: "PHONE", label: "Teléfono" },
],
defaultValue: "WATER", defaultValue: "WATER",
}, },
], ],
columns: [ columns: [
{ key: "customerId", label: "Cliente #", type: "text" }, NUMID_COLUMN,
{ key: "customerName", label: "Cliente", type: "text" }, { key: "customerName", label: "Cliente", type: "text" },
{ key: "propertyAddress", label: "Dirección", type: "text" }, { key: "propertyAddress", label: "Dirección", type: "text" },
{ key: "missing", label: "Faltante", type: "text" }, { key: "missing", label: "Faltante", type: "text" },
{ key: "dueDay", label: "Día de vencimiento", type: "text" },
], ],
async run(prisma, p) { async run(prisma, p) {
const kind = (p.serviceKind ?? "WATER") as const kind = (
| "WATER" p.serviceKind && REQUIRED_FIELDS[p.serviceKind as ServiceKind]
| "ELECTRICITY" ? p.serviceKind
| "PHONE"; : "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< const rows = await prisma.$queryRaw<
Array<{ Array<{
customerId: string; customerId: string;
customerName: string; customerName: string;
nameMissing: boolean; nameMissing: boolean;
propertyId: string;
propertyAddress: string | null; propertyAddress: string | null;
dueDay: string | null;
missing: string; missing: string;
}> }>
>` >`
SELECT SELECT
c.id AS customerId, c.id AS customerId,
c.name AS customerName, c.name AS customerName,
c.nameMissing AS nameMissing, c.nameMissing AS nameMissing,
pr.id AS propertyId,
pr.addressLine1 AS propertyAddress, pr.addressLine1 AS propertyAddress,
ps.dueDay AS dueDay, CONCAT_WS(', ', ${Prisma.join(
CASE required.map(([field, label]) => Prisma.sql`IF(${blank(field)}, ${label}, NULL)`),
WHEN ps.accountNumber IS NULL OR ps.accountNumber = '' THEN 'Sin número de cuenta' )}) AS missing
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 FROM property_services ps
JOIN properties pr ON pr.id = ps.propertyId AND pr.archivedAt IS NULL 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 JOIN customers c ON c.id = pr.customerId AND c.archivedAt IS NULL
WHERE ps.kind = ${kind} AND ps.active = 1 WHERE ps.kind = ${kind} AND ps.active = 1 AND c.status = 1
AND ( AND (${Prisma.join(required.map(([field]) => blank(field)), " OR ")})
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 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) => ({ const out = rows.map((r) => ({
customerId: r.customerId.slice(0, 8), numid: nums.numid(r.customerId),
customerName: nameOf({ customerName: nameOf({ name: r.customerName, nameMissing: r.nameMissing }),
name: r.customerName,
nameMissing: r.nameMissing,
}),
propertyAddress: r.propertyAddress ?? "—", propertyAddress: r.propertyAddress ?? "—",
missing: r.missing, missing: r.missing,
dueDay: r.dueDay ?? "—",
})); }));
return { return {
rows: out, rows: out,
totals: { rows: out.length }, totals: { rows: out.length },
subtitle: `Servicio: ${ subtitle: `Servicio: ${serviceLabel(kind)} · ${out.length} servicios con datos faltantes`,
kind === "WATER" ? "Agua" : kind === "ELECTRICITY" ? "Luz" : "Teléfono"
} · ${out.length} propiedades con datos faltantes`,
}; };
}, },
}; };
@@ -422,6 +571,7 @@ const reporteDeEfectivo: ReportDef = {
], ],
columns: [ columns: [
{ key: "date", label: "Fecha", type: "date" }, { key: "date", label: "Fecha", type: "date" },
NUMID_COLUMN,
{ key: "customerName", label: "Cliente", type: "text" }, { key: "customerName", label: "Cliente", type: "text" },
{ key: "concept", label: "Concepto", type: "text" }, { key: "concept", label: "Concepto", type: "text" },
{ key: "source", label: "Origen", type: "text" }, { key: "source", label: "Origen", type: "text" },
@@ -436,6 +586,8 @@ const reporteDeEfectivo: ReportDef = {
NOT_VOIDED, NOT_VOIDED,
{ amount: { gt: 0 } }, { amount: { gt: 0 } },
{ currency }, { currency },
// A credit opening balance has no check number but is not cash.
{ OR: [{ typeId: null }, { type: { nameEn: { not: BALANCE_FORWARD_TYPE } } }] },
{ {
OR: [ OR: [
{ legacySourceTable: { in: ["EFECTIVO", "EFECTIVO_BACKUP"] } }, { legacySourceTable: { in: ["EFECTIVO", "EFECTIVO_BACKUP"] } },
@@ -468,15 +620,17 @@ const reporteDeEfectivo: ReportDef = {
message: true, message: true,
legacySourceTable: true, legacySourceTable: true,
type: { select: { nameEs: true, nameEn: 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); let total = new Prisma.Decimal(0);
const out = rows.map((r) => { const out = rows.map((r) => {
total = total.plus(r.amount); total = total.plus(r.amount);
return { return {
date: r.transactionDate.toISOString().slice(0, 10), date: r.transactionDate.toISOString().slice(0, 10),
numid: nums.numid(r.customer.id),
customerName: nameOf(r.customer), customerName: nameOf(r.customer),
concept: r.message ?? r.type?.nameEs ?? r.type?.nameEn ?? "—", concept: r.message ?? r.type?.nameEs ?? r.type?.nameEn ?? "—",
source: r.legacySourceTable ?? "—", source: r.legacySourceTable ?? "—",
@@ -541,6 +695,8 @@ const vigente: ReportDef = {
], ],
columns: [ columns: [
{ key: "policyNumber", label: "Póliza", type: "text" }, { key: "policyNumber", label: "Póliza", type: "text" },
NUMID_COLUMN,
INSURANCE_NO_COLUMN,
{ key: "customerName", label: "Cliente", type: "text" }, { key: "customerName", label: "Cliente", type: "text" },
{ key: "provider", label: "Aseguradora", type: "text" }, { key: "provider", label: "Aseguradora", type: "text" },
{ key: "agent", label: "Agente", type: "text" }, { key: "agent", label: "Agente", type: "text" },
@@ -571,11 +727,12 @@ const vigente: ReportDef = {
policyTo: true, policyTo: true,
netPremium: true, netPremium: true,
agentName: true, agentName: true,
customer: { select: { name: true, nameMissing: true } }, customer: { select: { id: true, name: true, nameMissing: true } },
insuranceProvider: { select: { name: true } }, insuranceProvider: { select: { name: true } },
}, },
}); });
const nums = await legacyNumbers(prisma, rows.map((r) => r.customer.id));
let totalPremium = new Prisma.Decimal(0); let totalPremium = new Prisma.Decimal(0);
const out = rows.map((r) => { const out = rows.map((r) => {
const daysTo = r.policyTo const daysTo = r.policyTo
@@ -586,6 +743,8 @@ const vigente: ReportDef = {
if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium); if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium);
return { return {
policyNumber: r.policyNumber, policyNumber: r.policyNumber,
numid: nums.numid(r.customer.id),
insuranceNo: nums.insurance(r.customer.id),
customerName: nameOf(r.customer), customerName: nameOf(r.customer),
provider: r.insuranceProvider?.name ?? "—", provider: r.insuranceProvider?.name ?? "—",
agent: r.agentName ?? "—", agent: r.agentName ?? "—",
@@ -689,6 +848,8 @@ const avisoRenovacion: ReportDef = {
// ReportRunner.tsx) — same trade-off edoCuentaDatos makes for "statement". // ReportRunner.tsx) — same trade-off edoCuentaDatos makes for "statement".
columns: [ columns: [
{ key: "policyNumber", label: "Póliza", type: "text" }, { key: "policyNumber", label: "Póliza", type: "text" },
NUMID_COLUMN,
INSURANCE_NO_COLUMN,
{ key: "customerName", label: "Cliente", type: "text" }, { key: "customerName", label: "Cliente", type: "text" },
{ key: "provider", label: "Aseguradora", type: "text" }, { key: "provider", label: "Aseguradora", type: "text" },
{ key: "policyTo", label: "Vence", type: "date" }, { key: "policyTo", label: "Vence", type: "date" },
@@ -720,11 +881,16 @@ const avisoRenovacion: ReportDef = {
select: renewalLetterSelect(generation), select: renewalLetterSelect(generation),
}); });
const nums = await legacyNumbers(prisma, rows.map((r) => r.customer.id));
let totalPremium = new Prisma.Decimal(0); let totalPremium = new Prisma.Decimal(0);
let sentCount = 0; let sentCount = 0;
const out = rows.map((r) => { const out = rows.map((r) => {
if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium); 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++; if (letter.sentAt) sentCount++;
return letter; return letter;
}); });
@@ -802,6 +968,7 @@ const edoCuentaDatos: ReportDef = {
}, },
}); });
if (!customer) return { rows: [], subtitle: "Cliente no encontrado" }; 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 // The source-table exclusion, the balance floor and the year scope below
// are BillingService.statement's, because this report and // are BillingService.statement's, because this report and
@@ -966,6 +1133,7 @@ const edoCuentaDatos: ReportDef = {
{ {
__kind: "header", __kind: "header",
name: nameOf(customer), name: nameOf(customer),
numid,
address: customer.addressLine1 ?? "", address: customer.addressLine1 ?? "",
city: [customer.city, customer.state].filter(Boolean).join(", "), city: [customer.city, customer.state].filter(Boolean).join(", "),
phone: customer.phone ?? "", phone: customer.phone ?? "",
@@ -983,7 +1151,7 @@ const edoCuentaDatos: ReportDef = {
...carried, ...carried,
...movements, ...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: [ columns: [
NUMID_COLUMN,
{ key: "customerName", label: "Cliente", type: "text" }, { key: "customerName", label: "Cliente", type: "text" },
{ key: "reference", label: "Referencia", type: "text" }, { key: "reference", label: "Referencia", type: "text" },
{ key: "period", label: "Periodo", type: "text" }, { key: "period", label: "Periodo", type: "text" },
@@ -1050,7 +1219,7 @@ const chequeCount: ReportDef = {
period: true, period: true,
outstanding: true, outstanding: true,
type: { select: { nameEn: true, nameEs: 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; if (outstandingCount) totalsOut["sin fondos"] = outstandingCount;
const nums = await legacyNumbers(prisma, rows.map((r) => r.customer.id));
return { return {
rows: rows.map((r) => ({ rows: rows.map((r) => ({
numid: nums.numid(r.customer.id),
customerName: nameOf(r.customer), customerName: nameOf(r.customer),
reference: r.reference ?? "—", reference: r.reference ?? "—",
period: r.period ?? "—", 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 * `TYPE OF TRX` values already in `type_transactions`, resolved by name once
* per confirm rather than hard-coded as ids, which differ per environment. * 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", ELECTRIC: "ELECTRIC",
WATER: "WATER", WATER: "WATER",
TELEPHONE: "TELEPHONE", TELEPHONE: "TELEPHONE",
+3
View File
@@ -390,6 +390,9 @@ function StatementLayout({ result }: { result: ReportRunResult }) {
<div className="statement"> <div className="statement">
<header className="statement-head"> <header className="statement-head">
<h2 className="statement-name">{String(header.name ?? "—")}</h2> <h2 className="statement-name">{String(header.name ?? "—")}</h2>
{header.numid && header.numid !== "—" ? (
<p className="muted">NUMid {String(header.numid)}</p>
) : null}
{Boolean(header.address) && ( {Boolean(header.address) && (
<p className="muted">{String(header.address)}</p> <p className="muted">{String(header.address)}</p>
)} )}
+14 -11
View File
@@ -164,18 +164,21 @@ def main():
services.append((str(uuid.uuid4()), pid, kind, account, meter, route, services.append((str(uuid.uuid4()), pid, kind, account, meter, route,
due, 1 if active else 0, notes)) due, 1 if active else 0, notes))
# WATER (primary + extra meters) # WATER — the CESPT account only. DATMEX has no water meter column.
if s(row["agua"]) or flag("water1", False): if s(row["agua"]) or flag("water1", False):
svc("WATER", account=s(row["agua"]), meter=s(row["medidor"]), svc("WATER", account=s(row["agua"]), due=s(row["diagua"]),
route=s(row["ruta"]), due=s(row["diagua"]), active=flag("water1")) active=flag("water1"))
for mcol, rcol in (("medidor2", "ruta2"), ("medidor3", "ruta3")): # ELECTRIC — rpu account + the CFE meter and route beside it. MEDIDOR/
if s(row[mcol]) or s(row[rcol]): # RUTA are the electric meter, not water: they are filled on exactly
svc("WATER", meter=s(row[mcol]), route=s(row[rcol]), # the 1113 rows that carry an RPU (100% overlap), the meters are CFE's
notes="secondary meter", active=flag("water1")) # alphanumeric `53MT58` form, and every legacy query that reads them is
# ELECTRIC (rpu account + extras); luz_tipo as note # a LUZ one (LUZ, FALTANTES DE LUZ, "luz ruta 325 y 329"). They used to
for rc in ("rpu", "rpu2", "rpu3"): # land on the WATER row, leaving every electric service without a meter.
if s(row[rc]): # Paired by suffix: medidor2/ruta2 belong to rpu2. luz_tipo as note.
svc("ELECTRIC", account=s(row[rc]), for rc, mc, rtc in (("rpu", "medidor", "ruta"), ("rpu2", "medidor2", "ruta2"),
("rpu3", "medidor3", "ruta3")):
if s(row[rc]) or s(row[mc]):
svc("ELECTRIC", account=s(row[rc]), meter=s(row[mc]), route=s(row[rtc]),
notes=s(row["luz_tipo"]) if rc == "rpu" else None, notes=s(row["luz_tipo"]) if rc == "rpu" else None,
active=flag("electric")) active=flag("electric"))
# GAS — the column mixes two things: an account/meter number for 160 of # GAS — the column mixes two things: an account/meter number for 160 of