seubert-claims/ondeck/src/lib/sync/mappers.ts
lorentz fad9e18662 feat(imageright): Task Audit feature for SHAPE clients
- Add TaskAudit/TaskAuditStatus Prisma models linked to Client/Task/User.
- Add ImageRightClient (auth, findFilesByFileNumber, findDocuments,
  getSortedFolders) per Vertafore REST v1 API.
- Add audit engine that resolves per-item target dates/windows from the
  SHAPE_IR_Filing_Audit_Spec.md checklist, navigates real ImageRight folder
  structure, and links results to existing Tasks where found.
- Add GET/POST /api/clients/[id]/task-audit (SHAPE/SHAPE2-gated) and a
  Document Audit tab in the client detail page.
- Add Client.amsCustomerNumber (AFW_Customer.CustNo) — ImageRight's
  FileNumberPart1 expects this AMS360 account number, not the amsCustomerId
  GUID. Backfilled via one-off /api/admin/backfill-ams-customer-number.
- Fix folder/document traversal against verified live ImageRight data:
  real folders are nested under a per-year "Policy Term" folder (not flat
  SUBMISSION/PRERENEWAL as in the source spec doc), and document listing
  requires POST /api/documents/find with {FileId, ParentId} — the
  previously assumed /api/containers/{id} endpoint 404s on real ids.
- Add jest coverage for client auth/find calls and folder/doc-type matching.

Known open items (need process-owner input, not resolved here):
- Real IR document types (Correspondences, Pre-Renewal Information, Loss
  Run) don't map 1:1 to the spec's generic labels (EMAIL, EXCEL DOC, PDF).
- Spec's second folder_path segment (e.g. PRERENEWAL) has no matching real
  subfolder for at least one tested client; currently ignored for folder
  navigation and only surfaced in the audit result label.
- Whether an audit item should be skipped when no open Task exists for it,
  rather than always running and reporting INCOMPLETE.
2026-07-08 10:39:12 +00:00

147 lines
4.1 KiB
TypeScript

import { AfwCustomer, AfwPolicy, AfwEmployee } from './afw-queries'
/**
* Map AFW Customer to local Client data
*/
export function mapAfwCustomerToClient(
afwCustomer: AfwCustomer
): any {
// Determine customer name: use FirmNameCust for businesses, or construct from LastName/FirstName for individuals
let customerName = afwCustomer.FirmNameCust
if (!customerName && (afwCustomer.LastName || afwCustomer.FirstName)) {
const parts = [afwCustomer.LastName, afwCustomer.FirstName].filter(Boolean)
customerName = parts.join(', ')
}
if (!customerName) {
customerName = 'Unknown Customer'
}
return {
amsCustomerId: afwCustomer.CustId,
amsCustomerNumber: afwCustomer.CustNo,
name: customerName,
addressLine1: afwCustomer.Addr1,
addressLine2: afwCustomer.Addr2,
city: afwCustomer.City,
state: afwCustomer.State,
zipCode: afwCustomer.ZipCode,
phone: afwCustomer.BusPhone,
email: afwCustomer.EMail,
producerCode: afwCustomer.Prod1Code,
amsCreatedAt: null,
amsModifiedAt: afwCustomer.ChangedDate,
lastSyncedAt: new Date(),
}
}
/**
* Map AFW Policy to local Policy data (with enriched fields)
*/
export function mapAfwPolicyToPolicy(
afwPolicy: AfwPolicy,
clientId: string
): any {
return {
amsPolicyId: afwPolicy.PolId,
client: {
connect: { id: clientId },
},
policyNumber: afwPolicy.PolNo,
policyType: afwPolicy.PolTypeLOB,
effectiveDate: afwPolicy.PolEffDate,
expirationDate: afwPolicy.PolExpDate,
carrierName: afwPolicy.ParentCompanyName,
writingCompanyName: afwPolicy.WritingCompanyName,
premiumAmount: null, // Not in enriched query
status: afwPolicy.Status,
billMethod: afwPolicy.BillMethod,
businessType: afwPolicy.BusinessType,
department: afwPolicy.PolicyDepartment,
executiveName: afwPolicy.ExecFormattedName,
csrName: afwPolicy.CsrFormattedName,
additionalRep1: afwPolicy.AddRep1FormattedName,
additionalRep2: afwPolicy.AddRep2FormattedName,
additionalExec1: afwPolicy.AddExec1FormattedName,
additionalExec2: afwPolicy.AddExec2FormattedName,
amsCreatedAt: null,
amsModifiedAt: null,
lastSyncedAt: new Date(),
}
}
/**
* Map AFW Employee to local User data
*/
export function mapAfwEmployeeToUser(
afwEmployee: AfwEmployee
): any {
const displayName = [afwEmployee.FirstName, afwEmployee.LastName]
.filter(Boolean)
.join(' ') || afwEmployee.ShortName || afwEmployee.EmpCode
return {
email: afwEmployee.EMail || `${afwEmployee.EmpCode}@placeholder.local`,
displayName,
department: afwEmployee.DefaultGLDeptCode,
isActive: afwEmployee.Status === 'A',
}
}
/**
* Normalize phone number format
*/
export function normalizePhoneNumber(phone: string | null): string | null {
if (!phone) return null
// Remove all non-digit characters
const digits = phone.replace(/\D/g, '')
// Format as (XXX) XXX-XXXX if 10 digits
if (digits.length === 10) {
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`
}
return phone
}
/**
* Normalize email format
*/
export function normalizeEmail(email: string | null): string | null {
if (!email) return null
return email.toLowerCase().trim()
}
/**
* Determine if a record should be updated based on modification dates
*/
export function shouldUpdateRecord(
localModifiedAt: Date | null,
afwModifiedAt: Date | null
): boolean {
if (!localModifiedAt) return true
if (!afwModifiedAt) return false
return afwModifiedAt > localModifiedAt
}
/**
* Calculate days until policy expiration
*/
export function calculateDaysUntilExpiration(expirationDate: Date): number {
const now = new Date()
const expDate = new Date(expirationDate)
const diffTime = expDate.getTime() - now.getTime()
return Math.ceil(diffTime / (1000 * 60 * 60 * 24))
}
/**
* Determine if a policy is eligible for renewal workflow
*/
export function isPolicyEligibleForRenewal(
expirationDate: Date,
daysThreshold: number = 90
): boolean {
const daysUntil = calculateDaysUntilExpiration(expirationDate)
return daysUntil > 0 && daysUntil <= daysThreshold
}