Insurance module: policy browser (list/search/detail) + renewals view
Plan step 4. Adds the policies API and the Spanish-first /polizas pages on top of the customer records the customer module already exposes. API (apps/api/src/policies): - GET /policies — search over policy number, customer name, agent, vehicle license plate, insured-driver name and legacy id; filters for vigencia bucket, ramo, aseguradora and liquidation state; five sort orders. - GET /policies/stats — bucket counts plus premium in force split by currency (MXN and USD can't be summed). - GET /policies/facets — ramos/aseguradoras with counts for the dropdowns. - GET /policies/:id — full policy plus the owning customer. Vigencia is derived from policyTo as active/expiring/expired/undated. "undated" is a real bucket rather than an error case: 528 of the 2378 migrated policies carry no end date at all. Web: - /polizas — renewals-first browser; the stat cells double as vigencia filters, with a secondary row for ramo, aseguradora and sort order. - /polizas/[id] — vigencia hero, condiciones y primas, pagos, vehículos, asegurados/beneficiarios, siniestros, the verbatim legacy coverage columns, and documents. - Nav gains Clientes | Pólizas with a real active state, and the two modules cross-link in both directions. Also fixes a display bug on the customer detail page: it headlined policies.total, which is dead data — only 2 of 2378 rows are non-zero (1585 are literally 0, 791 null), and one of those two is lower than its own net premium. That rendered "$0.00 Total" on 1585 policies. Premium headlines and the premium sort now use netPremium (2377/2378 populated); total is shown only where it is non-zero, as raw source data. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Prisma } from "@jorgecuadros/database";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
/**
|
||||
* Vigencia buckets, derived from `policyTo` against today. `undated` is a real
|
||||
* bucket rather than an error case: 528 of the migrated policies carry no end
|
||||
* date at all (the legacy Access tables left it blank), so they can neither be
|
||||
* called current nor expired.
|
||||
*/
|
||||
export type PolicyStatus = "active" | "expiring" | "expired" | "undated";
|
||||
|
||||
export type PolicySort =
|
||||
| "expiry_desc"
|
||||
| "expiry_asc"
|
||||
| "customer"
|
||||
| "number"
|
||||
| "premium_desc";
|
||||
|
||||
export interface ListParams {
|
||||
query?: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status?: PolicyStatus;
|
||||
/** Window in days for the `expiring` bucket. */
|
||||
days: number;
|
||||
typeId?: string;
|
||||
providerId?: string;
|
||||
liquidated?: boolean;
|
||||
sort: PolicySort;
|
||||
}
|
||||
|
||||
/** Midnight today, UTC — policy dates are stored date-only at 00:00 UTC. */
|
||||
function today(): Date {
|
||||
const now = new Date();
|
||||
return new Date(
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
|
||||
);
|
||||
}
|
||||
|
||||
function addDays(d: Date, days: number): Date {
|
||||
return new Date(d.getTime() + days * 86400000);
|
||||
}
|
||||
|
||||
function statusOf(policyTo: Date | null, from: Date, soon: Date): PolicyStatus {
|
||||
if (!policyTo) return "undated";
|
||||
if (policyTo < from) return "expired";
|
||||
return policyTo <= soon ? "expiring" : "active";
|
||||
}
|
||||
|
||||
function daysUntil(policyTo: Date | null, from: Date): number | null {
|
||||
if (!policyTo) return null;
|
||||
return Math.round((policyTo.getTime() - from.getTime()) / 86400000);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PoliciesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
private statusWhere(
|
||||
status: PolicyStatus | undefined,
|
||||
days: number,
|
||||
): Prisma.PolicyWhereInput {
|
||||
const from = today();
|
||||
switch (status) {
|
||||
case "active":
|
||||
return { policyTo: { gte: from } };
|
||||
case "expiring":
|
||||
return { policyTo: { gte: from, lte: addDays(from, days) } };
|
||||
case "expired":
|
||||
return { policyTo: { lt: from } };
|
||||
case "undated":
|
||||
return { policyTo: null };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
private orderBy(sort: PolicySort): Prisma.PolicyOrderByWithRelationInput[] {
|
||||
switch (sort) {
|
||||
case "expiry_asc":
|
||||
return [{ policyTo: "asc" }];
|
||||
case "customer":
|
||||
return [{ customer: { name: "asc" } }, { policyTo: "desc" }];
|
||||
case "number":
|
||||
return [{ policyNumber: "asc" }];
|
||||
case "premium_desc":
|
||||
// Sorts on netPremium, not total: `total` is 0 or null on all but 2 of
|
||||
// the 2378 migrated policies, so ordering by it is meaningless.
|
||||
return [{ netPremium: "desc" }];
|
||||
default:
|
||||
// MySQL sorts NULLs last on DESC, which puts the 528 undated policies
|
||||
// at the end instead of the top — the behaviour we want by default.
|
||||
return [{ policyTo: "desc" }];
|
||||
}
|
||||
}
|
||||
|
||||
/** Policy list with search, vigencia/type/provider filters, paginated. */
|
||||
async list(params: ListParams) {
|
||||
const { query, page, pageSize, status, days, typeId, providerId, liquidated, sort } =
|
||||
params;
|
||||
|
||||
const where: Prisma.PolicyWhereInput = { ...this.statusWhere(status, days) };
|
||||
|
||||
if (query && query.trim()) {
|
||||
const q = query.trim();
|
||||
where.OR = [
|
||||
{ policyNumber: { contains: q } },
|
||||
{ customer: { name: { contains: q } } },
|
||||
{ agentName: { contains: q } },
|
||||
{ vehicles: { some: { licensePlate: { contains: q } } } },
|
||||
{ insuredDrivers: { some: { fullName: { contains: q } } } },
|
||||
{ legacyId: { contains: q } },
|
||||
];
|
||||
}
|
||||
if (typeId) where.policyTypeId = typeId;
|
||||
if (providerId) where.insuranceProviderId = providerId;
|
||||
if (liquidated !== undefined) where.liquidated = liquidated;
|
||||
|
||||
const [total, rows] = await this.prisma.$transaction([
|
||||
this.prisma.policy.count({ where }),
|
||||
this.prisma.policy.findMany({
|
||||
where,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
orderBy: this.orderBy(sort),
|
||||
select: {
|
||||
id: true,
|
||||
policyNumber: true,
|
||||
agentName: true,
|
||||
policyFrom: true,
|
||||
policyTo: true,
|
||||
netPremium: true,
|
||||
total: true,
|
||||
currency: true,
|
||||
liquidated: true,
|
||||
customer: { select: { id: true, name: true, city: true } },
|
||||
policyType: { select: { id: true, name: true } },
|
||||
insuranceProvider: { select: { id: true, name: true } },
|
||||
_count: { select: { vehicles: true, installments: true, documents: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const from = today();
|
||||
const soon = addDays(from, days);
|
||||
|
||||
const items = rows.map((r) => ({
|
||||
id: r.id,
|
||||
policyNumber: r.policyNumber,
|
||||
agentName: r.agentName,
|
||||
policyFrom: r.policyFrom,
|
||||
policyTo: r.policyTo,
|
||||
netPremium: r.netPremium,
|
||||
total: r.total,
|
||||
currency: r.currency,
|
||||
liquidated: r.liquidated,
|
||||
customerId: r.customer.id,
|
||||
customerName: r.customer.name,
|
||||
customerCity: r.customer.city,
|
||||
policyType: r.policyType,
|
||||
insuranceProvider: r.insuranceProvider,
|
||||
status: statusOf(r.policyTo, from, soon),
|
||||
daysToExpiry: daysUntil(r.policyTo, from),
|
||||
vehicleCount: r._count.vehicles,
|
||||
installmentCount: r._count.installments,
|
||||
documentCount: r._count.documents,
|
||||
}));
|
||||
|
||||
return { items, total, page, pageSize, pageCount: Math.ceil(total / pageSize) };
|
||||
}
|
||||
|
||||
/** Top-line counts for the policies page header. */
|
||||
async stats(days: number) {
|
||||
const from = today();
|
||||
const soon = addDays(from, days);
|
||||
|
||||
const [total, active, expiring, expired, undated, liquidated] =
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.policy.count(),
|
||||
this.prisma.policy.count({ where: { policyTo: { gte: from } } }),
|
||||
this.prisma.policy.count({
|
||||
where: { policyTo: { gte: from, lte: soon } },
|
||||
}),
|
||||
this.prisma.policy.count({ where: { policyTo: { lt: from } } }),
|
||||
this.prisma.policy.count({ where: { policyTo: null } }),
|
||||
this.prisma.policy.count({ where: { liquidated: true } }),
|
||||
]);
|
||||
|
||||
// Premium in force, per currency — the two currencies can't be summed.
|
||||
const inForce = await this.prisma.policy.groupBy({
|
||||
by: ["currency"],
|
||||
where: { policyTo: { gte: from } },
|
||||
_sum: { total: true, netPremium: true },
|
||||
_count: { _all: true },
|
||||
});
|
||||
|
||||
return {
|
||||
total,
|
||||
active,
|
||||
expiring,
|
||||
expired,
|
||||
undated,
|
||||
liquidated,
|
||||
pending: total - liquidated,
|
||||
days,
|
||||
premiumInForce: inForce.map((r) => ({
|
||||
currency: r.currency,
|
||||
total: r._sum.total,
|
||||
netPremium: r._sum.netPremium,
|
||||
count: r._count._all,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Filter dropdown options, with counts so empty choices are visible. */
|
||||
async facets() {
|
||||
const [types, providers] = await this.prisma.$transaction([
|
||||
this.prisma.policyType.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
select: { id: true, name: true, _count: { select: { policies: true } } },
|
||||
}),
|
||||
this.prisma.insuranceProvider.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
select: { id: true, name: true, _count: { select: { policies: true } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
types: types.map((t) => ({ id: t.id, name: t.name, count: t._count.policies })),
|
||||
providers: providers.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
count: p._count.policies,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Full policy view, including the owning customer. */
|
||||
async detail(id: string, days: number) {
|
||||
const policy = await this.prisma.policy.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
customer: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
nameSource: true,
|
||||
city: true,
|
||||
state: true,
|
||||
phone: true,
|
||||
mobile: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
policyType: true,
|
||||
insuranceProvider: true,
|
||||
installments: { orderBy: { sequence: "asc" } },
|
||||
vehicles: true,
|
||||
insuredDrivers: true,
|
||||
beneficiaries: true,
|
||||
claims: { include: { adjuster: true } },
|
||||
documents: true,
|
||||
properties: {
|
||||
select: { id: true, addressLine1: true, addressLine2: true, zone: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!policy) {
|
||||
throw new NotFoundException(`Policy ${id} not found`);
|
||||
}
|
||||
|
||||
const from = today();
|
||||
return {
|
||||
...policy,
|
||||
status: statusOf(policy.policyTo, from, addDays(from, days)),
|
||||
daysToExpiry: daysUntil(policy.policyTo, from),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user