The 44 customers with no recoverable name render as "(SIN NOMBRE)", and
ordering the list by name alone floated all of them to the top — "(" sorts
before every letter — so the first two screens of the customer browser were
nothing but placeholders. Small number, worst possible position.
Adds customers.nameMissing, set by the transform and used as the primary sort
key so those records land at the end of the list. Denormalized rather than
computed in the query because the list is paginated in SQL, so the ordering
has to be expressible as a column.
Applied to the dev DB as an ALTER + UPDATE in place (no truncate), so the
existing loaded data and its FKs were left alone.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
153 lines
4.7 KiB
TypeScript
153 lines
4.7 KiB
TypeScript
import { Injectable, NotFoundException } from "@nestjs/common";
|
|
import { Prisma } from "@jorgecuadros/database";
|
|
import { PrismaService } from "../prisma/prisma.service";
|
|
|
|
export interface ListParams {
|
|
query?: string;
|
|
page: number;
|
|
pageSize: number;
|
|
line?: "utility" | "insurance" | "both";
|
|
}
|
|
|
|
@Injectable()
|
|
export class CustomersService {
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
/** Unified customer list with search + business-line filter, paginated. */
|
|
async list({ query, page, pageSize, line }: ListParams) {
|
|
const where: Prisma.CustomerWhereInput = {};
|
|
|
|
if (query && query.trim()) {
|
|
const q = query.trim();
|
|
where.OR = [
|
|
{ name: { contains: q } },
|
|
{ email: { contains: q } },
|
|
{ phone: { contains: q } },
|
|
{ mobile: { contains: q } },
|
|
{ city: { contains: q } },
|
|
{ legacyRefs: { some: { legacyId: { contains: q } } } },
|
|
];
|
|
}
|
|
if (line === "utility") where.properties = { some: {} };
|
|
if (line === "insurance") where.policies = { some: {} };
|
|
if (line === "both") {
|
|
where.properties = { some: {} };
|
|
where.policies = { some: {} };
|
|
}
|
|
|
|
const [total, rows] = await this.prisma.$transaction([
|
|
this.prisma.customer.count({ where }),
|
|
this.prisma.customer.findMany({
|
|
where,
|
|
skip: (page - 1) * pageSize,
|
|
take: pageSize,
|
|
// Nameless records last: ordering by name alone floats every
|
|
// "(SIN NOMBRE)" to the top, since "(" sorts before every letter.
|
|
orderBy: [{ nameMissing: "asc" }, { name: "asc" }],
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
nameSource: true,
|
|
city: true,
|
|
state: true,
|
|
email: true,
|
|
phone: true,
|
|
mobile: true,
|
|
status: true,
|
|
_count: { select: { properties: true, policies: true, transactions: true } },
|
|
},
|
|
}),
|
|
]);
|
|
|
|
const items = rows.map((r) => ({
|
|
id: r.id,
|
|
name: r.name,
|
|
nameSource: r.nameSource,
|
|
city: r.city,
|
|
state: r.state,
|
|
email: r.email,
|
|
phone: r.phone,
|
|
mobile: r.mobile,
|
|
status: r.status,
|
|
propertyCount: r._count.properties,
|
|
policyCount: r._count.policies,
|
|
transactionCount: r._count.transactions,
|
|
hasUtilities: r._count.properties > 0,
|
|
hasInsurance: r._count.policies > 0,
|
|
}));
|
|
|
|
return { items, total, page, pageSize, pageCount: Math.ceil(total / pageSize) };
|
|
}
|
|
|
|
/** Full unified customer view: identity + both business lines + ledger. */
|
|
async detail(id: string) {
|
|
const customer = await this.prisma.customer.findUnique({
|
|
where: { id },
|
|
include: {
|
|
legacyRefs: true,
|
|
properties: {
|
|
include: { services: true, trustAccount: true, documents: true },
|
|
},
|
|
policies: {
|
|
orderBy: { policyFrom: "desc" },
|
|
include: {
|
|
policyType: true,
|
|
insuranceProvider: true,
|
|
installments: { orderBy: { sequence: "asc" } },
|
|
vehicles: true,
|
|
insuredDrivers: true,
|
|
beneficiaries: true,
|
|
claims: true,
|
|
documents: true,
|
|
},
|
|
},
|
|
transactions: {
|
|
orderBy: { transactionDate: "desc" },
|
|
take: 100,
|
|
include: { type: true },
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!customer) {
|
|
throw new NotFoundException(`Customer ${id} not found`);
|
|
}
|
|
|
|
// Ledger totals per domain + currency (the "one statement across both
|
|
// business lines" payoff), computed in the DB rather than in JS.
|
|
const summary = await this.prisma.transaction.groupBy({
|
|
by: ["domain", "currency"],
|
|
where: { customerId: id },
|
|
_sum: { amount: true },
|
|
_count: { _all: true },
|
|
});
|
|
|
|
return {
|
|
...customer,
|
|
transactionSummary: summary.map((s) => ({
|
|
domain: s.domain,
|
|
currency: s.currency,
|
|
total: s._sum.amount,
|
|
count: s._count._all,
|
|
})),
|
|
};
|
|
}
|
|
|
|
/** Top-line counts for a dashboard header. */
|
|
async stats() {
|
|
const [customers, withUtilities, withInsurance, policies, properties, transactions] =
|
|
await this.prisma.$transaction([
|
|
this.prisma.customer.count(),
|
|
this.prisma.customer.count({ where: { properties: { some: {} } } }),
|
|
this.prisma.customer.count({ where: { policies: { some: {} } } }),
|
|
this.prisma.policy.count(),
|
|
this.prisma.property.count(),
|
|
this.prisma.transaction.count(),
|
|
]);
|
|
const bothLines = await this.prisma.customer.count({
|
|
where: { properties: { some: {} }, policies: { some: {} } },
|
|
});
|
|
return { customers, withUtilities, withInsurance, bothLines, policies, properties, transactions };
|
|
}
|
|
}
|