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:
@@ -4,6 +4,7 @@ import { PrismaModule } from "./prisma/prisma.module";
|
||||
import { UsersModule } from "./users/users.module";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
import { CustomersModule } from "./customers/customers.module";
|
||||
import { PoliciesModule } from "./policies/policies.module";
|
||||
import { AppController } from "./app.controller";
|
||||
|
||||
@Module({
|
||||
@@ -13,6 +14,7 @@ import { AppController } from "./app.controller";
|
||||
UsersModule,
|
||||
AuthModule,
|
||||
CustomersModule,
|
||||
PoliciesModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Controller, Get, Param, Query, UseGuards } from "@nestjs/common";
|
||||
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
||||
import {
|
||||
PoliciesService,
|
||||
type PolicySort,
|
||||
type PolicyStatus,
|
||||
} from "./policies.service";
|
||||
|
||||
const STATUSES: PolicyStatus[] = ["active", "expiring", "expired", "undated"];
|
||||
const SORTS: PolicySort[] = [
|
||||
"expiry_desc",
|
||||
"expiry_asc",
|
||||
"customer",
|
||||
"number",
|
||||
"premium_desc",
|
||||
];
|
||||
|
||||
/** Clamped expiry window; 30 days is the default renewal horizon. */
|
||||
function parseDays(days?: string): number {
|
||||
return Math.min(365, Math.max(1, Number(days) || 30));
|
||||
}
|
||||
|
||||
@UseGuards(AuthenticatedGuard)
|
||||
@Controller("policies")
|
||||
export class PoliciesController {
|
||||
constructor(private readonly policies: PoliciesService) {}
|
||||
|
||||
@Get("stats")
|
||||
stats(@Query("days") days?: string) {
|
||||
return this.policies.stats(parseDays(days));
|
||||
}
|
||||
|
||||
@Get("facets")
|
||||
facets() {
|
||||
return this.policies.facets();
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@Query("query") query?: string,
|
||||
@Query("page") page?: string,
|
||||
@Query("pageSize") pageSize?: string,
|
||||
@Query("status") status?: string,
|
||||
@Query("days") days?: string,
|
||||
@Query("typeId") typeId?: string,
|
||||
@Query("providerId") providerId?: string,
|
||||
@Query("liquidated") liquidated?: string,
|
||||
@Query("sort") sort?: string,
|
||||
) {
|
||||
return this.policies.list({
|
||||
query,
|
||||
page: Math.max(1, Number(page) || 1),
|
||||
pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)),
|
||||
status: STATUSES.includes(status as PolicyStatus)
|
||||
? (status as PolicyStatus)
|
||||
: undefined,
|
||||
days: parseDays(days),
|
||||
typeId: typeId || undefined,
|
||||
providerId: providerId || undefined,
|
||||
liquidated:
|
||||
liquidated === "true" ? true : liquidated === "false" ? false : undefined,
|
||||
sort: SORTS.includes(sort as PolicySort)
|
||||
? (sort as PolicySort)
|
||||
: "expiry_desc",
|
||||
});
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
detail(@Param("id") id: string, @Query("days") days?: string) {
|
||||
return this.policies.detail(id, parseDays(days));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PoliciesController } from "./policies.controller";
|
||||
import { PoliciesService } from "./policies.service";
|
||||
|
||||
@Module({
|
||||
controllers: [PoliciesController],
|
||||
providers: [PoliciesService],
|
||||
})
|
||||
export class PoliciesModule {}
|
||||
@@ -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