Mirrors the utility statement intake on the insurance side: a policy_ocr batch/document pair of tables, a GMX parser, a matcher keyed on Policy.policyNumber, and a "Captura" screen under /polizas that proposes policy -> customer for staff to confirm. Lifts the OCR seam out of StatementsModule into its own OcrModule so PolicyOcrModule can inject OCR_PROVIDER without taking on the rest of the statement pipeline; StatementsModule now imports it and binds nothing itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
948 lines
34 KiB
Plaintext
948 lines
34 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"
|
|
// "native" covers local dev. The musl target is declared EXPLICITLY because
|
|
// Prisma picks the engine by sniffing the build environment: the Docker build
|
|
// stage has no openssl, so it detected plain "linux-musl", while the runtime
|
|
// stage (which needs openssl for other reasons) then demanded
|
|
// "linux-musl-openssl-3.0.x" and refused to start. Naming it here makes the
|
|
// engine that ships independent of what happens to be installed at build time.
|
|
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
|
|
}
|
|
|
|
datasource db {
|
|
provider = "mysql"
|
|
url = env("DATABASE_URL")
|
|
}
|
|
|
|
enum Currency {
|
|
USD
|
|
MXN
|
|
}
|
|
|
|
enum TransactionDomain {
|
|
UTILITY
|
|
INSURANCE
|
|
TRUST
|
|
}
|
|
|
|
/// How a ledger row entered the system. Every capture path funnels through
|
|
/// BillingService (single write path, single audit trail); this records which
|
|
/// one, so an auto-captured receipt is auditable without joining the statement
|
|
/// tables. `OCR` is reserved for the statement auto-capture pipeline
|
|
/// (docs/RECEIPT_CAPTURE_SPEC.md §2), which posts through the same batch path
|
|
/// as hand-keyed check batches.
|
|
enum TransactionCaptureSource {
|
|
MANUAL
|
|
BATCH
|
|
OCR
|
|
}
|
|
|
|
enum ServiceKind {
|
|
WATER
|
|
ELECTRIC
|
|
GAS
|
|
CABLE
|
|
PROPERTY_TAX
|
|
FEDERAL_ZONE
|
|
ALARM
|
|
/// Telephone was never unpivoted out of DATMEX — the numbers sat on
|
|
/// `Property.phone1/2/3` as contact fields even though the legacy ledger
|
|
/// billed phone as its own transaction type. OCR matching needs a real
|
|
/// service row to match a Telnor bill against, so it becomes one; see
|
|
/// `migration/backfill_statement_match_fields.py`.
|
|
TELEPHONE
|
|
OTHER
|
|
}
|
|
|
|
/// Ordered access tier (rank): ADMIN > MANAGER > STAFF > VIEWER. VIEWER is the
|
|
/// read-only role; STAFF and above can write. Enforced by the API's ability
|
|
/// matrix (apps/api/src/auth/abilities.ts), not by the enum itself.
|
|
enum UserRole {
|
|
ADMIN
|
|
MANAGER
|
|
STAFF
|
|
VIEWER
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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?
|
|
// True when `name` is the "(SIN NOMBRE)" placeholder. Denormalized so lists
|
|
// can sort nameless records last — ordering by `name` alone puts them first,
|
|
// since "(" sorts before every letter.
|
|
nameMissing Boolean @default(false)
|
|
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)
|
|
// Soft-delete marker. Distinct from `status` (a legacy business flag): a
|
|
// non-null archivedAt hides the row from default lists while preserving it
|
|
// and its legacy provenance. Never hard-delete migrated data.
|
|
archivedAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
legacyRefs CustomerLegacyRef[]
|
|
properties Property[]
|
|
policies Policy[]
|
|
vehicles Vehicle[]
|
|
transactions Transaction[]
|
|
|
|
statementDocuments StatementDocument[]
|
|
policyOcrDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentCustomer")
|
|
|
|
@@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?
|
|
// Soft-delete marker (see Customer.archivedAt). Never hard-delete migrated
|
|
// policy data; archiving hides it from default lists.
|
|
archivedAt 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[]
|
|
renewalNotices RenewalNotice[]
|
|
|
|
ocrMatchedDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentPolicy")
|
|
ocrCreatedDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentCreatedPolicy")
|
|
|
|
@@unique([legacySourceDb, legacySourceTable, legacyId])
|
|
@@index([policyNumber])
|
|
@@map("policies")
|
|
}
|
|
|
|
enum RenewalNoticeChannel {
|
|
MAIL
|
|
EMAIL
|
|
}
|
|
|
|
/// Replaces the legacy `CONTROL <ramo> RENEW[2/3] X MES` reports — a
|
|
/// per-batch printed checklist of who'd been sent which reminder. One row
|
|
/// per notice generation actually sent for a policy, so "who got a 1st/2nd/
|
|
/// 3rd notice and when" is a query instead of a paper trail. See
|
|
/// docs/RENEWAL_NOTICES.md for the legacy report chain this replaces.
|
|
model RenewalNotice {
|
|
id String @id @default(uuid())
|
|
policyId String
|
|
policy Policy @relation(fields: [policyId], references: [id])
|
|
// 1 = first notice (bare RENEW), 2 = RENEW2, 3 = RENEW3 in the legacy naming.
|
|
generation Int
|
|
channel RenewalNoticeChannel @default(MAIL)
|
|
sentAt DateTime?
|
|
sentById String?
|
|
notes String? @db.Text
|
|
createdAt DateTime @default(now())
|
|
|
|
// One row per generation per policy — matches the legacy's 1st/2nd/3rd
|
|
// notice cadence; re-running the same generation for a policy updates it
|
|
// rather than duplicating a log entry.
|
|
@@unique([policyId, generation])
|
|
@@map("renewal_notices")
|
|
}
|
|
|
|
/// 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
|
|
// One legacy policy row can carry up to 3 vehicles, so they share a
|
|
// legacyId (the source row number) — provenance is NOT unique per vehicle.
|
|
// Sync rebuilds legacy vehicles by scoped delete + reinsert instead of upsert.
|
|
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")
|
|
}
|
|
|
|
/// Insurance OCR intake (mirrors statement_batches / statement_documents for
|
|
/// the utility side). One upload session of policy PDFs from a provider
|
|
/// portal (GMX, etc.) — the parser proposes policyNumber → existing Policy
|
|
/// (or "new, pick customer"), staff confirms, and the system attaches the
|
|
/// source PDF and optionally writes a premium Transaction.
|
|
model PolicyOcrBatch {
|
|
id String @id @default(uuid())
|
|
/// Which insurance provider portal the batch came from. "GMX" today;
|
|
/// future providers (AXA, GNP, …) extend the parser, not this table.
|
|
provider String @default("GMX")
|
|
status PolicyOcrBatchStatus @default(UPLOADED)
|
|
uploadedById String
|
|
uploadedBy User @relation("PolicyOcrBatchUploader", fields: [uploadedById], references: [id])
|
|
label String?
|
|
fileCount Int @default(0)
|
|
/// Set when the pipeline fails as a whole (bad PDF, OCR binaries missing).
|
|
error String? @db.Text
|
|
createdAt DateTime @default(now())
|
|
completedAt DateTime?
|
|
|
|
documents PolicyOcrDocument[]
|
|
|
|
@@index([status, createdAt])
|
|
@@map("policy_ocr_batches")
|
|
}
|
|
|
|
enum PolicyOcrBatchStatus {
|
|
UPLOADED
|
|
PROCESSING
|
|
READY_FOR_REVIEW
|
|
COMPLETED
|
|
FAILED
|
|
}
|
|
|
|
/// One parsed policy page — one Policy → one Customer (after staff confirms).
|
|
model PolicyOcrDocument {
|
|
id String @id @default(uuid())
|
|
batchId String
|
|
batch PolicyOcrBatch @relation(fields: [batchId], references: [id], onDelete: Cascade)
|
|
pageNumber Int
|
|
/// The rendered page image in object storage. Source PDF kept too on the
|
|
/// batch (statement pattern) so re-running a corrected parser is possible.
|
|
storageKey String
|
|
status PolicyOcrDocumentStatus @default(PENDING_OCR)
|
|
|
|
ocrRawText String? @db.Text
|
|
ocrConfidence Decimal? @db.Decimal(4, 3)
|
|
/// Which parser claimed the page ("GMX" today).
|
|
provider String?
|
|
|
|
// Extracted header fields, all staff-editable in review.
|
|
extractedPolicyNumber String?
|
|
extractedInsuredName String?
|
|
extractedAdditionalInsured String?
|
|
extractedAgentName String?
|
|
extractedLegalAddress String? @db.Text
|
|
extractedZip String?
|
|
extractedPolicyFrom DateTime?
|
|
extractedPolicyTo DateTime?
|
|
extractedPolicyDate DateTime?
|
|
extractedCurrency String?
|
|
extractedNetPremium Decimal? @db.Decimal(12, 2)
|
|
extractedPolicyFee Decimal? @db.Decimal(12, 2)
|
|
extractedBrokerFee Decimal? @db.Decimal(12, 2)
|
|
extractedTotal Decimal? @db.Decimal(12, 2)
|
|
/// Per-coverage rows from the GMX "Material damages" / "Additional risk"
|
|
/// tables — preserved verbatim so a missing premium receipt still leaves
|
|
/// the coverages auditable.
|
|
extractedCoveragesJson Json?
|
|
extractedPremiumPayment String?
|
|
|
|
// Match by `Policy.policyNumber` → existing Policy / Customer.
|
|
matchedPolicyId String?
|
|
matchedPolicy Policy? @relation("PolicyOcrDocumentPolicy", fields: [matchedPolicyId], references: [id])
|
|
matchedCustomerId String?
|
|
matchedCustomer Customer? @relation("PolicyOcrDocumentCustomer", fields: [matchedCustomerId], references: [id])
|
|
/// All policies carrying the same number, with their customer. One is
|
|
/// normal; >1 means the policy number is shared across customers and a
|
|
/// human must pick.
|
|
matchCandidates Json?
|
|
matchNote String?
|
|
|
|
reviewedById String?
|
|
reviewedBy User? @relation("PolicyOcrDocumentReviewer", fields: [reviewedById], references: [id])
|
|
reviewedAt DateTime?
|
|
|
|
createdPolicyId String? @unique
|
|
createdPolicy Policy? @relation("PolicyOcrDocumentCreatedPolicy", fields: [createdPolicyId], references: [id])
|
|
postedTransactionId String? @unique
|
|
postedTransaction Transaction? @relation("PolicyOcrDocumentTransaction", fields: [postedTransactionId], references: [id])
|
|
|
|
createdAt DateTime @default(now())
|
|
|
|
@@unique([batchId, pageNumber])
|
|
@@index([status])
|
|
@@index([matchedCustomerId])
|
|
@@map("policy_ocr_documents")
|
|
}
|
|
|
|
enum PolicyOcrDocumentStatus {
|
|
PENDING_OCR
|
|
OCR_FAILED
|
|
NEEDS_REVIEW
|
|
MATCHED
|
|
CONFIRMED
|
|
POSTED
|
|
REJECTED
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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?
|
|
/// Clave catastral (DATMEX.clave) — the cadastral key, format `KA903009`.
|
|
/// Property-level, not per-service: it is printed on both the CESPT water
|
|
/// bill and the predial statement, which is exactly why it is a useful
|
|
/// secondary match key when a bill's account number does not OCR cleanly.
|
|
/// Distinct from the numeric DATMEX.predial that `PROPERTY_TAX.accountNumber`
|
|
/// carries — that column is not unique (663 distinct across 1135 rows) and
|
|
/// is not what any statement prints.
|
|
cadastralKey String?
|
|
// Soft-delete marker (see Customer.archivedAt).
|
|
archivedAt DateTime?
|
|
legacySourceTable String?
|
|
legacyId String?
|
|
createdAt DateTime @default(now())
|
|
|
|
services PropertyService[]
|
|
documents ServiceDocument[]
|
|
trustAccount TrustAccount?
|
|
|
|
@@unique([legacySourceTable, legacyId])
|
|
@@index([cadastralKey])
|
|
@@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
|
|
|
|
statementDocuments StatementDocument[]
|
|
|
|
// The OCR matcher looks a service up by (kind, accountNumber) — always
|
|
// scoped to one kind, never fuzzily across every identifier column, so a
|
|
// water account number cannot collide with an unrelated phone number.
|
|
@@index([kind, accountNumber])
|
|
@@index([kind, meterNumber])
|
|
@@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")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Statement OCR intake (RECEIPT_CAPTURE_SPEC §2)
|
|
//
|
|
// Each utility company mails 300+ paper statements a month, one per customer,
|
|
// which staff key in by hand. These two tables are the intake side of removing
|
|
// that: a batch of scanned PDFs is split per page, OCR'd, matched to a
|
|
// PropertyService by its scoped account number, and queued for review. Nothing
|
|
// here writes to the ledger — confirming a document posts it through
|
|
// `BillingService.createBatch`, the same path hand-keyed batches take.
|
|
//
|
|
// Everything ingested is a CHARGE (a bill awaiting payment), never a proof of
|
|
// payment: the office scans what it must pay, and settles it by check through
|
|
// the existing capture flow.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
enum StatementBatchStatus {
|
|
UPLOADED
|
|
PROCESSING
|
|
READY_FOR_REVIEW
|
|
COMPLETED
|
|
FAILED
|
|
}
|
|
|
|
enum StatementDocumentStatus {
|
|
PENDING_OCR
|
|
OCR_FAILED
|
|
/// No confident match, or the extraction itself was low-confidence.
|
|
NEEDS_REVIEW
|
|
/// Confident auto-match, awaiting a human confirm.
|
|
MATCHED
|
|
/// Staff confirmed; not yet posted.
|
|
CONFIRMED
|
|
POSTED
|
|
/// Duplicate, unreadable, or wrong batch.
|
|
REJECTED
|
|
}
|
|
|
|
/// One upload session — e.g. "October CFE statements".
|
|
model StatementBatch {
|
|
id String @id @default(uuid())
|
|
/// What kind of service every statement in this batch bills. The parser
|
|
/// still detects the provider per page and flags any page that disagrees,
|
|
/// rather than trusting the uploader's label.
|
|
serviceKind ServiceKind
|
|
status StatementBatchStatus @default(UPLOADED)
|
|
uploadedById String
|
|
uploadedBy User @relation("StatementBatchUploader", fields: [uploadedById], references: [id])
|
|
label String?
|
|
fileCount Int @default(0)
|
|
/// Set when the pipeline fails as a whole (bad PDF, OCR binaries missing).
|
|
error String? @db.Text
|
|
createdAt DateTime @default(now())
|
|
completedAt DateTime?
|
|
|
|
documents StatementDocument[]
|
|
|
|
@@index([status, createdAt])
|
|
@@map("statement_batches")
|
|
}
|
|
|
|
/// One statement — one customer, one period — after splitting the batch.
|
|
model StatementDocument {
|
|
id String @id @default(uuid())
|
|
batchId String
|
|
batch StatementBatch @relation(fields: [batchId], references: [id], onDelete: Cascade)
|
|
/// 1-based page of the source PDF this was split from.
|
|
pageNumber Int
|
|
/// The rendered page image in object storage. The source PDF is kept too, so
|
|
/// a reviewer can always see exactly what the parser read.
|
|
storageKey String
|
|
status StatementDocumentStatus @default(PENDING_OCR)
|
|
|
|
/// Raw OCR text, kept even after a manual correction so a mismatch between
|
|
/// what the machine read and what staff entered stays auditable.
|
|
ocrRawText String? @db.Text
|
|
/// Mean per-word confidence reported by the OCR engine, 0..1.
|
|
ocrConfidence Decimal? @db.Decimal(4, 3)
|
|
/// Which parser claimed the page ("CFE", "CESPT", "TELNOR").
|
|
provider String?
|
|
|
|
// Extracted, then staff-corrected in place. `extractedAccountRef` is already
|
|
// normalised for matching (CFE leading zeros stripped, Telnor LADA removed).
|
|
extractedAccountRef String?
|
|
extractedAmount Decimal? @db.Decimal(12, 2)
|
|
extractedPeriod String?
|
|
extractedDueDate DateTime?
|
|
/// Clave catastral when the statement prints one — a second key to match on
|
|
/// when the account number is unreadable.
|
|
extractedCadastralKey String?
|
|
|
|
matchedPropertyServiceId String?
|
|
matchedPropertyService PropertyService? @relation(fields: [matchedPropertyServiceId], references: [id])
|
|
matchedCustomerId String?
|
|
matchedCustomer Customer? @relation(fields: [matchedCustomerId], references: [id])
|
|
/// Why this landed where it did — "exact account match", "no candidate",
|
|
/// "2 candidates". Shown in the review queue so staff can trust or distrust
|
|
/// the suggestion without opening the image.
|
|
matchNote String?
|
|
|
|
reviewedById String?
|
|
reviewedBy User? @relation("StatementDocumentReviewer", fields: [reviewedById], references: [id])
|
|
reviewedAt DateTime?
|
|
|
|
postedTransactionId String? @unique
|
|
postedTransaction Transaction? @relation(fields: [postedTransactionId], references: [id])
|
|
|
|
createdAt DateTime @default(now())
|
|
|
|
@@unique([batchId, pageNumber])
|
|
@@index([status])
|
|
@@index([matchedCustomerId])
|
|
@@map("statement_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)
|
|
/// How this row was captured. NULL = migrated from Access (the legacy*
|
|
/// columns below say which table). Set explicitly on everything the app
|
|
/// books, so an OCR-posted receipt is distinguishable from a hand-keyed one
|
|
/// without joining the statement tables.
|
|
captureSource TransactionCaptureSource?
|
|
/// Back-pointer to the artifact that produced this row — a
|
|
/// `StatementDocument.id` for OCR captures (see RECEIPT_CAPTURE_SPEC §2).
|
|
/// Unique among live rows via the app's duplicate guard, not a DB constraint,
|
|
/// because a voided row must not block a corrected re-post of the same
|
|
/// document.
|
|
captureRef String?
|
|
// Append + void: booked rows are never edited or hard-deleted. A non-null
|
|
// voidedAt reverses the movement — it MUST be excluded from every balance
|
|
// and total (SUM/count) so a voided amount stops affecting the books.
|
|
voidedAt DateTime?
|
|
voidedById String?
|
|
legacySourceDb String?
|
|
legacySourceTable String?
|
|
legacyId String?
|
|
createdAt DateTime @default(now())
|
|
|
|
/// Set only on OCR-posted rows — the statement page this came from.
|
|
statementDocument StatementDocument?
|
|
policyOcrDocument PolicyOcrDocument? @relation("PolicyOcrDocumentTransaction")
|
|
|
|
@@unique([legacySourceDb, legacySourceTable, legacyId])
|
|
@@index([customerId, transactionDate])
|
|
// By-check reconciliation (billing.byCheck / the cheque-count report) looks
|
|
// rows up by check number alone — the legacy EDITA CHEQUE COUNT lookup.
|
|
@@index([checkNumber])
|
|
// Drives the duplicate-post guard in BillingService.createBatch.
|
|
@@index([captureRef])
|
|
@@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")
|
|
}
|
|
|
|
/// The institution a chequera is held at. Purely a grouping label for the
|
|
/// accounts under it — no money hangs off a Bank directly.
|
|
model Bank {
|
|
id String @id @default(uuid())
|
|
name String @unique
|
|
/// "MX" | "US" — informational, used only to label the account picker.
|
|
country String?
|
|
accounts BankAccount[]
|
|
|
|
@@map("banks")
|
|
}
|
|
|
|
/// One physical chequera. Currency is fixed per account, because a real bank
|
|
/// account is: there is deliberately NO currency column on BankTransaction, a
|
|
/// movement inherits its account's. This is what keeps the MXN (Utilities /
|
|
/// Scotiabank) and USD (Seguros) registers from ever being summed together,
|
|
/// the same rule the customer ledger follows per currency.
|
|
model BankAccount {
|
|
id String @id @default(uuid())
|
|
bankId String
|
|
bank Bank @relation(fields: [bankId], references: [id])
|
|
/// Staff-facing name, e.g. "Utilities — Scotiabank (MXN)".
|
|
label String
|
|
currency Currency
|
|
/// Hint only, never enforced — one chequera can pay for more than one line.
|
|
businessLine TransactionDomain?
|
|
active Boolean @default(true)
|
|
movements BankTransaction[]
|
|
|
|
@@map("bank_accounts")
|
|
}
|
|
|
|
/// 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())
|
|
// Required: a movement with no known account isn't reconcilable against a
|
|
// statement. Every migrated row is SCOTHIA = the Utilities MXN account.
|
|
bankAccountId String
|
|
bankAccount BankAccount @relation(fields: [bankAccountId], references: [id])
|
|
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?
|
|
// Append + void (see Transaction.voidedAt): excluded from income/expense/net.
|
|
voidedAt DateTime?
|
|
voidedById String?
|
|
legacySourceTable String?
|
|
legacyId String?
|
|
|
|
// Provenance stays globally unique: every legacy row belongs to the one
|
|
// Scotiabank account, so adding accounts never collides here.
|
|
@@unique([legacySourceTable, legacyId])
|
|
@@index([bankAccountId, transactionDate])
|
|
@@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)
|
|
// UI text-size preference, so it follows the person between machines
|
|
// instead of living only in one browser's localStorage. Range is clamped
|
|
// API-side (see UpdatePreferencesDto) to match the web's presets.
|
|
uiScale Float @default(1)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
activityLogs ActivityLog[]
|
|
|
|
statementBatches StatementBatch[] @relation("StatementBatchUploader")
|
|
statementsReviewed StatementDocument[] @relation("StatementDocumentReviewer")
|
|
|
|
policyOcrBatches PolicyOcrBatch[] @relation("PolicyOcrBatchUploader")
|
|
policyOcrReviewed PolicyOcrDocument[] @relation("PolicyOcrDocumentReviewer")
|
|
|
|
@@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")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Admin database operations (Operaciones): backup / restore / re-import / sync.
|
|
// Each long-running op is one OpsJob row so the web UI can poll status + tail
|
|
// the captured log. Rows are the audit trail for who ran a destructive op.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
enum OpsJobKind {
|
|
BACKUP
|
|
RESTORE
|
|
REIMPORT
|
|
SYNC
|
|
}
|
|
|
|
enum OpsJobStatus {
|
|
RUNNING
|
|
SUCCESS
|
|
FAILED
|
|
}
|
|
|
|
model OpsJob {
|
|
id String @id @default(uuid())
|
|
kind OpsJobKind
|
|
status OpsJobStatus @default(RUNNING)
|
|
// Combined stdout+stderr of the spawned process, appended as it runs.
|
|
log String @db.LongText
|
|
// Op-specific inputs (e.g. the backup filename a RESTORE targets). No FK on
|
|
// createdById — the actor id is stored flat, like activity_logs' userId use.
|
|
params Json?
|
|
createdById String?
|
|
startedAt DateTime @default(now())
|
|
finishedAt DateTime?
|
|
|
|
@@index([status])
|
|
@@index([startedAt])
|
|
@@map("ops_jobs")
|
|
}
|