Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b8de0fdca | ||
|
|
bc749055e7 | ||
|
|
75e9f582b4 | ||
|
|
48e01ddd21 | ||
|
|
8c144fe8c4 | ||
|
|
4f2f064955 | ||
|
|
2f99bd5f98 | ||
|
|
458b2b272d | ||
|
|
81938877ed |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jorgecuadros/api",
|
||||
"version": "1.0.20",
|
||||
"version": "1.0.23",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
|
||||
@@ -701,11 +701,14 @@ export class BillingService {
|
||||
/**
|
||||
* One customer's statement across both business lines.
|
||||
*
|
||||
* Returns the *whole* ledger rather than a page of it: the heaviest customer
|
||||
* Scoped to the current calendar year and listed oldest-first, matching the
|
||||
* legacy EDO CUENTA report the office has printed for years: an opening
|
||||
* balance at the top, then the year's movements in the order they happened.
|
||||
*
|
||||
* Returns the *whole* year rather than a page of it: the heaviest customer
|
||||
* carries 365 movements (mean 26), and a running balance is meaningless if
|
||||
* the client only holds a slice. The running balance is accumulated per
|
||||
* currency in chronological order, then the list is handed back newest-first
|
||||
* with each row's balance-after already attached.
|
||||
* currency in chronological order, with each row's balance-after attached.
|
||||
*/
|
||||
async statement(customerId: string) {
|
||||
const customer = await this.prisma.customer.findUnique({
|
||||
@@ -790,15 +793,52 @@ export class BillingService {
|
||||
},
|
||||
});
|
||||
|
||||
// The statement covers one calendar year. The floor above normally lands on
|
||||
// January 1st of it already — the legacy publish writes one BALANCE FORWARD
|
||||
// per customer per year — in which case nothing extra is dropped here. When
|
||||
// it doesn't (a customer the last publish skipped, or one that never had an
|
||||
// opening balance), the earlier rows still have to be *counted* or every
|
||||
// balance below is wrong, so they are folded into `opening` rather than
|
||||
// listed. That is the same thing a BALANCE FORWARD row does, just computed.
|
||||
const yearStart = new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1));
|
||||
|
||||
const running = new Map<string, Prisma.Decimal>();
|
||||
const movements = rows.map((r) => {
|
||||
/** Balance carried into `yearStart`, per currency. */
|
||||
const opening = new Map<string, Prisma.Decimal>();
|
||||
/** The same carried balance split by business line, keyed `domain|currency`. */
|
||||
const openingByDomain = new Map<
|
||||
string,
|
||||
{ domain: TransactionDomain; currency: string; amount: Prisma.Decimal }
|
||||
>();
|
||||
/** The rows the statement lists — this year's. Totals are built from these. */
|
||||
const visible: typeof rows = [];
|
||||
|
||||
const movements = rows.flatMap((r) => {
|
||||
const voided = r.voidedAt != null;
|
||||
const prev = running.get(r.currency) ?? new Prisma.Decimal(0);
|
||||
// Neither a voided row nor an outstanding (unpaid) one moves the running
|
||||
// balance — both show tagged, with the balance unchanged from the previous
|
||||
// live movement. Outstanding rows start counting once resolved.
|
||||
const next = voided || r.outstanding ? prev : prev.plus(r.amount);
|
||||
const counted = !voided && !r.outstanding;
|
||||
const next = counted ? prev.plus(r.amount) : prev;
|
||||
running.set(r.currency, next);
|
||||
|
||||
if (r.transactionDate < yearStart) {
|
||||
if (counted) {
|
||||
opening.set(r.currency, next);
|
||||
const dk = `${r.domain}|${r.currency}`;
|
||||
const od = openingByDomain.get(dk) ?? {
|
||||
domain: r.domain,
|
||||
currency: r.currency,
|
||||
amount: new Prisma.Decimal(0),
|
||||
};
|
||||
od.amount = od.amount.plus(r.amount);
|
||||
openingByDomain.set(dk, od);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
visible.push(r);
|
||||
return {
|
||||
id: r.id,
|
||||
transactionDate: r.transactionDate,
|
||||
@@ -818,7 +858,6 @@ export class BillingService {
|
||||
balanceAfter: next.toFixed(2),
|
||||
};
|
||||
});
|
||||
movements.reverse();
|
||||
|
||||
// Per-currency summary, and the same split by business line so the two
|
||||
// ledgers are visibly one statement without being illegally added up.
|
||||
@@ -846,7 +885,29 @@ export class BillingService {
|
||||
}
|
||||
>();
|
||||
|
||||
for (const r of rows) {
|
||||
for (const [currency] of opening) {
|
||||
perCurrency.set(currency, {
|
||||
currency,
|
||||
charges: new Prisma.Decimal(0),
|
||||
credits: new Prisma.Decimal(0),
|
||||
chargeCount: 0,
|
||||
creditCount: 0,
|
||||
count: 0,
|
||||
first: null,
|
||||
last: null,
|
||||
});
|
||||
}
|
||||
for (const [key, o] of openingByDomain) {
|
||||
perDomain.set(key, {
|
||||
domain: o.domain,
|
||||
currency: o.currency,
|
||||
charges: new Prisma.Decimal(0),
|
||||
credits: new Prisma.Decimal(0),
|
||||
count: 0,
|
||||
});
|
||||
}
|
||||
|
||||
for (const r of visible) {
|
||||
// Voided rows never enter a total; outstanding rows don't either until
|
||||
// they're resolved (legacy SALDOS ULTIMO 0's `HAVING NOPAGO = 0`).
|
||||
if (r.voidedAt != null || r.outstanding) continue;
|
||||
@@ -896,7 +957,7 @@ export class BillingService {
|
||||
string,
|
||||
{ name: string; currency: string; total: Prisma.Decimal; count: number }
|
||||
>();
|
||||
for (const r of rows) {
|
||||
for (const r of visible) {
|
||||
if (r.voidedAt != null || r.outstanding) continue;
|
||||
if (!r.amount.lessThan(0)) continue;
|
||||
const name = r.type?.nameEs || r.type?.nameEn || "Sin clasificar";
|
||||
@@ -915,25 +976,37 @@ export class BillingService {
|
||||
propertyCount: customer._count.properties,
|
||||
policyCount: customer._count.policies,
|
||||
},
|
||||
summary: [...perCurrency.values()].map((c) => ({
|
||||
year: yearStart.getUTCFullYear(),
|
||||
summary: [...perCurrency.values()].map((c) => {
|
||||
const open = opening.get(c.currency) ?? new Prisma.Decimal(0);
|
||||
return {
|
||||
currency: c.currency,
|
||||
/** Balance carried in from before this year — legacy's BALANCE FORWARD. */
|
||||
opening: open.toFixed(2),
|
||||
charges: c.charges.toFixed(2),
|
||||
credits: c.credits.toFixed(2),
|
||||
balance: c.charges.plus(c.credits).toFixed(2),
|
||||
balance: open.plus(c.charges).plus(c.credits).toFixed(2),
|
||||
chargeCount: c.chargeCount,
|
||||
creditCount: c.creditCount,
|
||||
count: c.count,
|
||||
firstMovement: c.first,
|
||||
lastMovement: c.last,
|
||||
})),
|
||||
byDomain: [...perDomain.values()].map((d) => ({
|
||||
};
|
||||
}),
|
||||
byDomain: [...perDomain.values()].map((d) => {
|
||||
const open =
|
||||
openingByDomain.get(`${d.domain}|${d.currency}`)?.amount ??
|
||||
new Prisma.Decimal(0);
|
||||
return {
|
||||
domain: d.domain,
|
||||
currency: d.currency,
|
||||
opening: open.toFixed(2),
|
||||
charges: d.charges.toFixed(2),
|
||||
credits: d.credits.toFixed(2),
|
||||
balance: d.charges.plus(d.credits).toFixed(2),
|
||||
balance: open.plus(d.charges).plus(d.credits).toFixed(2),
|
||||
count: d.count,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
byType: [...byType.values()]
|
||||
.map((t) => ({
|
||||
name: t.name,
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { Prisma } from "@jorgecuadros/database";
|
||||
import { BillingService } from "./billing.service";
|
||||
|
||||
/**
|
||||
* The statement is a *year* statement, like the EDO CUENTA report the office
|
||||
* prints: this year's movements, oldest-first, opening on the balance carried
|
||||
* in from before it.
|
||||
*
|
||||
* The carrying is the part worth testing. Dropping earlier rows from the list
|
||||
* is easy; dropping them from the arithmetic too would restart every balance at
|
||||
* zero on January 1st, and nothing would throw — the numbers would just be
|
||||
* wrong, which is exactly how the double-counting bug lived for years.
|
||||
*/
|
||||
describe("statement year scoping", () => {
|
||||
const YEAR = new Date().getUTCFullYear();
|
||||
|
||||
function d(iso: string) {
|
||||
return new Date(`${iso}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
type RowSpec = {
|
||||
id: string;
|
||||
date: Date;
|
||||
amount: string;
|
||||
currency?: string;
|
||||
domain?: string;
|
||||
voidedAt?: Date | null;
|
||||
outstanding?: boolean;
|
||||
};
|
||||
|
||||
function row(r: RowSpec) {
|
||||
return {
|
||||
id: r.id,
|
||||
transactionDate: r.date,
|
||||
domain: r.domain ?? "UTILITY",
|
||||
amount: new Prisma.Decimal(r.amount),
|
||||
currency: r.currency ?? "MXN",
|
||||
reference: null,
|
||||
period: null,
|
||||
checkNumber: null,
|
||||
message: null,
|
||||
legacySourceTable: null,
|
||||
voidedAt: r.voidedAt ?? null,
|
||||
outstanding: r.outstanding ?? false,
|
||||
type: { nameEn: "WATER", nameEs: "AGUA" },
|
||||
};
|
||||
}
|
||||
|
||||
/** No BALANCE FORWARD row, so the floor is null and every row is fetched. */
|
||||
function serviceWith(rows: RowSpec[]) {
|
||||
const prisma = {
|
||||
customer: {
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: "c1",
|
||||
name: "CUADROS, JORGE H.",
|
||||
preferredCurrency: "MXN",
|
||||
_count: { properties: 0, policies: 0 },
|
||||
}),
|
||||
},
|
||||
transaction: {
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
findMany: jest.fn().mockResolvedValue(rows.map(row)),
|
||||
},
|
||||
};
|
||||
return new BillingService(prisma as never);
|
||||
}
|
||||
|
||||
it("lists the year's movements oldest-first", async () => {
|
||||
const s = await serviceWith([
|
||||
{ id: "a", date: d(`${YEAR}-01-02`), amount: "-100" },
|
||||
{ id: "b", date: d(`${YEAR}-03-04`), amount: "250" },
|
||||
{ id: "c", date: d(`${YEAR}-07-16`), amount: "-40" },
|
||||
]).statement("c1");
|
||||
|
||||
expect(s.movements.map((m) => m.id)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("leaves earlier years off the list", async () => {
|
||||
const s = await serviceWith([
|
||||
{ id: "old", date: d(`${YEAR - 1}-11-30`), amount: "-500" },
|
||||
{ id: "new", date: d(`${YEAR}-02-11`), amount: "-100" },
|
||||
]).statement("c1");
|
||||
|
||||
expect(s.movements.map((m) => m.id)).toEqual(["new"]);
|
||||
});
|
||||
|
||||
it("carries the earlier years' balance instead of discarding it", async () => {
|
||||
// 1,000 credit left over from last year, 300 charged this year: the
|
||||
// customer is 700 in credit, not 300 in debt.
|
||||
const s = await serviceWith([
|
||||
{ id: "old", date: d(`${YEAR - 1}-12-15`), amount: "1000" },
|
||||
{ id: "new", date: d(`${YEAR}-02-11`), amount: "-300" },
|
||||
]).statement("c1");
|
||||
|
||||
const mxn = s.summary.find((x) => x.currency === "MXN");
|
||||
expect(mxn?.opening).toBe("1000.00");
|
||||
expect(mxn?.charges).toBe("-300.00");
|
||||
expect(mxn?.balance).toBe("700.00");
|
||||
// The running balance on the listed row picks up where last year left off.
|
||||
expect(s.movements[0].balanceAfter).toBe("700.00");
|
||||
});
|
||||
|
||||
it("carries it per business line as well", async () => {
|
||||
const s = await serviceWith([
|
||||
{ id: "old", date: d(`${YEAR - 1}-12-15`), amount: "1000", domain: "INSURANCE" },
|
||||
{ id: "new", date: d(`${YEAR}-02-11`), amount: "-300", domain: "INSURANCE" },
|
||||
]).statement("c1");
|
||||
|
||||
const line = s.byDomain.find((x) => x.domain === "INSURANCE");
|
||||
expect(line?.opening).toBe("1000.00");
|
||||
expect(line?.balance).toBe("700.00");
|
||||
});
|
||||
|
||||
it("still reports a currency that only moved in earlier years", async () => {
|
||||
// Otherwise a customer sitting on a dollar credit they haven't touched all
|
||||
// year would appear to have no dollar balance at all.
|
||||
const s = await serviceWith([
|
||||
{ id: "old", date: d(`${YEAR - 2}-05-01`), amount: "180.83", currency: "USD" },
|
||||
{ id: "new", date: d(`${YEAR}-02-11`), amount: "-300" },
|
||||
]).statement("c1");
|
||||
|
||||
const usd = s.summary.find((x) => x.currency === "USD");
|
||||
expect(usd?.balance).toBe("180.83");
|
||||
expect(usd?.count).toBe(0);
|
||||
});
|
||||
|
||||
it("does not carry a voided earlier row", async () => {
|
||||
const s = await serviceWith([
|
||||
{ id: "old", date: d(`${YEAR - 1}-12-15`), amount: "1000", voidedAt: d(`${YEAR - 1}-12-16`) },
|
||||
{ id: "new", date: d(`${YEAR}-02-11`), amount: "-300" },
|
||||
]).statement("c1");
|
||||
|
||||
const mxn = s.summary.find((x) => x.currency === "MXN");
|
||||
expect(mxn?.opening).toBe("0.00");
|
||||
expect(mxn?.balance).toBe("-300.00");
|
||||
});
|
||||
|
||||
it("reports the year it covers", async () => {
|
||||
const s = await serviceWith([
|
||||
{ id: "a", date: d(`${YEAR}-01-02`), amount: "-100" },
|
||||
]).statement("c1");
|
||||
|
||||
expect(s.year).toBe(YEAR);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,10 @@ import { Currency } from "@jorgecuadros/database";
|
||||
// Each child DTO covers create; updates reuse the same shape with all fields
|
||||
// optional via the corresponding Update class. Route supplies the policyId.
|
||||
|
||||
// A policy split into several exhibiciones prices each payment on its own —
|
||||
// the Access form printed the whole money row once per pago — so the premium
|
||||
// breakdown repeats here. `amount` remains what was actually collected and is
|
||||
// never recomputed from the breakdown; the two differ by rounding in the books.
|
||||
export class InstallmentDto {
|
||||
@IsInt() sequence!: number;
|
||||
@IsOptional() @IsNumber() amount?: number;
|
||||
@@ -20,6 +24,13 @@ export class InstallmentDto {
|
||||
@IsOptional() @IsString() paidDate?: string;
|
||||
@IsOptional() @IsString() checkNumber?: string;
|
||||
@IsOptional() @IsBoolean() isCash?: boolean;
|
||||
@IsOptional() @IsNumber() netPremium?: number;
|
||||
@IsOptional() @IsNumber() surcharge?: number;
|
||||
@IsOptional() @IsNumber() policyFee?: number;
|
||||
@IsOptional() @IsNumber() tax?: number;
|
||||
@IsOptional() @IsNumber() taxRate?: number;
|
||||
@IsOptional() @IsNumber() total?: number;
|
||||
@IsOptional() @IsNumber() commission?: number;
|
||||
}
|
||||
export class UpdateInstallmentDto {
|
||||
@IsOptional() @IsInt() sequence?: number;
|
||||
@@ -29,6 +40,13 @@ export class UpdateInstallmentDto {
|
||||
@IsOptional() @IsString() paidDate?: string;
|
||||
@IsOptional() @IsString() checkNumber?: string;
|
||||
@IsOptional() @IsBoolean() isCash?: boolean;
|
||||
@IsOptional() @IsNumber() netPremium?: number;
|
||||
@IsOptional() @IsNumber() surcharge?: number;
|
||||
@IsOptional() @IsNumber() policyFee?: number;
|
||||
@IsOptional() @IsNumber() tax?: number;
|
||||
@IsOptional() @IsNumber() taxRate?: number;
|
||||
@IsOptional() @IsNumber() total?: number;
|
||||
@IsOptional() @IsNumber() commission?: number;
|
||||
}
|
||||
|
||||
export class VehicleDto {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsOptional, IsString, MinLength } from "class-validator";
|
||||
import { IsNumber, IsOptional, IsString, Max, Min, MinLength } from "class-validator";
|
||||
|
||||
export class ProviderDto {
|
||||
@IsString() @MinLength(1) name!: string;
|
||||
@@ -7,13 +7,19 @@ export class UpdateProviderDto {
|
||||
@IsOptional() @IsString() @MinLength(1) name?: string;
|
||||
}
|
||||
|
||||
// `taxRate` is the IVA fraction for this line of business (0.08 = 8%), the
|
||||
// legacy one-row IMPUESTOS / IMPUESTOS_AUTOS tables made editable. Bounded at
|
||||
// 1 because a rate is a fraction, not a percentage: 8 entered here would tax a
|
||||
// $600 premium $4,800, and the mistake is easy to make.
|
||||
export class PolicyTypeDto {
|
||||
@IsString() @MinLength(1) name!: string;
|
||||
@IsOptional() @IsString() shortDescription?: string;
|
||||
@IsOptional() @IsNumber() @Min(0) @Max(1) taxRate?: number;
|
||||
}
|
||||
export class UpdatePolicyTypeDto {
|
||||
@IsOptional() @IsString() @MinLength(1) name?: string;
|
||||
@IsOptional() @IsString() shortDescription?: string;
|
||||
@IsOptional() @IsNumber() @Min(0) @Max(1) taxRate?: number;
|
||||
}
|
||||
|
||||
export class AdjusterDto {
|
||||
|
||||
@@ -250,7 +250,16 @@ export class PoliciesService {
|
||||
const [types, providers] = await this.prisma.$transaction([
|
||||
this.prisma.policyType.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
select: { id: true, name: true, _count: { select: { policies: true } } },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
shortDescription: true,
|
||||
// The capture form computes IVA client-side as the operator types,
|
||||
// so the rate has to travel with the type list it already loads —
|
||||
// an extra round-trip per keystroke is not an option.
|
||||
taxRate: true,
|
||||
_count: { select: { policies: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.insuranceProvider.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
@@ -259,7 +268,13 @@ export class PoliciesService {
|
||||
]);
|
||||
|
||||
return {
|
||||
types: types.map((t) => ({ id: t.id, name: t.name, count: t._count.policies })),
|
||||
types: types.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
shortDescription: t.shortDescription,
|
||||
taxRate: t.taxRate,
|
||||
count: t._count.policies,
|
||||
})),
|
||||
providers: providers.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
@@ -397,6 +412,13 @@ export class PoliciesService {
|
||||
paidDate: toDate(dto.paidDate) ?? undefined,
|
||||
checkNumber: dto.checkNumber,
|
||||
isCash: dto.isCash,
|
||||
netPremium: dto.netPremium,
|
||||
surcharge: dto.surcharge,
|
||||
policyFee: dto.policyFee,
|
||||
tax: dto.tax,
|
||||
taxRate: dto.taxRate,
|
||||
total: dto.total,
|
||||
commission: dto.commission,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -412,6 +434,13 @@ export class PoliciesService {
|
||||
...(dto.paidDate !== undefined && { paidDate: toDate(dto.paidDate) }),
|
||||
checkNumber: dto.checkNumber,
|
||||
isCash: dto.isCash,
|
||||
netPremium: dto.netPremium,
|
||||
surcharge: dto.surcharge,
|
||||
policyFee: dto.policyFee,
|
||||
tax: dto.tax,
|
||||
taxRate: dto.taxRate,
|
||||
total: dto.total,
|
||||
commission: dto.commission,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,12 +6,16 @@ import {
|
||||
IsString,
|
||||
MinLength,
|
||||
} from "class-validator";
|
||||
import { Currency } from "@jorgecuadros/database";
|
||||
import { Currency, PaymentFrequency } from "@jorgecuadros/database";
|
||||
import { IsEnum } from "class-validator";
|
||||
|
||||
/** Editable policy-header fields. coveragesJson (freeform legacy blob) is not
|
||||
* exposed for editing. Dates arrive as ISO strings and are coerced by the
|
||||
* service. `total` is legacy-dead data — the UI uses netPremium. */
|
||||
* service. `total` is legacy-dead data on migrated rows — list and sort code
|
||||
* still uses netPremium — but the capture form writes it going forward, along
|
||||
* with `tax`, from the arithmetic in premium.ts. Both arrive as plain numbers
|
||||
* rather than being recomputed server-side: the printed policy is the record
|
||||
* of truth and staff must be able to key its rounding verbatim. */
|
||||
export class CreatePolicyDto {
|
||||
@IsString() @MinLength(1) policyNumber!: string;
|
||||
@IsString() @MinLength(1) customerId!: string;
|
||||
@@ -24,10 +28,14 @@ export class CreatePolicyDto {
|
||||
@IsOptional() @IsString() policyTo?: string;
|
||||
@IsOptional() @IsInt() coveragePeriodDays?: number;
|
||||
@IsOptional() @IsNumber() netPremium?: number;
|
||||
@IsOptional() @IsNumber() surcharge?: number;
|
||||
@IsOptional() @IsNumber() policyFee?: number;
|
||||
@IsOptional() @IsNumber() brokerFee?: number;
|
||||
@IsOptional() @IsNumber() commission?: number;
|
||||
@IsOptional() @IsNumber() tax?: number;
|
||||
@IsOptional() @IsNumber() taxRate?: number;
|
||||
@IsOptional() @IsNumber() total?: number;
|
||||
@IsOptional() @IsEnum(PaymentFrequency) paymentFrequency?: PaymentFrequency;
|
||||
@IsOptional() @IsEnum(Currency) currency?: Currency;
|
||||
@IsOptional() @IsString() observations?: string;
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
@@ -48,10 +56,14 @@ export class UpdatePolicyDto {
|
||||
@IsOptional() @IsString() policyTo?: string;
|
||||
@IsOptional() @IsInt() coveragePeriodDays?: number;
|
||||
@IsOptional() @IsNumber() netPremium?: number;
|
||||
@IsOptional() @IsNumber() surcharge?: number;
|
||||
@IsOptional() @IsNumber() policyFee?: number;
|
||||
@IsOptional() @IsNumber() brokerFee?: number;
|
||||
@IsOptional() @IsNumber() commission?: number;
|
||||
@IsOptional() @IsNumber() tax?: number;
|
||||
@IsOptional() @IsNumber() taxRate?: number;
|
||||
@IsOptional() @IsNumber() total?: number;
|
||||
@IsOptional() @IsEnum(PaymentFrequency) paymentFrequency?: PaymentFrequency;
|
||||
@IsOptional() @IsEnum(Currency) currency?: Currency;
|
||||
@IsOptional() @IsString() observations?: string;
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
DEFAULT_TAX_RATE,
|
||||
computeTax,
|
||||
computeTotal,
|
||||
resolveTaxRate,
|
||||
surchargeApplies,
|
||||
taxableBase,
|
||||
} from "./premium";
|
||||
|
||||
/**
|
||||
* The reference case is policy 7006785 (MULT, semestral, GMX, two payments) as
|
||||
* it stands in the Access books — the screen Jorge sent. Both of its money
|
||||
* rows are asserted, because the second one is the case that proves the
|
||||
* surcharge belongs in the taxable base and that a zero policy fee is a real
|
||||
* value rather than a missing one.
|
||||
*/
|
||||
describe("premium arithmetic", () => {
|
||||
it("matches the first payment of policy 7006785", () => {
|
||||
const parts = { netPremium: 610.86, surcharge: 8.55, policyFee: 31.0 };
|
||||
expect(taxableBase(parts)).toBe(650.41);
|
||||
expect(computeTax(parts, 0.08)).toBe(52.03);
|
||||
expect(computeTotal(parts, 0.08)).toBe(702.44);
|
||||
});
|
||||
|
||||
it("matches the second payment of policy 7006785", () => {
|
||||
const parts = { netPremium: 589.71, surcharge: 8.26, policyFee: 0 };
|
||||
expect(computeTax(parts, 0.08)).toBe(47.84);
|
||||
expect(computeTotal(parts, 0.08)).toBe(645.81);
|
||||
});
|
||||
|
||||
it("excluding the surcharge does NOT reconcile", () => {
|
||||
// Guards the one decision in this module that is easy to get wrong: the
|
||||
// spoken-language version of the rule ("prima neta + derecho * 8%") gives
|
||||
// 51.35, and the printed policy says 52.03.
|
||||
const withoutSurcharge = { netPremium: 610.86, surcharge: 0, policyFee: 31.0 };
|
||||
expect(computeTax(withoutSurcharge, 0.08)).not.toBe(52.03);
|
||||
});
|
||||
|
||||
it("treats blank and null money as zero, not NaN", () => {
|
||||
expect(taxableBase({ netPremium: "610.86", surcharge: null, policyFee: "" })).toBe(
|
||||
610.86,
|
||||
);
|
||||
expect(computeTax({ netPremium: undefined, surcharge: null, policyFee: null }, 0.08))
|
||||
.toBe(0);
|
||||
});
|
||||
|
||||
it("rounds half-up to cents", () => {
|
||||
// 100.06 * 0.08 = 8.0048 -> 8.00; 100.13 * 0.08 = 8.0104 -> 8.01.
|
||||
expect(computeTax({ netPremium: 100.06, surcharge: 0, policyFee: 0 }, 0.08)).toBe(8);
|
||||
expect(computeTax({ netPremium: 100.13, surcharge: 0, policyFee: 0 }, 0.08)).toBe(8.01);
|
||||
});
|
||||
|
||||
describe("surchargeApplies", () => {
|
||||
it("is false for the two single-payment frequencies", () => {
|
||||
expect(surchargeApplies("ANNUAL")).toBe(false);
|
||||
expect(surchargeApplies("SINGLE")).toBe(false);
|
||||
});
|
||||
it("is true for every split frequency", () => {
|
||||
expect(surchargeApplies("SEMIANNUAL")).toBe(true);
|
||||
expect(surchargeApplies("QUARTERLY")).toBe(true);
|
||||
expect(surchargeApplies("MONTHLY")).toBe(true);
|
||||
});
|
||||
it("allows it when the frequency is unknown", () => {
|
||||
// Every migrated policy is null here — the original ETL dropped FORMA
|
||||
// PAGO — and those rows DO carry recargo figures in the legacy data.
|
||||
expect(surchargeApplies(null)).toBe(true);
|
||||
expect(surchargeApplies(undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTaxRate", () => {
|
||||
it("prefers the rate the policy was issued at", () => {
|
||||
expect(resolveTaxRate(0.16, 0.08)).toBe(0.16);
|
||||
});
|
||||
it("falls back to the line of business", () => {
|
||||
expect(resolveTaxRate(null, 0.08)).toBe(0.08);
|
||||
});
|
||||
it("falls back to the default when nothing is configured", () => {
|
||||
expect(resolveTaxRate(null, null)).toBe(DEFAULT_TAX_RATE);
|
||||
expect(resolveTaxRate(undefined, "")).toBe(DEFAULT_TAX_RATE);
|
||||
});
|
||||
it("accepts a zero rate as a real choice, not as absent", () => {
|
||||
// An exempt line of business must read 0, not silently fall through to 8%.
|
||||
expect(resolveTaxRate(null, 0)).toBe(0);
|
||||
});
|
||||
it("accepts Prisma's decimal strings", () => {
|
||||
expect(resolveTaxRate(null, "0.0800")).toBe(0.08);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* The premium arithmetic the Access capture form did in unbound calculated
|
||||
* controls, moved somewhere it can be tested.
|
||||
*
|
||||
* Two figures are derived, everything else is keyed by hand:
|
||||
*
|
||||
* base = netPremium + surcharge + policyFee
|
||||
* tax = round(base * rate)
|
||||
* total = base + tax
|
||||
*
|
||||
* The surcharge IS part of the taxable base. That is not an assumption — it is
|
||||
* the only reading that reconciles the books. Policy 7006785 (MULT, semestral,
|
||||
* two payments) prints IVA 52.03 and 47.84 against net premiums 610.86 / 589.71,
|
||||
* surcharges 8.55 / 8.26 and policy fees 31.00 / 0.00; excluding the surcharge
|
||||
* gives 51.35, which matches nothing on the page.
|
||||
*
|
||||
* The surcharge itself is NEVER derived. It is the carrier's financing charge
|
||||
* for paying in installments, quoted per policy, so staff key it in. It only
|
||||
* ever appears on a policy that is not paid annually or in a single exhibición
|
||||
* — `surchargeApplies` is what the UI uses to grey the field out.
|
||||
*/
|
||||
|
||||
/** Used when neither the policy nor its type carries a rate. Matches the
|
||||
* single row both legacy IMPUESTOS tables held (0.08 = 8%). */
|
||||
export const DEFAULT_TAX_RATE = 0.08;
|
||||
|
||||
export type PaymentFrequencyValue =
|
||||
| "ANNUAL"
|
||||
| "SEMIANNUAL"
|
||||
| "QUARTERLY"
|
||||
| "MONTHLY"
|
||||
| "SINGLE";
|
||||
|
||||
/** Paying in more than one exhibición is what earns a surcharge. A null
|
||||
* frequency (every migrated row — Access's FORMA PAGO was dropped by the
|
||||
* original ETL) is treated as "unknown, allow it" rather than "annual":
|
||||
* refusing to show a figure that is sitting in the legacy data would hide it. */
|
||||
export function surchargeApplies(
|
||||
frequency: PaymentFrequencyValue | null | undefined,
|
||||
): boolean {
|
||||
return frequency !== "ANNUAL" && frequency !== "SINGLE";
|
||||
}
|
||||
|
||||
function num(v: unknown): number {
|
||||
if (v === null || v === undefined || v === "") return 0;
|
||||
const n = typeof v === "number" ? v : Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/** Half-up to cents, the way the printed policy rounds. */
|
||||
export function round2(n: number): number {
|
||||
return Math.round((n + Number.EPSILON) * 100) / 100;
|
||||
}
|
||||
|
||||
export interface PremiumParts {
|
||||
netPremium?: unknown;
|
||||
surcharge?: unknown;
|
||||
policyFee?: unknown;
|
||||
}
|
||||
|
||||
export function taxableBase(p: PremiumParts): number {
|
||||
return round2(num(p.netPremium) + num(p.surcharge) + num(p.policyFee));
|
||||
}
|
||||
|
||||
export function computeTax(p: PremiumParts, rate: number): number {
|
||||
return round2(taxableBase(p) * rate);
|
||||
}
|
||||
|
||||
export function computeTotal(p: PremiumParts, rate: number): number {
|
||||
return round2(taxableBase(p) + computeTax(p, rate));
|
||||
}
|
||||
|
||||
/** Rate ladder: the figure stored on the policy (so an old policy keeps the
|
||||
* rate it was issued at even after the catalog changes), else the rate on its
|
||||
* line of business, else the shipped default. */
|
||||
export function resolveTaxRate(
|
||||
policyRate: unknown,
|
||||
policyTypeRate: unknown,
|
||||
): number {
|
||||
for (const candidate of [policyRate, policyTypeRate]) {
|
||||
if (candidate === null || candidate === undefined || candidate === "") continue;
|
||||
const n = Number(candidate);
|
||||
if (Number.isFinite(n) && n >= 0) return n;
|
||||
}
|
||||
return DEFAULT_TAX_RATE;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
nameTokens,
|
||||
suggestCustomersByName,
|
||||
suggestionNote,
|
||||
type CustomerNameRow,
|
||||
} from "./name-matcher";
|
||||
|
||||
/**
|
||||
* Every row here is a real name out of the customer book (1536 rows, dev
|
||||
* mirror of production), chosen because it is one of the shapes that breaks
|
||||
* naive matching: surname-first ordering, a middle initial, a Spanish double
|
||||
* surname, a joint account, a missing comma, and the `(SIN NOMBRE)`
|
||||
* placeholder the migration left for customers whose DATGRAL row had no name.
|
||||
*/
|
||||
const BOOK: CustomerNameRow[] = [
|
||||
{ id: "c1", name: "WAGONER, PAMELA" },
|
||||
{ id: "c2", name: "MCWILLIAMS, BRIAN MICHAEL" },
|
||||
{ id: "c3", name: "MCWILLIAMS, BRIAN" },
|
||||
{ id: "c4", name: "WEAKLAND, RICHARD E." },
|
||||
{ id: "c5", name: "ESTRADA, JERRY & MARILYN" },
|
||||
{ id: "c6", name: "CABALLERO PRIETO, GUILLERMO" },
|
||||
{ id: "c7", name: "GREENE STEPHANIE" },
|
||||
{ id: "c8", name: "(SIN NOMBRE)" },
|
||||
{ id: "c9", name: "MUÑOZ, LUIS ALBERTO" },
|
||||
{ id: "c10", name: "SMITH, DANIEL" },
|
||||
{ id: "c11", name: "SMITH, JOHN" },
|
||||
];
|
||||
|
||||
describe("nameTokens", () => {
|
||||
it("makes the two orderings the same set", () => {
|
||||
expect(nameTokens("PAMELA WAGONER").sort()).toEqual(
|
||||
nameTokens("WAGONER, PAMELA").sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops initials, particles and corporate suffixes", () => {
|
||||
expect(nameTokens("WEAKLAND, RICHARD E.")).toEqual(["WEAKLAND", "RICHARD"]);
|
||||
expect(nameTokens("GARCIA DE LA TORRE, ANA")).toEqual(["GARCIA", "TORRE", "ANA"]);
|
||||
expect(nameTokens("CONSTRUCTORA BAJA S.A. DE C.V.")).toEqual([
|
||||
"CONSTRUCTORA",
|
||||
"BAJA",
|
||||
]);
|
||||
});
|
||||
|
||||
it("folds accents so OCR's MUNOZ reaches the book's MUÑOZ", () => {
|
||||
expect(nameTokens("MUÑOZ")).toEqual(["MUNOZ"]);
|
||||
});
|
||||
|
||||
it("drops the phone number ANA prints against the insured name", () => {
|
||||
// Observed verbatim from the ANA automobile face.
|
||||
expect(nameTokens("MARIA GARCIA Ph.3102001538")).toEqual([
|
||||
"MARIA",
|
||||
"GARCIA",
|
||||
"PH",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("suggestCustomersByName", () => {
|
||||
it("matches the reversed name exactly", () => {
|
||||
const [top] = suggestCustomersByName("PAMELA WAGONER", BOOK);
|
||||
expect(top).toMatchObject({ customerId: "c1", tier: "EXACT", score: 1 });
|
||||
});
|
||||
|
||||
it("treats a printed middle name the book lacks as a partial hit", () => {
|
||||
const hits = suggestCustomersByName("PAMELA DENISE WAGONER", BOOK);
|
||||
expect(hits[0]).toMatchObject({ customerId: "c1", tier: "PARTIAL" });
|
||||
expect(hits[0].score).toBeCloseTo(2 / 3);
|
||||
});
|
||||
|
||||
it("ranks the exact row above the row that merely contains it", () => {
|
||||
// Both MCWILLIAMS rows are reachable from this name; the one that holds
|
||||
// the middle name is the exact set and must come first.
|
||||
const hits = suggestCustomersByName("BRIAN MICHAEL MCWILLIAMS", BOOK);
|
||||
expect(hits.map((h) => h.customerId)).toEqual(["c2", "c3"]);
|
||||
expect(hits[0].tier).toBe("EXACT");
|
||||
expect(hits[1].tier).toBe("PARTIAL");
|
||||
});
|
||||
|
||||
it("reaches a joint account from the one spouse the carrier printed", () => {
|
||||
const hits = suggestCustomersByName("JERRY ESTRADA", BOOK);
|
||||
expect(hits[0]).toMatchObject({ customerId: "c5", tier: "PARTIAL" });
|
||||
});
|
||||
|
||||
it("will not reach a joint account on given names alone", () => {
|
||||
// No surname printed: `JERRY MARILYN` overlaps ESTRADA, JERRY & MARILYN
|
||||
// on two tokens, and matching on that would book a stranger's policy.
|
||||
expect(suggestCustomersByName("JERRY MARILYN", BOOK)).toEqual([]);
|
||||
});
|
||||
|
||||
it("matches a Spanish double surname regardless of where the comma fell", () => {
|
||||
const [top] = suggestCustomersByName("GUILLERMO CABALLERO PRIETO", BOOK);
|
||||
expect(top).toMatchObject({ customerId: "c6", tier: "EXACT" });
|
||||
});
|
||||
|
||||
it("still matches a book row that has no comma", () => {
|
||||
const [top] = suggestCustomersByName("STEPHANIE GREENE", BOOK);
|
||||
expect(top).toMatchObject({ customerId: "c7", tier: "EXACT" });
|
||||
});
|
||||
|
||||
it("never suggests the (SIN NOMBRE) placeholder", () => {
|
||||
expect(suggestCustomersByName("SIN NOMBRE", BOOK)).toEqual([]);
|
||||
expect(suggestCustomersByName("NOMBRE DEL ASEGURADO", BOOK)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns nothing on a shared surname alone", () => {
|
||||
// 185 surnames are shared by 524 customers; one token is not evidence.
|
||||
expect(suggestCustomersByName("SMITH", BOOK)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns nothing for a different person with the same surname", () => {
|
||||
expect(suggestCustomersByName("ROBERT SMITH", BOOK)).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a page-sized blob", () => {
|
||||
// GMX's especificación has no field labels and the parser has handed its
|
||||
// whole first page over as the insured name.
|
||||
const blob =
|
||||
"ESPECIFICACION DE LA POLIZA DE SEGURO DE RESPONSABILIDAD CIVIL " +
|
||||
"EXPEDIDA A FAVOR DE PAMELA WAGONER CON VIGENCIA DEL 01 DE ENERO";
|
||||
expect(suggestCustomersByName(blob, BOOK)).toEqual([]);
|
||||
});
|
||||
|
||||
it("caps the list", () => {
|
||||
expect(suggestCustomersByName("BRIAN MICHAEL MCWILLIAMS", BOOK, 1)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("handles a null insured name", () => {
|
||||
expect(suggestCustomersByName(null, BOOK)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("suggestionNote", () => {
|
||||
it("says nothing when there is nothing", () => {
|
||||
expect(suggestionNote([])).toBeNull();
|
||||
});
|
||||
|
||||
it("names a single exact hit", () => {
|
||||
expect(suggestionNote(suggestCustomersByName("PAMELA WAGONER", BOOK))).toBe(
|
||||
"posible cliente por nombre: WAGONER, PAMELA",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a tie rather than picking one", () => {
|
||||
// The book really does hold EMERY, LAURA twice and KIRCHHOFF, CINDY
|
||||
// three times.
|
||||
const dupes: CustomerNameRow[] = [
|
||||
{ id: "d1", name: "EMERY, LAURA" },
|
||||
{ id: "d2", name: "EMERY, LAURA" },
|
||||
];
|
||||
expect(suggestionNote(suggestCustomersByName("LAURA EMERY", dupes))).toBe(
|
||||
"2 clientes tienen ese mismo nombre; elija cuál",
|
||||
);
|
||||
});
|
||||
|
||||
it("lists partial hits", () => {
|
||||
expect(suggestionNote(suggestCustomersByName("PAMELA DENISE WAGONER", BOOK))).toBe(
|
||||
"posibles clientes por nombre: WAGONER, PAMELA",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Suggests which existing customer a printed insured name belongs to.
|
||||
*
|
||||
* The office books customers surname-first ("WAGONER, PAMELA") and carriers
|
||||
* print them given-name-first ("PAMELA DENISE WAGONER"), so a string compare
|
||||
* never hits. Comparing *token sets* does, and it is order-insensitive by
|
||||
* construction — which is the whole trick.
|
||||
*
|
||||
* **These are suggestions, never matches.** Nothing here sets
|
||||
* `matchedCustomerId` or `confident`; the review screen offers the ranked
|
||||
* names and a human picks. That line is not caution, it is what the book
|
||||
* measures out to: of 1536 customers, 1487 have a distinct normalized token
|
||||
* set — but loosen the rule to surname + first given name only and 131 of
|
||||
* them (8.5%) collide, because the book holds `MCWILLIAMS, BRIAN MICHAEL`
|
||||
* *and* `MCWILLIAMS, BRIAN`, and `CUADROS, JORGE JR` alongside three
|
||||
* `CUADROS, JORGE H.`. 185 surnames are shared by 524 customers, so a
|
||||
* surname alone carries no information at all.
|
||||
*
|
||||
* The two tiers below are drawn at the two places that measurement puts a
|
||||
* cliff: full token-set equality, where cross-person collisions are
|
||||
* effectively zero, and strict containment, where they are common enough
|
||||
* that the result can only ever be a hint.
|
||||
*/
|
||||
|
||||
/** A customer row as the matcher needs it — id and the book's name. */
|
||||
export interface CustomerNameRow {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type NameMatchTier = "EXACT" | "PARTIAL";
|
||||
|
||||
export interface CustomerNameSuggestion {
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
/**
|
||||
* `EXACT` — the two names carry the same tokens, in any order.
|
||||
* `PARTIAL` — one name's tokens are all present in the other's, plus the
|
||||
* surname. A printed middle name the book does not hold, or a joint
|
||||
* account where the carrier named one spouse, both land here.
|
||||
*/
|
||||
tier: NameMatchTier;
|
||||
/** Shared tokens over the longer name's token count, 0..1. */
|
||||
score: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Words that carry no identity. Spanish particles and the ampersand joining
|
||||
* a couple are noise; the corporate suffixes are dropped so `S.A. DE C.V.`
|
||||
* does not make every company look alike.
|
||||
*/
|
||||
const NOISE = new Set([
|
||||
"DE", "DEL", "LA", "LAS", "LOS", "Y", "AND", "VDA",
|
||||
"JR", "SR", "II", "III", "IV",
|
||||
"SA", "CV", "SAPI", "SRL", "RL", "SC", "INC", "LLC", "LTD", "CORP", "CO",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Placeholder rows the migration left behind. Fourteen customers are named
|
||||
* literally `(SIN NOMBRE)`; without this they would be one 14-way tie on
|
||||
* every unreadable name.
|
||||
*/
|
||||
const PLACEHOLDER = new Set(["SIN NOMBRE", "NOMBRE SIN"]);
|
||||
|
||||
/**
|
||||
* A name blob longer than this is not a name. GMX's PVL especificación has
|
||||
* no field labels, and the parser has been seen handing its entire first
|
||||
* page over as `insuredName`; matching that against the book would find
|
||||
* a surname somewhere in the prose and suggest a stranger.
|
||||
*/
|
||||
const MAX_TOKENS = 8;
|
||||
const MAX_CHARS = 80;
|
||||
|
||||
/**
|
||||
* Splits a name into comparable tokens.
|
||||
*
|
||||
* Accents go first, and deliberately in both directions: the book holds
|
||||
* `MUÑOZ` where OCR routinely reads `MUNOZ`, and folding both to the same
|
||||
* ASCII makes that a hit rather than a miss.
|
||||
*
|
||||
* Tokens containing digits are dropped outright. ANA's automobile face
|
||||
* prints the phone number hard against the insured name — the parser has
|
||||
* emitted `MARIA GARCIA Ph.3102001538` — and the digits would otherwise
|
||||
* be an extra token forever blocking `EXACT`.
|
||||
*
|
||||
* Single letters are dropped as initials: the book is full of
|
||||
* `WEAKLAND, RICHARD E.`, and a carrier that prints the middle name in
|
||||
* full should still match the row that abbreviates it.
|
||||
*/
|
||||
export function nameTokens(raw: string): string[] {
|
||||
const cleaned = raw
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9]+/g, " ")
|
||||
.trim();
|
||||
|
||||
const tokens = cleaned
|
||||
.split(" ")
|
||||
.filter((t) => t.length > 1 && !/\d/.test(t) && !NOISE.has(t));
|
||||
|
||||
return [...new Set(tokens)];
|
||||
}
|
||||
|
||||
/** The surname tokens — everything before the comma the book writes. */
|
||||
function surnameTokens(bookName: string): string[] {
|
||||
const comma = bookName.indexOf(",");
|
||||
// 54 of 1536 rows have no comma at all ("GREENE STEPHANIE",
|
||||
// "FAROOQ VAKIL"), and which half is the surname is unknowable. Requiring
|
||||
// a surname we cannot identify would silently exclude those rows, so they
|
||||
// fall back to requiring nothing beyond the containment rule.
|
||||
if (comma < 0) return [];
|
||||
return nameTokens(bookName.slice(0, comma));
|
||||
}
|
||||
|
||||
function isPlaceholder(tokens: string[]): boolean {
|
||||
return tokens.length === 0 || PLACEHOLDER.has([...tokens].sort().join(" "));
|
||||
}
|
||||
|
||||
function containsAll(haystack: Set<string>, needles: string[]): boolean {
|
||||
return needles.every((n) => haystack.has(n));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ranks the book against one printed name.
|
||||
*
|
||||
* Returns at most `limit` suggestions, `EXACT` before `PARTIAL` and higher
|
||||
* score first. An empty array means the printed name was unusable (too
|
||||
* long, too few real tokens) or nothing in the book came close — both of
|
||||
* which leave the review screen exactly as it is today.
|
||||
*/
|
||||
export function suggestCustomersByName(
|
||||
printedName: string | null | undefined,
|
||||
customers: CustomerNameRow[],
|
||||
limit = 3,
|
||||
): CustomerNameSuggestion[] {
|
||||
if (!printedName || printedName.length > MAX_CHARS) return [];
|
||||
|
||||
const printed = nameTokens(printedName);
|
||||
// One usable token is a surname or a given name on its own, and 34% of the
|
||||
// book shares a surname with someone. Nothing useful can come of it.
|
||||
if (printed.length < 2 || printed.length > MAX_TOKENS) return [];
|
||||
|
||||
const printedSet = new Set(printed);
|
||||
const out: CustomerNameSuggestion[] = [];
|
||||
|
||||
for (const c of customers) {
|
||||
const book = nameTokens(c.name);
|
||||
if (isPlaceholder(book) || book.length < 2) continue;
|
||||
|
||||
const bookSet = new Set(book);
|
||||
const overlap = printed.filter((t) => bookSet.has(t)).length;
|
||||
// Two shared tokens is the floor: one is a bare surname collision.
|
||||
if (overlap < 2) continue;
|
||||
|
||||
const bookInPrinted = containsAll(printedSet, book);
|
||||
const printedInBook = containsAll(bookSet, printed);
|
||||
if (!bookInPrinted && !printedInBook) continue;
|
||||
|
||||
// When the book's name is the shorter one, containment already proves
|
||||
// the surname was printed. When the printed name is shorter — the book
|
||||
// holds a middle name or a second spouse the carrier omitted — the
|
||||
// surname must be there explicitly, or `JERRY MARILYN` would match
|
||||
// `ESTRADA, JERRY & MARILYN` on given names alone.
|
||||
if (!bookInPrinted && !containsAll(printedSet, surnameTokens(c.name))) continue;
|
||||
|
||||
out.push({
|
||||
customerId: c.id,
|
||||
customerName: c.name,
|
||||
tier: bookInPrinted && printedInBook ? "EXACT" : "PARTIAL",
|
||||
score: overlap / Math.max(book.length, printed.length),
|
||||
});
|
||||
}
|
||||
|
||||
out.sort((a, b) => {
|
||||
if (a.tier !== b.tier) return a.tier === "EXACT" ? -1 : 1;
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
return a.customerName.localeCompare(b.customerName);
|
||||
});
|
||||
|
||||
return out.slice(0, limit);
|
||||
}
|
||||
|
||||
/** Review-queue wording for what the suggestions amount to. */
|
||||
export function suggestionNote(suggestions: CustomerNameSuggestion[]): string | null {
|
||||
if (suggestions.length === 0) return null;
|
||||
|
||||
const exact = suggestions.filter((s) => s.tier === "EXACT");
|
||||
// More than one exact hit is the duplicate-customer case the book really
|
||||
// has (`EMERY, LAURA` twice, `KIRCHHOFF, CINDY` three times). Saying so is
|
||||
// more useful than naming whichever one sorted first.
|
||||
if (exact.length > 1) {
|
||||
return `${exact.length} clientes tienen ese mismo nombre; elija cuál`;
|
||||
}
|
||||
if (exact.length === 1) {
|
||||
return `posible cliente por nombre: ${exact[0].customerName}`;
|
||||
}
|
||||
return `posibles clientes por nombre: ${suggestions.map((s) => s.customerName).join(", ")}`;
|
||||
}
|
||||
@@ -689,8 +689,23 @@ describe("parsePolicy / ANA automobile", () => {
|
||||
// reading would shift every value one column left.
|
||||
expect(p.netPremium).toBe(298.61);
|
||||
expect(p.policyFee).toBe(30);
|
||||
expect(p.tax).toBe(26.29);
|
||||
expect(p.total).toBe(354.9);
|
||||
expect(p.notes.join(" | ")).toMatch(/impuesto: 26\.29/);
|
||||
});
|
||||
|
||||
it("reads a TAX that reconciles against the rest of the row", () => {
|
||||
// 298.61 + 30.00 = 328.61, taxed at 8% -> 26.29, totalling 354.90. The
|
||||
// whole row agreeing is what proves the positional mapping landed on the
|
||||
// right cells rather than merely on six numbers.
|
||||
const base = p.netPremium! + p.policyFee!;
|
||||
expect(Math.round(base * 0.08 * 100) / 100).toBe(p.tax);
|
||||
expect(Math.round((base + p.tax!) * 100) / 100).toBe(p.total);
|
||||
});
|
||||
|
||||
it("does not fold LOCAL TAX into the IVA", () => {
|
||||
// It prints 0.00 here, so nothing to fold — but the guard is that a
|
||||
// non-zero one would surface as a note instead of inflating `tax`.
|
||||
expect(p.notes.join(" | ")).not.toMatch(/impuesto local/);
|
||||
});
|
||||
|
||||
it("reads the vehicle by token role, not by column", () => {
|
||||
|
||||
@@ -36,6 +36,17 @@ export interface ParsedPolicy {
|
||||
netPremium: number | null;
|
||||
policyFee: number | null;
|
||||
brokerFee: number | null;
|
||||
/**
|
||||
* IVA, off A.N.A.'s `TAX` cell. Null on GMX — its certificate carries no
|
||||
* premium at all, so there is no tax on it to read either.
|
||||
*
|
||||
* The adjacent `LOCAL TAX` cell is deliberately NOT folded in here. It is a
|
||||
* separate levy with no column of its own, and summing the two would report
|
||||
* an IVA figure that no longer divides back to a rate — the whole point of
|
||||
* storing it. It prints 0.00 on every policy seen so far and is surfaced as
|
||||
* a note when it is not.
|
||||
*/
|
||||
tax: number | null;
|
||||
total: number | null;
|
||||
/** "CONTADO" / "MENSUAL" / … — premium-payment cadence text. */
|
||||
premiumPayment: string | null;
|
||||
@@ -319,6 +330,7 @@ function emptyParsedPolicy(provider: string): ParsedPolicy {
|
||||
netPremium: null,
|
||||
policyFee: null,
|
||||
brokerFee: null,
|
||||
tax: null,
|
||||
total: null,
|
||||
premiumPayment: null,
|
||||
coverages: [],
|
||||
@@ -1178,6 +1190,7 @@ interface AnaHeader {
|
||||
coveragePeriodDays: number | null;
|
||||
netPremium: number | null;
|
||||
policyFee: number | null;
|
||||
tax: number | null;
|
||||
total: number | null;
|
||||
}
|
||||
|
||||
@@ -1253,8 +1266,13 @@ function parseAnaHeader(lines: string[], notes: string[]): AnaHeader {
|
||||
// ----- money row ---------------------------------------------------------
|
||||
const row = anaMoneyRow(lines);
|
||||
if (!row) notes.push("no se pudo leer el renglón de primas");
|
||||
if (row?.tax) notes.push(`impuesto: ${row.tax.toFixed(2)}`);
|
||||
if (row?.discount) notes.push(`descuento: ${row.discount.toFixed(2)}`);
|
||||
// LOCAL TAX has no destination column and prints 0.00 on every A.N.A. policy
|
||||
// seen so far. A non-zero one means the total will not reconcile against the
|
||||
// stored IVA, so say so rather than folding it in and hiding the difference.
|
||||
if (row?.localTax) {
|
||||
notes.push(`impuesto local ${row.localTax.toFixed(2)} no capturado`);
|
||||
}
|
||||
|
||||
return {
|
||||
policyNumber,
|
||||
@@ -1266,6 +1284,7 @@ function parseAnaHeader(lines: string[], notes: string[]): AnaHeader {
|
||||
coveragePeriodDays,
|
||||
netPremium: row?.netPremium ?? null,
|
||||
policyFee: row?.policyFee ?? null,
|
||||
tax: row?.tax ?? null,
|
||||
total: row?.total ?? null,
|
||||
};
|
||||
}
|
||||
@@ -1455,6 +1474,7 @@ function parseAnaAutomobile(lines: string[]): ParsedPolicy {
|
||||
currency: anaCurrency(text, notes),
|
||||
netPremium: header.netPremium,
|
||||
policyFee: header.policyFee,
|
||||
tax: header.tax,
|
||||
total: header.total,
|
||||
premiumPayment: paymentDeadline,
|
||||
coverages,
|
||||
@@ -1854,6 +1874,7 @@ function parseAnaDriverPolicy(lines: string[]): ParsedPolicy {
|
||||
currency: anaCurrency(text, notes),
|
||||
netPremium: header.netPremium,
|
||||
policyFee: header.policyFee,
|
||||
tax: header.tax,
|
||||
total: header.total,
|
||||
coverages,
|
||||
coveragePeriodDays: header.coveragePeriodDays,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { PolicyMatcherService } from "./policy-matcher.service";
|
||||
import type { PrismaService } from "../prisma/prisma.service";
|
||||
import type { ParsedPolicy } from "./parsers/policy-parser";
|
||||
|
||||
function parsed(over: Partial<ParsedPolicy> = {}): ParsedPolicy {
|
||||
return {
|
||||
provider: "GMX",
|
||||
policyNumber: null,
|
||||
insuredName: null,
|
||||
notes: [],
|
||||
coverages: [],
|
||||
vehicles: [],
|
||||
drivers: [],
|
||||
...over,
|
||||
} as unknown as ParsedPolicy;
|
||||
}
|
||||
|
||||
function prismaStub(policies: unknown[], customers: { id: string; name: string }[]) {
|
||||
const findManyPolicy = jest.fn().mockResolvedValue(policies);
|
||||
const findManyCustomer = jest.fn().mockResolvedValue(customers);
|
||||
return {
|
||||
prisma: {
|
||||
policy: { findMany: findManyPolicy },
|
||||
customer: { findMany: findManyCustomer },
|
||||
} as unknown as PrismaService,
|
||||
findManyPolicy,
|
||||
findManyCustomer,
|
||||
};
|
||||
}
|
||||
|
||||
const BOOK = [
|
||||
{ id: "cust-1", name: "WAGONER, PAMELA" },
|
||||
{ id: "cust-2", name: "SMITH, JOHN" },
|
||||
];
|
||||
|
||||
describe("PolicyMatcherService name suggestions", () => {
|
||||
it("suggests a customer when the policy number is new", async () => {
|
||||
const { prisma } = prismaStub([], BOOK);
|
||||
const svc = new PolicyMatcherService(prisma);
|
||||
|
||||
const r = await svc.match(
|
||||
parsed({ policyNumber: "P-999", insuredName: "PAMELA DENISE WAGONER" } as never),
|
||||
);
|
||||
|
||||
expect(r.customerSuggestions).toEqual([
|
||||
expect.objectContaining({ customerId: "cust-1", tier: "PARTIAL" }),
|
||||
]);
|
||||
// The suggestion is surfaced, never applied.
|
||||
expect(r.customerId).toBeNull();
|
||||
expect(r.confident).toBe(false);
|
||||
expect(r.note).toContain("posibles clientes por nombre: WAGONER, PAMELA");
|
||||
});
|
||||
|
||||
it("suggests when the policy number could not be read at all", async () => {
|
||||
const { prisma } = prismaStub([], BOOK);
|
||||
const svc = new PolicyMatcherService(prisma);
|
||||
|
||||
const r = await svc.match(parsed({ insuredName: "PAMELA WAGONER" } as never));
|
||||
|
||||
expect(r.customerSuggestions[0]).toMatchObject({ customerId: "cust-1", tier: "EXACT" });
|
||||
expect(r.customerId).toBeNull();
|
||||
expect(r.note).toBe(
|
||||
"no se pudo leer el número de póliza; posible cliente por nombre: WAGONER, PAMELA",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not touch the book when the policy number hits", async () => {
|
||||
const { prisma, findManyCustomer } = prismaStub(
|
||||
[
|
||||
{
|
||||
id: "pol-1",
|
||||
policyNumber: "P-1",
|
||||
customerId: "cust-2",
|
||||
customer: { name: "SMITH, JOHN" },
|
||||
},
|
||||
],
|
||||
BOOK,
|
||||
);
|
||||
const svc = new PolicyMatcherService(prisma);
|
||||
|
||||
const r = await svc.match(
|
||||
parsed({ policyNumber: "P-1", insuredName: "PAMELA WAGONER" } as never),
|
||||
);
|
||||
|
||||
expect(r.confident).toBe(true);
|
||||
expect(r.customerId).toBe("cust-2");
|
||||
expect(r.customerSuggestions).toEqual([]);
|
||||
expect(findManyCustomer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads the customer book once across a batch", async () => {
|
||||
const { prisma, findManyCustomer } = prismaStub([], BOOK);
|
||||
const svc = new PolicyMatcherService(prisma);
|
||||
|
||||
await svc.match(parsed({ policyNumber: "A", insuredName: "PAMELA WAGONER" } as never));
|
||||
await svc.match(parsed({ policyNumber: "B", insuredName: "JOHN SMITH" } as never));
|
||||
|
||||
expect(findManyCustomer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import type { ParsedPolicy } from "./parsers/policy-parser";
|
||||
import {
|
||||
suggestCustomersByName,
|
||||
suggestionNote,
|
||||
type CustomerNameRow,
|
||||
type CustomerNameSuggestion,
|
||||
} from "./name-matcher";
|
||||
|
||||
export interface MatchResult {
|
||||
policyId: string | null;
|
||||
@@ -14,8 +20,24 @@ export interface MatchResult {
|
||||
* the policy number is shared across customers and a human must pick.
|
||||
*/
|
||||
candidates: { policyId: string; customerId: string; customerName: string; policyNumber: string }[];
|
||||
/**
|
||||
* Customers whose name resembles the printed insured name. Populated only
|
||||
* when the policy number resolved to nothing, and never used to set
|
||||
* `customerId` or `confident` — see the class comment.
|
||||
*/
|
||||
customerSuggestions: CustomerNameSuggestion[];
|
||||
}
|
||||
|
||||
/**
|
||||
* How long the customer book is reused across documents in a batch.
|
||||
*
|
||||
* A twenty-page batch would otherwise read all 1536 rows twenty times. The
|
||||
* only cost of the staleness is that a customer created in the last minute
|
||||
* is not suggested — the picker still finds them, so nothing is lost that a
|
||||
* reviewer cannot do in one click.
|
||||
*/
|
||||
const BOOK_TTL_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Resolves a parsed policy page to an existing Policy (and its customer) the
|
||||
* office already holds.
|
||||
@@ -33,14 +55,29 @@ export interface MatchResult {
|
||||
* policy numbers across customers do occur (same group policy bound by two
|
||||
* related parties), and picking one arbitrarily would silently book the
|
||||
* wrong coverage.
|
||||
*
|
||||
* On that zero-hit path only, the printed name is used to *rank the picker*
|
||||
* — see `name-matcher.ts`. That is not a walk-back of the rule above: the
|
||||
* suggestion never reaches `customerId` or `confident`, a human still picks,
|
||||
* and the ranking exists because the office writes names surname-first
|
||||
* ("WAGONER, PAMELA") while carriers print them given-name-first ("PAMELA
|
||||
* DENISE WAGONER"), so the reviewer is retyping a name the machine could
|
||||
* have offered.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PolicyMatcherService {
|
||||
private book: { rows: CustomerNameRow[]; loadedAt: number } | null = null;
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async match(parsed: ParsedPolicy): Promise<MatchResult> {
|
||||
if (!parsed.policyNumber) {
|
||||
return this.unmatched("no se pudo leer el número de póliza");
|
||||
// No number to search on, so the page goes to review with a picker —
|
||||
// the same place the name suggestions help.
|
||||
return this.unmatched(
|
||||
"no se pudo leer el número de póliza",
|
||||
await this.suggestByName(parsed.insuredName),
|
||||
);
|
||||
}
|
||||
|
||||
const rows = await this.prisma.policy.findMany({
|
||||
@@ -61,22 +98,33 @@ export class PolicyMatcherService {
|
||||
}));
|
||||
|
||||
if (rows.length === 0) {
|
||||
const suggestions = await this.suggestByName(parsed.insuredName);
|
||||
const hint = suggestionNote(suggestions);
|
||||
return {
|
||||
policyId: null,
|
||||
customerId: null,
|
||||
note: `no se encontró ninguna póliza con el número ${parsed.policyNumber}`,
|
||||
note: [
|
||||
`no se encontró ninguna póliza con el número ${parsed.policyNumber}`,
|
||||
hint,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("; "),
|
||||
confident: false,
|
||||
candidates: [],
|
||||
customerSuggestions: suggestions,
|
||||
};
|
||||
}
|
||||
|
||||
if (rows.length > 1) {
|
||||
// The policy number did find rows; the reviewer picks among those, and
|
||||
// adding name guesses on top would only add noise.
|
||||
return {
|
||||
policyId: null,
|
||||
customerId: null,
|
||||
note: `${rows.length} pólizas comparten el número ${parsed.policyNumber}`,
|
||||
confident: false,
|
||||
candidates,
|
||||
customerSuggestions: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,16 +134,47 @@ export class PolicyMatcherService {
|
||||
note: `coincidencia exacta por número de póliza ${parsed.policyNumber}`,
|
||||
confident: true,
|
||||
candidates,
|
||||
customerSuggestions: [],
|
||||
};
|
||||
}
|
||||
|
||||
private unmatched(note: string): MatchResult {
|
||||
private async suggestByName(
|
||||
insuredName: string | null | undefined,
|
||||
): Promise<CustomerNameSuggestion[]> {
|
||||
if (!insuredName) return [];
|
||||
return suggestCustomersByName(insuredName, await this.customerBook());
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole customer book, held briefly. 1536 rows of `{id, name}` is a
|
||||
* few hundred kilobytes and the comparison is pure token-set work, so
|
||||
* scanning it beats any SQL approximation — and a `LIKE` search would in
|
||||
* any case have to guess which token is the surname, which is the one
|
||||
* thing the office's own data does not agree on.
|
||||
*/
|
||||
private async customerBook(): Promise<CustomerNameRow[]> {
|
||||
if (this.book && Date.now() - this.book.loadedAt < BOOK_TTL_MS) {
|
||||
return this.book.rows;
|
||||
}
|
||||
const rows = await this.prisma.customer.findMany({
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
this.book = { rows, loadedAt: Date.now() };
|
||||
return rows;
|
||||
}
|
||||
|
||||
private unmatched(
|
||||
note: string,
|
||||
customerSuggestions: CustomerNameSuggestion[] = [],
|
||||
): MatchResult {
|
||||
const hint = suggestionNote(customerSuggestions);
|
||||
return {
|
||||
policyId: null,
|
||||
customerId: null,
|
||||
note,
|
||||
note: [note, hint].filter(Boolean).join("; "),
|
||||
confident: false,
|
||||
candidates: [],
|
||||
customerSuggestions,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ export class ConfirmPolicyDocumentDto {
|
||||
@IsOptional() @IsNumber() netPremium?: number;
|
||||
@IsOptional() @IsNumber() policyFee?: number;
|
||||
@IsOptional() @IsNumber() brokerFee?: number;
|
||||
@IsOptional() @IsNumber() tax?: number;
|
||||
@IsOptional() @IsNumber() total?: number;
|
||||
@IsOptional() @IsString() premiumPayment?: string;
|
||||
/** Printed term in days. Omitted leaves the parsed value (or the schema's
|
||||
@@ -79,6 +80,7 @@ export class ReviewPolicyDocumentDto {
|
||||
@IsOptional() @IsNumber() netPremium?: number;
|
||||
@IsOptional() @IsNumber() policyFee?: number;
|
||||
@IsOptional() @IsNumber() brokerFee?: number;
|
||||
@IsOptional() @IsNumber() tax?: number;
|
||||
@IsOptional() @IsNumber() total?: number;
|
||||
@IsOptional() @IsString() premiumPayment?: string;
|
||||
@IsOptional() @IsInt() @Min(1) @Max(3660) coveragePeriodDays?: number;
|
||||
|
||||
@@ -192,6 +192,8 @@ export class PolicyOcrService {
|
||||
parsed.policyFee != null ? new Prisma.Decimal(parsed.policyFee) : null,
|
||||
extractedBrokerFee:
|
||||
parsed.brokerFee != null ? new Prisma.Decimal(parsed.brokerFee) : null,
|
||||
extractedTax:
|
||||
parsed.tax != null ? new Prisma.Decimal(parsed.tax) : null,
|
||||
extractedTotal:
|
||||
parsed.total != null ? new Prisma.Decimal(parsed.total) : null,
|
||||
extractedCoveragesJson: parsed.coverages.length
|
||||
@@ -211,6 +213,9 @@ export class PolicyOcrService {
|
||||
matchCandidates: match.candidates.length
|
||||
? (match.candidates as unknown as Prisma.InputJsonValue)
|
||||
: Prisma.DbNull,
|
||||
customerSuggestions: match.customerSuggestions.length
|
||||
? (match.customerSuggestions as unknown as Prisma.InputJsonValue)
|
||||
: Prisma.DbNull,
|
||||
matchNote: notes.join("; "),
|
||||
},
|
||||
});
|
||||
@@ -362,6 +367,7 @@ export class PolicyOcrService {
|
||||
dto.policyFee != null ? new Prisma.Decimal(dto.policyFee) : undefined,
|
||||
extractedBrokerFee:
|
||||
dto.brokerFee != null ? new Prisma.Decimal(dto.brokerFee) : undefined,
|
||||
extractedTax: dto.tax != null ? new Prisma.Decimal(dto.tax) : undefined,
|
||||
extractedTotal:
|
||||
dto.total != null ? new Prisma.Decimal(dto.total) : undefined,
|
||||
extractedCoveragesJson: dto.coveragesJson
|
||||
@@ -796,6 +802,7 @@ function buildPolicyUpdateFromDoc(
|
||||
extractedNetPremium: Prisma.Decimal | null;
|
||||
extractedPolicyFee: Prisma.Decimal | null;
|
||||
extractedBrokerFee: Prisma.Decimal | null;
|
||||
extractedTax: Prisma.Decimal | null;
|
||||
extractedTotal: Prisma.Decimal | null;
|
||||
extractedCoveragesJson: Prisma.JsonValue | null;
|
||||
extractedPremiumPayment: string | null;
|
||||
@@ -842,6 +849,10 @@ function buildPolicyUpdateFromDoc(
|
||||
netPremium: numOrUndef(item.netPremium, doc.extractedNetPremium),
|
||||
policyFee: numOrUndef(item.policyFee, doc.extractedPolicyFee),
|
||||
brokerFee: numOrUndef(item.brokerFee, doc.extractedBrokerFee),
|
||||
// `taxRate` is deliberately left alone. A.N.A. prints the IVA amount, not
|
||||
// the rate, and back-dividing it would mint a rate the document never
|
||||
// stated — the policy form resolves one from the line of business instead.
|
||||
tax: numOrUndef(item.tax, doc.extractedTax),
|
||||
total: numOrUndef(item.total, doc.extractedTotal),
|
||||
// coveragesJson / observations: freeform, keep the GMX data when present.
|
||||
coveragesJson:
|
||||
@@ -883,6 +894,7 @@ function buildPolicyCreateFromDoc(
|
||||
extractedNetPremium: Prisma.Decimal | null;
|
||||
extractedPolicyFee: Prisma.Decimal | null;
|
||||
extractedBrokerFee: Prisma.Decimal | null;
|
||||
extractedTax: Prisma.Decimal | null;
|
||||
extractedTotal: Prisma.Decimal | null;
|
||||
extractedCoveragesJson: Prisma.JsonValue | null;
|
||||
extractedPremiumPayment: string | null;
|
||||
@@ -933,6 +945,10 @@ function buildPolicyCreateFromDoc(
|
||||
netPremium: numOrUndef(item.netPremium, doc.extractedNetPremium),
|
||||
policyFee: numOrUndef(item.policyFee, doc.extractedPolicyFee),
|
||||
brokerFee: numOrUndef(item.brokerFee, doc.extractedBrokerFee),
|
||||
// `taxRate` is deliberately left alone. A.N.A. prints the IVA amount, not
|
||||
// the rate, and back-dividing it would mint a rate the document never
|
||||
// stated — the policy form resolves one from the line of business instead.
|
||||
tax: numOrUndef(item.tax, doc.extractedTax),
|
||||
total: numOrUndef(item.total, doc.extractedTotal),
|
||||
coveragesJson:
|
||||
item.coveragesJson !== undefined
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import { Prisma } from "@jorgecuadros/database";
|
||||
import { BALANCE_FORWARD_TYPE } from "../billing/billing.service";
|
||||
import {
|
||||
intParam,
|
||||
NOT_VOIDED,
|
||||
@@ -751,8 +752,8 @@ const edoCuentaDatos: ReportDef = {
|
||||
title: "Estado de cuenta",
|
||||
description:
|
||||
"Estado de cuenta de un cliente: saldos por moneda, desglose por " +
|
||||
"ramo y concepto, y el historial completo de movimientos con saldo " +
|
||||
"corrido. El reporte del cliente final.",
|
||||
"ramo y concepto, y los movimientos del año en curso con saldo " +
|
||||
"corrido, abriendo con el saldo anterior. El reporte del cliente final.",
|
||||
domain: "estado-cuenta",
|
||||
legacyName: "EDO CUENTA DATOS",
|
||||
format: "statement",
|
||||
@@ -789,13 +790,31 @@ const edoCuentaDatos: ReportDef = {
|
||||
});
|
||||
if (!customer) return { rows: [], subtitle: "Cliente no encontrado" };
|
||||
|
||||
// Reuse the same NOT_VOIDED + STATEMENT_EXCLUDED_SOURCE_TABLES filter
|
||||
// as BillingService.statement so the numbers match what the customer
|
||||
// already sees in /estado-cuenta/[id].
|
||||
// The source-table exclusion, the balance floor and the year scope below
|
||||
// are BillingService.statement's, because this report and
|
||||
// /estado-cuenta/[id] are the same statement — one printable, one on
|
||||
// screen — and a customer holding both must not read two balances.
|
||||
const floor = await prisma.transaction.findFirst({
|
||||
where: {
|
||||
customerId,
|
||||
voidedAt: null,
|
||||
type: { nameEn: BALANCE_FORWARD_TYPE },
|
||||
},
|
||||
orderBy: { transactionDate: "desc" },
|
||||
select: { transactionDate: true },
|
||||
});
|
||||
|
||||
const rows = await prisma.transaction.findMany({
|
||||
where: {
|
||||
customerId,
|
||||
voidedAt: null,
|
||||
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
||||
// NULL-safe: `NULL NOT IN (...)` is NULL, not true, so a bare `notIn`
|
||||
// drops every app-captured row (they have no legacySourceTable) — the
|
||||
// same defect this report's on-screen twin was fixed for.
|
||||
OR: [
|
||||
{ legacySourceTable: null },
|
||||
{
|
||||
legacySourceTable: {
|
||||
notIn: [
|
||||
"EFECTIVO",
|
||||
@@ -806,6 +825,8 @@ const edoCuentaDatos: ReportDef = {
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
@@ -822,12 +843,28 @@ const edoCuentaDatos: ReportDef = {
|
||||
},
|
||||
});
|
||||
|
||||
// Compute running balance per currency, then return newest-first.
|
||||
// Scoped to the calendar year and listed oldest-first, the way the legacy
|
||||
// EDO CUENTA sheet reads. Rows from earlier years still move the running
|
||||
// balance — they are folded into `opening` and printed as a single "saldo
|
||||
// anterior" line, which is what a BALANCE FORWARD row is.
|
||||
const yearStart = new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1));
|
||||
const year = yearStart.getUTCFullYear();
|
||||
|
||||
const running = new Map<string, Prisma.Decimal>();
|
||||
const movements = rows.map((r) => {
|
||||
const opening = new Map<string, Prisma.Decimal>();
|
||||
const visible: typeof rows = [];
|
||||
|
||||
const movements = rows.flatMap((r) => {
|
||||
const prev = running.get(r.currency) ?? new Prisma.Decimal(0);
|
||||
const next = prev.plus(r.amount);
|
||||
running.set(r.currency, next);
|
||||
|
||||
if (r.transactionDate < yearStart) {
|
||||
opening.set(r.currency, next);
|
||||
return [];
|
||||
}
|
||||
|
||||
visible.push(r);
|
||||
return {
|
||||
date: r.transactionDate.toISOString().slice(0, 10),
|
||||
domain: r.domain,
|
||||
@@ -840,14 +877,38 @@ const edoCuentaDatos: ReportDef = {
|
||||
balanceAfter: next.toFixed(2),
|
||||
};
|
||||
});
|
||||
movements.reverse();
|
||||
|
||||
// Per-currency summary + per-domain breakdown.
|
||||
// The carried balance, printed as the statement's first line — same shape
|
||||
// as a movement row so it needs nothing special from the renderer.
|
||||
const carried = [...opening.entries()]
|
||||
.filter(([, amount]) => !amount.isZero())
|
||||
.map(([currency, amount]) => ({
|
||||
date: yearStart.toISOString().slice(0, 10),
|
||||
domain: "UTILITY",
|
||||
currency,
|
||||
reference: "",
|
||||
period: `Al cierre de ${year - 1}`,
|
||||
checkNumber: "",
|
||||
concept: "SALDO ANTERIOR",
|
||||
amount: amount.toFixed(2),
|
||||
balanceAfter: amount.toFixed(2),
|
||||
}));
|
||||
|
||||
// Per-currency summary, seeded with the carried balance so it reconciles
|
||||
// against the last running balance printed below.
|
||||
const perCurrency = new Map<
|
||||
string,
|
||||
{ currency: string; charges: Prisma.Decimal; credits: Prisma.Decimal; count: number }
|
||||
>();
|
||||
for (const r of rows) {
|
||||
for (const [currency, amount] of opening) {
|
||||
perCurrency.set(currency, {
|
||||
currency,
|
||||
charges: amount.lessThan(0) ? amount : new Prisma.Decimal(0),
|
||||
credits: amount.lessThan(0) ? new Prisma.Decimal(0) : amount,
|
||||
count: 0,
|
||||
});
|
||||
}
|
||||
for (const r of visible) {
|
||||
const c =
|
||||
perCurrency.get(r.currency) ??
|
||||
{
|
||||
@@ -881,9 +942,10 @@ const edoCuentaDatos: ReportDef = {
|
||||
count: c.count,
|
||||
})),
|
||||
{ __kind: "movements-header" },
|
||||
...carried,
|
||||
...movements,
|
||||
],
|
||||
subtitle: `${nameOf(customer)} · ${rows.length} movimientos`,
|
||||
subtitle: `${nameOf(customer)} · ${year} · ${visible.length} movimientos`,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jorgecuadros/web",
|
||||
"version": "1.0.20",
|
||||
"version": "1.0.23",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 4500",
|
||||
|
||||
@@ -18,6 +18,10 @@ const TYPE: ChildConfig = {
|
||||
fields: [
|
||||
{ key: "name", label: "Nombre" },
|
||||
{ key: "shortDescription", label: "Descripción" },
|
||||
// The rate is stored as a fraction, not a percentage, and the label has to
|
||||
// say so: 8 typed here would tax a $600 premium $4,800. The API rejects
|
||||
// anything above 1 rather than trusting the label alone.
|
||||
{ key: "taxRate", label: "IVA (0.08 = 8%)", type: "number", step: "0.0001" },
|
||||
],
|
||||
};
|
||||
const ADJUSTER: ChildConfig = {
|
||||
|
||||
@@ -36,10 +36,12 @@ import type {
|
||||
* charge and an insurance payment finally sit on the same page, under the same
|
||||
* person, with a running balance.
|
||||
*
|
||||
* The running balance is per currency (the API accumulates it chronologically
|
||||
* before handing the list back newest-first), so the movement table is scoped
|
||||
* to one currency at a time — a column that alternated between pesos and
|
||||
* dollars would be a meaningless number.
|
||||
* The running balance is per currency, so the movement table is scoped to one
|
||||
* currency at a time — a column that alternated between pesos and dollars would
|
||||
* be a meaningless number.
|
||||
*
|
||||
* Like the legacy EDO CUENTA report, the table covers the current year only and
|
||||
* runs oldest-first, opening on the balance carried in from before it.
|
||||
*/
|
||||
export default function EstadoCuentaDetailPage({
|
||||
params,
|
||||
@@ -194,7 +196,7 @@ function StatementView({ id }: { id: string }) {
|
||||
<section className="section">
|
||||
<SectionHead
|
||||
rule="cuenta"
|
||||
title="Movimientos"
|
||||
title={`Movimientos ${data.year}`}
|
||||
count={movements.length}
|
||||
countSuffix={movements.length === 1 ? "movimiento" : "movimientos"}
|
||||
right={
|
||||
@@ -260,7 +262,7 @@ function StatementView({ id }: { id: string }) {
|
||||
<div className="card">
|
||||
{movements.length === 0 ? (
|
||||
<div className="empty-inline">
|
||||
Sin movimientos en {currency}
|
||||
Sin movimientos de {data.year} en {currency}
|
||||
{domain ? ` para ${domainLabel(domain)}` : ""}.
|
||||
</div>
|
||||
) : (
|
||||
@@ -282,6 +284,26 @@ function StatementView({ id }: { id: string }) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/*
|
||||
The carried balance, shown the way the legacy report shows
|
||||
it: a BALANCE FORWARD line above the year's movements. It
|
||||
only appears when there is something to carry — when the
|
||||
customer's opening-balance row is itself dated inside this
|
||||
year (the usual case) it is listed as an ordinary movement
|
||||
and this row is zero, so it is left out.
|
||||
|
||||
Suppressed under a business-line filter: the carried balance
|
||||
is the customer's, across both lines, and printing it above
|
||||
one line's rows would read as that line's opening balance.
|
||||
*/}
|
||||
{!domain && Number(active?.opening ?? 0) !== 0 && (
|
||||
<OpeningRow
|
||||
opening={active!.opening}
|
||||
currency={currency}
|
||||
year={data.year}
|
||||
canVoid={canVoid}
|
||||
/>
|
||||
)}
|
||||
{movements.map((m) => (
|
||||
<StatementRow
|
||||
key={m.id}
|
||||
@@ -481,6 +503,44 @@ function ConceptosSection({
|
||||
);
|
||||
}
|
||||
|
||||
/** The balance carried into the statement year — legacy's BALANCE FORWARD. */
|
||||
function OpeningRow({
|
||||
opening,
|
||||
currency,
|
||||
year,
|
||||
canVoid,
|
||||
}: {
|
||||
opening: string;
|
||||
currency: LedgerCurrency;
|
||||
year: number;
|
||||
canVoid: boolean;
|
||||
}) {
|
||||
return (
|
||||
<tr>
|
||||
<td className="mono" style={{ whiteSpace: "nowrap" }}>
|
||||
{formatDate(`${year}-01-01T00:00:00.000Z`)}
|
||||
</td>
|
||||
<td className="tx-domain-cell">Ambas líneas</td>
|
||||
<td>
|
||||
Saldo anterior
|
||||
<div className="tx-concept">Al cierre de {year - 1}</div>
|
||||
</td>
|
||||
<td className="tx-ref">—</td>
|
||||
<td className="num">
|
||||
<span className={`tx-amount ${Number(opening) < 0 ? "neg" : "pos"}`}>
|
||||
{formatMoney(opening, currency)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="num">
|
||||
<span className={`bal-running ${balanceTone(opening)}`}>
|
||||
{formatMoney(opening, currency)}
|
||||
</span>
|
||||
</td>
|
||||
{canVoid && <td />}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function StatementRow({
|
||||
m,
|
||||
canVoid,
|
||||
|
||||
@@ -926,6 +926,15 @@ button {
|
||||
color: var(--ink-soft);
|
||||
margin-bottom: 0.4375rem;
|
||||
}
|
||||
/* Sub-label under an input: the computed figure behind an override field, or
|
||||
why a field is disabled. Quiet enough not to compete with .field-label. */
|
||||
.field-hint {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.35;
|
||||
color: var(--muted-2);
|
||||
margin-top: 0.3125rem;
|
||||
}
|
||||
.input {
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
@@ -940,6 +949,12 @@ button {
|
||||
.input::placeholder {
|
||||
color: var(--muted-2);
|
||||
}
|
||||
.input:disabled,
|
||||
.select:disabled {
|
||||
background: var(--surface-2, var(--surface));
|
||||
color: var(--muted-2);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand-600);
|
||||
@@ -3114,3 +3129,61 @@ button {
|
||||
border-color: var(--brand-500);
|
||||
color: var(--brand-700);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Layout + text utilities the screens already assumed
|
||||
Several components were written against these names before any rule
|
||||
defined them, so they rendered as bare inline spans. The visible symptom
|
||||
was the policy OCR review header running together —
|
||||
"Para revisarPágina 1700489616· PAMELA DENISE WAGONERLICENCIASANA" —
|
||||
because JSX drops the newline between sibling elements and the `gap` those
|
||||
call sites pass does nothing without a flex container.
|
||||
========================================================================== */
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
/* The muted line under a page title, and the same voice reused inline. Only
|
||||
the block form takes a margin — as a flex child it would shift the item
|
||||
off the row's centre line. */
|
||||
.page-sub {
|
||||
color: var(--muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
p.page-sub {
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
/* A neutral chip. Same shape as `.badge` so the OCR statuses, policy type and
|
||||
carrier read as the labels they are rather than as running prose. */
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.1875rem 0.5625rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
background: var(--paper-2);
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--line-strong);
|
||||
}
|
||||
/* The warning sibling of `.state-error`, used where a page needs a human to
|
||||
choose between candidates rather than reporting a failure. */
|
||||
.state-warn {
|
||||
background: var(--servicios-tint);
|
||||
border: 1px solid rgba(154, 106, 18, 0.25);
|
||||
color: var(--servicios-ink);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1.125rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,13 @@ import {
|
||||
premiumHeadline,
|
||||
SIN_NOMBRE,
|
||||
} from "@/lib/labels";
|
||||
import type { AdjusterRow, Installment, PolicyDetail } from "@/lib/types";
|
||||
import {
|
||||
PAYMENT_FREQUENCY_LABELS,
|
||||
type AdjusterRow,
|
||||
type Installment,
|
||||
type PolicyDetail,
|
||||
} from "@/lib/types";
|
||||
import { formatRate } from "@/lib/premium";
|
||||
|
||||
export default function PolizaDetailPage({
|
||||
params,
|
||||
@@ -186,6 +192,10 @@ function ChildrenEditor({
|
||||
const INSTALLMENTS: ChildConfig = {
|
||||
apiKind: "installments",
|
||||
title: "Pagos",
|
||||
// A policy paid in several exhibiciones prices each payment on its own, so
|
||||
// the whole premium breakdown repeats per row — that is the two-row money
|
||||
// block on the Access form. `amount` stays what was actually collected and
|
||||
// is deliberately separate from `total`; they differ by rounding.
|
||||
fields: [
|
||||
{ key: "sequence", label: "Sec.", type: "number" },
|
||||
{ key: "amount", label: "Monto", type: "number" },
|
||||
@@ -195,6 +205,12 @@ function ChildrenEditor({
|
||||
{ key: "paidDate", label: "Pagado", type: "date" },
|
||||
{ key: "checkNumber", label: "Cheque" },
|
||||
{ key: "isCash", label: "Efectivo", type: "checkbox" },
|
||||
{ key: "netPremium", label: "Prima neta", type: "number" },
|
||||
{ key: "surcharge", label: "Recargo", type: "number" },
|
||||
{ key: "policyFee", label: "Derecho", type: "number" },
|
||||
{ key: "tax", label: "IVA", type: "number" },
|
||||
{ key: "total", label: "Prima total", type: "number" },
|
||||
{ key: "commission", label: "Comisión", type: "number" },
|
||||
],
|
||||
};
|
||||
const VEHICLES: ChildConfig = {
|
||||
@@ -412,15 +428,40 @@ function CondicionesSection({ data }: { data: PolicyDetail }) {
|
||||
data.coveragePeriodDays ? `${data.coveragePeriodDays} días` : null
|
||||
}
|
||||
/>
|
||||
<KV
|
||||
label="Forma de pago"
|
||||
value={
|
||||
data.paymentFrequency
|
||||
? PAYMENT_FREQUENCY_LABELS[data.paymentFrequency]
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<KV label="Prima neta" value={formatMoney(data.netPremium, cur)} />
|
||||
{/* Only ever set on a policy paid in installments, so showing an
|
||||
empty row on the other 98% would be noise. */}
|
||||
{data.surcharge != null && Number(data.surcharge) !== 0 && (
|
||||
<KV label="Recargo" value={formatMoney(data.surcharge, cur)} />
|
||||
)}
|
||||
<KV label="Derecho de póliza" value={formatMoney(data.policyFee, cur)} />
|
||||
<KV label="Comisión" value={formatMoney(data.commission, cur)} />
|
||||
<KV label="Honorarios" value={formatMoney(data.brokerFee, cur)} />
|
||||
{/* Access never stored IVA — it was a calculated control on the form
|
||||
— so every migrated policy reads null here until it is edited. */}
|
||||
{data.tax != null && (
|
||||
<KV
|
||||
label={
|
||||
data.taxRate != null
|
||||
? `IVA (${formatRate(Number(data.taxRate))})`
|
||||
: "IVA"
|
||||
}
|
||||
value={formatMoney(data.tax, cur)}
|
||||
/>
|
||||
)}
|
||||
{/* The legacy `total` is 0 or null on all but 2 of 2378 policies —
|
||||
only show it when it actually carries a figure. */}
|
||||
{data.total != null && Number(data.total) > 0 && (
|
||||
<KV label="Total" value={formatMoney(data.total, cur)} />
|
||||
<KV label="Prima total" value={formatMoney(data.total, cur)} />
|
||||
)}
|
||||
<KV label="Comisión" value={formatMoney(data.commission, cur)} />
|
||||
<KV label="Honorarios" value={formatMoney(data.brokerFee, cur)} />
|
||||
<KV
|
||||
label="Liquidación"
|
||||
value={
|
||||
|
||||
@@ -9,6 +9,9 @@ export type FieldDef = {
|
||||
type?: "text" | "number" | "date" | "checkbox" | "select";
|
||||
options?: { value: string; label: string }[];
|
||||
width?: number;
|
||||
/** Numeric granularity. Defaults to money (0.01); a tax rate stored as a
|
||||
* fraction needs finer, or the browser rejects 0.0825 as off-step. */
|
||||
step?: string;
|
||||
};
|
||||
|
||||
export type ChildConfig = {
|
||||
@@ -158,7 +161,7 @@ export function ChildCollection({
|
||||
<input
|
||||
className="input"
|
||||
type={f.type === "number" ? "number" : f.type === "date" ? "date" : "text"}
|
||||
step={f.type === "number" ? "0.01" : undefined}
|
||||
step={f.type === "number" ? f.step ?? "0.01" : undefined}
|
||||
value={String(values[f.key] ?? "")}
|
||||
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })}
|
||||
/>
|
||||
|
||||
@@ -4,12 +4,22 @@ import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { CustomerPicker } from "@/components/CustomerPicker";
|
||||
import { createPolicy, getLookups, updatePolicy } from "@/lib/api";
|
||||
import type {
|
||||
Currency,
|
||||
LookupsResponse,
|
||||
PolicyDetail,
|
||||
PolicyInput,
|
||||
import {
|
||||
PAYMENT_FREQUENCY_LABELS,
|
||||
type Currency,
|
||||
type LookupsResponse,
|
||||
type PaymentFrequency,
|
||||
type PolicyDetail,
|
||||
type PolicyInput,
|
||||
} from "@/lib/types";
|
||||
import {
|
||||
computeTax,
|
||||
computeTotal,
|
||||
formatRate,
|
||||
resolveTaxRate,
|
||||
surchargeApplies,
|
||||
taxableBase,
|
||||
} from "@/lib/premium";
|
||||
|
||||
function toDateInput(v: string | null | undefined): string {
|
||||
if (!v) return "";
|
||||
@@ -36,9 +46,16 @@ type V = {
|
||||
policyFrom: string;
|
||||
policyTo: string;
|
||||
netPremium: string;
|
||||
surcharge: string;
|
||||
policyFee: string;
|
||||
brokerFee: string;
|
||||
commission: string;
|
||||
/** Blank means "use the computed figure". Only ever holds a value once the
|
||||
* operator overrides it, so a later change to prima neta keeps flowing
|
||||
* through instead of being frozen by a value the form itself wrote. */
|
||||
tax: string;
|
||||
total: string;
|
||||
paymentFrequency: PaymentFrequency | "";
|
||||
currency: Currency;
|
||||
liquidated: boolean;
|
||||
liquidationNumber: string;
|
||||
@@ -58,9 +75,13 @@ function initial(p?: PolicyDetail): V {
|
||||
policyFrom: toDateInput(p?.policyFrom),
|
||||
policyTo: toDateInput(p?.policyTo),
|
||||
netPremium: p?.netPremium != null ? String(p.netPremium) : "",
|
||||
surcharge: p?.surcharge != null ? String(p.surcharge) : "",
|
||||
policyFee: p?.policyFee != null ? String(p.policyFee) : "",
|
||||
brokerFee: p?.brokerFee != null ? String(p.brokerFee) : "",
|
||||
commission: p?.commission != null ? String(p.commission) : "",
|
||||
tax: p?.tax != null ? String(p.tax) : "",
|
||||
total: p?.total != null ? String(p.total) : "",
|
||||
paymentFrequency: p?.paymentFrequency ?? "",
|
||||
currency: (p?.currency as Currency) ?? "MXN",
|
||||
liquidated: p?.liquidated ?? false,
|
||||
liquidationNumber: p?.liquidationNumber ?? "",
|
||||
@@ -101,6 +122,27 @@ export function PolicyForm({
|
||||
setV((p) => ({ ...p, [k]: val }));
|
||||
}
|
||||
|
||||
// IVA and Total are the only two figures the form derives. Everything else,
|
||||
// the recargo included, is keyed by hand — the carrier quotes the financing
|
||||
// charge, we do not compute it.
|
||||
const selectedType = lookups?.types.find((t) => t.id === v.policyTypeId);
|
||||
const taxRate = resolveTaxRate(policy?.taxRate, selectedType?.taxRate);
|
||||
const parts = {
|
||||
netPremium: v.netPremium,
|
||||
// A recargo on an annual policy is a data-entry mistake, so it is dropped
|
||||
// from the arithmetic as well as disabled in the UI. Otherwise switching
|
||||
// ANNUAL after typing one would leave it silently inflating the IVA.
|
||||
surcharge: surchargeApplies(v.paymentFrequency || null) ? v.surcharge : "",
|
||||
policyFee: v.policyFee,
|
||||
};
|
||||
const computedTax = computeTax(parts, taxRate);
|
||||
const computedTotal = computeTotal(parts, taxRate);
|
||||
// Blank field = take the computed figure. A typed one wins, so staff can key
|
||||
// the carrier's rounding verbatim when it disagrees with ours by a centavo.
|
||||
const effectiveTax = v.tax.trim() === "" ? computedTax : Number(v.tax);
|
||||
const effectiveTotal = v.total.trim() === "" ? computedTotal : Number(v.total);
|
||||
const showSurcharge = surchargeApplies(v.paymentFrequency || null);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!customerId) {
|
||||
@@ -118,9 +160,17 @@ export function PolicyForm({
|
||||
policyFrom: s(v.policyFrom),
|
||||
policyTo: s(v.policyTo),
|
||||
netPremium: numOrUndef(v.netPremium),
|
||||
surcharge: showSurcharge ? numOrUndef(v.surcharge) : undefined,
|
||||
policyFee: numOrUndef(v.policyFee),
|
||||
brokerFee: numOrUndef(v.brokerFee),
|
||||
commission: numOrUndef(v.commission),
|
||||
// The derived figures are persisted, not recomputed on read: the printed
|
||||
// policy is the record of truth and a later rate change must not silently
|
||||
// restate what was issued. `taxRate` rides along for the same reason.
|
||||
tax: Number.isFinite(effectiveTax) ? effectiveTax : undefined,
|
||||
taxRate,
|
||||
total: Number.isFinite(effectiveTotal) ? effectiveTotal : undefined,
|
||||
paymentFrequency: v.paymentFrequency || undefined,
|
||||
currency: v.currency,
|
||||
liquidated: v.liquidated,
|
||||
liquidationNumber: s(v.liquidationNumber),
|
||||
@@ -208,7 +258,7 @@ export function PolicyForm({
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<h2 className="section-title" style={{ marginBottom: 14 }}>Vigencia y prima</h2>
|
||||
<h2 className="section-title" style={{ marginBottom: 14 }}>Vigencia</h2>
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span className="field-label">Emisión</span>
|
||||
@@ -225,21 +275,84 @@ export function PolicyForm({
|
||||
<input className="input" type="date" value={v.policyTo}
|
||||
onChange={(e) => set("policyTo", e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Forma de pago</span>
|
||||
<select className="select" value={v.paymentFrequency}
|
||||
onChange={(e) =>
|
||||
set("paymentFrequency", e.target.value as PaymentFrequency | "")
|
||||
}>
|
||||
<option value="">—</option>
|
||||
{(
|
||||
Object.keys(PAYMENT_FREQUENCY_LABELS) as PaymentFrequency[]
|
||||
).map((f) => (
|
||||
<option key={f} value={f}>{PAYMENT_FREQUENCY_LABELS[f]}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<h2 className="section-title" style={{ marginBottom: 4 }}>Primas</h2>
|
||||
<p className="muted" style={{ fontSize: 12, marginBottom: 14 }}>
|
||||
IVA y prima total se calculan solos sobre (prima neta + recargo +
|
||||
derecho de póliza). Puede sobrescribirlos si la póliza impresa
|
||||
redondea distinto.
|
||||
</p>
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span className="field-label">Prima neta</span>
|
||||
<input className="input" type="number" step="0.01" value={v.netPremium}
|
||||
onChange={(e) => set("netPremium", e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Recargo</span>
|
||||
<input className="input" type="number" step="0.01" value={v.surcharge}
|
||||
disabled={!showSurcharge}
|
||||
onChange={(e) => set("surcharge", e.target.value)} />
|
||||
<span className="field-hint">
|
||||
{showSurcharge
|
||||
? "Lo cotiza la aseguradora — se captura a mano."
|
||||
: "No aplica en pago anual ni de contado."}
|
||||
</span>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Derecho de póliza</span>
|
||||
<input className="input" type="number" step="0.01" value={v.policyFee}
|
||||
onChange={(e) => set("policyFee", e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">IVA ({formatRate(taxRate)})</span>
|
||||
<input className="input" type="number" step="0.01"
|
||||
placeholder={computedTax.toFixed(2)} value={v.tax}
|
||||
onChange={(e) => set("tax", e.target.value)} />
|
||||
<span className="field-hint">
|
||||
Calculado: {computedTax.toFixed(2)} sobre base{" "}
|
||||
{taxableBase(parts).toFixed(2)}
|
||||
{selectedType?.taxRate == null &&
|
||||
policy?.taxRate == null &&
|
||||
" · tasa por omisión, configúrela en Catálogos"}
|
||||
</span>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Prima total</span>
|
||||
<input className="input" type="number" step="0.01"
|
||||
placeholder={computedTotal.toFixed(2)} value={v.total}
|
||||
onChange={(e) => set("total", e.target.value)} />
|
||||
<span className="field-hint">
|
||||
Calculado: {computedTotal.toFixed(2)}
|
||||
</span>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Comisión</span>
|
||||
<input className="input" type="number" step="0.01" value={v.commission}
|
||||
onChange={(e) => set("commission", e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Honorarios</span>
|
||||
<input className="input" type="number" step="0.01" value={v.brokerFee}
|
||||
onChange={(e) => set("brokerFee", e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
PolicyOcrBatchDetail,
|
||||
PolicyOcrConfirmDocument,
|
||||
PolicyOcrCoverage,
|
||||
PolicyOcrCustomerSuggestion,
|
||||
PolicyOcrDocument,
|
||||
PolicyOcrReviewInput,
|
||||
} from "@/lib/types";
|
||||
@@ -180,8 +181,11 @@ export function PolicyOcrReview({ id }: { id: string }) {
|
||||
{STATUS_LABEL[batch.status] ?? batch.status}
|
||||
</p>
|
||||
</div>
|
||||
<Link className="btn btn-ghost" href="/polizas">
|
||||
Volver a pólizas
|
||||
{/* Back to the capture screen this batch was uploaded from, not to
|
||||
the policy list — same as the statement review screen, which
|
||||
returns to /recibos. */}
|
||||
<Link className="btn btn-ghost" href="/polizas/captura">
|
||||
Volver a captura
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
@@ -272,6 +276,8 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
||||
policyDate: doc.extractedPolicyDate?.slice(0, 10) ?? "",
|
||||
currency: doc.extractedCurrency ?? "USD",
|
||||
netPremium: doc.extractedNetPremium ?? "",
|
||||
policyFee: doc.extractedPolicyFee ?? "",
|
||||
tax: doc.extractedTax ?? "",
|
||||
total: doc.extractedTotal ?? "",
|
||||
premiumPayment: doc.extractedPremiumPayment ?? "",
|
||||
coveragePeriodDays: doc.extractedCoveragePeriodDays?.toString() ?? "",
|
||||
@@ -310,6 +316,8 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
||||
policyDate: v.policyDate || undefined,
|
||||
currency,
|
||||
netPremium: numOrUndef(v.netPremium),
|
||||
policyFee: numOrUndef(v.policyFee),
|
||||
tax: numOrUndef(v.tax),
|
||||
total: numOrUndef(v.total),
|
||||
premiumPayment: trimOrUndef(v.premiumPayment),
|
||||
coveragePeriodDays: numOrUndef(v.coveragePeriodDays),
|
||||
@@ -332,6 +340,8 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
||||
policyDate: reviewInput.policyDate,
|
||||
currency: (currency as "MXN" | "USD" | "EUR" | undefined) ?? undefined,
|
||||
netPremium: reviewInput.netPremium,
|
||||
policyFee: reviewInput.policyFee,
|
||||
tax: reviewInput.tax,
|
||||
total: reviewInput.total,
|
||||
premiumPayment: reviewInput.premiumPayment,
|
||||
coveragePeriodDays: reviewInput.coveragePeriodDays,
|
||||
@@ -351,17 +361,19 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
||||
const locked = doc.status === "POSTED" || doc.status === "REJECTED";
|
||||
const matchedExisting = !!doc.matchedPolicy;
|
||||
const candidates = doc.matchCandidates ?? [];
|
||||
const suggestions: PolicyOcrCustomerSuggestion[] = doc.customerSuggestions ?? [];
|
||||
|
||||
return (
|
||||
<article className="card" style={{ padding: 16 }}>
|
||||
<header className="row" style={{ gap: 12, alignItems: "center" }}>
|
||||
<span className="tag">{STATUS_LABEL[doc.status] ?? doc.status}</span>
|
||||
<span className="page-sub">Página {doc.pageNumber}</span>
|
||||
{doc.extractedPolicyNumber && (
|
||||
<strong style={{ marginLeft: 8 }}>{doc.extractedPolicyNumber}</strong>
|
||||
)}
|
||||
{/* No hand-rolled separators or margins here: `.row` is a flex
|
||||
container and its gap does the spacing. A literal "· " would leave
|
||||
a dot floating in that gap. */}
|
||||
{doc.extractedPolicyNumber && <strong>{doc.extractedPolicyNumber}</strong>}
|
||||
{doc.extractedInsuredName && (
|
||||
<span className="page-sub">· {doc.extractedInsuredName}</span>
|
||||
<span className="page-sub">{doc.extractedInsuredName}</span>
|
||||
)}
|
||||
{/* Read-only: the parser names the type, the confirm step resolves it
|
||||
to a policy_types row. Reassigning it is the policy screen's job,
|
||||
@@ -495,7 +507,25 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
||||
onChange={(e) => set("netPremium", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Total">
|
||||
<Field label="Derecho de póliza">
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={v.policyFee}
|
||||
onChange={(e) => set("policyFee", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="IVA">
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={v.tax}
|
||||
onChange={(e) => set("tax", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Prima total">
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
@@ -670,6 +700,34 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/*
|
||||
* Name suggestions, never a preselection. The office writes
|
||||
* customers surname-first and carriers print them given-name-first,
|
||||
* so without this the reviewer retypes a name the parser already
|
||||
* read. One click fills the picker above; nothing is chosen until
|
||||
* they click. Kept outside the <Field> label — a label must not
|
||||
* wrap other interactive controls.
|
||||
*/}
|
||||
{!policyId && !customerId && !locked && canReview && suggestions.length > 0 && (
|
||||
<div className="row" style={{ gap: 8, flexWrap: "wrap" }}>
|
||||
<span className="page-sub">Sugerencias por nombre:</span>
|
||||
{suggestions.map((s) => (
|
||||
<button
|
||||
key={s.customerId}
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => {
|
||||
setCustomerId(s.customerId);
|
||||
setCustomerName(s.customerName);
|
||||
}}
|
||||
>
|
||||
{s.customerName}
|
||||
{s.tier === "PARTIAL" && <span className="page-sub"> · parcial</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="field">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { PaymentFrequency } from "./types";
|
||||
|
||||
/**
|
||||
* Client-side twin of apps/api/src/policies/premium.ts. Duplicated rather than
|
||||
* shared because the API and the web app do not share a package today, and
|
||||
* both need it: the form computes IVA and Total live as the operator types,
|
||||
* the API stores what it is sent.
|
||||
*
|
||||
* base = prima neta + recargo + derecho de póliza
|
||||
* IVA = round(base * tasa)
|
||||
* Total = base + IVA
|
||||
*
|
||||
* The recargo is inside the taxable base — that is what reconciles the Access
|
||||
* books (policy 7006785 prints IVA 52.03 on 610.86 + 8.55 + 31.00; leaving the
|
||||
* recargo out gives 51.35, which matches nothing on the page).
|
||||
*/
|
||||
|
||||
/** Applied when neither the policy nor its type carries a rate. The single
|
||||
* row both legacy IMPUESTOS tables held. */
|
||||
export const DEFAULT_TAX_RATE = 0.08;
|
||||
|
||||
/** Paying in more than one exhibición is what earns a recargo. A null
|
||||
* frequency (every migrated policy) is treated as "unknown, allow it": the
|
||||
* legacy recargo figures are real and hiding the field would hide them. */
|
||||
export function surchargeApplies(
|
||||
frequency: PaymentFrequency | null | undefined,
|
||||
): boolean {
|
||||
return frequency !== "ANNUAL" && frequency !== "SINGLE";
|
||||
}
|
||||
|
||||
export function num(v: string | number | null | undefined): number {
|
||||
if (v === null || v === undefined || v === "") return 0;
|
||||
const n = typeof v === "number" ? v : Number(String(v).trim());
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/** Half-up to cents, matching how the printed policy rounds. */
|
||||
export function round2(n: number): number {
|
||||
return Math.round((n + Number.EPSILON) * 100) / 100;
|
||||
}
|
||||
|
||||
export interface PremiumParts {
|
||||
netPremium: string | number | null | undefined;
|
||||
surcharge: string | number | null | undefined;
|
||||
policyFee: string | number | null | undefined;
|
||||
}
|
||||
|
||||
export function taxableBase(p: PremiumParts): number {
|
||||
return round2(num(p.netPremium) + num(p.surcharge) + num(p.policyFee));
|
||||
}
|
||||
|
||||
export function computeTax(p: PremiumParts, rate: number): number {
|
||||
return round2(taxableBase(p) * rate);
|
||||
}
|
||||
|
||||
export function computeTotal(p: PremiumParts, rate: number): number {
|
||||
return round2(taxableBase(p) + computeTax(p, rate));
|
||||
}
|
||||
|
||||
/** Rate ladder: what the policy was issued at, else its line of business, else
|
||||
* the default. Keeps an old policy reading back at its original rate after
|
||||
* somebody edits the catalog. */
|
||||
export function resolveTaxRate(
|
||||
policyRate: string | number | null | undefined,
|
||||
policyTypeRate: string | number | null | undefined,
|
||||
): number {
|
||||
for (const candidate of [policyRate, policyTypeRate]) {
|
||||
if (candidate === null || candidate === undefined || candidate === "") continue;
|
||||
const n = Number(candidate);
|
||||
if (Number.isFinite(n) && n >= 0) return n;
|
||||
}
|
||||
return DEFAULT_TAX_RATE;
|
||||
}
|
||||
|
||||
/** 0.08 -> "8%". Rates are stored as fractions but read as percentages. */
|
||||
export function formatRate(rate: number): string {
|
||||
const pct = round2(rate * 100);
|
||||
return `${pct}%`;
|
||||
}
|
||||
@@ -3,6 +3,24 @@
|
||||
|
||||
export type Currency = "USD" | "MXN";
|
||||
|
||||
/** How the premium is split into payments. Anything other than ANNUAL/SINGLE
|
||||
* is what earns a recargo. Null on every migrated policy — the original ETL
|
||||
* dropped Access's FORMA PAGO column entirely. */
|
||||
export type PaymentFrequency =
|
||||
| "ANNUAL"
|
||||
| "SEMIANNUAL"
|
||||
| "QUARTERLY"
|
||||
| "MONTHLY"
|
||||
| "SINGLE";
|
||||
|
||||
export const PAYMENT_FREQUENCY_LABELS: Record<PaymentFrequency, string> = {
|
||||
ANNUAL: "Anual",
|
||||
SEMIANNUAL: "Semestral",
|
||||
QUARTERLY: "Trimestral",
|
||||
MONTHLY: "Mensual",
|
||||
SINGLE: "Contado",
|
||||
};
|
||||
|
||||
export type Role = "ADMIN" | "MANAGER" | "STAFF" | "VIEWER";
|
||||
|
||||
export type Ability =
|
||||
@@ -289,6 +307,16 @@ export interface Installment {
|
||||
paidDate: string | null;
|
||||
checkNumber: string | null;
|
||||
isCash: boolean;
|
||||
/** Per-payment premium breakdown — a policy paid in several exhibiciones
|
||||
* prices each one separately. `amount` is what was actually collected and
|
||||
* can differ from `total` by rounding; it is not derived from these. */
|
||||
netPremium: string | null;
|
||||
surcharge: string | null;
|
||||
policyFee: string | null;
|
||||
tax: string | null;
|
||||
taxRate: string | null;
|
||||
total: string | null;
|
||||
commission: string | null;
|
||||
}
|
||||
|
||||
export interface Vehicle {
|
||||
@@ -398,10 +426,14 @@ export interface PolicyInput {
|
||||
policyTo?: string;
|
||||
coveragePeriodDays?: number;
|
||||
netPremium?: number;
|
||||
surcharge?: number;
|
||||
policyFee?: number;
|
||||
brokerFee?: number;
|
||||
commission?: number;
|
||||
tax?: number;
|
||||
taxRate?: number;
|
||||
total?: number;
|
||||
paymentFrequency?: PaymentFrequency;
|
||||
currency?: Currency;
|
||||
observations?: string;
|
||||
notes?: string;
|
||||
@@ -419,6 +451,13 @@ export interface InstallmentInput {
|
||||
paidDate?: string;
|
||||
checkNumber?: string;
|
||||
isCash?: boolean;
|
||||
netPremium?: number;
|
||||
surcharge?: number;
|
||||
policyFee?: number;
|
||||
tax?: number;
|
||||
taxRate?: number;
|
||||
total?: number;
|
||||
commission?: number;
|
||||
}
|
||||
export interface VehicleInput {
|
||||
make?: string;
|
||||
@@ -469,6 +508,10 @@ export interface PolicyTypeRow {
|
||||
id: string;
|
||||
name: string;
|
||||
shortDescription: string | null;
|
||||
/** IVA fraction for this line of business, 0.08 = 8%. Null means "not
|
||||
* configured" and the form falls back to DEFAULT_TAX_RATE — it does NOT
|
||||
* mean the line is untaxed. Serialized as a decimal string by Prisma. */
|
||||
taxRate: string | null;
|
||||
_count?: { policies: number };
|
||||
}
|
||||
export interface AdjusterRow {
|
||||
@@ -567,10 +610,14 @@ export interface PolicyDetail {
|
||||
policyTo: string | null;
|
||||
coveragePeriodDays: number | null;
|
||||
netPremium: string | null;
|
||||
surcharge: string | null;
|
||||
policyFee: string | null;
|
||||
brokerFee: string | null;
|
||||
commission: string | null;
|
||||
tax: string | null;
|
||||
taxRate: string | null;
|
||||
total: string | null;
|
||||
paymentFrequency: PaymentFrequency | null;
|
||||
currency: string | null;
|
||||
observations: string | null;
|
||||
notes: string | null;
|
||||
@@ -994,6 +1041,8 @@ export interface BillingFacets {
|
||||
|
||||
export interface StatementSummary {
|
||||
currency: LedgerCurrency;
|
||||
/** Balance carried in from before the statement year — legacy's BALANCE FORWARD. */
|
||||
opening: string;
|
||||
charges: string;
|
||||
credits: string;
|
||||
balance: string;
|
||||
@@ -1007,6 +1056,7 @@ export interface StatementSummary {
|
||||
export interface StatementDomainRow {
|
||||
domain: TransactionDomain;
|
||||
currency: LedgerCurrency;
|
||||
opening: string;
|
||||
charges: string;
|
||||
credits: string;
|
||||
balance: string;
|
||||
@@ -1042,6 +1092,8 @@ export interface Statement {
|
||||
propertyCount: number;
|
||||
policyCount: number;
|
||||
};
|
||||
/** Calendar year the statement covers; movements are scoped to it. */
|
||||
year: number;
|
||||
summary: StatementSummary[];
|
||||
byDomain: StatementDomainRow[];
|
||||
byType: StatementTypeRow[];
|
||||
@@ -1477,6 +1529,18 @@ export interface PolicyOcrMatchCandidate {
|
||||
policyNumber: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A customer whose name resembles the printed insured name. A suggestion,
|
||||
* not a match — the API never preselects one.
|
||||
*/
|
||||
export interface PolicyOcrCustomerSuggestion {
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
/** `EXACT` = same name tokens in any order; `PARTIAL` = one contains the other. */
|
||||
tier: "EXACT" | "PARTIAL";
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface PolicyOcrDocument {
|
||||
id: string;
|
||||
pageNumber: number;
|
||||
@@ -1496,6 +1560,10 @@ export interface PolicyOcrDocument {
|
||||
extractedNetPremium: string | null;
|
||||
extractedPolicyFee: string | null;
|
||||
extractedBrokerFee: string | null;
|
||||
/** IVA off A.N.A.'s `TAX` cell. Null on GMX — its certificate carries no
|
||||
* premium, so no tax either. `LOCAL TAX` is not folded in; a non-zero one
|
||||
* shows up in `matchNote`. */
|
||||
extractedTax: string | null;
|
||||
extractedTotal: string | null;
|
||||
extractedCoveragesJson: PolicyOcrCoverage[] | null;
|
||||
extractedPremiumPayment: string | null;
|
||||
@@ -1512,6 +1580,7 @@ export interface PolicyOcrDocument {
|
||||
} | null;
|
||||
matchedCustomer: { id: string; name: string } | null;
|
||||
matchCandidates: PolicyOcrMatchCandidate[] | null;
|
||||
customerSuggestions: PolicyOcrCustomerSuggestion[] | null;
|
||||
matchNote: string | null;
|
||||
}
|
||||
|
||||
@@ -1529,6 +1598,7 @@ export interface PolicyOcrReviewInput {
|
||||
netPremium?: number;
|
||||
policyFee?: number;
|
||||
brokerFee?: number;
|
||||
tax?: number;
|
||||
total?: number;
|
||||
premiumPayment?: string;
|
||||
coveragePeriodDays?: number;
|
||||
@@ -1555,6 +1625,7 @@ export interface PolicyOcrConfirmDocument {
|
||||
netPremium?: number;
|
||||
policyFee?: number;
|
||||
brokerFee?: number;
|
||||
tax?: number;
|
||||
total?: number;
|
||||
premiumPayment?: string;
|
||||
coveragePeriodDays?: number;
|
||||
|
||||
@@ -179,6 +179,29 @@ Each of these is a known, deliberate stopping point rather than a bug.
|
||||
be added.
|
||||
- No `SKIPPED_NO_EMAIL` worklist (see 1.9).
|
||||
|
||||
**Captura de pólizas — desglose de primas** (built 2026-08-18)
|
||||
- **IVA y prima total no existen en los datos legacy.** En Access eran
|
||||
controles calculados sin campo, así que las 2,378 pólizas migradas leen
|
||||
`tax` y `total` en null hasta que alguien las edite. No es recuperable: no
|
||||
hay de dónde.
|
||||
- **`LOCAL TAX` de A.N.A. no se captura.** El IVA (`TAX`) sí — se guarda desde
|
||||
2026-08-18 — pero `LOCAL TAX` es un gravamen distinto sin columna destino y
|
||||
**no** se suma al IVA: sumarlo daría una cifra que ya no divide de vuelta a
|
||||
una tasa. Imprime 0.00 en todas las pólizas vistas hasta hoy; una distinta
|
||||
de cero levanta la nota *"impuesto local N no capturado"* y significa que
|
||||
`total` no cuadra contra `netPremium + policyFee + tax`.
|
||||
- **`Policy.taxRate` queda en null por la ruta OCR.** A.N.A. imprime el monto
|
||||
del IVA, no la tasa, y despejarla a la inversa inventaría una tasa que el
|
||||
documento nunca declaró. El formulario resuelve una desde el ramo.
|
||||
- **El recargo no se valida contra la forma de pago en datos migrados.** El
|
||||
formulario lo deshabilita en ANUAL/CONTADO, pero
|
||||
`backfill_policy_premium_breakdown.py` solo advierte cuando encuentra una
|
||||
póliza anual con recargo; no la corrige.
|
||||
- **Las parcialidades 3 y 4 no llevan desglose.** Access solo dibujó la fila
|
||||
de dinero dos veces, así que una póliza trimestral capturada hoy sí puede
|
||||
llenar las cuatro a mano, pero no hay nada legacy que migrar a las dos
|
||||
últimas.
|
||||
|
||||
**Policy OCR** — [`POLICY_OCR.md`](POLICY_OCR.md)
|
||||
- **GMX and A.N.A. only.** The dispatcher is a `[provider, pattern]` table plus
|
||||
a parser map, so a third carrier is one function and two entries — but no
|
||||
|
||||
@@ -440,6 +440,12 @@ take a `policyType` select param. The workflow is *not* MULT-only: the legacy
|
||||
|
||||
Params: ramo (with an "todos" option), aseguradora, date range on `policyFrom`.
|
||||
Columns: póliza, cliente, ramo, aseguradora, vigencia, prima neta, forma de pago.
|
||||
|
||||
ℹ️ `forma de pago` became a real column on 2026-08-18 (`Policy.paymentFrequency`).
|
||||
It is **null on every policy migrated before that date** — the original ETL
|
||||
marked Access's `FORMA PAGO` consumed and then never wrote it anywhere — so the
|
||||
report must render null as "—" rather than assuming annual. Running
|
||||
`backfill_policy_premium_breakdown.py` recovers it from the staged Parquet.
|
||||
Totals: count + prima neta sum per currency (**never collapse MXN and USD** —
|
||||
same constraint as the billing module).
|
||||
|
||||
|
||||
@@ -134,6 +134,55 @@ customers do occur (one group policy bound by two related parties), and
|
||||
picking arbitrarily would silently book the wrong coverage against the wrong
|
||||
person.
|
||||
|
||||
### Name suggestions on the zero-hit path
|
||||
|
||||
When the policy number finds nothing — the new-policy case, where a human has
|
||||
to pick a customer anyway — `name-matcher.ts` ranks the customer book against
|
||||
the printed insured name and the review screen offers the top three as
|
||||
one-click buttons above the picker. They are written to
|
||||
`policy_ocr_documents.customerSuggestions`, deliberately **not** to
|
||||
`matchCandidates`, so a name hint can never be read as a policy-number hit.
|
||||
Nothing sets `matchedCustomerId`; the rule above is unchanged.
|
||||
|
||||
The problem is only ordering: the office books customers surname-first
|
||||
(`WAGONER, PAMELA`) and carriers print them given-name-first
|
||||
(`PAMELA DENISE WAGONER`), so a string compare never hits while a **token-set**
|
||||
compare does. Names are normalized (accents folded, so OCR's `MUNOZ` reaches
|
||||
the book's `MUÑOZ`; initials, `DE`/`LA`/`Y`, `JR`, `S.A. DE C.V.` and any token
|
||||
containing a digit dropped — ANA prints the phone hard against the name as
|
||||
`Ph.3102001538`). Two tiers:
|
||||
|
||||
| Tier | Rule |
|
||||
|---|---|
|
||||
| `EXACT` | identical token sets, any order |
|
||||
| `PARTIAL` | one set contains the other, ≥2 shared tokens, **and** the surname is present |
|
||||
|
||||
Both thresholds come from measuring the real book (1536 customers):
|
||||
|
||||
- 1487 distinct token sets, so `EXACT` cross-person collisions are ~0
|
||||
- loosen to surname + first given name and 131 customers (8.5%) collide —
|
||||
the book holds `MCWILLIAMS, BRIAN MICHAEL` *and* `MCWILLIAMS, BRIAN`
|
||||
- 185 surnames are shared by 524 customers, so one token is never evidence;
|
||||
hence the ≥2 floor and the explicit surname requirement, which is what stops
|
||||
`JERRY MARILYN` reaching `ESTRADA, JERRY & MARILYN` on given names alone
|
||||
|
||||
Replaying every book row as a carrier would print it (given-name-first, joint
|
||||
spouse dropped): 97.9% top-ranked correct, 0.9% no suggestion, 1.2% a
|
||||
different row — and all but two of those are the same human on a duplicate or
|
||||
variant row (`MOLNAR, JANOS` vs `MOLNAR, JANOS`, `IBARRA, ISMAEL &`). The
|
||||
two genuine wrong-person cases are `CUADROS, JORGE JR` against three
|
||||
`CUADROS, JORGE H.`, and they appear as a tie in the list rather than as a
|
||||
single answer.
|
||||
|
||||
A blob is refused outright (>8 tokens or >80 characters): GMX's especificación
|
||||
has no field labels and the parser has been seen handing its whole first page
|
||||
over as `insuredName`, which would find a surname somewhere in the prose.
|
||||
`(SIN NOMBRE)` — 14 rows the migration left — is skipped on both sides.
|
||||
|
||||
**Not used for utility statements.** There the registrant genuinely is not the
|
||||
customer (the `CATT, RANDY` finding above), so the same trick would be wrong,
|
||||
not merely noisy.
|
||||
|
||||
## GMX ships two unrelated documents for the same policy
|
||||
|
||||
The office downloads both from the same portal, and either can land in a
|
||||
@@ -316,6 +365,27 @@ A.N.A.'s faces do print one — the `DISCOUNT / PREMIUM / POLICY FEE / TAX /
|
||||
LOCAL TAX / TOTAL` row is on the same page — so an ANA document reaches the
|
||||
review queue with `netPremium` populated and `postPremium` already ticked.
|
||||
|
||||
Four of those six cells are stored: `PREMIUM` → `netPremium`, `POLICY FEE` →
|
||||
`policyFee`, `TAX` → `tax` (`extractedTax` on the document, `Policy.tax` on
|
||||
confirm), `TOTAL` → `total`. `DISCOUNT` and `LOCAL TAX` are reported as notes
|
||||
instead:
|
||||
|
||||
- **`DISCOUNT`** has no column, and it prints as a bare `-` when unused, which
|
||||
is what makes the row positional rather than "find six amounts".
|
||||
- **`LOCAL TAX`** is a separate levy and is deliberately **not** summed into
|
||||
`tax`. Folding it in would produce an IVA figure that no longer divides back
|
||||
to a rate, which is the reason to store it at all. It reads 0.00 on every
|
||||
A.N.A. policy seen so far; a non-zero one raises
|
||||
*"impuesto local N no capturado"* and means `total` will not reconcile
|
||||
against `netPremium + policyFee + tax`.
|
||||
|
||||
`Policy.taxRate` is left null by confirm. A.N.A. prints the IVA **amount**, not
|
||||
the rate, and back-dividing one would mint a rate the document never stated —
|
||||
the capture form resolves it from the line of business instead
|
||||
(`PolicyType.taxRate`, see `apps/api/src/policies/premium.ts`). The figures do
|
||||
agree: 298.61 + 30.00 taxed at 8% is 26.29, totalling 354.90, asserted in
|
||||
`policy-parser.spec.ts`.
|
||||
|
||||
Deductible and loss participation are stored as **strings** (`"5%"`, `"20%"`,
|
||||
`"USD 1,000"`) — they are printed as a mix of percentages, currency amounts
|
||||
and free text, and normalising them would lose the distinction.
|
||||
@@ -457,6 +527,17 @@ term, the excluded sections, the column-position split on the driver's policy,
|
||||
its different section order, and — for both faces — that a doubled or tripled
|
||||
input yields one set of coverages and one driver rather than one per copy.
|
||||
|
||||
`apps/api/src/policy-ocr/name-matcher.spec.ts` — 21 cases on the customer name
|
||||
suggestions, every fixture name lifted from the real book: the reversed name,
|
||||
the printed middle name, the exact row outranking the row that merely contains
|
||||
it, the joint account reached from one spouse (and refused when only given
|
||||
names are printed), the Spanish double surname with the comma in either place,
|
||||
the 54 rows with no comma at all, the `(SIN NOMBRE)` placeholder, a bare shared
|
||||
surname, and the page-sized blob. Four more in
|
||||
`policy-matcher.service.spec.ts` pin the wiring: suggestions on the zero-hit
|
||||
and unreadable-number paths, no book read at all when the policy number hits,
|
||||
and one book read across a batch.
|
||||
|
||||
Four of the GMX cases are regression tests for ways the parser can silently attach
|
||||
the *wrong* value rather than none — a neighbouring coverage's prose read as
|
||||
a deductible, the page-level `DEDUCIBLES:` paragraph read as one, a coverage
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
Recovers the premium breakdown the original policy transform dropped.
|
||||
|
||||
`transform_policies.py` modeled prima neta, derecho de póliza and comisión and
|
||||
nothing else, which lost two things from every migrated policy:
|
||||
|
||||
1. RECARGO — the financing surcharge on a policy paid in more than one
|
||||
exhibición, plus the whole second money row (`p_neta_2`, `recargo_2`,
|
||||
`d_pol_2`, `com_2`) that a semestral policy carries because each payment
|
||||
is priced separately. These were not deleted, they were swept into
|
||||
`policies.coveragesJson` as loose strings alongside the real coverages —
|
||||
unqueryable, and mislabeled as coverage amounts.
|
||||
|
||||
2. FORMA PAGO — dropped outright. The column was marked "consumed" by the
|
||||
transform's coverage sweep but never written to any column, so it exists
|
||||
nowhere in the platform database. It is the field that decides whether a
|
||||
recargo is legitimate on a row at all, so it cannot be inferred back from
|
||||
the money.
|
||||
|
||||
The transform has been fixed in the same commit, so a full `run_all.py` now
|
||||
produces all of this directly. This script exists for a database that must not
|
||||
be re-imported: it reads the same staged Parquet and patches in place.
|
||||
|
||||
What it does NOT do: invent IVA or the printed TOTAL. Those were never columns
|
||||
in the home tables — they were unbound calculated controls on the Access form —
|
||||
so there is genuinely nothing to recover, and both stay null until a human
|
||||
edits the policy. The app computes them (apps/api/src/policies/premium.ts).
|
||||
|
||||
Idempotent, and never overwrites a non-null value: a figure a human has since
|
||||
corrected in the app wins over the legacy one.
|
||||
|
||||
./.venv/bin/python backfill_policy_premium_breakdown.py --env dev
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from dbenv import connect
|
||||
from sync import parse_mode
|
||||
|
||||
STG = Path(__file__).parent / "output" / "stg_seguros"
|
||||
LEGACY_DB = "SEGUROS 16_be"
|
||||
NULL = "∅"
|
||||
|
||||
# Legacy column -> what it is, per source table. Only the tables that actually
|
||||
# carry a breakdown appear; the auto tables have no recargo and no second row.
|
||||
HOME_TABLES = ("mult", "incendio", "m_empr")
|
||||
|
||||
# Keys the transform used to dump into coveragesJson that are now real columns.
|
||||
# Stripped once migrated so the blob stops pretending they are coverages.
|
||||
MIGRATED_COVERAGE_KEYS = (
|
||||
"recargo", "recargo_2", "p_neta_2", "d_pol_2", "com_2",
|
||||
)
|
||||
|
||||
_FREQ = {
|
||||
"ANNUAL": "ANNUAL",
|
||||
"ANUAL": "ANNUAL",
|
||||
"SEMESTRAL": "SEMIANNUAL",
|
||||
"TRIMESTRAL": "QUARTERLY",
|
||||
"MENSUAL": "MONTHLY",
|
||||
"CONTADO": "SINGLE",
|
||||
}
|
||||
|
||||
|
||||
def s(v):
|
||||
if v is None or pd.isna(v):
|
||||
return None
|
||||
v = str(v).strip()
|
||||
return None if v in ("", NULL) else v
|
||||
|
||||
|
||||
def dec(v):
|
||||
v = s(v)
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
return Decimal(v.replace(",", ""))
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def freq(v):
|
||||
return _FREQ.get((s(v) or "").upper())
|
||||
|
||||
|
||||
def load(name):
|
||||
df = pd.read_parquet(STG / f"{name}.parquet").sort_values("_row_num").reset_index(drop=True)
|
||||
for c in df.columns:
|
||||
if c != "_row_num":
|
||||
df[c] = df[c].astype("string").str.strip()
|
||||
return df
|
||||
|
||||
|
||||
def main():
|
||||
env, _sync_mode = parse_mode()
|
||||
|
||||
# Fails closed rather than reporting a clean run over nothing: an empty
|
||||
# staging directory and a policy set with no recargo look identical from
|
||||
# the database side, and "0 rows updated" would read as success.
|
||||
if not STG.exists():
|
||||
print(f"[policy-premium] staged Parquet missing at {STG} — run extract/load first.")
|
||||
return 3
|
||||
|
||||
conn = connect(env)
|
||||
c = conn.cursor()
|
||||
print(f"[policy-premium] target env: {env}")
|
||||
|
||||
# Every policy that came from the insurance ETL, keyed by provenance. The
|
||||
# id is needed to reach the installments, coveragesJson to strip the keys.
|
||||
c.execute(
|
||||
"SELECT legacySourceTable, legacyId, id, coveragesJson "
|
||||
"FROM policies WHERE legacySourceDb = %s AND legacyId IS NOT NULL",
|
||||
(LEGACY_DB,),
|
||||
)
|
||||
by_key = {(t, lid): (pid, cov) for t, lid, pid, cov in c.fetchall()}
|
||||
print(f" {len(by_key)} migrated polic(ies) in the target database")
|
||||
|
||||
pol_updates = [] # (surcharge, paymentFrequency, coveragesJson, policyId)
|
||||
inst_updates = [] # (netPremium, surcharge, policyFee, commission, policyId, seq)
|
||||
seen_tables = 0
|
||||
|
||||
for table in HOME_TABLES + (
|
||||
"tabla_autos", "tabla_autos_ampl", "tabla_autos_limit",
|
||||
"tabla_autos_ampl_r", "tabla_autos_rc_r", "mca2", "licencias",
|
||||
):
|
||||
path = STG / f"{table}.parquet"
|
||||
if not path.exists():
|
||||
continue
|
||||
seen_tables += 1
|
||||
df = load(table)
|
||||
home = table in HOME_TABLES
|
||||
|
||||
for _, row in df.iterrows():
|
||||
key = (table, str(int(row["_row_num"])))
|
||||
hit = by_key.get(key)
|
||||
if not hit:
|
||||
continue
|
||||
pid, cov_raw = hit
|
||||
|
||||
surcharge = dec(row.get("recargo")) if home else None
|
||||
frequency = freq(row.get("forma_pago"))
|
||||
|
||||
# Strip the now-modeled keys out of the coverage blob. Rewritten
|
||||
# only when something actually changes, so a policy whose blob a
|
||||
# human has edited is left byte-identical.
|
||||
cov_new = None
|
||||
if cov_raw:
|
||||
try:
|
||||
cov = json.loads(cov_raw) if isinstance(cov_raw, str) else cov_raw
|
||||
except (TypeError, ValueError):
|
||||
cov = None
|
||||
if isinstance(cov, dict):
|
||||
kept = {k: v for k, v in cov.items() if k not in MIGRATED_COVERAGE_KEYS}
|
||||
if len(kept) != len(cov):
|
||||
cov_new = json.dumps(kept, ensure_ascii=False) if kept else None
|
||||
|
||||
if surcharge is not None or frequency is not None or cov_new is not None:
|
||||
pol_updates.append((surcharge, frequency, cov_new, cov_new is not None, pid))
|
||||
|
||||
# Per-payment breakdown. Slot 1 is the unsuffixed money row, slot 2
|
||||
# the _2 twin; the auto tables have a single slot and no recargo.
|
||||
if home:
|
||||
slots = [
|
||||
(1, "p_neta", "recargo", "d_pol", "com"),
|
||||
(2, "p_neta_2", "recargo_2", "d_pol_2", "com_2"),
|
||||
]
|
||||
else:
|
||||
pn = "prima1" if table == "mca2" else "prima_neta"
|
||||
dp = "d_poliza1" if table == "mca2" else "d_poliza"
|
||||
slots = [(1, pn, None, dp, None)]
|
||||
|
||||
for seq, pn, rc, dp, cm in slots:
|
||||
vals = (
|
||||
dec(row.get(pn)) if pn else None,
|
||||
dec(row.get(rc)) if rc else None,
|
||||
dec(row.get(dp)) if dp else None,
|
||||
dec(row.get(cm)) if cm else None,
|
||||
)
|
||||
if all(v is None for v in vals):
|
||||
continue
|
||||
inst_updates.append((*vals, pid, seq))
|
||||
|
||||
if not seen_tables:
|
||||
print(f"[policy-premium] no policy tables staged under {STG} — nothing to do.")
|
||||
return 3
|
||||
|
||||
# COALESCE on every target: a column a human has already filled in the app
|
||||
# keeps its value, the legacy figure only lands where there is a hole.
|
||||
for surcharge, frequency, cov_new, rewrite_cov, pid in pol_updates:
|
||||
c.execute(
|
||||
"UPDATE policies SET "
|
||||
" surcharge = COALESCE(surcharge, %s), "
|
||||
" paymentFrequency = COALESCE(paymentFrequency, %s), "
|
||||
" coveragesJson = IF(%s, %s, coveragesJson) "
|
||||
"WHERE id = %s",
|
||||
(surcharge, frequency, 1 if rewrite_cov else 0, cov_new, pid),
|
||||
)
|
||||
|
||||
for netp, surch, fee, comm, pid, seq in inst_updates:
|
||||
c.execute(
|
||||
"UPDATE policy_payment_installments SET "
|
||||
" netPremium = COALESCE(netPremium, %s), "
|
||||
" surcharge = COALESCE(surcharge, %s), "
|
||||
" policyFee = COALESCE(policyFee, %s), "
|
||||
" commission = COALESCE(commission, %s) "
|
||||
"WHERE policyId = %s AND sequence = %s",
|
||||
(netp, surch, fee, comm, pid, seq),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
print(f" policies : {len(pol_updates)} row(s) touched")
|
||||
print(f" installments: {len(inst_updates)} row(s) touched")
|
||||
|
||||
# --- validation ---------------------------------------------------------
|
||||
c.execute("SELECT COUNT(*) FROM policies WHERE surcharge IS NOT NULL AND surcharge <> 0")
|
||||
n_surch = c.fetchone()[0]
|
||||
c.execute("SELECT COUNT(*) FROM policies WHERE paymentFrequency IS NOT NULL")
|
||||
n_freq = c.fetchone()[0]
|
||||
c.execute(
|
||||
"SELECT COUNT(*) FROM policies "
|
||||
"WHERE paymentFrequency IN ('ANNUAL','SINGLE') AND surcharge IS NOT NULL AND surcharge <> 0"
|
||||
)
|
||||
n_bad = c.fetchone()[0]
|
||||
c.execute(
|
||||
"SELECT COUNT(*) FROM policies WHERE coveragesJson IS NOT NULL "
|
||||
"AND JSON_EXTRACT(coveragesJson, '$.recargo') IS NOT NULL"
|
||||
)
|
||||
n_left = c.fetchone()[0]
|
||||
|
||||
print(f" -> policies with a recargo : {n_surch}")
|
||||
print(f" -> policies with a forma pago : {n_freq}")
|
||||
print(f" -> recargo still in coverages : {n_left}")
|
||||
|
||||
# A surcharge on an annual policy contradicts the rule the capture form
|
||||
# enforces, so it is worth surfacing rather than leaving for someone to
|
||||
# find in a total. It is a warning, not a failure: the books are the books.
|
||||
if n_bad:
|
||||
print(f" !! {n_bad} annual/contado polic(ies) carry a non-zero recargo — review by hand")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main() or 0)
|
||||
@@ -59,6 +59,12 @@ STEPS = [
|
||||
# touches.
|
||||
"backfill_statement_match_fields.py",
|
||||
"transform_policies.py",
|
||||
# Premium breakdown (recargo, per-payment figures, forma de pago).
|
||||
# transform_policies.py now writes these directly, so on a full rebuild
|
||||
# this is a no-op that re-asserts them; on a database migrated before the
|
||||
# breakdown existed it is what recovers them out of coveragesJson.
|
||||
# Must follow transform_policies.py, which truncates the installments.
|
||||
"backfill_policy_premium_breakdown.py",
|
||||
"transform_transactions.py",
|
||||
"prune_empty_customers.py",
|
||||
# Seeds the Scotiabank chequera that every SCOTHIA movement is booked into;
|
||||
@@ -78,6 +84,12 @@ SYNC_STEPS = [
|
||||
# touches.
|
||||
"backfill_statement_match_fields.py",
|
||||
"transform_policies.py",
|
||||
# Premium breakdown (recargo, per-payment figures, forma de pago).
|
||||
# transform_policies.py now writes these directly, so on a full rebuild
|
||||
# this is a no-op that re-asserts them; on a database migrated before the
|
||||
# breakdown existed it is what recovers them out of coveragesJson.
|
||||
# Must follow transform_policies.py, which truncates the installments.
|
||||
"backfill_policy_premium_breakdown.py",
|
||||
"transform_transactions.py",
|
||||
# Manual-safe prune: drops legacy-owned empties that the customer upsert
|
||||
# re-creates from Parquet, but leaves manually-added customers alone.
|
||||
|
||||
@@ -18,6 +18,17 @@ Design (validated against staged data):
|
||||
policies are skipped and counted (required FK).
|
||||
- Payment slots: c_1er_pago is the first amount, pago_subsec the recurring
|
||||
amount for slots 2-4; efectivo is a cash flag, no_cheque the check ref.
|
||||
- Premium breakdown: a policy paid in more than one exhibicion prices EACH
|
||||
payment separately, which is why the home tables carry the whole money row
|
||||
twice (p_neta/recargo/d_pol/com and their _2 twins). The unsuffixed set is
|
||||
the policy header, the suffixed one belongs to payment 2, and both are
|
||||
written per installment as well. This used to be lost: `recargo` and the
|
||||
_2 columns fell into coveragesJson as loose strings and forma_pago was
|
||||
marked consumed but never written anywhere at all.
|
||||
- IVA and the printed TOTAL are NOT in Access for the home tables. They were
|
||||
unbound calculated controls on the form, so there is nothing to migrate;
|
||||
the app computes them (apps/api/src/policies/premium.ts) from
|
||||
(p_neta + recargo + d_pol) * rate.
|
||||
- Any source column not explicitly modeled (coverage amounts: edificio,
|
||||
contenidos, robo, cristales, ...) is preserved verbatim in coveragesJson,
|
||||
so nothing is lost in consolidation.
|
||||
@@ -93,20 +104,55 @@ def truthy(v):
|
||||
return (s(v) or "0").lower() in {"1", "-1", "true", "si", "sí", "yes", "x"}
|
||||
|
||||
|
||||
# Access FORMA PAGO -> PaymentFrequency. The whole staged corpus holds exactly
|
||||
# four spellings (ANNUAL 1851, SEMESTRAL 29, semestral 2, CONTADO 1); anything
|
||||
# else is left null rather than guessed, because the value decides whether a
|
||||
# recargo is legitimate on the row.
|
||||
_FREQ = {
|
||||
"ANNUAL": "ANNUAL",
|
||||
"ANUAL": "ANNUAL",
|
||||
"SEMESTRAL": "SEMIANNUAL",
|
||||
"TRIMESTRAL": "QUARTERLY",
|
||||
"MENSUAL": "MONTHLY",
|
||||
"CONTADO": "SINGLE",
|
||||
}
|
||||
|
||||
|
||||
def freq(v):
|
||||
return _FREQ.get((s(v) or "").upper())
|
||||
|
||||
|
||||
def slot_dec(row, slot, key):
|
||||
"""One figure of a payment's premium breakdown, or None when the source
|
||||
table has no such column. Deliberately not zero: a zero d_pol on the second
|
||||
payment is a real figure in the books and must stay distinguishable from
|
||||
'this table never had that column'."""
|
||||
col = slot.get(key)
|
||||
return dec(row.get(col)) if col else None
|
||||
|
||||
|
||||
# --- per-table config ------------------------------------------------------- #
|
||||
# fields: policy column -> source column. installments: list of slot dicts.
|
||||
# vehicles: 'trip_underscore' | 'single' | 'mca2' | None. drivers: 'mca2' |
|
||||
# 'licencias' | None.
|
||||
# `pneta`/`recarg`/`dpol`/`com` on a slot are that payment's own share of the
|
||||
# premium. Only the first two payments have one in Access — the form only ever
|
||||
# drew the money row twice — so slots 3 and 4 carry none and keep just the
|
||||
# amount actually collected.
|
||||
HOME_INST = [
|
||||
dict(seq=1, amt="c_1er_pago", cu="moned", d="fecha_pago", ck="no_cheque", cash="efectivo"),
|
||||
dict(seq=2, amt="pago_subsec", cu="moned_2", d="fecha_pago_2", ck="no_cheque_2", cash="efectivo_2"),
|
||||
dict(seq=1, amt="c_1er_pago", cu="moned", d="fecha_pago", ck="no_cheque", cash="efectivo",
|
||||
pneta="p_neta", recarg="recargo", dpol="d_pol", com="com"),
|
||||
dict(seq=2, amt="pago_subsec", cu="moned_2", d="fecha_pago_2", ck="no_cheque_2", cash="efectivo_2",
|
||||
pneta="p_neta_2", recarg="recargo_2", dpol="d_pol_2", com="com_2"),
|
||||
dict(seq=3, amt="pago_subsec", cu="moned_3", d="fecha_pago_3", ck="no_cheque_3", cash="efectivo_3"),
|
||||
dict(seq=4, amt="pago_subsec", cu="moned_4", d="fecha_pago_4", ck="no_cheque_4", cash="efectivo_4"),
|
||||
]
|
||||
HOME_FIELDS = dict(polno="no_poliza", agent="agent", comp="comp", desde="desde", hasta="hasta",
|
||||
forma="forma_pago", curcol="moned", pneta="p_neta", dpol="d_pol", com="com",
|
||||
forma="forma_pago", curcol="moned", pneta="p_neta", recarg="recargo",
|
||||
dpol="d_pol", com="com",
|
||||
liquidada="liquidada", numliq="num_liquidacion", fliq="f_liquida1", renov="renovacion")
|
||||
AUTO_SINGLE_INST = [dict(seq=1, amt="total", cu="moneda", d="fecha_pago", ck="no_cheque", cash="efectivo")]
|
||||
AUTO_SINGLE_INST = [dict(seq=1, amt="total", cu="moneda", d="fecha_pago", ck="no_cheque", cash="efectivo",
|
||||
pneta="prima_neta", dpol="d_poliza")]
|
||||
|
||||
CONFIGS = {
|
||||
"incendio": dict(ptype="INCENDIO", idcol="num_id", fields={**HOME_FIELDS, "curcol": "moneda"},
|
||||
@@ -157,7 +203,8 @@ CONFIGS = {
|
||||
forma="forma_pago", curcol="moneda", pneta="prima_neta", dpol="d_poliza",
|
||||
total="total", liquidada="liquidada", numliq="num_liquidacion",
|
||||
fliq="f_liquida1", renov="renovacion"),
|
||||
inst=[dict(seq=1, amt="total", cu="moneda", d="fecha_pago", ck="no_cheque", cash="efectivo")],
|
||||
inst=[dict(seq=1, amt="total", cu="moneda", d="fecha_pago", ck="no_cheque",
|
||||
cash="efectivo", pneta="prima_neta", dpol="d_poliza")],
|
||||
veh=None, drv="licencias"),
|
||||
}
|
||||
|
||||
@@ -200,6 +247,10 @@ def main():
|
||||
consumed = {cfg["idcol"], *F.values()}
|
||||
for slot in cfg["inst"]:
|
||||
consumed |= {slot["amt"], slot["cu"], slot["d"], slot["ck"], slot["cash"]}
|
||||
# The per-payment premium columns are now modeled, so they must
|
||||
# leave the coveragesJson sweep — otherwise every recargo would be
|
||||
# written twice, once as a column and once as a fake coverage.
|
||||
consumed |= {slot[k] for k in ("pneta", "recarg", "dpol", "com") if slot.get(k)}
|
||||
|
||||
for _, row in df.iterrows():
|
||||
cid = cust.get(norm_id(row[cfg["idcol"]]))
|
||||
@@ -237,9 +288,11 @@ def main():
|
||||
dt(row.get(F.get("desde", ""))) if F.get("desde") else None,
|
||||
dt(row.get(F.get("hasta", ""))) if F.get("hasta") else None,
|
||||
dec(row.get(F.get("pneta", ""))) if F.get("pneta") else None,
|
||||
dec(row.get(F.get("recarg", ""))) if F.get("recarg") else None,
|
||||
dec(row.get(F.get("dpol", ""))) if F.get("dpol") else None,
|
||||
dec(row.get(F.get("com", ""))) if F.get("com") else None,
|
||||
dec(row.get(F.get("total", ""))) if F.get("total") else None,
|
||||
freq(row.get(F.get("forma", ""))) if F.get("forma") else None,
|
||||
cur(row.get(F.get("curcol", ""))) if F.get("curcol") else "MXN",
|
||||
s(row.get("observaciones")),
|
||||
json.dumps(cov, ensure_ascii=False) if cov else None,
|
||||
@@ -255,9 +308,12 @@ def main():
|
||||
pdate = dt(row.get(slot["d"]))
|
||||
if amt is None and pdate is None:
|
||||
continue
|
||||
# Slot breakdown, where the source table has one.
|
||||
insts.append((str(uuid.uuid4()), pid, slot["seq"], amt,
|
||||
cur(row.get(slot["cu"])), pdate, s(row.get(slot["ck"])),
|
||||
1 if truthy(row.get(slot["cash"])) else 0))
|
||||
1 if truthy(row.get(slot["cash"])) else 0,
|
||||
slot_dec(row, slot, "pneta"), slot_dec(row, slot, "recarg"),
|
||||
slot_dec(row, slot, "dpol"), slot_dec(row, slot, "com")))
|
||||
|
||||
# vehicles
|
||||
def add_vehicle(make, model, body, engine, plate, year=None, state=None):
|
||||
@@ -328,16 +384,19 @@ def main():
|
||||
1 if truthy(r["concluido"]) else 0, s(r["resolucion"])))
|
||||
|
||||
pol_cols = ("id,policyNumber,customerId,policyTypeId,insuranceProviderId,agentName,policyDate,"
|
||||
"policyFrom,policyTo,netPremium,policyFee,commission,total,currency,observations,"
|
||||
"policyFrom,policyTo,netPremium,surcharge,policyFee,commission,total,paymentFrequency,"
|
||||
"currency,observations,"
|
||||
"coveragesJson,liquidated,liquidationNumber,liquidationDate,legacySourceDb,"
|
||||
"legacySourceTable,legacyId,updatedAt")
|
||||
ph = ",".join(["%s"] * 23)
|
||||
ph = ",".join(["%s"] * 25)
|
||||
pol_upsert = (
|
||||
f"INSERT INTO policies ({pol_cols}) VALUES ({ph}) ON DUPLICATE KEY UPDATE "
|
||||
"customerId=VALUES(customerId),policyNumber=VALUES(policyNumber),policyTypeId=VALUES(policyTypeId),"
|
||||
"insuranceProviderId=VALUES(insuranceProviderId),agentName=VALUES(agentName),policyDate=VALUES(policyDate),"
|
||||
"policyFrom=VALUES(policyFrom),policyTo=VALUES(policyTo),netPremium=VALUES(netPremium),policyFee=VALUES(policyFee),"
|
||||
"commission=VALUES(commission),total=VALUES(total),currency=VALUES(currency),observations=VALUES(observations),"
|
||||
"policyFrom=VALUES(policyFrom),policyTo=VALUES(policyTo),netPremium=VALUES(netPremium),"
|
||||
"surcharge=VALUES(surcharge),policyFee=VALUES(policyFee),"
|
||||
"commission=VALUES(commission),total=VALUES(total),paymentFrequency=VALUES(paymentFrequency),"
|
||||
"currency=VALUES(currency),observations=VALUES(observations),"
|
||||
"coveragesJson=VALUES(coveragesJson),liquidated=VALUES(liquidated),liquidationNumber=VALUES(liquidationNumber),"
|
||||
"liquidationDate=VALUES(liquidationDate),updatedAt=VALUES(updatedAt),archivedAt=NULL")
|
||||
|
||||
@@ -399,8 +458,9 @@ def main():
|
||||
|
||||
|
||||
c.executemany("INSERT INTO policy_payment_installments "
|
||||
"(id,policyId,sequence,amount,currency,paidDate,checkNumber,isCash) "
|
||||
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s)", insts)
|
||||
"(id,policyId,sequence,amount,currency,paidDate,checkNumber,isCash,"
|
||||
"netPremium,surcharge,policyFee,commission) "
|
||||
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", insts)
|
||||
c.executemany("INSERT INTO vehicles (id,customerId,policyId,make,model,modelYear,bodyType,"
|
||||
"engineNumber,licensePlate,stateCode,legacySourceTable,legacyId) "
|
||||
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", vehicles)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jorgecuadros-platform",
|
||||
"version": "1.0.20",
|
||||
"version": "1.0.23",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jorgecuadros/database",
|
||||
"version": "1.0.20",
|
||||
"version": "1.0.23",
|
||||
"private": true,
|
||||
"main": "generated/client/index.js",
|
||||
"types": "generated/client/index.d.ts",
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
-- Ranked customers whose name matches the printed insured name, for the
|
||||
-- documents whose policy number found nothing and therefore need a customer
|
||||
-- picked by hand. Kept in its own column rather than folded into
|
||||
-- `matchCandidates`, which the review screen reads as policy-number hits —
|
||||
-- a name is a suggestion and must never be able to masquerade as a match.
|
||||
ALTER TABLE `policy_ocr_documents`
|
||||
ADD COLUMN `customerSuggestions` JSON NULL;
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
-- Premium breakdown the Access capture form had and this schema did not:
|
||||
-- RECARGO, IVA and PRIMA TOTAL on the policy header, the same six figures per
|
||||
-- payment on the installments, and the FORMA PAGO that decides whether a
|
||||
-- surcharge applies at all.
|
||||
--
|
||||
-- IVA and TOTAL were never columns in Access — they were unbound calculated
|
||||
-- controls on the form — so there is nothing to backfill for them here and
|
||||
-- every migrated row stays null until somebody edits the policy. RECARGO and
|
||||
-- the per-installment figures DO exist in the legacy data; they are currently
|
||||
-- stranded inside `policies.coveragesJson` (the migration swept every column
|
||||
-- it did not model into that blob) and are recovered by
|
||||
-- `migration/backfill_policy_premium_breakdown.py`, not by this migration.
|
||||
|
||||
ALTER TABLE `policy_types`
|
||||
ADD COLUMN `taxRate` DECIMAL(6, 4) NULL;
|
||||
|
||||
ALTER TABLE `policies`
|
||||
ADD COLUMN `surcharge` DECIMAL(12, 2) NULL,
|
||||
ADD COLUMN `tax` DECIMAL(12, 2) NULL,
|
||||
ADD COLUMN `taxRate` DECIMAL(6, 4) NULL,
|
||||
ADD COLUMN `paymentFrequency` ENUM('ANNUAL', 'SEMIANNUAL', 'QUARTERLY', 'MONTHLY', 'SINGLE') NULL;
|
||||
|
||||
ALTER TABLE `policy_payment_installments`
|
||||
ADD COLUMN `netPremium` DECIMAL(12, 2) NULL,
|
||||
ADD COLUMN `surcharge` DECIMAL(12, 2) NULL,
|
||||
ADD COLUMN `policyFee` DECIMAL(12, 2) NULL,
|
||||
ADD COLUMN `tax` DECIMAL(12, 2) NULL,
|
||||
ADD COLUMN `taxRate` DECIMAL(6, 4) NULL,
|
||||
ADD COLUMN `total` DECIMAL(12, 2) NULL,
|
||||
ADD COLUMN `commission` DECIMAL(12, 2) NULL;
|
||||
|
||||
-- Seed the rate the books actually use. The legacy IMPUESTOS and
|
||||
-- IMPUESTOS_AUTOS tables each held exactly one row, both 0.0800, covering the
|
||||
-- home and auto lines respectively; applying it to every existing type
|
||||
-- reproduces current behaviour rather than changing it. Types created later
|
||||
-- start null and fall back to the API default.
|
||||
UPDATE `policy_types` SET `taxRate` = 0.0800 WHERE `taxRate` IS NULL;
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
-- A.N.A. prints IVA on the policy face and the parser already read it, but
|
||||
-- `ParsedPolicy` had no field for it, so the figure only ever reached a review
|
||||
-- note and the confirmed Policy was written with `tax` null. This gives it a
|
||||
-- column, matching the premium fields beside it.
|
||||
--
|
||||
-- GMX stays null: its certificate carries no premium at all, so there is no
|
||||
-- tax on it to read either.
|
||||
ALTER TABLE `policy_ocr_documents`
|
||||
ADD COLUMN `extractedTax` DECIMAL(12, 2) NULL;
|
||||
@@ -158,10 +158,31 @@ model InsuranceProvider {
|
||||
@@map("insurance_providers")
|
||||
}
|
||||
|
||||
/// How the premium is split into payments. Drives whether a surcharge
|
||||
/// applies at all: the legacy books only ever charge `recargo` on a policy
|
||||
/// paid in more than one exhibición, never on an annual one. Values come from
|
||||
/// the Access `FORMA PAGO` column (ANNUAL / SEMESTRAL / CONTADO) plus the
|
||||
/// quarterly option Jorge sells today but never recorded in Access.
|
||||
enum PaymentFrequency {
|
||||
ANNUAL
|
||||
SEMIANNUAL
|
||||
QUARTERLY
|
||||
MONTHLY
|
||||
/// Legacy "CONTADO" — the whole premium in one payment, no schedule.
|
||||
SINGLE
|
||||
}
|
||||
|
||||
model PolicyType {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
shortDescription String?
|
||||
/// IVA rate charged on this line of business, as a fraction (0.08 = 8%).
|
||||
/// Replaces the legacy one-row IMPUESTOS / IMPUESTOS_AUTOS tables, which
|
||||
/// held exactly one rate each — per line of business, editable without a
|
||||
/// deploy, because the rate is a tax rule and tax rules change. Null falls
|
||||
/// back to DEFAULT_TAX_RATE in the API rather than to "no tax", so a type
|
||||
/// nobody has configured still computes the same 8% the books use today.
|
||||
taxRate Decimal? @db.Decimal(6, 4)
|
||||
policies Policy[]
|
||||
|
||||
@@map("policy_types")
|
||||
@@ -185,10 +206,32 @@ model Policy {
|
||||
policyTo DateTime?
|
||||
coveragePeriodDays Int? @default(365)
|
||||
netPremium Decimal? @db.Decimal(12, 2)
|
||||
/// "Recargo" — the financing surcharge for paying in installments. Entered
|
||||
/// by hand, never derived: it is quoted by the carrier, not computed here.
|
||||
/// Only ever set when `paymentFrequency` is not ANNUAL/SINGLE, and it IS
|
||||
/// part of the taxable base (verified against the Access books: policy
|
||||
/// 7006785 only reconciles as (610.86 + 8.55 + 31.00) * 0.08 = 52.03).
|
||||
surcharge Decimal? @db.Decimal(12, 2)
|
||||
policyFee Decimal? @db.Decimal(12, 2)
|
||||
brokerFee Decimal? @db.Decimal(12, 2)
|
||||
commission Decimal? @db.Decimal(12, 2)
|
||||
/// IVA. Access never stored this — it was an unbound calculated control on
|
||||
/// the form — so every legacy row starts null and is filled going forward.
|
||||
/// Stored rather than computed on read because the printed policy is the
|
||||
/// record of truth and its rounding must survive a later rate change.
|
||||
tax Decimal? @db.Decimal(12, 2)
|
||||
/// The rate actually applied when `tax` was written, as a fraction. Kept on
|
||||
/// the row so a policy issued at 8% still reads back as 8% after somebody
|
||||
/// edits the PolicyType to a new rate.
|
||||
taxRate Decimal? @db.Decimal(6, 4)
|
||||
/// Prima total = netPremium + surcharge + policyFee + tax. Populated by the
|
||||
/// capture form from now on. NOTE the legacy rows: `total` is 0 or null on
|
||||
/// all but 2 of 2378 migrated policies, so list/sort code must keep using
|
||||
/// netPremium as the headline (see policies.service.ts).
|
||||
total Decimal? @db.Decimal(12, 2)
|
||||
/// ANNUAL on all but 31 legacy rows — and the migration used to drop the
|
||||
/// column entirely, so every pre-2026 policy reads null here.
|
||||
paymentFrequency PaymentFrequency?
|
||||
currency Currency @default(MXN)
|
||||
observations String? @db.Text
|
||||
notes String? @db.Text
|
||||
@@ -267,6 +310,23 @@ model PolicyPaymentInstallment {
|
||||
checkNumber String?
|
||||
isCash Boolean @default(false)
|
||||
|
||||
// Per-payment premium breakdown. A policy paid in more than one exhibición
|
||||
// prices EACH payment separately — its own net premium, its own surcharge,
|
||||
// its own IVA — which is why the Access form printed the whole money row
|
||||
// twice (P NETA / RECARGO / D POL / IVA / TOTAL / COM, once per pago) and
|
||||
// why these cannot live on the policy header alone. `amount` stays the
|
||||
// authoritative figure actually collected: it is what the cheque was
|
||||
// written for and it drifts from `total` by a peso or two in the books
|
||||
// (policy 7006785: amount 702.73 vs total 702.44), so it is deliberately
|
||||
// NOT recomputed from this breakdown.
|
||||
netPremium Decimal? @db.Decimal(12, 2)
|
||||
surcharge Decimal? @db.Decimal(12, 2)
|
||||
policyFee Decimal? @db.Decimal(12, 2)
|
||||
tax Decimal? @db.Decimal(12, 2)
|
||||
taxRate Decimal? @db.Decimal(6, 4)
|
||||
total Decimal? @db.Decimal(12, 2)
|
||||
commission Decimal? @db.Decimal(12, 2)
|
||||
|
||||
@@map("policy_payment_installments")
|
||||
}
|
||||
|
||||
@@ -443,6 +503,11 @@ model PolicyOcrDocument {
|
||||
extractedNetPremium Decimal? @db.Decimal(12, 2)
|
||||
extractedPolicyFee Decimal? @db.Decimal(12, 2)
|
||||
extractedBrokerFee Decimal? @db.Decimal(12, 2)
|
||||
/// IVA off A.N.A.'s `TAX` cell. Null on GMX, whose certificate carries no
|
||||
/// premium at all. The adjacent `LOCAL TAX` is a separate levy with no
|
||||
/// column of its own and is NOT summed in — it would make the figure stop
|
||||
/// dividing back to a rate; the parser reports a non-zero one as a note.
|
||||
extractedTax Decimal? @db.Decimal(12, 2)
|
||||
extractedTotal Decimal? @db.Decimal(12, 2)
|
||||
/// Per-coverage rows from the GMX "Material damages" / "Additional risk"
|
||||
/// tables and ANA's numbered risk sections — preserved verbatim so a
|
||||
@@ -476,6 +541,13 @@ model PolicyOcrDocument {
|
||||
/// normal; >1 means the policy number is shared across customers and a
|
||||
/// human must pick.
|
||||
matchCandidates Json?
|
||||
/// `CustomerNameSuggestion[]` — customers whose name matches the printed
|
||||
/// insured name, ranked. A SUGGESTION, never a match: it is deliberately
|
||||
/// kept out of `matchCandidates` so the review screen cannot mistake a
|
||||
/// name hint for a policy-number hit, and it never sets
|
||||
/// `matchedCustomerId`. Only populated when the policy number found
|
||||
/// nothing, which is exactly when staff have to pick a customer by hand.
|
||||
customerSuggestions Json?
|
||||
/// Text, not VARCHAR(191): this carries the parser's whole note trail, and
|
||||
/// a multi-section ANA policy runs past 191 characters routinely. Silently
|
||||
/// truncating it drops the tail notes, which are the ones that say what
|
||||
|
||||
Reference in New Issue
Block a user