Files
jorgecuadros-platform/packages/database/prisma/schema.prisma
T
rmancinasandClaude Opus 4.8 594ee7cfca Migration: recover blank customer names from secondary legacy tables
DATGRAL.NOMBRE is blank on 266 legacy rows (140 utilities, 126 insurance),
which surfaced in the UI as 257 customers literally named "(SIN NOMBRE)".
The blank is real — those cells are empty in the Access files, not lost in
extraction — but the rows mostly are not junk: 176 of the 257 carry a
property, a policy, or transactions.

The old PHP importer handled this by skipping blank-name rows outright
(jorgecuadros-intra-webapp/src/tools/customerAdapter.php:47,81). That was
worse than it looks: every other adapter resolved its customer FK through
the customer_mapping table those skipped rows never entered, so their
properties and policies were silently dropped (customerServiceAdapter.php:45)
and their transactions were written against customer_id 0
(customerBalanceAdapter.php:52). So: recover the name instead of skipping.

Names come from the secondary tables that still carry them, most trustworthy
first — UTILSEG (the office's own hand-maintained name <-> id cross-reference
spanning both lines), then the billing runs (IVA 2015, COBRO3) and the policy
rows' NOMBRE ASEG (MULT, M EMPR, INCENDIO). A linked customer can also borrow
the name its insurance record resolved to. Result: 213 of 257 recovered, 44
still genuinely nameless anywhere in the source.

customers.nameSource records which table each recovered name came from, so a
reconstructed name is never mistaken for one that was really on the record —
the list tags it "nombre recuperado", the detail header names the source, and
a still-unnamed customer renders muted italic instead of as a normal name.

Also fixes run_all.py: transform_properties and transform_policies truncate
service_documents/policy_documents, but blob_extract.py was not in the step
list, so a full re-run left the uploaded MinIO objects with no rows pointing
at them. Hit exactly that while reloading for this change.

Verified end-to-end: full pipeline re-run against dev reproduces every prior
count (1682 customers, 1519 properties, 2378 policies, 45861 transactions,
22354 bank rows, 70 documents) with zero orphans, and both apps build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 20:49:43 -07:00

510 lines
16 KiB
Plaintext

// Unified customer / insurance / utilities data model.
// See C:\Users\ricar\.claude\plans\logical-yawning-tome.md for the migration
// plan this schema implements (source: UTILITIES.accdb, SEGUROS 16_be.mdb,
// SCOTHIA.mdb). Every model that originates from a legacy Access table
// carries legacySource* provenance columns so migrated rows are traceable
// back to their Access original and the ETL can be re-run idempotently.
generator client {
provider = "prisma-client-js"
output = "../generated/client"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
enum Currency {
USD
MXN
}
enum TransactionDomain {
UTILITY
INSURANCE
TRUST
}
enum ServiceKind {
WATER
ELECTRIC
GAS
CABLE
PROPERTY_TAX
FEDERAL_ZONE
ALARM
OTHER
}
enum UserRole {
ADMIN
STAFF
}
// ---------------------------------------------------------------------------
// Identity — the actual point of the project: one customer record shared by
// both business lines.
// ---------------------------------------------------------------------------
model Customer {
id String @id @default(uuid())
name String
// Which legacy table `name` actually came from. Null = DATGRAL.NOMBRE, the
// normal case. Anything else means DATGRAL's name was blank and the name was
// recovered from a secondary table (see migration/transform_customers.py),
// so staff can tell a reconstructed name from an original one.
nameSource String?
addressLine1 String?
addressLine2 String?
city String?
state String?
zipCode String?
country String?
phone String?
mobile String?
fax String?
email String?
notes String? @db.Text
identificationType String?
identificationNumber String?
identificationExpiration DateTime?
customerSince DateTime?
status Boolean @default(true)
minimumBalance Decimal? @db.Decimal(12, 2)
feeAmount Decimal? @db.Decimal(12, 2)
preferredCurrency Currency @default(USD)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
legacyRefs CustomerLegacyRef[]
properties Property[]
policies Policy[]
vehicles Vehicle[]
transactions Transaction[]
@@map("customers")
}
/// Generalizes the old app's customer_mapping table: one row per legacy
/// record folded into this customer, from either source system.
model CustomerLegacyRef {
id String @id @default(uuid())
customerId String
customer Customer @relation(fields: [customerId], references: [id])
sourceSystem String // "utilities" | "insurance"
sourceTable String // e.g. "DATGRAL", "COBRO3"
legacyId String // stringified legacy id (source columns are often DOUBLE)
createdAt DateTime @default(now())
@@unique([sourceSystem, sourceTable, legacyId])
@@map("customer_legacy_refs")
}
// ---------------------------------------------------------------------------
// Insurance domain
// ---------------------------------------------------------------------------
model InsuranceProvider {
id String @id @default(uuid())
name String @unique
policies Policy[]
@@map("insurance_providers")
}
model PolicyType {
id String @id @default(uuid())
name String @unique
shortDescription String?
policies Policy[]
@@map("policy_types")
}
/// Consolidates INCENDIO/MULT/M EMPR/all auto-table variants/LICENCIAS into
/// one table with a policyType discriminator, instead of one Access table
/// per line of business.
model Policy {
id String @id @default(uuid())
policyNumber String
customerId String
customer Customer @relation(fields: [customerId], references: [id])
policyTypeId String?
policyType PolicyType? @relation(fields: [policyTypeId], references: [id])
insuranceProviderId String?
insuranceProvider InsuranceProvider? @relation(fields: [insuranceProviderId], references: [id])
agentName String?
policyDate DateTime?
policyFrom DateTime?
policyTo DateTime?
coveragePeriodDays Int? @default(365)
netPremium Decimal? @db.Decimal(12, 2)
policyFee Decimal? @db.Decimal(12, 2)
brokerFee Decimal? @db.Decimal(12, 2)
commission Decimal? @db.Decimal(12, 2)
total Decimal? @db.Decimal(12, 2)
currency Currency @default(MXN)
observations String? @db.Text
notes String? @db.Text
coveragesJson Json?
endorsement Boolean @default(false)
liquidated Boolean @default(false)
liquidationNumber String?
liquidationDate DateTime?
legacySourceDb String?
legacySourceTable String?
legacyId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
installments PolicyPaymentInstallment[]
vehicles Vehicle[]
insuredDrivers InsuredDriver[]
beneficiaries PolicyBeneficiary[]
claims Claim[]
documents PolicyDocument[]
properties Property[]
@@unique([legacySourceDb, legacySourceTable, legacyId])
@@index([policyNumber])
@@map("policies")
}
/// Unpivots the 4 hardcoded payment-installment columns found on every
/// legacy policy table (1ER PAGO/FECHA PAGO/NO CHEQUE, ...2, ...3, ...4).
model PolicyPaymentInstallment {
id String @id @default(uuid())
policyId String
policy Policy @relation(fields: [policyId], references: [id])
sequence Int
amount Decimal? @db.Decimal(12, 2)
currency Currency @default(MXN)
dueDate DateTime?
paidDate DateTime?
checkNumber String?
isCash Boolean @default(false)
@@map("policy_payment_installments")
}
/// Unpivots MCA2's 3 hardcoded vehicle slots (and the single-vehicle auto
/// tables) into one row per vehicle.
model Vehicle {
id String @id @default(uuid())
customerId String?
customer Customer? @relation(fields: [customerId], references: [id])
policyId String?
policy Policy? @relation(fields: [policyId], references: [id])
make String?
model String?
modelYear String?
bodyType String?
engineNumber String?
licensePlate String?
vinNumber String?
stateCode String?
notes String? @db.Text
legacySourceTable String?
legacyId String?
@@map("vehicles")
}
/// Unpivots the repeated named-insured/license columns in MCA2/LICENCIAS.
model InsuredDriver {
id String @id @default(uuid())
policyId String
policy Policy @relation(fields: [policyId], references: [id])
fullName String?
birthDate DateTime?
sex String?
occupation String?
licenseNumber String?
licenseState String?
@@map("insured_drivers")
}
/// From BENEF — already a clean child table in the source data.
model PolicyBeneficiary {
id String @id @default(uuid())
policyId String
policy Policy @relation(fields: [policyId], references: [id])
name String?
address String?
phone String?
email String?
@@map("policy_beneficiaries")
}
/// From DATOS (siniestros).
model Claim {
id String @id @default(uuid())
policyId String
policy Policy @relation(fields: [policyId], references: [id])
claimType String?
incidentDate DateTime?
reportedDate DateTime?
description String? @db.Text
adjusterId String?
adjuster Adjuster? @relation(fields: [adjusterId], references: [id])
claimedAmount Decimal? @db.Decimal(12, 2)
settledAmount Decimal? @db.Decimal(12, 2)
settlementDate DateTime?
checkNumber String?
resolved Boolean @default(false)
resolutionNotes String? @db.Text
@@map("claims")
}
/// From AJUSTADORES / AJUSTADORESATLAS.
model Adjuster {
id String @id @default(uuid())
company String?
city String?
name String?
phone String?
beeper String?
claims Claim[]
@@map("adjusters")
}
/// Extracted LONGBINARY blobs from the policy tables — file lives in object
/// storage, only the pointer + type lives here.
model PolicyDocument {
id String @id @default(uuid())
policyId String
policy Policy @relation(fields: [policyId], references: [id])
documentType String
storageKey String
originalColumn String?
createdAt DateTime @default(now())
@@map("policy_documents")
}
// ---------------------------------------------------------------------------
// Utilities domain
// ---------------------------------------------------------------------------
/// From DATMEX — shared with the insurance domain so a property can carry
/// both a home-insurance policy and utility service enrollments.
model Property {
id String @id @default(uuid())
customerId String
customer Customer @relation(fields: [customerId], references: [id])
policyId String?
policy Policy? @relation(fields: [policyId], references: [id])
addressLine1 String?
addressLine2 String?
phone1 String?
phone2 String?
phone3 String?
zone String?
legacySourceTable String?
legacyId String?
createdAt DateTime @default(now())
services PropertyService[]
documents ServiceDocument[]
trustAccount TrustAccount?
@@map("properties")
}
/// Unpivots DATMEX's inline service columns and PROFILE's enrollment flags
/// into one row per enrolled service per property.
model PropertyService {
id String @id @default(uuid())
propertyId String
property Property @relation(fields: [propertyId], references: [id])
kind ServiceKind
accountNumber String?
meterNumber String?
route String?
dueDay String?
active Boolean @default(true)
notes String? @db.Text
@@map("property_services")
}
model ServiceDocument {
id String @id @default(uuid())
propertyId String
property Property @relation(fields: [propertyId], references: [id])
documentType String
storageKey String
createdAt DateTime @default(now())
@@map("service_documents")
}
/// From TRUSTVENCE.
model TrustAccount {
id String @id @default(uuid())
propertyId String @unique
property Property @relation(fields: [propertyId], references: [id])
bankName String?
trustNumber String?
bankFee Decimal? @db.Decimal(12, 2)
dueDate1 DateTime?
dueDate2 DateTime?
@@map("trust_accounts")
}
// ---------------------------------------------------------------------------
// Shared financial ledger — one office, one set of books.
// ---------------------------------------------------------------------------
/// ES/EN transaction-type lookup, carried over from the old schema.
model TypeTransaction {
id String @id @default(uuid())
nameEn String
nameEs String?
isService Boolean @default(false)
transactions Transaction[]
@@map("type_transactions")
}
/// Unifies utilities' EFECTIVO/EFECTIVO FM3/EFECTIVO_BACKUP/FEE ANUAL/
/// datos2/fee15/billing/CHEQUE FM3/IVA 2015 and insurance's EFECTIVO.
model Transaction {
id String @id @default(uuid())
customerId String
customer Customer @relation(fields: [customerId], references: [id])
domain TransactionDomain
typeId String?
type TypeTransaction? @relation(fields: [typeId], references: [id])
transactionDate DateTime
period String?
reference String?
amount Decimal @db.Decimal(12, 2)
currency Currency @default(MXN)
exchangeRate Decimal? @db.Decimal(10, 4)
checkNumber String?
message String? @db.Text
outstanding Boolean @default(false)
legacySourceDb String?
legacySourceTable String?
legacyId String?
createdAt DateTime @default(now())
@@index([customerId, transactionDate])
@@map("transactions")
}
/// From TIPO HIST.
model ExchangeRate {
id String @id @default(uuid())
rate Decimal @db.Decimal(10, 4)
effectiveDate DateTime
effectiveHour DateTime?
@@map("exchange_rates")
}
// ---------------------------------------------------------------------------
// Company bank register (SCOTHIA.mdb) — the office's own operating account,
// deliberately separate from customer-facing Transaction records.
// ---------------------------------------------------------------------------
/// From TABLA RAMODOS ("ramo" = line of business).
model BusinessLineCategory {
id String @id @default(uuid())
name String @unique
bankTransactions BankTransaction[]
@@map("business_line_categories")
}
/// Unifies SCOTHIA's DATOS E (egresos) / DATOS I (ingresos) into one
/// signed-amount table: income positive, expense negative.
model BankTransaction {
id String @id @default(uuid())
transactionDate DateTime
transactionType String?
reference String?
concept String?
amount Decimal @db.Decimal(12, 2)
categoryId String?
category BusinessLineCategory? @relation(fields: [categoryId], references: [id])
cleared Boolean @default(false)
transferred Boolean @default(false)
notes String? @db.Text
amountInWords String?
legacySourceTable String?
legacyId String?
@@map("bank_transactions")
}
// ---------------------------------------------------------------------------
// Admin / shared
// ---------------------------------------------------------------------------
model User {
id String @id @default(uuid())
name String
email String @unique
passwordHash String
role UserRole @default(STAFF)
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
activityLogs ActivityLog[]
@@map("users")
}
model ActivityLog {
id String @id @default(uuid())
userId String?
user User? @relation(fields: [userId], references: [id])
event String
level String
message Json?
createdAt DateTime @default(now())
@@map("activity_logs")
}
model EmailTemplate {
id String @id @default(uuid())
name String
subject String
templateSource String @db.Text
@@map("email_templates")
}
model EmailCampaign {
id String @id @default(uuid())
campaignName String
subject String?
body String? @db.Text
status String @default("in_progress")
emailSentCount Int @default(0)
createdAt DateTime @default(now())
@@map("email_campaigns")
}
model EmailLog {
id String @id @default(uuid())
customerId String?
emailAddress String?
emailType String?
requestBody String? @db.Text
responseBody String? @db.Text
sentAt DateTime @default(now())
@@map("email_log")
}