Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
031b13f8c8 | ||
|
|
2cf07ed5ee | ||
|
|
36158ae761 | ||
|
|
fa38ff581e | ||
|
|
c4213aa697 |
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=
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
# through this workflow at all.
|
||||
# 4. app (api + web) the new images.
|
||||
# 5. verify ask the running API what it actually is.
|
||||
# 6. prune images reclaim the superseded api/web images. LAST, and
|
||||
# after verify: Docker will not prune an image a
|
||||
# container references, so the running stack is what
|
||||
# protects the release we just shipped.
|
||||
#
|
||||
# Rollback = re-dispatch with an older `tag`. That rolls back CODE only; the
|
||||
# schema stays forward. This is exactly why every schema change must be
|
||||
@@ -386,3 +390,24 @@ jobs:
|
||||
echo "dispatched '$WANT'; tiers report '$API_VER' (not directly comparable)"
|
||||
;;
|
||||
esac
|
||||
|
||||
# --- housekeeping ------------------------------------------------------
|
||||
# Runs LAST, and only after the verify step proved the new containers are
|
||||
# up. See deploy/scripts/prune-images.mjs: Docker refuses to prune an
|
||||
# image a container references, so "the stack is running" is what makes
|
||||
# the current images safe. Pruning earlier would have nothing holding
|
||||
# them.
|
||||
#
|
||||
# continue-on-error: reclaiming disk is not what the deploy is for. A
|
||||
# prune that fails leaves a fat host, not a broken release.
|
||||
- name: Prune unused images
|
||||
continue-on-error: true
|
||||
env:
|
||||
PORTAINER_URL: ${{ secrets.PORTAINER_URL_GALACTUS }}
|
||||
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
|
||||
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
||||
# Grace window. Keeps the previous few releases on disk so a rollback
|
||||
# dispatch is a stack swap instead of a re-pull.
|
||||
KEEP_HOURS: "168"
|
||||
NODE_TLS_REJECT_UNAUTHORIZED: "0"
|
||||
run: node deploy/scripts/prune-images.mjs
|
||||
@@ -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 }),
|
||||
|
||||
@@ -46,6 +46,20 @@ describe("renderRenewalEmail", () => {
|
||||
expect(result.html).toContain("Calle Uno 123");
|
||||
});
|
||||
|
||||
it("omits the premium when the sender did not ask for it", () => {
|
||||
// The unattended sweep quotes no amount: the premium can still be
|
||||
// re-rated at renewal, and a number a robot mailed out is one the office
|
||||
// has to walk back.
|
||||
const result = renderRenewalEmail(letter(), { includePremium: false });
|
||||
|
||||
expect(result.html).not.toContain("Prima");
|
||||
expect(result.html).not.toContain("1,392.00");
|
||||
// Everything else the customer needs is still there.
|
||||
expect(result.html).toContain("POL-123");
|
||||
expect(result.html).toContain("01/09/2026");
|
||||
expect(result.html).toContain("Ana Pérez");
|
||||
});
|
||||
|
||||
it("uses overdue wording for generation three", () => {
|
||||
const result = renderRenewalEmail(letter({ generation: 3 }));
|
||||
|
||||
|
||||
@@ -34,10 +34,23 @@ function row(label: string, value: string): string {
|
||||
return `<tr><th style="padding:8px 12px;text-align:left;background:#f4f4f4;border:1px solid #ddd">${escapeHtml(label)}</th><td style="padding:8px 12px;border:1px solid #ddd">${escapeHtml(value)}</td></tr>`;
|
||||
}
|
||||
|
||||
export function renderRenewalEmail(letter: RenewalLetterRow): {
|
||||
/**
|
||||
* Render one renewal letter.
|
||||
*
|
||||
* `includePremium` decides whether the "Prima" row appears. The unattended
|
||||
* sweep sends without it — an amount quoted by a robot, on a premium that may
|
||||
* still be re-rated at renewal, is a number the office has to walk back — and
|
||||
* every staff-triggered send (the manual barrido and the per-row "Enviar
|
||||
* aviso") keeps it, because a person chose to quote it.
|
||||
*/
|
||||
export function renderRenewalEmail(
|
||||
letter: RenewalLetterRow,
|
||||
options: { includePremium?: boolean } = {},
|
||||
): {
|
||||
subject: string;
|
||||
html: string;
|
||||
} {
|
||||
const includePremium = options.includePremium !== false;
|
||||
const expired = letter.generation === 3;
|
||||
const subject = expired
|
||||
? `Póliza vencida: ${letter.policyNumber}`
|
||||
@@ -51,7 +64,7 @@ export function renderRenewalEmail(letter: RenewalLetterRow): {
|
||||
row("Tipo de póliza", letter.policyType),
|
||||
row("Aseguradora", letter.provider),
|
||||
row("Fecha de vencimiento", displayDate(letter.policyTo)),
|
||||
row("Prima", money(premium, letter.currency)),
|
||||
...(includePremium ? [row("Prima", money(premium, letter.currency))] : []),
|
||||
row("Cliente", letter.customerName),
|
||||
row("Correo", letter.customerEmail ?? "No disponible"),
|
||||
row("Teléfono", phone),
|
||||
|
||||
@@ -183,6 +183,50 @@ describe("renewal notices write the shared notification log", () => {
|
||||
expect(prisma.renewalNotice.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("quotes the premium on a staff-triggered sweep but not the scheduled one", async () => {
|
||||
const manual = build({});
|
||||
await manual.service.sweep("user-1");
|
||||
expect(manual.record.mock.calls[0][0].bodySnapshot).toContain("Prima");
|
||||
|
||||
const automatic = build({});
|
||||
await automatic.service.scheduledSweep();
|
||||
const body = automatic.record.mock.calls[0][0].bodySnapshot;
|
||||
// The snapshot has to match the mail that actually went out, or the
|
||||
// office reads a letter the customer never received.
|
||||
expect(body).not.toContain("Prima");
|
||||
expect(body).toContain("700442181");
|
||||
});
|
||||
|
||||
it("scopes a sweep to one aseguradora without advancing the catch-up window", async () => {
|
||||
const { service, prisma, send } = build({});
|
||||
|
||||
const result = await service.sweep("user-1", { providerId: "gmx-id" });
|
||||
|
||||
expect(result.sent).toBe(1);
|
||||
expect(result.providerId).toBe("gmx-id");
|
||||
expect(prisma.policy.findMany.mock.calls[0][0].where).toMatchObject({
|
||||
insuranceProviderId: "gmx-id",
|
||||
});
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
// Only one carrier was mailed, so the days this run covered are still owed
|
||||
// to every other carrier: advancing `lastSuccessfulAt` would move them out
|
||||
// of tomorrow's window and they would never be sent.
|
||||
const release = prisma.scheduledJobState.update.mock.calls.at(-1)?.[0];
|
||||
expect(release.data.lastSuccessfulAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("advances the catch-up window on a clean unfiltered sweep", async () => {
|
||||
const { service, prisma } = build({});
|
||||
|
||||
await service.sweep("user-1");
|
||||
|
||||
expect(prisma.policy.findMany.mock.calls[0][0].where).not.toHaveProperty(
|
||||
"insuranceProviderId",
|
||||
);
|
||||
const release = prisma.scheduledJobState.update.mock.calls.at(-1)?.[0];
|
||||
expect(release.data.lastSuccessfulAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("does not fail a delivered notice when the log write throws", async () => {
|
||||
const { service, record } = build({});
|
||||
record.mockRejectedValue(new Error("log table gone"));
|
||||
|
||||
@@ -25,6 +25,13 @@ class RenewalFlagsDto {
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
class SweepRenewalsDto extends RenewalFlagsDto {
|
||||
/** Sweep one aseguradora only (GMX, ANA, …). Omitted = todas. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
providerId?: string;
|
||||
}
|
||||
|
||||
class SendRenewalDto extends RenewalFlagsDto {
|
||||
@IsString()
|
||||
policyId!: string;
|
||||
@@ -43,17 +50,22 @@ export class RenewalsController {
|
||||
constructor(private readonly renewals: RenewalsService) {}
|
||||
|
||||
@Get("pending")
|
||||
pending(@Query("days") days?: string) {
|
||||
pending(
|
||||
@Query("days") days?: string,
|
||||
@Query("providerId") providerId?: string,
|
||||
) {
|
||||
return this.renewals.pending(
|
||||
Math.min(365, Math.max(1, Number(days) || 30)),
|
||||
providerId?.trim() || undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("sweep")
|
||||
@RequireAbility("renewal:send")
|
||||
sweep(@Body() dto: RenewalFlagsDto, @Req() req: Request) {
|
||||
sweep(@Body() dto: SweepRenewalsDto, @Req() req: Request) {
|
||||
return this.renewals.sweep((req.user as { id: string }).id, {
|
||||
debug: dto?.debug,
|
||||
providerId: dto?.providerId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -84,10 +84,14 @@ export class RenewalsService implements OnModuleInit {
|
||||
|
||||
/** The unattended run always sends for real: `debug` is a per-click switch
|
||||
* in the UI, never persisted, so the schedule cannot inherit a forgotten
|
||||
* test toggle and silently stop mailing customers. */
|
||||
* test toggle and silently stop mailing customers.
|
||||
*
|
||||
* `automatic` is what drops the premium from the letter — see
|
||||
* `renderRenewalEmail`. It is set here and nowhere else, so every sweep a
|
||||
* person clicks still quotes the amount. */
|
||||
async scheduledSweep(): Promise<void> {
|
||||
try {
|
||||
await this.sweep();
|
||||
await this.sweep(undefined, { automatic: true });
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Falló el barrido de renovaciones: ${(error as Error).message}`,
|
||||
@@ -95,7 +99,10 @@ export class RenewalsService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
async pending(days = 30) {
|
||||
/** @param providerId Restrict to one aseguradora. The list has to agree
|
||||
* with what a sweep would send, or the carrier-scoped barrido shows rows it
|
||||
* will not mail. */
|
||||
async pending(days = 30, providerId?: string) {
|
||||
const today = dateInTimeZone(new Date());
|
||||
const state = await this.prisma.scheduledJobState.findUnique({
|
||||
where: { name: JOB_NAME },
|
||||
@@ -111,6 +118,7 @@ export class RenewalsService implements OnModuleInit {
|
||||
item,
|
||||
today,
|
||||
state?.lastSuccessfulAt ?? null,
|
||||
providerId,
|
||||
),
|
||||
})),
|
||||
);
|
||||
@@ -122,8 +130,20 @@ export class RenewalsService implements OnModuleInit {
|
||||
);
|
||||
}
|
||||
|
||||
async sweep(userId?: string, flags: { debug?: boolean } = {}) {
|
||||
/**
|
||||
* @param flags.providerId Sweep only one aseguradora. GMX and ANA are worked
|
||||
* as separate batches by the office, so mixing them in one run is what this
|
||||
* exists to prevent.
|
||||
* @param flags.automatic Set only by the scheduler. Drops the premium from
|
||||
* the letter.
|
||||
*/
|
||||
async sweep(
|
||||
userId?: string,
|
||||
flags: { debug?: boolean; providerId?: string; automatic?: boolean } = {},
|
||||
) {
|
||||
const debug = !!flags.debug;
|
||||
const providerId = flags.providerId?.trim() || undefined;
|
||||
const includePremium = !flags.automatic;
|
||||
const now = new Date();
|
||||
const state = await this.acquireLock(now);
|
||||
|
||||
@@ -145,6 +165,7 @@ export class RenewalsService implements OnModuleInit {
|
||||
cadence,
|
||||
today,
|
||||
state.lastSuccessfulAt,
|
||||
providerId,
|
||||
);
|
||||
eligible += policies.length;
|
||||
|
||||
@@ -157,13 +178,17 @@ export class RenewalsService implements OnModuleInit {
|
||||
await this.recordLog(policy, cadence.generation, "", {
|
||||
status: "SKIPPED_NO_EMAIL",
|
||||
debug,
|
||||
includePremium,
|
||||
});
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.deliver(policy, cadence.generation, to, userId, debug);
|
||||
await this.deliver(policy, cadence.generation, to, userId, {
|
||||
debug,
|
||||
includePremium,
|
||||
});
|
||||
sent++;
|
||||
} catch (error) {
|
||||
failures.push({
|
||||
@@ -182,11 +207,18 @@ export class RenewalsService implements OnModuleInit {
|
||||
failed: failures.length,
|
||||
failures,
|
||||
debug,
|
||||
providerId: providerId ?? null,
|
||||
};
|
||||
// A debug run must not advance `lastSuccessfulAt`: it wrote no
|
||||
// RenewalNotice rows, so the days it "covered" are still owed, and
|
||||
// narrowing tomorrow's window back to a single day would drop them.
|
||||
await this.releaseLock(!debug && failures.length === 0 ? now : null);
|
||||
//
|
||||
// A carrier-scoped run must not advance it either, for the same reason
|
||||
// one step out: it looked at the whole window but only mailed one
|
||||
// aseguradora, so every other carrier's letters in those days would fall
|
||||
// outside tomorrow's window and never be sent at all.
|
||||
const complete = !debug && !providerId && failures.length === 0;
|
||||
await this.releaseLock(complete ? now : null);
|
||||
void this.audit.log(userId, "renewalNotice.sweep", result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
@@ -233,12 +265,14 @@ export class RenewalsService implements OnModuleInit {
|
||||
throw new BadRequestException("El cliente no tiene correo registrado.");
|
||||
}
|
||||
|
||||
// A person clicked this, so the premium stays in the letter — only the
|
||||
// scheduler's unattended run omits it.
|
||||
const { sentAt, providerMessageId, addressedTo } = await this.deliver(
|
||||
policy,
|
||||
generation,
|
||||
to,
|
||||
userId,
|
||||
debug,
|
||||
{ debug, includePremium: true },
|
||||
);
|
||||
return {
|
||||
policyId,
|
||||
@@ -270,10 +304,12 @@ export class RenewalsService implements OnModuleInit {
|
||||
generation: number,
|
||||
to: string,
|
||||
userId?: string,
|
||||
debug = false,
|
||||
options: { debug?: boolean; includePremium?: boolean } = {},
|
||||
) {
|
||||
const debug = !!options.debug;
|
||||
const includePremium = options.includePremium !== false;
|
||||
const letter = toRenewalLetterRow(policy, generation);
|
||||
const message = renderRenewalEmail(letter);
|
||||
const message = renderRenewalEmail(letter, { includePremium });
|
||||
const addressedTo = debug ? DEBUG_RECIPIENT : to;
|
||||
|
||||
let result: Awaited<ReturnType<MailService["send"]>>;
|
||||
@@ -291,6 +327,7 @@ export class RenewalsService implements OnModuleInit {
|
||||
status: "FAILED",
|
||||
error: detail,
|
||||
debug,
|
||||
includePremium,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
@@ -324,6 +361,7 @@ export class RenewalsService implements OnModuleInit {
|
||||
providerResponse: result.response || undefined,
|
||||
sendDate: sentAt,
|
||||
debug,
|
||||
includePremium,
|
||||
});
|
||||
void this.audit.log(userId, "renewalNotice.send", {
|
||||
policyId: policy.id,
|
||||
@@ -355,10 +393,15 @@ export class RenewalsService implements OnModuleInit {
|
||||
error?: string;
|
||||
sendDate?: Date;
|
||||
debug?: boolean;
|
||||
/** Must match what `deliver` rendered, or `bodySnapshot` shows the
|
||||
* office a letter the customer never received. */
|
||||
includePremium?: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
const letter = toRenewalLetterRow(policy, generation);
|
||||
const message = renderRenewalEmail(letter);
|
||||
const message = renderRenewalEmail(letter, {
|
||||
includePremium: outcome.includePremium,
|
||||
});
|
||||
try {
|
||||
await this.notificationLog.record({
|
||||
notificationType: "RENEWAL_NOTICE",
|
||||
@@ -391,11 +434,13 @@ export class RenewalsService implements OnModuleInit {
|
||||
cadence: (typeof RENEWAL_CADENCE)[number],
|
||||
today: Date,
|
||||
lastSuccessfulAt: Date | null,
|
||||
providerId?: string,
|
||||
) {
|
||||
const window = renewalWindow(today, cadence.offsetDays, lastSuccessfulAt);
|
||||
return this.prisma.policy.findMany({
|
||||
where: {
|
||||
archivedAt: null,
|
||||
...(providerId && { insuranceProviderId: providerId }),
|
||||
policyTo: { gte: window.from, lte: window.to },
|
||||
customer: {
|
||||
archivedAt: null,
|
||||
|
||||
@@ -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
|
||||
WHERE t.customerId = c.id AND t.voidedAt IS NULL
|
||||
AND t.amount < 0
|
||||
AND t.transactionDate >= ${cutoff}) AS lastCharge,
|
||||
(SELECT SUM(t.amount) FROM transactions t
|
||||
WHERE t.customerId = c.id AND t.voidedAt IS NULL
|
||||
AND t.currency = ${currency}) AS balance
|
||||
JOIN type_transactions tt ON tt.id = t.typeId
|
||||
WHERE t.customerId = c.id AND t.voidedAt IS NULL
|
||||
AND t.outstanding = 0 AND tt.nameEn = ${concept}) AS lastPaid
|
||||
FROM property_services ps
|
||||
JOIN properties pr ON pr.id = ps.propertyId AND pr.archivedAt IS NULL
|
||||
JOIN customers c ON c.id = pr.customerId AND c.archivedAt IS NULL
|
||||
WHERE ps.kind = ${kind} AND ps.active = 1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM transactions t
|
||||
WHERE t.customerId = c.id AND t.voidedAt IS NULL
|
||||
AND t.amount < 0 AND t.transactionDate >= ${cutoff}
|
||||
)
|
||||
ORDER BY c.nameMissing ASC, c.name ASC, pr.addressLine1 ASC
|
||||
WHERE ps.kind = ${kind} AND ps.active = 1 ${inactive}
|
||||
AND NOT EXISTS (${conceptRow(0)})
|
||||
GROUP BY c.id, c.name, c.nameMissing
|
||||
ORDER BY c.nameMissing ASC, c.name ASC
|
||||
`;
|
||||
|
||||
let totalBalance = new Prisma.Decimal(0);
|
||||
const nums = await legacyNumbers(prisma, rows.map((r) => r.customerId));
|
||||
let unfunded = 0;
|
||||
const out = rows.map((r) => {
|
||||
const b = r.balance ?? new Prisma.Decimal(0);
|
||||
totalBalance = totalBalance.plus(b);
|
||||
if (Number(r.unfunded)) unfunded++;
|
||||
return {
|
||||
customerId: r.customerId.slice(0, 8),
|
||||
customerName: nameOf({
|
||||
name: r.customerName,
|
||||
nameMissing: r.nameMissing,
|
||||
}),
|
||||
propertyAddress: r.propertyAddress ?? "—",
|
||||
accountNumber: r.accountNumber ?? "—",
|
||||
lastCharge: r.lastCharge
|
||||
? r.lastCharge.toISOString().slice(0, 10)
|
||||
: "—",
|
||||
balance: b.toFixed(2),
|
||||
numid: nums.numid(r.customerId),
|
||||
customerName: nameOf({ name: r.customerName, nameMissing: r.nameMissing }),
|
||||
propertyAddress: r.addresses ?? "—",
|
||||
accountNumber: r.accounts ?? "—",
|
||||
status: Number(r.unfunded) ? "Sin fondos" : "Sin pago",
|
||||
lastPaid: isoDay(r.lastPaid),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
rows: out,
|
||||
totals: {
|
||||
rows: out.length,
|
||||
balance: totalBalance.toFixed(2),
|
||||
currency,
|
||||
clientes: out.length,
|
||||
"sin pago": out.length - unfunded,
|
||||
"sin fondos": unfunded,
|
||||
},
|
||||
subtitle: `Servicio: ${
|
||||
kind === "WATER" ? "Agua" : kind === "ELECTRICITY" ? "Luz" : "Teléfono"
|
||||
} · ${days} días · ${out.length} propiedades sin cargo reciente`,
|
||||
subtitle: `Servicio: ${serviceLabel(kind)} · ${isoDay(from)} a ${isoDay(to)} · ${out.length} clientes sin pago registrado`,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* FALTANTES DE (AGUA / LUZ / TEL).
|
||||
* Data-quality report: properties enrolled in a service that are missing
|
||||
* the key identifier the legacy system required (account/meter/route).
|
||||
* Different `faltante` per service kind in the legacy because the
|
||||
* service's identifier fields differ; here we flag any of the three
|
||||
* common identifiers being blank.
|
||||
* What each service must have on file to be paid and matched — the columns
|
||||
* legacy's FALTANTES DE AGUA/LUZ/TEL read (DATMEX.AGUA; MEDIDOR + RPU;
|
||||
* TELEFONO), in the field `scopedRefField` stores each one in. The first port
|
||||
* demanded account AND meter AND route of every kind; water and phone have no
|
||||
* route, so every one of them was flagged.
|
||||
*/
|
||||
const REQUIRED_FIELDS: Partial<
|
||||
Record<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,82 +476,65 @@ const faltantes: ReportDef = {
|
||||
key: "serviceKind",
|
||||
label: "Servicio",
|
||||
kind: "select",
|
||||
options: [
|
||||
{ value: "WATER", label: "Agua" },
|
||||
{ value: "ELECTRICITY", label: "Luz" },
|
||||
{ value: "PHONE", label: "Teléfono" },
|
||||
],
|
||||
options: SERVICE_OPTIONS.filter((o) => REQUIRED_FIELDS[o.value]),
|
||||
defaultValue: "WATER",
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
{ key: "customerId", label: "Cliente #", type: "text" },
|
||||
NUMID_COLUMN,
|
||||
{ key: "customerName", label: "Cliente", type: "text" },
|
||||
{ key: "propertyAddress", label: "Dirección", type: "text" },
|
||||
{ key: "missing", label: "Faltante", type: "text" },
|
||||
{ key: "dueDay", label: "Día de vencimiento", type: "text" },
|
||||
],
|
||||
async run(prisma, p) {
|
||||
const kind = (p.serviceKind ?? "WATER") as
|
||||
| "WATER"
|
||||
| "ELECTRICITY"
|
||||
| "PHONE";
|
||||
const kind = (
|
||||
p.serviceKind && REQUIRED_FIELDS[p.serviceKind as ServiceKind]
|
||||
? p.serviceKind
|
||||
: "WATER"
|
||||
) as ServiceKind;
|
||||
const required = REQUIRED_FIELDS[kind]!;
|
||||
|
||||
// Column names come from the constant above, never from the request.
|
||||
const blank = (field: string) =>
|
||||
Prisma.sql`(ps.${Prisma.raw(field)} IS NULL OR TRIM(ps.${Prisma.raw(field)}) = '')`;
|
||||
|
||||
// A row per (property, missing field). The "missing" string describes
|
||||
// what's blank so the report is self-explanatory when printed.
|
||||
const rows = await prisma.$queryRaw<
|
||||
Array<{
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
nameMissing: boolean;
|
||||
propertyId: string;
|
||||
propertyAddress: string | null;
|
||||
dueDay: string | null;
|
||||
missing: string;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
c.id AS customerId,
|
||||
c.name AS customerName,
|
||||
c.nameMissing AS nameMissing,
|
||||
pr.id AS propertyId,
|
||||
c.id AS customerId,
|
||||
c.name AS customerName,
|
||||
c.nameMissing AS nameMissing,
|
||||
pr.addressLine1 AS propertyAddress,
|
||||
ps.dueDay AS dueDay,
|
||||
CASE
|
||||
WHEN ps.accountNumber IS NULL OR ps.accountNumber = '' THEN 'Sin número de cuenta'
|
||||
WHEN ps.meterNumber IS NULL OR ps.meterNumber = '' THEN 'Sin número de medidor'
|
||||
WHEN ps.route IS NULL OR ps.route = '' THEN 'Sin ruta'
|
||||
ELSE ''
|
||||
END AS missing
|
||||
CONCAT_WS(', ', ${Prisma.join(
|
||||
required.map(([field, label]) => Prisma.sql`IF(${blank(field)}, ${label}, NULL)`),
|
||||
)}) AS missing
|
||||
FROM property_services ps
|
||||
JOIN properties pr ON pr.id = ps.propertyId AND pr.archivedAt IS NULL
|
||||
JOIN customers c ON c.id = pr.customerId AND c.archivedAt IS NULL
|
||||
WHERE ps.kind = ${kind} AND ps.active = 1
|
||||
AND (
|
||||
ps.accountNumber IS NULL OR ps.accountNumber = ''
|
||||
OR ps.meterNumber IS NULL OR ps.meterNumber = ''
|
||||
OR ps.route IS NULL OR ps.route = ''
|
||||
)
|
||||
WHERE ps.kind = ${kind} AND ps.active = 1 AND c.status = 1
|
||||
AND (${Prisma.join(required.map(([field]) => blank(field)), " OR ")})
|
||||
ORDER BY c.nameMissing ASC, c.name ASC, pr.addressLine1 ASC
|
||||
`;
|
||||
|
||||
const nums = await legacyNumbers(prisma, rows.map((r) => r.customerId));
|
||||
const out = rows.map((r) => ({
|
||||
customerId: r.customerId.slice(0, 8),
|
||||
customerName: nameOf({
|
||||
name: r.customerName,
|
||||
nameMissing: r.nameMissing,
|
||||
}),
|
||||
numid: nums.numid(r.customerId),
|
||||
customerName: nameOf({ name: r.customerName, nameMissing: r.nameMissing }),
|
||||
propertyAddress: r.propertyAddress ?? "—",
|
||||
missing: r.missing,
|
||||
dueDay: r.dueDay ?? "—",
|
||||
}));
|
||||
|
||||
return {
|
||||
rows: out,
|
||||
totals: { rows: out.length },
|
||||
subtitle: `Servicio: ${
|
||||
kind === "WATER" ? "Agua" : kind === "ELECTRICITY" ? "Luz" : "Teléfono"
|
||||
} · ${out.length} propiedades con datos faltantes`,
|
||||
subtitle: `Servicio: ${serviceLabel(kind)} · ${out.length} servicios con datos faltantes`,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -422,6 +571,7 @@ const reporteDeEfectivo: ReportDef = {
|
||||
],
|
||||
columns: [
|
||||
{ key: "date", label: "Fecha", type: "date" },
|
||||
NUMID_COLUMN,
|
||||
{ key: "customerName", label: "Cliente", type: "text" },
|
||||
{ key: "concept", label: "Concepto", type: "text" },
|
||||
{ key: "source", label: "Origen", type: "text" },
|
||||
@@ -436,6 +586,8 @@ const reporteDeEfectivo: ReportDef = {
|
||||
NOT_VOIDED,
|
||||
{ amount: { gt: 0 } },
|
||||
{ currency },
|
||||
// A credit opening balance has no check number but is not cash.
|
||||
{ OR: [{ typeId: null }, { type: { nameEn: { not: BALANCE_FORWARD_TYPE } } }] },
|
||||
{
|
||||
OR: [
|
||||
{ legacySourceTable: { in: ["EFECTIVO", "EFECTIVO_BACKUP"] } },
|
||||
@@ -468,15 +620,17 @@ const reporteDeEfectivo: ReportDef = {
|
||||
message: true,
|
||||
legacySourceTable: true,
|
||||
type: { select: { nameEs: true, nameEn: true } },
|
||||
customer: { select: { name: true, nameMissing: true } },
|
||||
customer: { select: { id: true, name: true, nameMissing: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const nums = await legacyNumbers(prisma, rows.map((r) => r.customer.id));
|
||||
let total = new Prisma.Decimal(0);
|
||||
const out = rows.map((r) => {
|
||||
total = total.plus(r.amount);
|
||||
return {
|
||||
date: r.transactionDate.toISOString().slice(0, 10),
|
||||
numid: nums.numid(r.customer.id),
|
||||
customerName: nameOf(r.customer),
|
||||
concept: r.message ?? r.type?.nameEs ?? r.type?.nameEn ?? "—",
|
||||
source: r.legacySourceTable ?? "—",
|
||||
@@ -541,6 +695,8 @@ const vigente: ReportDef = {
|
||||
],
|
||||
columns: [
|
||||
{ key: "policyNumber", label: "Póliza", type: "text" },
|
||||
NUMID_COLUMN,
|
||||
INSURANCE_NO_COLUMN,
|
||||
{ key: "customerName", label: "Cliente", type: "text" },
|
||||
{ key: "provider", label: "Aseguradora", type: "text" },
|
||||
{ key: "agent", label: "Agente", type: "text" },
|
||||
@@ -571,11 +727,12 @@ const vigente: ReportDef = {
|
||||
policyTo: true,
|
||||
netPremium: true,
|
||||
agentName: true,
|
||||
customer: { select: { name: true, nameMissing: true } },
|
||||
customer: { select: { id: true, name: true, nameMissing: true } },
|
||||
insuranceProvider: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const nums = await legacyNumbers(prisma, rows.map((r) => r.customer.id));
|
||||
let totalPremium = new Prisma.Decimal(0);
|
||||
const out = rows.map((r) => {
|
||||
const daysTo = r.policyTo
|
||||
@@ -586,6 +743,8 @@ const vigente: ReportDef = {
|
||||
if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium);
|
||||
return {
|
||||
policyNumber: r.policyNumber,
|
||||
numid: nums.numid(r.customer.id),
|
||||
insuranceNo: nums.insurance(r.customer.id),
|
||||
customerName: nameOf(r.customer),
|
||||
provider: r.insuranceProvider?.name ?? "—",
|
||||
agent: r.agentName ?? "—",
|
||||
@@ -689,6 +848,8 @@ const avisoRenovacion: ReportDef = {
|
||||
// ReportRunner.tsx) — same trade-off edoCuentaDatos makes for "statement".
|
||||
columns: [
|
||||
{ key: "policyNumber", label: "Póliza", type: "text" },
|
||||
NUMID_COLUMN,
|
||||
INSURANCE_NO_COLUMN,
|
||||
{ key: "customerName", label: "Cliente", type: "text" },
|
||||
{ key: "provider", label: "Aseguradora", type: "text" },
|
||||
{ key: "policyTo", label: "Vence", type: "date" },
|
||||
@@ -720,11 +881,16 @@ const avisoRenovacion: ReportDef = {
|
||||
select: renewalLetterSelect(generation),
|
||||
});
|
||||
|
||||
const nums = await legacyNumbers(prisma, rows.map((r) => r.customer.id));
|
||||
let totalPremium = new Prisma.Decimal(0);
|
||||
let sentCount = 0;
|
||||
const out = rows.map((r) => {
|
||||
if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium);
|
||||
const letter = toRenewalLetterRow(r, generation);
|
||||
const letter = {
|
||||
...toRenewalLetterRow(r, generation),
|
||||
numid: nums.numid(r.customer.id),
|
||||
insuranceNo: nums.insurance(r.customer.id),
|
||||
};
|
||||
if (letter.sentAt) sentCount++;
|
||||
return letter;
|
||||
});
|
||||
@@ -802,6 +968,7 @@ const edoCuentaDatos: ReportDef = {
|
||||
},
|
||||
});
|
||||
if (!customer) return { rows: [], subtitle: "Cliente no encontrado" };
|
||||
const numid = (await legacyNumbers(prisma, [customer.id])).numid(customer.id);
|
||||
|
||||
// The source-table exclusion, the balance floor and the year scope below
|
||||
// are BillingService.statement's, because this report and
|
||||
@@ -966,6 +1133,7 @@ const edoCuentaDatos: ReportDef = {
|
||||
{
|
||||
__kind: "header",
|
||||
name: nameOf(customer),
|
||||
numid,
|
||||
address: customer.addressLine1 ?? "",
|
||||
city: [customer.city, customer.state].filter(Boolean).join(", "),
|
||||
phone: customer.phone ?? "",
|
||||
@@ -983,7 +1151,7 @@ const edoCuentaDatos: ReportDef = {
|
||||
...carried,
|
||||
...movements,
|
||||
],
|
||||
subtitle: `${nameOf(customer)} · ${year} · ${visible.length} movimientos`,
|
||||
subtitle: `NUMid ${numid} · ${nameOf(customer)} · ${year} · ${visible.length} movimientos`,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1021,6 +1189,7 @@ const chequeCount: ReportDef = {
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
NUMID_COLUMN,
|
||||
{ key: "customerName", label: "Cliente", type: "text" },
|
||||
{ key: "reference", label: "Referencia", type: "text" },
|
||||
{ key: "period", label: "Periodo", type: "text" },
|
||||
@@ -1050,7 +1219,7 @@ const chequeCount: ReportDef = {
|
||||
period: true,
|
||||
outstanding: true,
|
||||
type: { select: { nameEn: true, nameEs: true } },
|
||||
customer: { select: { name: true, nameMissing: true } },
|
||||
customer: { select: { id: true, name: true, nameMissing: true } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1076,8 +1245,10 @@ const chequeCount: ReportDef = {
|
||||
}
|
||||
if (outstandingCount) totalsOut["sin fondos"] = outstandingCount;
|
||||
|
||||
const nums = await legacyNumbers(prisma, rows.map((r) => r.customer.id));
|
||||
return {
|
||||
rows: rows.map((r) => ({
|
||||
numid: nums.numid(r.customer.id),
|
||||
customerName: nameOf(r.customer),
|
||||
reference: r.reference ?? "—",
|
||||
period: r.period ?? "—",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4,7 +4,13 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import { formatDate, formatMoney } from "@/lib/labels";
|
||||
import { NotificationLogPanel } from "@/components/NotificationLogPanel";
|
||||
import { apiFetch, POLIZAS_LOG_SCOPE, type NotificationFlags } from "@/lib/api";
|
||||
import {
|
||||
apiFetch,
|
||||
getLookups,
|
||||
POLIZAS_LOG_SCOPE,
|
||||
type NotificationFlags,
|
||||
} from "@/lib/api";
|
||||
import type { ProviderRow } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Renewal notices — the "Pólizas" half of /notificaciones. Shows which
|
||||
@@ -22,6 +28,12 @@ import { apiFetch, POLIZAS_LOG_SCOPE, type NotificationFlags } from "@/lib/api";
|
||||
* thing here as it does for servicios: the mail is diverted to the override
|
||||
* inbox. It additionally does NOT mark the notice as sent, so a test send
|
||||
* leaves the row exactly where it was — pending.
|
||||
*
|
||||
* The barrido manual is scoped by aseguradora because the office works GMX and
|
||||
* ANA as separate batches. The selection filters the pending list too, so what
|
||||
* is on screen is exactly what "Ejecutar barrido" will mail. A carrier-scoped
|
||||
* run deliberately does not advance the sweep's catch-up window — it only
|
||||
* covered one carrier — so the other carriers' letters stay pending.
|
||||
*/
|
||||
|
||||
export interface RenewalLetter {
|
||||
@@ -46,6 +58,8 @@ export interface RenewalSweepResult {
|
||||
failed: number;
|
||||
failures: { policyId: string; generation: number; error: string }[];
|
||||
debug: boolean;
|
||||
/** Echoed back so the confirmation says which carrier actually ran. */
|
||||
providerId: string | null;
|
||||
}
|
||||
|
||||
export interface RenewalSendResult {
|
||||
@@ -68,6 +82,10 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
|
||||
const allowed = useCan("renewal:send");
|
||||
const debug = !!flags.debug;
|
||||
const [days, setDays] = useState(30);
|
||||
/** "" = ambas/todas. Holds an InsuranceProvider id, never a name — carriers
|
||||
* are renamed in the lookups screen and the filter must survive that. */
|
||||
const [providerId, setProviderId] = useState("");
|
||||
const [providers, setProviders] = useState<ProviderRow[]>([]);
|
||||
const [pending, setPending] = useState<RenewalLetter[] | null>(null);
|
||||
const [pendingError, setPendingError] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
@@ -81,8 +99,10 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
|
||||
const refresh = useCallback(async () => {
|
||||
setPendingError(null);
|
||||
try {
|
||||
const params = new URLSearchParams({ days: String(days) });
|
||||
if (providerId) params.set("providerId", providerId);
|
||||
const data = await apiFetch<RenewalLetter[]>(
|
||||
`/renewals/pending?days=${days}`,
|
||||
`/renewals/pending?${params.toString()}`,
|
||||
);
|
||||
setPending(data);
|
||||
} catch (e) {
|
||||
@@ -91,18 +111,44 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
|
||||
);
|
||||
setPending([]);
|
||||
}
|
||||
}, [days]);
|
||||
}, [days, providerId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (allowed) refresh();
|
||||
}, [allowed, refresh]);
|
||||
|
||||
// Carriers come from the same lookups the policy form uses, so a new
|
||||
// aseguradora shows up here without a code change.
|
||||
useEffect(() => {
|
||||
if (!allowed) return;
|
||||
let cancelled = false;
|
||||
getLookups()
|
||||
.then((data) => {
|
||||
if (!cancelled) setProviders(data.providers);
|
||||
})
|
||||
.catch(() => {
|
||||
// A failed lookup only costs the filter; the unfiltered sweep still
|
||||
// works, so this must not blank the screen.
|
||||
if (!cancelled) setProviders([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [allowed]);
|
||||
|
||||
const providerLabel =
|
||||
providers.find((item) => item.id === providerId)?.name ?? "todas las compañías";
|
||||
|
||||
async function handleSweep() {
|
||||
// Only worth confirming when debug is off — that is the case where real
|
||||
// customers receive mail. Mirrors "Ejecutar todos" on the servicios tab.
|
||||
// The carrier is named in the prompt: running GMX when ANA was meant is
|
||||
// exactly the mistake this filter exists to prevent, and it is not
|
||||
// reversible once the mail is out.
|
||||
if (!debug) {
|
||||
const ok = window.confirm(
|
||||
"debug está desactivado: los avisos irán a los correos reales de los clientes. ¿Ejecutar el barrido?",
|
||||
`debug está desactivado: los avisos irán a los correos reales de los clientes. ` +
|
||||
`¿Ejecutar el barrido de ${providerLabel}?`,
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
@@ -112,10 +158,11 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
|
||||
try {
|
||||
const result = await apiFetch<RenewalSweepResult>("/renewals/sweep", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ debug }),
|
||||
body: JSON.stringify({ debug, providerId: providerId || undefined }),
|
||||
});
|
||||
setNotice(
|
||||
`Enviados ${result.sent} avisos (${result.failed} con error).` +
|
||||
`Enviados ${result.sent} avisos de ${providerLabel} ` +
|
||||
`(${result.failed} con error).` +
|
||||
(result.debug
|
||||
? " Modo debug: fueron al buzón de pruebas y siguen pendientes."
|
||||
: ""),
|
||||
@@ -199,7 +246,9 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
|
||||
<h2 className="section-title">Barrido manual</h2>
|
||||
<p className="muted small" style={{ marginTop: 4 }}>
|
||||
Usa la fecha actual del servidor como referencia para seleccionar
|
||||
avisos vencidos a 30 y 15 días, y vencidos hace 7 días.
|
||||
avisos vencidos a 30 y 15 días, y vencidos hace 7 días. La
|
||||
compañía elegida filtra también la lista de abajo: se envía
|
||||
exactamente lo que está en pantalla.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -211,18 +260,38 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
|
||||
{sweeping ? "Enviando…" : "Ejecutar barrido"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="field" style={{ maxWidth: 180, marginTop: 12, marginBottom: 0 }}>
|
||||
<span className="field-label">Ventana (días)</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={days}
|
||||
onChange={(e) =>
|
||||
setDays(Math.min(365, Math.max(1, Number(e.target.value) || 30)))
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="row-actions"
|
||||
style={{ marginTop: 12, alignItems: "flex-end", gap: 16 }}
|
||||
>
|
||||
<div className="field" style={{ maxWidth: 180, marginBottom: 0 }}>
|
||||
<span className="field-label">Ventana (días)</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={days}
|
||||
onChange={(e) =>
|
||||
setDays(Math.min(365, Math.max(1, Number(e.target.value) || 30)))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="field" style={{ maxWidth: 260, marginBottom: 0 }}>
|
||||
<span className="field-label">Compañía</span>
|
||||
<select
|
||||
className="input"
|
||||
value={providerId}
|
||||
onChange={(e) => setProviderId(e.target.value)}
|
||||
>
|
||||
<option value="">Todas las compañías</option>
|
||||
{providers.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Delete unused images from the target host after a successful deploy.
|
||||
*
|
||||
* This exists because nothing else reclaims them. Every build.yml run pushes a
|
||||
* new api + web image, every deploy pulls both onto the host, and the previous
|
||||
* pair is left behind untagged-but-present forever. On galactus that reached
|
||||
* 63 images / 83.85GB (79.26GB of it unused) and filled the 98GB root
|
||||
* filesystem to 100% on 2026-08-20 — which surfaced as "re-import is broken",
|
||||
* because the Operaciones REIMPORT job leads with a mysqldump that could no
|
||||
* longer write its safety backup.
|
||||
*
|
||||
* Two things keep this from eating a live deployment:
|
||||
*
|
||||
* - Docker never prunes an image that a container references, running or
|
||||
* stopped. The five images the prod stacks use are therefore untouchable
|
||||
* for as long as their containers exist.
|
||||
* - `until` gives a grace window on top of that, so a rollback target stays
|
||||
* on disk instead of forcing a re-pull from the registry.
|
||||
*
|
||||
* TRAP: `until` filters on the image's CREATION time, not when the host pulled
|
||||
* it. Rolling back to an old tag pulls an image that is already older than the
|
||||
* window, so the grace period does NOT protect it — the running-container rule
|
||||
* is what does. That is why this step must run AFTER the app stack is deployed
|
||||
* and verified, never before.
|
||||
*
|
||||
* Required env:
|
||||
* PORTAINER_URL, PORTAINER_API_KEY, PORTAINER_ENDPOINT_ID
|
||||
* Optional:
|
||||
* KEEP_HOURS grace window in hours (default 168 = 7 days)
|
||||
*
|
||||
* TLS: Portainer here is self-signed; the caller sets
|
||||
* NODE_TLS_REJECT_UNAUTHORIZED=0 for this step.
|
||||
*/
|
||||
|
||||
function required(name) {
|
||||
const v = process.env[name];
|
||||
if (!v) {
|
||||
console.error(`missing required env: ${name}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
const PORTAINER_URL = required("PORTAINER_URL").replace(/\/+$/, "");
|
||||
const API_KEY = required("PORTAINER_API_KEY");
|
||||
const ENDPOINT_ID = required("PORTAINER_ENDPOINT_ID");
|
||||
const KEEP_HOURS = process.env.KEEP_HOURS || "168";
|
||||
|
||||
const DOCKER = `${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker`;
|
||||
|
||||
// `dangling: ["false"]` is what makes this `docker image prune -a` rather than
|
||||
// the default, which only collects untagged layers. The tagged-but-superseded
|
||||
// api/web images are the whole problem, and the default filter walks straight
|
||||
// past them.
|
||||
const FILTERS = JSON.stringify({
|
||||
dangling: ["false"],
|
||||
until: [`${KEEP_HOURS}h`],
|
||||
});
|
||||
|
||||
function human(bytes) {
|
||||
if (!bytes) return "0B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let i = 0;
|
||||
let n = bytes;
|
||||
while (n >= 1024 && i < units.length - 1) {
|
||||
n /= 1024;
|
||||
i += 1;
|
||||
}
|
||||
return `${n.toFixed(i === 0 ? 0 : 2)}${units[i]}`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const url = `${DOCKER}/images/prune?filters=${encodeURIComponent(FILTERS)}`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "X-API-Key": API_KEY },
|
||||
});
|
||||
const body = await res.text();
|
||||
if (!res.ok) {
|
||||
throw new Error(`prune -> HTTP ${res.status} ${body.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
let report;
|
||||
try {
|
||||
report = JSON.parse(body);
|
||||
} catch {
|
||||
throw new Error(`prune returned non-JSON: ${body.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
const deleted = report.ImagesDeleted ?? [];
|
||||
const reclaimed = report.SpaceReclaimed ?? 0;
|
||||
console.log(
|
||||
`pruned images older than ${KEEP_HOURS}h and unused by any container`,
|
||||
);
|
||||
console.log(` entries removed : ${deleted.length}`);
|
||||
console.log(` space reclaimed : ${human(reclaimed)}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
// Non-fatal by contract: the step that calls this sets continue-on-error, so
|
||||
// housekeeping never turns a good deploy red. Exit non-zero anyway so the
|
||||
// failure is visible in the run rather than swallowed.
|
||||
console.error(`::warning::image prune FAILED: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -164,18 +164,21 @@ def main():
|
||||
services.append((str(uuid.uuid4()), pid, kind, account, meter, route,
|
||||
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):
|
||||
svc("WATER", account=s(row["agua"]), meter=s(row["medidor"]),
|
||||
route=s(row["ruta"]), due=s(row["diagua"]), active=flag("water1"))
|
||||
for mcol, rcol in (("medidor2", "ruta2"), ("medidor3", "ruta3")):
|
||||
if s(row[mcol]) or s(row[rcol]):
|
||||
svc("WATER", meter=s(row[mcol]), route=s(row[rcol]),
|
||||
notes="secondary meter", active=flag("water1"))
|
||||
# ELECTRIC (rpu account + extras); luz_tipo as note
|
||||
for rc in ("rpu", "rpu2", "rpu3"):
|
||||
if s(row[rc]):
|
||||
svc("ELECTRIC", account=s(row[rc]),
|
||||
svc("WATER", account=s(row["agua"]), due=s(row["diagua"]),
|
||||
active=flag("water1"))
|
||||
# ELECTRIC — rpu account + the CFE meter and route beside it. MEDIDOR/
|
||||
# RUTA are the electric meter, not water: they are filled on exactly
|
||||
# the 1113 rows that carry an RPU (100% overlap), the meters are CFE's
|
||||
# alphanumeric `53MT58` form, and every legacy query that reads them is
|
||||
# a LUZ one (LUZ, FALTANTES DE LUZ, "luz ruta 325 y 329"). They used to
|
||||
# land on the WATER row, leaving every electric service without a meter.
|
||||
# Paired by suffix: medidor2/ruta2 belong to rpu2. luz_tipo as note.
|
||||
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,
|
||||
active=flag("electric"))
|
||||
# GAS — the column mixes two things: an account/meter number for 160 of
|
||||
|
||||
+157
-12
@@ -15,14 +15,27 @@
|
||||
* is a raw lifetime sum. The platform only migrated the CURRENT-year
|
||||
* charge ledger (datos2); the per-year charge tables live in DreamHost
|
||||
* and were never staged. What survives before the cutover is therefore
|
||||
* the EFECTIVO cash journal — receipts with no matching charges — so
|
||||
* those sums read as the office owing money it does not owe.
|
||||
* a cash journal — receipts with no matching charges — so those sums read
|
||||
* as the office owing money it does not owe.
|
||||
*
|
||||
* B. DOUBLE-BOOKED 2026 RECEIPTS — one cash receipt recorded twice, once in
|
||||
* EFECTIVO with folio `N` and once in datos2 with reference `CN`. Both
|
||||
* rows are after the 2026-01-01 floor, so both count. The statement hides
|
||||
* them (STATEMENT_EXCLUDED_SOURCE_TABLES drops EFECTIVO); the balances
|
||||
* worklist, the movement browser and the /clientes/:id card do not.
|
||||
* READ THE COMPOSITION LINE BEFORE ACTING ON THIS. After the prior-period
|
||||
* import the group is mostly insurance-only customers whose rows come from
|
||||
* the seguros database's own EFECTIVO, which is that line's ONLY ledger.
|
||||
* Flooring those deletes receipts instead of removing a double count. The
|
||||
* "floor them, never carry" argument holds for the utilities rows alone.
|
||||
*
|
||||
* B. DOUBLE-BOOKED 2026 RECEIPTS — one cash receipt appearing twice, once in
|
||||
* EFECTIVO with folio `N` and once in datos2 with reference `CN`.
|
||||
*
|
||||
* This is NOT an office data-entry defect, which is what it looked like
|
||||
* while the pair count kept growing at ~40/month. EFECTIVO is the paper
|
||||
* receipt book and every receipt in it is POSTED to the datos2 ledger by
|
||||
* design — verified against the live legacy database, 296 of the 297
|
||||
* receipts written in 2026 carry a matching posting. Legacy summed the
|
||||
* ledger alone. The duplication was the migration flattening a journal and
|
||||
* its postings into one table, and since 1.0.26 the application drops the
|
||||
* journal from every balance. Section B now reports what the journal holds
|
||||
* and asserts that none of it still reaches a balance.
|
||||
*
|
||||
* The folio alone neither proves nor disproves a pair, so it is used as a
|
||||
* lead and never as the verdict. Folios are reused, so `C13483` can collide
|
||||
@@ -82,6 +95,44 @@ const BF_PREDICATE = `(
|
||||
AND t.legacySourceTable = 'datos2')
|
||||
)`;
|
||||
|
||||
/**
|
||||
* The rest of what a balance query drops, over and above the floor. Mirrors
|
||||
* NOT_CASH_JOURNAL and archiveIsHistorySql in billing.service.ts — an audit
|
||||
* that computes a different book than the application is worse than no audit,
|
||||
* because its numbers look authoritative and diff cleanly against yesterday's.
|
||||
*
|
||||
* The database qualifier is not decoration. `SEGUROS 16_be` keeps its own table
|
||||
* called EFECTIVO and that one is the insurance line's only ledger; matching on
|
||||
* the table name alone would report 55,444.95 USD of real receivables as
|
||||
* duplicate cash. See efectivo-is-a-journal-not-a-ledger.
|
||||
*/
|
||||
const NOT_CASH_JOURNAL = `(
|
||||
t.legacySourceDb IS NULL
|
||||
OR t.legacySourceDb <> 'UTILITIES'
|
||||
OR t.legacySourceTable IS NULL
|
||||
OR t.legacySourceTable NOT IN
|
||||
('EFECTIVO', 'EFECTIVO_BACKUP', 'EFECTIVO FM3', 'CHEQUE FM3', 'IVA 2015')
|
||||
)`;
|
||||
|
||||
/**
|
||||
* An imported period counts as history below the year start and is dropped at
|
||||
* or above it. Spelled as a positive OR: `NOT (col LIKE ... AND ...)` is NULL
|
||||
* for an app-captured row, which would silently drop every one.
|
||||
*
|
||||
* The bound is the running calendar year, matching currentYearStart() in the
|
||||
* application rather than the cutover — the app's current period is "this
|
||||
* year", whatever cut the data happens to reflect.
|
||||
*/
|
||||
const yearStart = `${new Date().getUTCFullYear()}-01-01`;
|
||||
const ARCHIVE_IS_HISTORY = `(
|
||||
t.legacySourceTable IS NULL
|
||||
OR t.legacySourceTable NOT LIKE 'datos2@%'
|
||||
OR t.transactionDate < '${yearStart}'
|
||||
)`;
|
||||
|
||||
/** Everything a balance drops apart from the floor itself. */
|
||||
const READ_SCOPE = `(${NOT_CASH_JOURNAL} AND ${ARCHIVE_IS_HISTORY})`;
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const limit = Number(arg(args, "--limit", "25"));
|
||||
@@ -127,9 +178,13 @@ async function main() {
|
||||
ROUND(SUM(CASE WHEN t.currency='MXN' THEN t.amount ELSE 0 END), 2) AS rawMxn,
|
||||
ROUND(SUM(CASE WHEN t.currency='USD' THEN t.amount ELSE 0 END), 2) AS rawUsd,
|
||||
ROUND(SUM(CASE WHEN t.currency='MXN' AND (b.floorDate IS NULL OR t.transactionDate >= b.floorDate)
|
||||
THEN t.amount ELSE 0 END), 2) AS todayMxn,
|
||||
THEN t.amount ELSE 0 END), 2) AS floorOnlyMxn,
|
||||
ROUND(SUM(CASE WHEN t.currency='USD' AND (b.floorDate IS NULL OR t.transactionDate >= b.floorDate)
|
||||
THEN t.amount ELSE 0 END), 2) AS todayUsd,
|
||||
THEN t.amount ELSE 0 END), 2) AS floorOnlyUsd,
|
||||
ROUND(SUM(CASE WHEN t.currency='MXN' AND (b.floorDate IS NULL OR t.transactionDate >= b.floorDate)
|
||||
AND ${READ_SCOPE} THEN t.amount ELSE 0 END), 2) AS todayMxn,
|
||||
ROUND(SUM(CASE WHEN t.currency='USD' AND (b.floorDate IS NULL OR t.transactionDate >= b.floorDate)
|
||||
AND ${READ_SCOPE} THEN t.amount ELSE 0 END), 2) AS todayUsd,
|
||||
ROUND(SUM(CASE WHEN t.currency='MXN' AND t.transactionDate >= ?
|
||||
THEN t.amount ELSE 0 END), 2) AS flooredMxn,
|
||||
ROUND(SUM(CASE WHEN t.currency='USD' AND t.transactionDate >= ?
|
||||
@@ -197,6 +252,39 @@ async function main() {
|
||||
cutover,
|
||||
);
|
||||
|
||||
// What the floorless population's balance is actually MADE OF.
|
||||
//
|
||||
// "Floor them, never carry" was written when this group looked like
|
||||
// utilities cash receipts whose charges were never migrated. It is not that
|
||||
// any more. After the prior-period import the group is 99 customers, and
|
||||
// almost all of them are insurance-only — their rows come from the seguros
|
||||
// database's own EFECTIVO, which is that line's ONLY ledger. Nothing posts
|
||||
// it a second time, so flooring it does not remove a double count, it
|
||||
// deletes receipts. Split the two so the remedy is chosen per population
|
||||
// rather than for the group.
|
||||
const [floorlessMix] = await prisma.$queryRawUnsafe(
|
||||
`
|
||||
WITH nobf AS (
|
||||
SELECT c.id FROM customers c
|
||||
WHERE EXISTS (SELECT 1 FROM transactions t WHERE t.customerId = c.id AND t.voidedAt IS NULL)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM transactions t LEFT JOIN type_transactions tt ON tt.id = t.typeId
|
||||
WHERE t.customerId = c.id AND t.voidedAt IS NULL AND ${BF_PREDICATE}
|
||||
)
|
||||
)
|
||||
SELECT
|
||||
COUNT(DISTINCT CASE WHEN t.legacySourceDb = 'SEGUROS 16_be' THEN t.customerId END) AS insCusts,
|
||||
ROUND(SUM(CASE WHEN t.legacySourceDb = 'SEGUROS 16_be' AND t.currency='MXN'
|
||||
THEN t.amount ELSE 0 END), 2) AS insMxn,
|
||||
ROUND(SUM(CASE WHEN t.legacySourceDb = 'SEGUROS 16_be' AND t.currency='USD'
|
||||
THEN t.amount ELSE 0 END), 2) AS insUsd,
|
||||
COUNT(DISTINCT CASE WHEN t.legacySourceDb <> 'SEGUROS 16_be' THEN t.customerId END) AS utilCusts
|
||||
FROM transactions t JOIN nobf n ON n.id = t.customerId
|
||||
WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND t.transactionDate < ?
|
||||
`,
|
||||
cutover,
|
||||
);
|
||||
|
||||
// ---- section B: double-booked receipts ---------------------------------
|
||||
const pairs = await prisma.$queryRawUnsafe(
|
||||
`
|
||||
@@ -269,6 +357,34 @@ async function main() {
|
||||
`,
|
||||
);
|
||||
|
||||
// REGRESSION GUARD. Since 1.0.26 the application drops the whole cash
|
||||
// journal from every balance, so none of section B's rows should reach one
|
||||
// any more. This counts the ones that still do: it is 0 while the exclusion
|
||||
// holds, and goes non-zero the moment someone reintroduces a balance query
|
||||
// that forgets it. A defect list that cannot tell you whether the defect is
|
||||
// still live is just history.
|
||||
const [stillCounted] = await prisma.$queryRawUnsafe(
|
||||
`
|
||||
WITH bfloor AS (
|
||||
SELECT t.customerId, MAX(t.transactionDate) AS floorDate
|
||||
FROM transactions t LEFT JOIN type_transactions tt ON tt.id = t.typeId
|
||||
WHERE t.voidedAt IS NULL AND ${BF_PREDICATE}
|
||||
GROUP BY t.customerId
|
||||
)
|
||||
SELECT COUNT(*) AS n,
|
||||
ROUND(SUM(CASE WHEN t.currency='MXN' THEN t.amount ELSE 0 END), 2) AS mxn,
|
||||
ROUND(SUM(CASE WHEN t.currency='USD' THEN t.amount ELSE 0 END), 2) AS usd
|
||||
FROM transactions t
|
||||
LEFT JOIN bfloor b ON b.customerId = t.customerId
|
||||
WHERE t.voidedAt IS NULL AND t.outstanding = 0
|
||||
AND t.legacySourceDb = 'UTILITIES'
|
||||
AND t.legacySourceTable IN
|
||||
('EFECTIVO', 'EFECTIVO_BACKUP', 'EFECTIVO FM3', 'CHEQUE FM3', 'IVA 2015')
|
||||
AND (b.floorDate IS NULL OR t.transactionDate >= b.floorDate)
|
||||
AND ${READ_SCOPE}
|
||||
`,
|
||||
);
|
||||
|
||||
// ---- CSV escapes -------------------------------------------------------
|
||||
if (args.includes("--csv-a")) return dumpCsv(floorless);
|
||||
if (args.includes("--csv-b")) return dumpCsv([...pairs, ...nearby]);
|
||||
@@ -276,8 +392,15 @@ async function main() {
|
||||
// ---- report ------------------------------------------------------------
|
||||
console.log("\nBOOK (voided and outstanding rows excluded)");
|
||||
console.log(` raw lifetime sum, no floor ${money(book.rawMxn)} MXN ${money(book.rawUsd)} USD`);
|
||||
console.log(` today (per-customer BF floor) ${money(book.todayMxn)} MXN ${money(book.todayUsd)} USD`);
|
||||
console.log(` BF floor only (pre-1.0.26) ${money(book.floorOnlyMxn)} MXN ${money(book.floorOnlyUsd)} USD`);
|
||||
console.log(` TODAY, as the app computes it ${money(book.todayMxn)} MXN ${money(book.todayUsd)} USD`);
|
||||
console.log(` flat floor at ${cutover} ${money(book.flooredMxn)} MXN ${money(book.flooredUsd)} USD`);
|
||||
console.log(
|
||||
" (the middle line is the floor alone, kept only so older runs of this\n" +
|
||||
" script still diff against something. The app has dropped the cash\n" +
|
||||
" journal and windowed the archives since 1.0.26; USD going to zero on\n" +
|
||||
" the utilities side is correct, that ledger is peso-denominated.)",
|
||||
);
|
||||
|
||||
const preRowsTotal = floorless.reduce((s, r) => s + d(r.preRows), 0);
|
||||
const wouldZero = floorless.filter((r) => d(r.postRows) === 0);
|
||||
@@ -291,6 +414,17 @@ async function main() {
|
||||
console.log(` balance moved by flooring: ${money(-deltaMxn)} MXN ${money(-deltaUsd)} USD`);
|
||||
console.log(` customers left with NO rows at all after the cut: ${wouldZero.length}` +
|
||||
` (their balance becomes 0 — an assertion, not a migrated figure)`);
|
||||
console.log(
|
||||
` of the ${floorless.length}: ${d(floorlessMix.insCusts)} carry insurance-line cash` +
|
||||
` (${d(floorlessMix.insMxn).toFixed(2)} MXN / ${d(floorlessMix.insUsd).toFixed(2)} USD)` +
|
||||
`, ${d(floorlessMix.utilCusts)} carry utilities rows`,
|
||||
);
|
||||
console.log(
|
||||
` READ THAT LINE BEFORE FLOORING ANYONE. The seguros EFECTIVO is that\n` +
|
||||
` line's only ledger — nothing posts it twice — so flooring those\n` +
|
||||
` customers deletes receipts rather than removing a double count.\n` +
|
||||
` The argument for flooring holds for the utilities rows alone.`,
|
||||
);
|
||||
console.log(
|
||||
`\n ${"customer".padEnd(30)} ${"pre".padStart(4)} ${"post".padStart(4)}` +
|
||||
` ${"today MXN".padStart(13)} ${"after MXN".padStart(13)} ${"first tx".padStart(10)}`,
|
||||
@@ -347,6 +481,16 @@ async function main() {
|
||||
console.log(` confirmed USD receipt posted to datos2 in MXN: ${converted.length}`);
|
||||
console.log(` datos2 C-refs with no EFECTIVO partner at all: ${d(unpaired.n)}`);
|
||||
console.log(` EFECTIVO side of the confirmed pairs: ${money(efecMxn)} MXN ${money(efecUsd)} USD`);
|
||||
console.log(
|
||||
` still reaching a balance after the 1.0.26 exclusion: ${d(stillCounted.n)} rows` +
|
||||
` (${d(stillCounted.mxn).toFixed(2)} MXN / ${d(stillCounted.usd).toFixed(2)} USD)` +
|
||||
`${d(stillCounted.n) === 0 ? " <- 0 is the passing value" : " <- REGRESSION"}`,
|
||||
);
|
||||
console.log(
|
||||
` The rows below still exist and always will; the ledger's own C<folio>\n` +
|
||||
` posting is the copy that counts. This section is now a record of what\n` +
|
||||
` the journal holds, not a list of money being double-counted.`,
|
||||
);
|
||||
console.log(
|
||||
`\n ${"customer".padEnd(28)} ${"datos2".padStart(10)} ${"efectivo".padStart(10)}` +
|
||||
` ${"datos2 amt".padStart(13)} ${"efectivo amt".padStart(13)} ref`,
|
||||
@@ -382,8 +526,9 @@ async function main() {
|
||||
"\nNOTE: nothing above has been changed. Section A is a proposal to move the\n" +
|
||||
"floor, not a carried-forward balance: the pre-cutover charge ledger was\n" +
|
||||
"never migrated, so no true opening balance can be computed from this\n" +
|
||||
"database. It exists in the DreamHost per-year tables. Section B is an\n" +
|
||||
"independent defect and does not need a corte to fix.",
|
||||
"database. It exists in the DreamHost per-year tables. Section B is no\n" +
|
||||
"longer an open defect — it was fixed read-side in 1.0.26 — and the line\n" +
|
||||
"that matters there is the regression count, which must stay at 0.",
|
||||
);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
|
||||
Reference in new issue
Block a user