Utilities module: property browser (list/search/detail) + trust renewals
Plan step 5. Properties, services and trust accounts become a first-class
browser the way /polizas is for insurance.
API (apps/api/src/properties):
GET /properties search over address, customer, service account
number, meter, trust number and phones; filters for
service kind, municipality, trust bank, trust bucket
(with|without|active|expiring|expired|undated) and
hasServices; 5 sorts
GET /properties/stats properties/owners/services/trusts, renewal counts,
service mix per kind
GET /properties/facets kinds, municipalities, banks — all with counts
GET /properties/:id services, fideicomiso, linked policy, owner and
sibling properties, owner-level utility ledger
Web: /servicios (renewals-first browser, clickable stat cells and service-mix
strip) and /servicios/[id]. Property cards on /clientes/[id] and linked
properties on /polizas/[id] now navigate into it.
Data findings baked into the design:
- The trust deadline staff chase is trust_accounts.dueDate2 (DATMEX vence2),
one year after vence1 on 531 of 541 dated trusts: 18 due within 30 days,
119 already overdue. Every renewal bucket keys off dueDate2 alone.
- properties.zone is dead (1444 of 1519 null, the rest near-unique), so the
geographic filter is the municipality carried in the predial service's
notes (ROSARITO 566 / TIJUANA 221 / ENSENADA 152, 939/939 populated).
- PropertyService.notes means a different thing per kind (municipality, CFE
PAR/IMPAR cycle, gas supply type, cable provider) and is labelled as such.
- 240 of 1519 properties have no service rows at all — its own bucket.
Sorting by trust due date scopes to properties that have a trust, since MySQL
would otherwise float the ~966 trust-less NULLs above every real due date;
the sort label and the result meta both say so.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ 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 { PropertiesModule } from "./properties/properties.module";
|
||||
import { AppController } from "./app.controller";
|
||||
|
||||
@Module({
|
||||
@@ -15,6 +16,7 @@ import { AppController } from "./app.controller";
|
||||
AuthModule,
|
||||
CustomersModule,
|
||||
PoliciesModule,
|
||||
PropertiesModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Controller, Get, Param, Query, UseGuards } from "@nestjs/common";
|
||||
import { ServiceKind } from "@jorgecuadros/database";
|
||||
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
||||
import {
|
||||
PropertiesService,
|
||||
type PropertySort,
|
||||
type TrustFilter,
|
||||
} from "./properties.service";
|
||||
|
||||
const KINDS: ServiceKind[] = [
|
||||
"WATER",
|
||||
"ELECTRIC",
|
||||
"GAS",
|
||||
"CABLE",
|
||||
"PROPERTY_TAX",
|
||||
"FEDERAL_ZONE",
|
||||
"ALARM",
|
||||
"OTHER",
|
||||
];
|
||||
|
||||
const TRUST_FILTERS: TrustFilter[] = [
|
||||
"with",
|
||||
"without",
|
||||
"active",
|
||||
"expiring",
|
||||
"expired",
|
||||
"undated",
|
||||
];
|
||||
|
||||
const SORTS: PropertySort[] = [
|
||||
"customer",
|
||||
"address",
|
||||
"services_desc",
|
||||
"trust_due_asc",
|
||||
"trust_due_desc",
|
||||
];
|
||||
|
||||
/** Clamped trust-renewal window; 30 days matches the policies module. */
|
||||
function parseDays(days?: string): number {
|
||||
return Math.min(365, Math.max(1, Number(days) || 30));
|
||||
}
|
||||
|
||||
@UseGuards(AuthenticatedGuard)
|
||||
@Controller("properties")
|
||||
export class PropertiesController {
|
||||
constructor(private readonly properties: PropertiesService) {}
|
||||
|
||||
@Get("stats")
|
||||
stats(@Query("days") days?: string) {
|
||||
return this.properties.stats(parseDays(days));
|
||||
}
|
||||
|
||||
@Get("facets")
|
||||
facets() {
|
||||
return this.properties.facets();
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@Query("query") query?: string,
|
||||
@Query("page") page?: string,
|
||||
@Query("pageSize") pageSize?: string,
|
||||
@Query("serviceKind") serviceKind?: string,
|
||||
@Query("municipality") municipality?: string,
|
||||
@Query("bank") bank?: string,
|
||||
@Query("trust") trust?: string,
|
||||
@Query("hasServices") hasServices?: string,
|
||||
@Query("customerId") customerId?: string,
|
||||
@Query("days") days?: string,
|
||||
@Query("sort") sort?: string,
|
||||
) {
|
||||
return this.properties.list({
|
||||
query,
|
||||
page: Math.max(1, Number(page) || 1),
|
||||
pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)),
|
||||
serviceKind: KINDS.includes(serviceKind as ServiceKind)
|
||||
? (serviceKind as ServiceKind)
|
||||
: undefined,
|
||||
municipality: municipality || undefined,
|
||||
bank: bank || undefined,
|
||||
trust: TRUST_FILTERS.includes(trust as TrustFilter)
|
||||
? (trust as TrustFilter)
|
||||
: undefined,
|
||||
hasServices:
|
||||
hasServices === "true" ? true : hasServices === "false" ? false : undefined,
|
||||
customerId: customerId || undefined,
|
||||
days: parseDays(days),
|
||||
sort: SORTS.includes(sort as PropertySort)
|
||||
? (sort as PropertySort)
|
||||
: "customer",
|
||||
});
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
detail(@Param("id") id: string, @Query("days") days?: string) {
|
||||
return this.properties.detail(id, parseDays(days));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PropertiesController } from "./properties.controller";
|
||||
import { PropertiesService } from "./properties.service";
|
||||
|
||||
@Module({
|
||||
controllers: [PropertiesController],
|
||||
providers: [PropertiesService],
|
||||
})
|
||||
export class PropertiesModule {}
|
||||
@@ -0,0 +1,446 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Prisma, ServiceKind } from "@jorgecuadros/database";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
/**
|
||||
* Trust (fideicomiso) renewal buckets, derived from `trustAccount.dueDate2`
|
||||
* against today. The migration loaded DATMEX's `vence1`/`vence2` pair as
|
||||
* `dueDate1`/`dueDate2`; on 531 of the 541 dated trusts `dueDate2` is exactly
|
||||
* one year after `dueDate1`, so `dueDate2` is the *next* annual due date — the
|
||||
* one staff chase — and `dueDate1` is the period it renewed from.
|
||||
*
|
||||
* `undated` is a real bucket, not an error: 12 trusts carry no dates at all.
|
||||
*/
|
||||
export type TrustStatus = "active" | "expiring" | "expired" | "undated";
|
||||
|
||||
/** `with`/`without` filter on the whole property set; the rest are trust buckets. */
|
||||
export type TrustFilter = "with" | "without" | TrustStatus;
|
||||
|
||||
export type PropertySort =
|
||||
| "customer"
|
||||
| "address"
|
||||
| "services_desc"
|
||||
| "trust_due_asc"
|
||||
| "trust_due_desc";
|
||||
|
||||
export interface ListParams {
|
||||
query?: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
serviceKind?: ServiceKind;
|
||||
/** Municipality from the predial service's notes — see `facets()`. */
|
||||
municipality?: string;
|
||||
bank?: string;
|
||||
trust?: TrustFilter;
|
||||
/** false = properties with no service rows at all (240 of 1519). */
|
||||
hasServices?: boolean;
|
||||
customerId?: string;
|
||||
/** Window in days for the `expiring` trust bucket. */
|
||||
days: number;
|
||||
sort: PropertySort;
|
||||
}
|
||||
|
||||
/** Municipality lives in the predial service's `notes` (939/939 populated,
|
||||
* exactly three values). FEDERAL_ZONE's notes hold the same idea but also
|
||||
* carry non-municipality values like "SUSPENDIDO", so predial is the source. */
|
||||
const MUNICIPALITY_KIND: ServiceKind = "PROPERTY_TAX";
|
||||
|
||||
/** Midnight today, UTC — trust 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 trustStatusOf(
|
||||
dueDate: Date | null | undefined,
|
||||
from: Date,
|
||||
soon: Date,
|
||||
): TrustStatus {
|
||||
if (!dueDate) return "undated";
|
||||
if (dueDate < from) return "expired";
|
||||
return dueDate <= soon ? "expiring" : "active";
|
||||
}
|
||||
|
||||
function daysUntil(dueDate: Date | null | undefined, from: Date): number | null {
|
||||
if (!dueDate) return null;
|
||||
return Math.round((dueDate.getTime() - from.getTime()) / 86400000);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PropertiesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
private trustWhere(
|
||||
trust: TrustFilter | undefined,
|
||||
days: number,
|
||||
): Prisma.PropertyWhereInput {
|
||||
const from = today();
|
||||
switch (trust) {
|
||||
case "with":
|
||||
return { trustAccount: { isNot: null } };
|
||||
case "without":
|
||||
return { trustAccount: { is: null } };
|
||||
case "active":
|
||||
return { trustAccount: { dueDate2: { gte: from } } };
|
||||
case "expiring":
|
||||
return {
|
||||
trustAccount: { dueDate2: { gte: from, lte: addDays(from, days) } },
|
||||
};
|
||||
case "expired":
|
||||
return { trustAccount: { dueDate2: { lt: from } } };
|
||||
case "undated":
|
||||
return { trustAccount: { is: { dueDate2: null } } };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
private orderBy(sort: PropertySort): Prisma.PropertyOrderByWithRelationInput[] {
|
||||
switch (sort) {
|
||||
case "address":
|
||||
return [{ addressLine1: "asc" }, { addressLine2: "asc" }];
|
||||
case "services_desc":
|
||||
return [{ services: { _count: "desc" } }, { customer: { name: "asc" } }];
|
||||
case "trust_due_asc":
|
||||
return [{ trustAccount: { dueDate2: "asc" } }];
|
||||
case "trust_due_desc":
|
||||
return [{ trustAccount: { dueDate2: "desc" } }];
|
||||
default:
|
||||
// Nameless customers last, same rule the customer list uses.
|
||||
return [
|
||||
{ customer: { nameMissing: "asc" } },
|
||||
{ customer: { name: "asc" } },
|
||||
{ addressLine1: "asc" },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/** Property list with search, service/trust/municipality filters, paginated. */
|
||||
async list(params: ListParams) {
|
||||
const {
|
||||
query,
|
||||
page,
|
||||
pageSize,
|
||||
serviceKind,
|
||||
municipality,
|
||||
bank,
|
||||
trust,
|
||||
hasServices,
|
||||
customerId,
|
||||
days,
|
||||
sort,
|
||||
} = params;
|
||||
|
||||
const and: Prisma.PropertyWhereInput[] = [this.trustWhere(trust, days)];
|
||||
|
||||
// Sorting by trust due date is only meaningful for properties that have a
|
||||
// trust; MySQL would otherwise float the ~966 trust-less rows (NULL first
|
||||
// on ASC) above every real due date. Scoping is explicit in the UI label.
|
||||
if (sort === "trust_due_asc" || sort === "trust_due_desc") {
|
||||
and.push({ trustAccount: { isNot: null } });
|
||||
}
|
||||
|
||||
if (query && query.trim()) {
|
||||
const q = query.trim();
|
||||
and.push({
|
||||
OR: [
|
||||
{ addressLine1: { contains: q } },
|
||||
{ addressLine2: { contains: q } },
|
||||
{ phone1: { contains: q } },
|
||||
{ phone2: { contains: q } },
|
||||
{ phone3: { contains: q } },
|
||||
{ zone: { contains: q } },
|
||||
{ legacyId: { contains: q } },
|
||||
{ customer: { name: { contains: q } } },
|
||||
{ services: { some: { accountNumber: { contains: q } } } },
|
||||
{ services: { some: { meterNumber: { contains: q } } } },
|
||||
{ trustAccount: { trustNumber: { contains: q } } },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (serviceKind) and.push({ services: { some: { kind: serviceKind } } });
|
||||
if (municipality)
|
||||
and.push({
|
||||
services: { some: { kind: MUNICIPALITY_KIND, notes: municipality } },
|
||||
});
|
||||
if (bank) and.push({ trustAccount: { bankName: bank } });
|
||||
if (hasServices !== undefined)
|
||||
and.push(hasServices ? { services: { some: {} } } : { services: { none: {} } });
|
||||
if (customerId) and.push({ customerId });
|
||||
|
||||
const where: Prisma.PropertyWhereInput = { AND: and };
|
||||
|
||||
const [total, rows] = await this.prisma.$transaction([
|
||||
this.prisma.property.count({ where }),
|
||||
this.prisma.property.findMany({
|
||||
where,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
orderBy: this.orderBy(sort),
|
||||
select: {
|
||||
id: true,
|
||||
addressLine1: true,
|
||||
addressLine2: true,
|
||||
phone1: true,
|
||||
phone2: true,
|
||||
phone3: true,
|
||||
zone: true,
|
||||
customer: {
|
||||
select: { id: true, name: true, city: true, state: true },
|
||||
},
|
||||
services: {
|
||||
select: { id: true, kind: true, active: true, notes: true },
|
||||
},
|
||||
trustAccount: {
|
||||
select: {
|
||||
bankName: true,
|
||||
trustNumber: true,
|
||||
bankFee: true,
|
||||
dueDate1: true,
|
||||
dueDate2: true,
|
||||
},
|
||||
},
|
||||
_count: { select: { services: true, documents: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const from = today();
|
||||
const soon = addDays(from, days);
|
||||
|
||||
const items = rows.map((r) => {
|
||||
const predial = r.services.find((s) => s.kind === MUNICIPALITY_KIND);
|
||||
return {
|
||||
id: r.id,
|
||||
addressLine1: r.addressLine1,
|
||||
addressLine2: r.addressLine2,
|
||||
zone: r.zone,
|
||||
phones: [r.phone1, r.phone2, r.phone3].filter(Boolean) as string[],
|
||||
customerId: r.customer.id,
|
||||
customerName: r.customer.name,
|
||||
customerCity: r.customer.city,
|
||||
customerState: r.customer.state,
|
||||
municipality: predial?.notes ?? null,
|
||||
services: r.services.map((s) => ({
|
||||
id: s.id,
|
||||
kind: s.kind,
|
||||
active: s.active,
|
||||
})),
|
||||
serviceCount: r._count.services,
|
||||
activeServiceCount: r.services.filter((s) => s.active).length,
|
||||
documentCount: r._count.documents,
|
||||
trust: r.trustAccount
|
||||
? {
|
||||
bankName: r.trustAccount.bankName,
|
||||
trustNumber: r.trustAccount.trustNumber,
|
||||
bankFee: r.trustAccount.bankFee,
|
||||
dueDate1: r.trustAccount.dueDate1,
|
||||
dueDate2: r.trustAccount.dueDate2,
|
||||
status: trustStatusOf(r.trustAccount.dueDate2, from, soon),
|
||||
daysToDue: daysUntil(r.trustAccount.dueDate2, from),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
return { items, total, page, pageSize, pageCount: Math.ceil(total / pageSize) };
|
||||
}
|
||||
|
||||
/** Top-line counts for the utilities page header. */
|
||||
async stats(days: number) {
|
||||
const from = today();
|
||||
const soon = addDays(from, days);
|
||||
|
||||
const [
|
||||
properties,
|
||||
owners,
|
||||
services,
|
||||
withoutServices,
|
||||
trusts,
|
||||
trustExpiring,
|
||||
trustExpired,
|
||||
documents,
|
||||
] = await this.prisma.$transaction([
|
||||
this.prisma.property.count(),
|
||||
this.prisma.customer.count({ where: { properties: { some: {} } } }),
|
||||
this.prisma.propertyService.count(),
|
||||
this.prisma.property.count({ where: { services: { none: {} } } }),
|
||||
this.prisma.property.count({ where: { trustAccount: { isNot: null } } }),
|
||||
this.prisma.property.count({
|
||||
where: { trustAccount: { dueDate2: { gte: from, lte: soon } } },
|
||||
}),
|
||||
this.prisma.property.count({
|
||||
where: { trustAccount: { dueDate2: { lt: from } } },
|
||||
}),
|
||||
this.prisma.serviceDocument.count(),
|
||||
]);
|
||||
|
||||
// Service mix, per kind — the operational headline for this line of
|
||||
// business (how many bills of each type the office pays every month).
|
||||
const byKind = await this.prisma.propertyService.groupBy({
|
||||
by: ["kind"],
|
||||
_count: { _all: true },
|
||||
orderBy: { _count: { kind: "desc" } },
|
||||
});
|
||||
|
||||
const activeByKind = await this.prisma.propertyService.groupBy({
|
||||
by: ["kind"],
|
||||
where: { active: true },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const activeMap = new Map(activeByKind.map((r) => [r.kind, r._count._all]));
|
||||
|
||||
return {
|
||||
properties,
|
||||
owners,
|
||||
services,
|
||||
withoutServices,
|
||||
trusts,
|
||||
trustExpiring,
|
||||
trustExpired,
|
||||
documents,
|
||||
days,
|
||||
byKind: byKind.map((r) => ({
|
||||
kind: r.kind,
|
||||
count: r._count._all,
|
||||
active: activeMap.get(r.kind) ?? 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Filter dropdown options, with counts so empty choices are visible. */
|
||||
async facets() {
|
||||
// Kept as separate awaits rather than one $transaction: Prisma's groupBy
|
||||
// result type is lost when the calls are widened into a promise array.
|
||||
const kinds = await this.prisma.propertyService.groupBy({
|
||||
by: ["kind"],
|
||||
_count: { _all: true },
|
||||
orderBy: { _count: { kind: "desc" } },
|
||||
});
|
||||
const municipalities = await this.prisma.propertyService.groupBy({
|
||||
by: ["notes"],
|
||||
where: { kind: MUNICIPALITY_KIND, notes: { not: null } },
|
||||
_count: { _all: true },
|
||||
orderBy: { _count: { notes: "desc" } },
|
||||
});
|
||||
const banks = await this.prisma.trustAccount.groupBy({
|
||||
by: ["bankName"],
|
||||
where: { bankName: { not: null } },
|
||||
_count: { _all: true },
|
||||
orderBy: { _count: { bankName: "desc" } },
|
||||
});
|
||||
|
||||
return {
|
||||
kinds: kinds.map((k) => ({ kind: k.kind, count: k._count._all })),
|
||||
municipalities: municipalities.map((m) => ({
|
||||
name: m.notes as string,
|
||||
count: m._count._all,
|
||||
})),
|
||||
banks: banks.map((b) => ({
|
||||
name: b.bankName as string,
|
||||
count: b._count._all,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Full property view: services, trust, documents, owner and siblings. */
|
||||
async detail(id: string, days: number) {
|
||||
const property = await this.prisma.property.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
customer: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
nameSource: true,
|
||||
addressLine1: true,
|
||||
city: true,
|
||||
state: true,
|
||||
phone: true,
|
||||
mobile: true,
|
||||
email: true,
|
||||
_count: { select: { properties: true, policies: true } },
|
||||
},
|
||||
},
|
||||
services: { orderBy: { kind: "asc" } },
|
||||
trustAccount: true,
|
||||
documents: true,
|
||||
policy: {
|
||||
select: {
|
||||
id: true,
|
||||
policyNumber: true,
|
||||
policyTo: true,
|
||||
policyType: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!property) {
|
||||
throw new NotFoundException(`Property ${id} not found`);
|
||||
}
|
||||
|
||||
// Other properties of the same owner, so staff can hop between them
|
||||
// without going back through the customer file.
|
||||
const siblings = await this.prisma.property.findMany({
|
||||
where: { customerId: property.customerId, id: { not: id } },
|
||||
orderBy: [{ addressLine1: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
addressLine1: true,
|
||||
addressLine2: true,
|
||||
zone: true,
|
||||
_count: { select: { services: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Utility-domain ledger for the OWNER, not for this property: the legacy
|
||||
// data ties payments to the customer, never to a specific property, so
|
||||
// these are shown as the customer's service movements.
|
||||
const transactions = await this.prisma.transaction.findMany({
|
||||
where: { customerId: property.customerId, domain: "UTILITY" },
|
||||
orderBy: { transactionDate: "desc" },
|
||||
take: 12,
|
||||
include: { type: true },
|
||||
});
|
||||
const ledger = await this.prisma.transaction.groupBy({
|
||||
by: ["currency"],
|
||||
where: { customerId: property.customerId, domain: "UTILITY" },
|
||||
_sum: { amount: true },
|
||||
_count: { _all: true },
|
||||
});
|
||||
|
||||
const from = today();
|
||||
const predial = property.services.find((s) => s.kind === MUNICIPALITY_KIND);
|
||||
|
||||
return {
|
||||
...property,
|
||||
municipality: predial?.notes ?? null,
|
||||
trustStatus: trustStatusOf(
|
||||
property.trustAccount?.dueDate2,
|
||||
from,
|
||||
addDays(from, days),
|
||||
),
|
||||
daysToTrustDue: daysUntil(property.trustAccount?.dueDate2, from),
|
||||
siblings: siblings.map((s) => ({
|
||||
id: s.id,
|
||||
addressLine1: s.addressLine1,
|
||||
addressLine2: s.addressLine2,
|
||||
zone: s.zone,
|
||||
serviceCount: s._count.services,
|
||||
})),
|
||||
customerTransactions: transactions,
|
||||
customerLedger: ledger.map((l) => ({
|
||||
currency: l.currency,
|
||||
total: l._sum.amount,
|
||||
count: l._count._all,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user