Files
jorgecuadros-platform/apps/api/src/policy-ocr/policy-ocr.dto.ts
T
rmancinasandClaude Opus 5 75e9f582b4
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m16s
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m50s
feat(policy-ocr): store the IVA A.N.A. already prints
The parser has been reading A.N.A.'s `TAX` cell since the ANA layout landed,
but `ParsedPolicy` had nowhere to put it, so the figure only ever reached a
review note and every OCR-confirmed policy was written with `tax` null — even
though the paper states it.

`TAX` now flows parser -> `extractedTax` -> `Policy.tax`, alongside the premium
fields beside it. GMX stays null: its certificate carries no premium at all, so
there is no tax on it either.

The other two cells stay out, for reasons worth keeping:

  - `LOCAL TAX` is a separate levy with no destination column, and summing it
    into `tax` would produce an IVA that no longer divides back to a rate —
    which is the whole reason to store the figure. It reads 0.00 on every
    policy seen so far; a non-zero one now raises a note saying the total will
    not reconcile, instead of quietly inflating the IVA.
  - `DISCOUNT` has no column and prints as a bare "-" when unused, which is
    what makes the row positional rather than "find six amounts".

`taxRate` is left null by confirm. A.N.A. prints the amount, not the rate, and
back-dividing it would mint a rate the document never stated; the capture form
resolves one from the line of business instead.

The review screen gains derecho de póliza next to the new IVA field. It was
already parsed and already written on confirm, but never shown — and an IVA
with no fee beside it leaves the reviewer unable to see why premium + fee + tax
equals the printed total.

The spec's assertion moved off the note and onto the field, plus a check that
the row reconciles: 298.61 + 30.00 at 8% is 26.29, totalling 354.90. That
agreement is what proves the positional mapping landed on the right cells
rather than merely on six numbers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 07:38:23 -07:00

99 lines
4.0 KiB
TypeScript

import { Type } from "class-transformer";
import {
IsArray,
IsDateString,
IsEnum,
IsInt,
IsNumber,
IsObject,
IsOptional,
IsString,
Max,
Min,
MinLength,
ValidateNested,
} from "class-validator";
/** One document's confirmed-after-review state. The service reads these
* fields and writes them onto either a matched Policy or a freshly created
* one. Anything null here is not written. */
export class ConfirmPolicyDocumentDto {
@IsString() documentId!: string;
/** Required when creating a new Policy; ignored if `policyId` is set. */
@IsOptional() @IsString() customerId?: string;
/** Reviewer's explicit lookup picks. Both beat the parsed name; omitted,
* the service resolves `policy_types` / `insurance_providers` by name and
* leaves the FK null when there is no such row. */
@IsOptional() @IsString() policyTypeId?: string;
@IsOptional() @IsString() insuranceProviderId?: string;
/** Set when the document matched an existing Policy. */
@IsOptional() @IsString() policyId?: string;
@IsOptional() @IsString() policyNumber?: string;
@IsOptional() @IsString() insuredName?: string;
@IsOptional() @IsString() additionalInsured?: string;
@IsOptional() @IsString() agentName?: string;
@IsOptional() @IsString() legalAddress?: string;
@IsOptional() @IsString() zip?: string;
@IsOptional() @IsDateString() policyFrom?: string;
@IsOptional() @IsDateString() policyTo?: string;
@IsOptional() @IsDateString() policyDate?: string;
@IsOptional() @IsEnum(["MXN", "USD", "EUR"]) currency?: "MXN" | "USD" | "EUR";
@IsOptional() @IsNumber() netPremium?: number;
@IsOptional() @IsNumber() policyFee?: number;
@IsOptional() @IsNumber() brokerFee?: number;
@IsOptional() @IsNumber() tax?: number;
@IsOptional() @IsNumber() total?: number;
@IsOptional() @IsString() premiumPayment?: string;
/** Printed term in days. Omitted leaves the parsed value (or the schema's
* 365 default) in place; ANA sells 3- and 4-day tourist policies. */
@IsOptional() @IsInt() @Min(1) @Max(3660) coveragePeriodDays?: number;
/** Coverages parsed off the PDF, passed through verbatim to Policy.coveragesJson. */
@IsOptional() @IsObject() coveragesJson?: unknown;
/** When true, write a Transaction(domain=INSURANCE, amount=-netPremium)
* in addition to creating/updating the Policy. Skipped if netPremium is
* null or zero. */
@IsOptional() postPremium?: boolean;
}
export class ConfirmPolicyBatchDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => ConfirmPolicyDocumentDto)
documents!: ConfirmPolicyDocumentDto[];
}
/** Staff correction of one document's extracted fields or its match. */
export class ReviewPolicyDocumentDto {
@IsOptional() @IsString() policyNumber?: string;
@IsOptional() @IsString() insuredName?: string;
@IsOptional() @IsString() additionalInsured?: string;
@IsOptional() @IsString() agentName?: string;
@IsOptional() @IsString() legalAddress?: string;
@IsOptional() @IsString() zip?: string;
@IsOptional() @IsDateString() policyFrom?: string;
@IsOptional() @IsDateString() policyTo?: string;
@IsOptional() @IsDateString() policyDate?: string;
@IsOptional() @IsString() currency?: string;
@IsOptional() @IsNumber() netPremium?: number;
@IsOptional() @IsNumber() policyFee?: number;
@IsOptional() @IsNumber() brokerFee?: number;
@IsOptional() @IsNumber() tax?: number;
@IsOptional() @IsNumber() total?: number;
@IsOptional() @IsString() premiumPayment?: string;
@IsOptional() @IsInt() @Min(1) @Max(3660) coveragePeriodDays?: number;
@IsOptional() @IsObject() coveragesJson?: unknown;
/** Set by the reviewer when the document matched an existing Policy. */
@IsOptional() @IsString() matchedPolicyId?: string;
/** Set by the reviewer when creating a new Policy. */
@IsOptional() @IsString() matchedCustomerId?: string;
/** Force-confirm a doc even when the matcher left it ambiguous. */
@IsOptional() forceConfirm?: boolean;
}
export class CreatePolicyOcrBatchDto {
@IsOptional() @IsString() @MinLength(1) label?: string;
}