diff --git a/src/common/validators/money-amount-string.validator.ts b/src/common/validators/money-amount-string.validator.ts index 2f5f985..e724a12 100644 --- a/src/common/validators/money-amount-string.validator.ts +++ b/src/common/validators/money-amount-string.validator.ts @@ -5,22 +5,18 @@ import { ValidatorConstraint, ValidatorConstraintInterface, } from "class-validator"; -import { normalizeMoneyAmountString } from "src/utils/unicode-digits"; +import { parseMoneyAmountToman } from "src/utils/unicode-digits"; @ValidatorConstraint({ name: "isMoneyAmountString", async: false }) -export class IsMoneyAmountStringConstraint - implements ValidatorConstraintInterface -{ +export class IsMoneyAmountStringConstraint implements ValidatorConstraintInterface { validate(value: unknown): boolean { if (value == null || value === "") return true; if (typeof value !== "string") return false; - const n = normalizeMoneyAmountString(value); - if (!n) return false; - return /^\d+(\.\d+)?$/.test(n); + return parseMoneyAmountToman(value) !== null; } defaultMessage(): string { - return "Must be a non-negative amount (digits only, optional decimal)."; + return "Must be a non-negative whole-Toman amount."; } } diff --git a/src/expert-claim/dto/expert-claim-v2.dto.spec.ts b/src/expert-claim/dto/expert-claim-v2.dto.spec.ts index ecf6bb3..7f45966 100644 --- a/src/expert-claim/dto/expert-claim-v2.dto.spec.ts +++ b/src/expert-claim/dto/expert-claim-v2.dto.spec.ts @@ -26,7 +26,7 @@ describe("SubmitExpertReplyV2Dto", () => { expect(errors).not.toHaveLength(0); }); - it("accepts a replacement part without a price", async () => { + it("rejects a replacement part without a price", async () => { const dto = plainToInstance(SubmitExpertReplyV2Dto, { description: "Damage assessment", parts: [ @@ -41,7 +41,7 @@ describe("SubmitExpertReplyV2Dto", () => { ], }); - expect(await validate(dto)).toHaveLength(0); + expect(await validate(dto)).not.toHaveLength(0); }); it("accepts a repair part without daghi", async () => { @@ -61,4 +61,33 @@ describe("SubmitExpertReplyV2Dto", () => { expect(await validate(dto)).toHaveLength(0); }); + + it("accepts the expert's repair and replacement pricing rules", async () => { + const dto = plainToInstance(SubmitExpertReplyV2Dto, { + description: "Damage assessment", + parts: [ + { + partId: 11, + typeOfDamage: TypeOfDamage.Repair, + salary: "۵,۰۰۰", + totalPayment: "5000", + factorNeeded: false, + }, + { + partId: 23, + typeOfDamage: TypeOfDamage.Change, + price: "۵۰,۰۰۰", + salary: "۱۰۰,۰۰۰", + totalPayment: "150000", + factorNeeded: false, + daghi: { + option: DaghiOption.RECYCLED_PARTS_VALUE, + price: "۱۰۰,۵۰۰,۰۰۰", + }, + }, + ], + }); + + expect(await validate(dto)).toHaveLength(0); + }); }); diff --git a/src/expert-claim/dto/expert-claim-v2.dto.ts b/src/expert-claim/dto/expert-claim-v2.dto.ts index 3a8418b..9e49507 100644 --- a/src/expert-claim/dto/expert-claim-v2.dto.ts +++ b/src/expert-claim/dto/expert-claim-v2.dto.ts @@ -12,7 +12,7 @@ import { IsInt, } from 'class-validator'; import { Type } from 'class-transformer'; -import { IsRepairLineAmountToman } from 'src/common/validators/repair-line-amount-toman.validator'; +import { IsMoneyAmountString } from 'src/common/validators/money-amount-string.validator'; import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum'; import { DamagedPartItem } from 'src/claim-request-management/dto/capture-requirements-v2.dto'; import { DaghiOption } from 'src/Types&Enums/claim-request-management/daghi-option.enum'; @@ -35,7 +35,7 @@ export class DaghiDetailsV2Dto { ) @IsString() @IsNotEmpty() - @IsRepairLineAmountToman() + @IsMoneyAmountString() price?: string; @ApiPropertyOptional({ @@ -56,24 +56,24 @@ export class PartPricingV2Dto { @ApiProperty({ enum: TypeOfDamage, - description: "'repair' requires price; 'change' may omit it.", + description: "'change' requires price; 'repair' may omit it.", }) @IsEnum(TypeOfDamage) typeOfDamage: TypeOfDamage; @ApiPropertyOptional({ example: "5000000", - description: "Required for repair lines; omitted for change lines. Use 0 if the full amount is in salary.", + description: "Required for change lines; omitted for repair lines. Use 0 if unused.", }) @ValidateIf( (part: PartPricingV2Dto) => - part.typeOfDamage === TypeOfDamage.Repair || + part.typeOfDamage === TypeOfDamage.Change || (part.price != null && (typeof part.price !== 'string' || part.price.trim() !== '')), ) @IsString() @IsNotEmpty() - @IsRepairLineAmountToman({ allowZero: true }) + @IsMoneyAmountString() price: string; @ApiProperty({ @@ -82,7 +82,7 @@ export class PartPricingV2Dto { }) @IsString() @IsNotEmpty() - @IsRepairLineAmountToman({ allowZero: true }) + @IsMoneyAmountString() salary: string; @ApiProperty({ @@ -91,7 +91,7 @@ export class PartPricingV2Dto { }) @IsString() @IsNotEmpty() - @IsRepairLineAmountToman({ allowZero: true }) + @IsMoneyAmountString() totalPayment: string; @ApiPropertyOptional({ diff --git a/src/helpers/expert-reply-pricing.spec.ts b/src/helpers/expert-reply-pricing.spec.ts index efd425e..8d2ad04 100644 --- a/src/helpers/expert-reply-pricing.spec.ts +++ b/src/helpers/expert-reply-pricing.spec.ts @@ -47,7 +47,7 @@ describe("getExpertReplyPricingValidationError", () => { ).toBeNull(); }); - it("rejects a repair line without a price", () => { + it("accepts a repair line without a price", () => { expect( getExpertReplyPricingValidationError([ { @@ -55,10 +55,9 @@ describe("getExpertReplyPricingValidationError", () => { typeOfDamage: TypeOfDamage.Repair, salary: "0", totalPayment: "0", - daghi: { option: DaghiOption.NO_VALUE }, }, ]), - ).toMatch(/price is also required/); + ).toBeNull(); }); it("rejects recycled-value daghi without its price", () => { @@ -67,6 +66,7 @@ describe("getExpertReplyPricingValidationError", () => { { partId: 201, typeOfDamage: TypeOfDamage.Change, + price: "0", salary: "0", totalPayment: "0", daghi: { option: DaghiOption.RECYCLED_PARTS_VALUE }, @@ -75,7 +75,7 @@ describe("getExpertReplyPricingValidationError", () => { ).toMatch(/requires a valid daghi price/); }); - it("accepts a replacement line without a part price", () => { + it("rejects a replacement line without a part price", () => { expect( getExpertReplyPricingValidationError([ { @@ -86,7 +86,7 @@ describe("getExpertReplyPricingValidationError", () => { daghi: { option: DaghiOption.NO_VALUE }, }, ]), - ).toBeNull(); + ).toMatch(/price is also required/); }); it("rejects an invalid price when a replacement line supplies one", () => { @@ -103,4 +103,22 @@ describe("getExpertReplyPricingValidationError", () => { ]), ).toMatch(/requires valid salary and totalPayment/); }); + + it("accepts a recycled-parts daghi price above the repair-line cap", () => { + expect( + getExpertReplyPricingValidationError([ + { + partId: 23, + typeOfDamage: TypeOfDamage.Change, + price: "۵۰,۰۰۰", + salary: "۱۰۰,۰۰۰", + totalPayment: "150000", + daghi: { + option: DaghiOption.RECYCLED_PARTS_VALUE, + price: "۱۰۰,۵۰۰,۰۰۰", + }, + }, + ]), + ).toBeNull(); + }); }); diff --git a/src/helpers/expert-reply-pricing.ts b/src/helpers/expert-reply-pricing.ts index c32d318..c60d1a0 100644 --- a/src/helpers/expert-reply-pricing.ts +++ b/src/helpers/expert-reply-pricing.ts @@ -1,4 +1,3 @@ -import { REPAIR_LINE_AMOUNT_TOMAN } from "src/constants/repair-amount-limits"; import { DaghiOption } from "src/Types&Enums/claim-request-management/daghi-option.enum"; import { TypeOfDamage } from "src/Types&Enums/claim-request-management/type-of-damage.enum"; import { parseMoneyAmountToman } from "src/utils/unicode-digits"; @@ -59,33 +58,22 @@ export function getExpertReplyPricingValidationError( part.price != null && (typeof part.price !== "string" || part.price.trim() !== ""); - const splitAmountIsValid = (amount: number | null) => - amount !== null && - (amount === 0 || - (amount >= REPAIR_LINE_AMOUNT_TOMAN.MIN && - amount <= REPAIR_LINE_AMOUNT_TOMAN.MAX)); - const totalIsValid = - totalPayment !== null && - totalPayment >= 0 && - totalPayment <= REPAIR_LINE_AMOUNT_TOMAN.MAX; - const priceIsRequired = part.typeOfDamage === TypeOfDamage.Repair; - const priceIsValid = - !hasPrice || (price !== null && splitAmountIsValid(price)); + const amountIsValid = (amount: number | null) => amount !== null; + const priceIsRequired = part.typeOfDamage === TypeOfDamage.Change; + const priceIsValid = !hasPrice || amountIsValid(price); if ( - !splitAmountIsValid(salary) || - !totalIsValid || - (priceIsRequired && !splitAmountIsValid(price)) || + !amountIsValid(salary) || + !amountIsValid(totalPayment) || + (priceIsRequired && !amountIsValid(price)) || !priceIsValid ) { - return `${label} requires valid salary and totalPayment; price is also required for '${TypeOfDamage.Repair}' damage. Price and salary may be 0; totalPayment may be 0.`; + return `${label} requires valid salary and totalPayment; price is also required for '${TypeOfDamage.Change}' damage. Price, salary, and totalPayment may be 0.`; } if ( daghi?.option === DaghiOption.RECYCLED_PARTS_VALUE && - (daghiPrice === null || - daghiPrice < REPAIR_LINE_AMOUNT_TOMAN.MIN || - daghiPrice > REPAIR_LINE_AMOUNT_TOMAN.MAX) + daghiPrice === null ) { return `${label} requires a valid daghi price when option is '${DaghiOption.RECYCLED_PARTS_VALUE}'.`; } diff --git a/src/request-management/entities/schema/partyRole.enum.ts b/src/request-management/entities/schema/partyRole.enum.ts index 82563f0..61fb12e 100644 --- a/src/request-management/entities/schema/partyRole.enum.ts +++ b/src/request-management/entities/schema/partyRole.enum.ts @@ -79,14 +79,44 @@ export class Insurance { @Prop({ type: [String] }) coverages?: string[]; - /** CAR_BODY only: mocked car-body insurance inquiry result */ + /** CAR_BODY inquiry result; raw provider data is retained in vehicle.inquiry.carBody.raw. */ @Prop({ type: MongooseSchema.Types.Mixed }) carBodyInsurance?: { policyNumber?: string; + policyId?: number; + contractId?: number; startDate?: string; endDate?: string; insurerCompany?: string; coverages?: string[]; + companyId?: number | string; + companyName?: string; + insurerName?: string; + insurerNationalCode?: string; + ownerNationalCode?: string; + ownerName?: string; + customerName?: string; + customerLastName?: string; + customerFatherName?: string; + customerMobile?: string; + customerAddress?: string; + customerPostalCode?: string; + chassisNumber?: string; + vin?: string; + motorNumber?: string; + vehicleGroup?: string; + vehicleSystem?: string; + vehicleKind?: string; + builtYear?: number; + cylinderCount?: number; + passengerCount?: number; + usage?: string; + issueDate?: string; + vehicleValue?: number; + totalPremium?: number; + noLossYearsCount?: number; + lossDocuments?: unknown[]; + hasEndorsement?: boolean; }; } export const InsuranceSchema = SchemaFactory.createForClass(Insurance); @@ -183,4 +213,4 @@ export class Party { @Prop({ type: PartyConfirmationSchema }) confirmation?: PartyConfirmation; } -export const PartySchema = SchemaFactory.createForClass(Party); \ No newline at end of file +export const PartySchema = SchemaFactory.createForClass(Party); diff --git a/src/request-management/entities/schema/request-management.schema.ts b/src/request-management/entities/schema/request-management.schema.ts index c1ad608..e6456eb 100644 --- a/src/request-management/entities/schema/request-management.schema.ts +++ b/src/request-management/entities/schema/request-management.schema.ts @@ -1,5 +1,5 @@ import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; -import { Types } from "mongoose"; +import { Schema as MongooseSchema, Types } from "mongoose"; import { ResendFirstPartyDto, ResendSecondPartyDto, @@ -79,6 +79,13 @@ export class CarBodyInsuranceDetail { @Prop({ type: [String] }) coverages?: string[]; + + /** Complete provider payload, retained for audit and future field mapping. */ + @Prop({ type: MongooseSchema.Types.Mixed }) + raw?: unknown; + + @Prop() + source?: string; } export class SecondPartyFile { diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 8016684..18ca9db 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -1513,7 +1513,7 @@ export class RequestManagementService { if (req.type === BlameRequestType.CAR_BODY && role === PartyRole.FIRST) { let carBodyInfo: any; try { - carBodyInfo = await this.sandHubService.getTejaratCarBodyInquiry( + carBodyInfo = await this.sandHubService.getCarBodyInquiry( { nationalCodeOfInsurer: body.nationalCodeOfInsurer, plate: body.plate, @@ -1523,13 +1523,13 @@ export class RequestManagementService { : inquiryOptions, ); this.recordPartyCaseInquiryStatus(req, "carBody", role, true, { - source: "TEJARAT_CAR_BODY_INQUIRY", + source: carBodyInfo.source, raw: carBodyInfo.raw, mapped: carBodyInfo.mapped, }); } catch (err: any) { this.logger.error( - `[TEJARAT] car body inquiry failed for request=${req._id}: ${err?.message || err}`, + `[CAR_BODY] inquiry failed for request=${req._id}: ${err?.message || err}`, ); this.recordPartyCaseInquiryStatus( req, @@ -1552,7 +1552,7 @@ export class RequestManagementService { party.vehicle.inquiry = { ...party.vehicle.inquiry, carBody: { - source: "TEJARAT_CAR_BODY_INQUIRY", + source: carBodyInfo.source, raw: carBodyInfo.raw, mapped: carBodyInfo.mapped, }, @@ -1562,19 +1562,35 @@ export class RequestManagementService { const m = carBodyInfo.mapped; (party.insurance as any).carBodyInsurance = { policyNumber: m.policyNumber ?? null, + policyId: m.policyId ?? null, + contractId: m.contractId ?? null, companyId: m.companyId ?? null, companyName: m.CompanyName ?? null, insurerName: m.insurerName ?? null, insurerNationalCode: m.insurerNationalCode ?? null, ownerNationalCode: m.ownerNationalCode ?? null, + ownerName: m.ownerName ?? null, + customerName: m.customerName ?? null, + customerLastName: m.customerLastName ?? null, + customerFatherName: m.customerFatherName ?? null, + customerMobile: m.customerMobile ?? null, + customerAddress: m.customerAddress ?? null, + customerPostalCode: m.customerPostalCode ?? null, chassisNumber: m.ChassisNumberField ?? null, vin: m.VinNumberField ?? null, motorNumber: m.EngineNumberField ?? null, vehicleGroup: m.vehicleGroupTitle ?? null, vehicleSystem: m.vehicleSystemTitle ?? null, + vehicleKind: m.vehicleKind ?? null, + builtYear: m.builtYear ?? null, + cylinderCount: m.cylinderCount ?? null, + passengerCount: m.passengerCount ?? null, + usage: m.usage ?? null, startDate: m.StartDate ?? null, endDate: m.EndDate ?? null, issueDate: m.IssueDate ?? null, + vehicleValue: m.vehicleValue ?? null, + totalPremium: m.totalPremium ?? null, noLossYearsCount: m.noLossYearsCount ?? null, lossDocuments: m.lossDocuments ?? [], hasEndorsement: m.hasEndorsement ?? null, @@ -3006,13 +3022,15 @@ export class RequestManagementService { sandHubReport.EndDate, }; - // For CAR_BODY type, also fetch and persist mocked car body insurance info + // For CAR_BODY type, persist the provider response and its mapped fields. if (request.type === "CAR_BODY" && partyType === "firstParty") { - const carBodyInfo = await this.mockCarBodyInsuranceInquiry( - request._id, - body.plate, - body.nationalCodeOfInsurer, + const carBodyInquiry = await this.sandHubService.getCarBodyInquiry( + { + nationalCodeOfInsurer: body.nationalCodeOfInsurer, + plate: body.plate, + } as any, ); + const carBodyInfo = carBodyInquiry.mapped as any; this.logger.log( `[CAR_BODY] Saving car body insurance data to blame file ${request._id}:`, @@ -3035,6 +3053,12 @@ export class RequestManagementService { setFields[ "firstPartyDetails.firstPartyCarBodyInsuranceDetail.coverages" ] = carBodyInfo.coverages; + setFields[ + "firstPartyDetails.firstPartyCarBodyInsuranceDetail.raw" + ] = carBodyInquiry.raw; + setFields[ + "firstPartyDetails.firstPartyCarBodyInsuranceDetail.source" + ] = carBodyInquiry.source; } // Build final update payload with proper MongoDB operators @@ -4423,40 +4447,6 @@ export class RequestManagementService { return { message: "Update saved successfully." }; } - // Mocked CAR_BODY insurance inquiry – replace with real external API later - private async mockCarBodyInsuranceInquiry( - requestId: string, - plate: any, - nationalCodeOfInsurer: string, - ): Promise<{ - policyNumber: string; - startDate: string; - endDate: string; - insurerCompany: string; - coverages: string[]; - }> { - this.logger.log( - `[CAR_BODY] Mocking car body insurance inquiry for request ${requestId} (plate=${JSON.stringify( - plate, - )}, nationalCodeOfInsurer=${nationalCodeOfInsurer})`, - ); - - const today = new Date(); - const oneYearLater = new Date( - today.getFullYear() + 1, - today.getMonth(), - today.getDate(), - ); - - return { - policyNumber: "CB-MOCK-123456", - startDate: today.toISOString().slice(0, 10), - endDate: oneYearLater.toISOString().slice(0, 10), - insurerCompany: "Mock Car Body Insurance Co.", - coverages: ["آتش‌سوزی", "سرقت", "بدنه کامل"], - }; - } - private async processResendUploads( files: { [key: string]: Express.Multer.File[] }, requestId: string, @@ -6325,17 +6315,21 @@ export class RequestManagementService { }); } - const carBodyInfo = await this.mockCarBodyInsuranceInquiry( - requestId, - firstPartyPlate.plate, - firstPartyPlate.nationalCodeOfInsurer, - ); + const carBodyInquiry = await this.sandHubService.getCarBodyInquiry({ + nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer, + plate: firstPartyPlate.plate, + } as any); + const carBodyInfo = carBodyInquiry.mapped as any; this.recordPartyCaseInquiryStatus( req, "carBody", PartyRole.FIRST, true, - carBodyInfo, + { + source: carBodyInquiry.source, + raw: carBodyInquiry.raw, + mapped: carBodyInfo, + }, ); const firstParty: any = { @@ -6364,7 +6358,14 @@ export class RequestManagementService { model: sandHubReport?.MapTypNam, type: `${sandHubReport?.UsageField || ""} / ${sandHubReport?.MapUsageName || "-"}`, isNew: firstPartyPlate.isNewCar, - inquiry: sandHubReport, + inquiry: { + ...sandHubReport, + carBody: { + source: carBodyInquiry.source, + raw: carBodyInquiry.raw, + mapped: carBodyInfo, + }, + }, }, insurance: { policyNumber: @@ -6381,6 +6382,8 @@ export class RequestManagementService { endDate: carBodyInfo.endDate, insurerCompany: carBodyInfo.insurerCompany, coverages: carBodyInfo.coverages, + raw: carBodyInquiry.raw, + source: carBodyInquiry.source, }, }, statement: { @@ -7585,11 +7588,11 @@ export class RequestManagementService { }; // CAR_BODY specific insurance info - const carBodyInfo = await this.mockCarBodyInsuranceInquiry( - request._id.toString(), - firstPartyPlate.plate, - firstPartyPlate.nationalCodeOfInsurer, - ); + const carBodyInquiry = await this.sandHubService.getCarBodyInquiry({ + nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer, + plate: firstPartyPlate.plate, + } as any); + const carBodyInfo = carBodyInquiry.mapped as any; firstPartyDetails.firstPartyCarBodyInsuranceDetail = { policyNumber: carBodyInfo.policyNumber, @@ -7597,6 +7600,8 @@ export class RequestManagementService { endDate: carBodyInfo.endDate, insurerCompany: carBodyInfo.insurerCompany, coverages: carBodyInfo.coverages, + raw: carBodyInquiry.raw, + source: carBodyInquiry.source, }; // Update the firstPartyDetails in the payload with all plate data @@ -8808,7 +8813,7 @@ export class RequestManagementService { partyRole === PartyRole.FIRST ) { try { - const carBodyInfo = await this.sandHubService.getTejaratCarBodyInquiry( + const carBodyInfo = await this.sandHubService.getCarBodyInquiry( { nationalCodeOfInsurer: partyData.nationalCodeOfInsurer, plate: partyData.plate as any, @@ -8816,14 +8821,14 @@ export class RequestManagementService { clientId ? { clientId } : undefined, ); this.recordPartyCaseInquiryStatus(req, "carBody", partyRole, true, { - source: "TEJARAT_CAR_BODY_INQUIRY", + source: carBodyInfo.source, raw: carBodyInfo.raw, mapped: carBodyInfo.mapped, }); party.vehicle.inquiry = { ...party.vehicle.inquiry, carBody: { - source: "TEJARAT_CAR_BODY_INQUIRY", + source: carBodyInfo.source, raw: carBodyInfo.raw, mapped: carBodyInfo.mapped, }, @@ -8831,8 +8836,38 @@ export class RequestManagementService { const m = carBodyInfo.mapped; (party.insurance as any).carBodyInsurance = { policyNumber: m.policyNumber ?? null, + policyId: m.policyId ?? null, + contractId: m.contractId ?? null, companyId: m.companyId ?? null, companyName: m.CompanyName ?? null, + insurerName: m.insurerName ?? null, + insurerNationalCode: m.insurerNationalCode ?? null, + ownerNationalCode: m.ownerNationalCode ?? null, + ownerName: m.ownerName ?? null, + customerName: m.customerName ?? null, + customerLastName: m.customerLastName ?? null, + customerFatherName: m.customerFatherName ?? null, + customerMobile: m.customerMobile ?? null, + customerAddress: m.customerAddress ?? null, + customerPostalCode: m.customerPostalCode ?? null, + chassisNumber: m.ChassisNumberField ?? null, + vin: m.VinNumberField ?? null, + motorNumber: m.EngineNumberField ?? null, + vehicleGroup: m.vehicleGroupTitle ?? null, + vehicleSystem: m.vehicleSystemTitle ?? null, + vehicleKind: m.vehicleKind ?? null, + builtYear: m.builtYear ?? null, + cylinderCount: m.cylinderCount ?? null, + passengerCount: m.passengerCount ?? null, + usage: m.usage ?? null, + startDate: m.StartDate ?? null, + endDate: m.EndDate ?? null, + issueDate: m.IssueDate ?? null, + vehicleValue: m.vehicleValue ?? null, + totalPremium: m.totalPremium ?? null, + noLossYearsCount: m.noLossYearsCount ?? null, + lossDocuments: m.lossDocuments ?? [], + hasEndorsement: m.hasEndorsement ?? null, }; const cbCompanyCode = m.companyId ?? m.CompanyCode; const cbCompanyName = m.CompanyName ?? m.companyPersianName; @@ -9633,7 +9668,7 @@ export class RequestManagementService { partyRole === PartyRole.FIRST ) { try { - const carBodyInfo = await this.sandHubService.getTejaratCarBodyInquiry( + const carBodyInfo = await this.sandHubService.getCarBodyInquiry( { nationalCodeOfInsurer: partyData.nationalCodeOfInsurer, plate: partyData.vin as any, // VIN used as identifier for CAR_BODY @@ -9641,14 +9676,14 @@ export class RequestManagementService { clientId ? { clientId } : undefined, ); this.recordPartyCaseInquiryStatus(req, "carBody", partyRole, true, { - source: "TEJARAT_CAR_BODY_VIN_INQUIRY", + source: carBodyInfo.source, raw: carBodyInfo.raw, mapped: carBodyInfo.mapped, }); party.vehicle.inquiry = { ...party.vehicle.inquiry, carBody: { - source: "TEJARAT_CAR_BODY_VIN_INQUIRY", + source: carBodyInfo.source, raw: carBodyInfo.raw, mapped: carBodyInfo.mapped, }, @@ -9656,8 +9691,38 @@ export class RequestManagementService { const m = carBodyInfo.mapped; (party.insurance as any).carBodyInsurance = { policyNumber: m.policyNumber ?? null, + policyId: m.policyId ?? null, + contractId: m.contractId ?? null, companyId: m.companyId ?? null, companyName: m.CompanyName ?? null, + insurerName: m.insurerName ?? null, + insurerNationalCode: m.insurerNationalCode ?? null, + ownerNationalCode: m.ownerNationalCode ?? null, + ownerName: m.ownerName ?? null, + customerName: m.customerName ?? null, + customerLastName: m.customerLastName ?? null, + customerFatherName: m.customerFatherName ?? null, + customerMobile: m.customerMobile ?? null, + customerAddress: m.customerAddress ?? null, + customerPostalCode: m.customerPostalCode ?? null, + chassisNumber: m.ChassisNumberField ?? null, + vin: m.VinNumberField ?? null, + motorNumber: m.EngineNumberField ?? null, + vehicleGroup: m.vehicleGroupTitle ?? null, + vehicleSystem: m.vehicleSystemTitle ?? null, + vehicleKind: m.vehicleKind ?? null, + builtYear: m.builtYear ?? null, + cylinderCount: m.cylinderCount ?? null, + passengerCount: m.passengerCount ?? null, + usage: m.usage ?? null, + startDate: m.StartDate ?? null, + endDate: m.EndDate ?? null, + issueDate: m.IssueDate ?? null, + vehicleValue: m.vehicleValue ?? null, + totalPremium: m.totalPremium ?? null, + noLossYearsCount: m.noLossYearsCount ?? null, + lossDocuments: m.lossDocuments ?? [], + hasEndorsement: m.hasEndorsement ?? null, }; const cbCompanyCode = m.companyId ?? m.CompanyCode; const cbCompanyName = m.CompanyName ?? m.companyPersianName; diff --git a/src/sand-hub/esg-car-body-inquiry.mapper.spec.ts b/src/sand-hub/esg-car-body-inquiry.mapper.spec.ts new file mode 100644 index 0000000..c9cce31 --- /dev/null +++ b/src/sand-hub/esg-car-body-inquiry.mapper.spec.ts @@ -0,0 +1,65 @@ +import { mapEsgCarBodyPolicyToInquiry } from "./esg-car-body-inquiry.mapper"; + +describe("mapEsgCarBodyPolicyToInquiry", () => { + it("maps the processed CAR_BODY lookup while retaining useful policy, customer, and vehicle fields", () => { + const mapped = mapEsgCarBodyPolicyToInquiry({ + product: "car-body", + insuranceLine: "CAR_BODY", + insuranceLineId: 4, + policyId: 15292336, + policy: { + CINumber: "70019846985", + ContractId: 12525, + BeginDate: "1405/05/18", + EndDate: "1406/05/18", + PolicyIssuDate: "1405/05/18", + VehicleValue: 18510000000, + TotalPremium: 73299600, + EndoNo: 0, + }, + customer: { + Name: "سهيل", + LastName: "حاجي زاده", + NationalCode: "4311402422", + Mobile: "09226187419", + }, + vehicle: { + BuiltYear: 1403, + ChassisNo: "NAAR03HFFRDE07024", + MotorNo: "172B0222818", + VIN: "NAAR03HFFRDE07024", + vehicleKind: { + Caption: "پژو 207I", + VehicleSystemCaption: "پژو", + CylinderCount: 4, + PassengerCount: 5, + }, + used: { Caption: "شخصي" }, + plaque: { + leftTwoDigits: "29", + serialLetter: "د", + threeDigits: "782", + rightTwoDigits: "60", + }, + }, + }); + + expect(mapped).toMatchObject({ + policyNumber: "70019846985", + policyId: 15292336, + contractId: 12525, + insurerName: null, + insurerNationalCode: null, + ownerName: "سهيل حاجي زاده", + ownerNationalCode: "4311402422", + VinNumberField: "NAAR03HFFRDE07024", + MapTypNam: "پژو 207I", + vehicleValue: 18510000000, + totalPremium: 73299600, + platePartOne: "29", + plateLetterTitle: "د", + platePartThree: "782", + plateSerialNumber: "60", + }); + }); +}); diff --git a/src/sand-hub/esg-car-body-inquiry.mapper.ts b/src/sand-hub/esg-car-body-inquiry.mapper.ts new file mode 100644 index 0000000..1a3a720 --- /dev/null +++ b/src/sand-hub/esg-car-body-inquiry.mapper.ts @@ -0,0 +1,79 @@ +/** + * Converts the processed CAR_BODY policy returned by the ESG/Fanavaran lookup + * into the same mapped contract used by the existing car-body inquiry flow. + * The original response is deliberately kept separately as `raw` by the + * caller, so newly exposed provider fields are never discarded. + */ +export function mapEsgCarBodyPolicyToInquiry( + response: Record, +): Record { + const policy = response.policy ?? {}; + const customer = response.customer ?? {}; + const vehicle = response.vehicle ?? {}; + const vehicleKind = vehicle.vehicleKind ?? {}; + const usage = vehicle.used ?? {}; + const plaque = vehicle.plaque ?? {}; + + const ownerName = + [customer.Name, customer.LastName] + .filter((value) => typeof value === "string" && value.trim()) + .join(" ") || null; + + return { + policyNumber: policy.CINumber ?? policy.PolicyNo ?? null, + policyId: response.policyId ?? policy.PolicyId ?? null, + contractId: policy.ContractId ?? null, + insuranceLine: response.insuranceLine ?? "CAR_BODY", + insuranceLineId: response.insuranceLineId ?? 4, + + // Provider responses do not currently include an insurer company identity. + companyId: policy.CompanyId ?? null, + CompanyCode: policy.CompanyId ?? null, + CompanyName: policy.CompanyName ?? null, + + // The lookup's customer is the policyholder/owner, not the insurer. + insurerName: null, + InsuranceFullName: null, + insurerNationalCode: null, + ownerNationalCode: customer.NationalCode ?? null, + ownerName, + customerName: customer.Name ?? null, + customerLastName: customer.LastName ?? null, + customerFatherName: customer.FatherName ?? null, + customerMobile: customer.Mobile ?? null, + customerAddress: customer.Address ?? null, + customerPostalCode: customer.PostalCode ?? null, + + motorNumber: vehicle.MotorNo ?? null, + EngineNumberField: vehicle.MotorNo ?? null, + chassisNumber: vehicle.ChassisNo ?? null, + ChassisNumberField: vehicle.ChassisNo ?? null, + vin: vehicle.VIN ?? vehicle.ChassisNo ?? null, + VinNumberField: vehicle.VIN ?? vehicle.ChassisNo ?? null, + vehicleGroupTitle: vehicleKind.VehicleCategoryCaption ?? null, + vehicleSystemTitle: vehicleKind.VehicleSystemCaption ?? null, + vehicleKind: vehicleKind.Caption ?? null, + MapTypNam: vehicleKind.Caption ?? vehicleKind.VehicleSystemCaption ?? null, + builtYear: vehicle.BuiltYear ?? null, + cylinderCount: vehicleKind.CylinderCount ?? null, + passengerCount: vehicleKind.PassengerCount ?? null, + usage: usage.Caption ?? null, + + IssueDate: policy.PolicyIssuDate ?? policy.IssuDate ?? null, + StartDate: policy.BeginDate ?? null, + EndDate: policy.EndDate ?? null, + vehicleValue: policy.VehicleValue ?? null, + totalPremium: policy.TotalPremium ?? null, + noLossYearsCount: + policy.YearsCountWithoutLoss ?? + policy.AdditionalCovYearsCountWithoutLoss ?? + null, + lossDocuments: [], + hasEndorsement: Number(policy.EndoNo ?? 0) > 0, + + platePartOne: plaque.leftTwoDigits ?? vehicle.PlaqueLeftNo ?? null, + plateLetterTitle: plaque.serialLetter ?? null, + platePartThree: plaque.threeDigits ?? vehicle.PlaqueRightNo ?? null, + plateSerialNumber: plaque.rightTwoDigits ?? vehicle.PlaqueSerial ?? null, + }; +} diff --git a/src/sand-hub/sand-hub.module.ts b/src/sand-hub/sand-hub.module.ts index 2385780..f02c30b 100644 --- a/src/sand-hub/sand-hub.module.ts +++ b/src/sand-hub/sand-hub.module.ts @@ -7,6 +7,7 @@ import { ClientModule } from "src/client/client.module"; import { SystemSettingsModule } from "src/system-settings/system-settings.module"; import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normalizer.module"; import { OfflineInquiryModule } from "src/offline-inquiry/offline-inquiry.module"; +import { LookupsModule } from "src/lookups/lookups.module"; import { SandHubDbService } from "src/sand-hub/entity/db-service/sand-hub.db.service"; import { SandHubModel, SandHubSchema } from "./entity/schema/sand-hub.schema"; import { SandHubService } from "./sand-hub.service"; @@ -22,6 +23,7 @@ import { SandHubService } from "./sand-hub.service"; PlateNormalizerModule, ClientModule, OfflineInquiryModule, + LookupsModule, MongooseModule.forFeature([ { name: SandHubModel.name, schema: SandHubSchema }, ]), diff --git a/src/sand-hub/sand-hub.service.spec.ts b/src/sand-hub/sand-hub.service.spec.ts index 284b02e..915623a 100644 --- a/src/sand-hub/sand-hub.service.spec.ts +++ b/src/sand-hub/sand-hub.service.spec.ts @@ -12,6 +12,9 @@ describe("SandHubService inquiry mocks", () => { const plateNormalizer = { normalizePlateText: (text: string) => text, }; + const lookupsService = { + findLastProcessedCarPolicy: jest.fn(), + }; let service: SandHubService; @@ -37,6 +40,7 @@ describe("SandHubService inquiry mocks", () => { { findPlateInquiry: jest.fn().mockResolvedValue(null), } as any, + lookupsService as any, ); externalInquirySettings.isInquiryLive.mockResolvedValue(false); externalInquirySettings.getMockCompanyContext.mockResolvedValue({ @@ -64,6 +68,77 @@ describe("SandHubService inquiry mocks", () => { expect(result.mapped.platePartThree).toBe(498); }); + it("uses the processed ESG CAR_BODY lookup and preserves it as raw data", async () => { + process.env.CLIENT_ID = "8"; + externalInquirySettings.isInquiryLive.mockResolvedValue(true); + lookupsService.findLastProcessedCarPolicy.mockResolvedValue({ + product: "car-body", + insuranceLine: "CAR_BODY", + insuranceLineId: 4, + policyId: 15292336, + policy: { CINumber: "70019846985", BeginDate: "1405/05/18" }, + customer: { Name: "سهيل", LastName: "حاجي زاده" }, + vehicle: { + VIN: "NAAR03HFFRDE07024", + plaque: { + leftTwoDigits: "16", + serialLetter: "ب", + threeDigits: "498", + rightTwoDigits: "60", + }, + }, + }); + + const result = await service.getCarBodyInquiry(userDetail); + + expect(lookupsService.findLastProcessedCarPolicy).toHaveBeenCalledWith( + "car-body", + { + nationalCode: "1234567890", + plaqueLeft: "16", + plaqueLetter: "12", + plaqueRight: "498", + plaqueSerial: "60", + }, + ); + expect(result.source).toBe("ESG_CAR_BODY_INQUIRY"); + expect(result.raw.policyId).toBe(15292336); + expect(result.mapped.policyNumber).toBe("70019846985"); + expect(httpService.post).not.toHaveBeenCalled(); + }); + + it("keeps ESG car-body inquiry in mock mode when the per-client toggle is off", async () => { + process.env.CLIENT_ID = "8"; + + const result = await service.getCarBodyInquiry(userDetail); + + expect(lookupsService.findLastProcessedCarPolicy).not.toHaveBeenCalled(); + expect(result.source).toBe("ESG_CAR_BODY_INQUIRY"); + expect(result.raw?.isSuccess).toBe(true); + expect(httpService.post).not.toHaveBeenCalled(); + }); + + it("uses the VIN lookup and VIN audit source for ESG car-body VIN inquiries", async () => { + process.env.CLIENT_ID = "8"; + externalInquirySettings.isInquiryLive.mockResolvedValue(true); + lookupsService.findLastProcessedCarPolicy.mockResolvedValue({ + policy: { CINumber: "70019846985" }, + customer: {}, + vehicle: {}, + }); + + const result = await service.getCarBodyInquiry({ + nationalCodeOfInsurer: "1234567890", + plate: "NAAR03HFFRDE07024", + }); + + expect(lookupsService.findLastProcessedCarPolicy).toHaveBeenCalledWith( + "car-body", + { nationalCode: "1234567890", vin: "NAAR03HFFRDE07024" }, + ); + expect(result.source).toBe("ESG_CAR_BODY_VIN_INQUIRY"); + }); + it("uses car-body mock shape in Tejarat helper when inquiry is off", async () => { const raw = await (service as any).makeTejaratRequest( "http://example/block-inquiry-tejarat/badane", diff --git a/src/sand-hub/sand-hub.service.ts b/src/sand-hub/sand-hub.service.ts index 6f90fb2..28b2f8a 100644 --- a/src/sand-hub/sand-hub.service.ts +++ b/src/sand-hub/sand-hub.service.ts @@ -16,10 +16,17 @@ import { PlateNormalizerService } from "src/utils/plate-normalizer/plate-normali import type { ExternalInquiryType } from "src/common/types/external-inquiry.types"; import type { MockInquiryCompanyContext } from "src/common/types/external-inquiry.types"; import { OfflineInquiryService } from "src/offline-inquiry/offline-inquiry.service"; +import { LookupsService } from "src/lookups/lookups.service"; import { resolveFanavaranClientKey } from "src/core/config/fanavaran-client.config"; import { SandHubDetailDto, SandHubInquiryOptions } from "./dto/sand-hub.dto"; import { jalaliToGregorianDate } from "src/helpers/date-jalali"; import { firstValueFrom } from "rxjs"; +import { mapEsgCarBodyPolicyToInquiry } from "./esg-car-body-inquiry.mapper"; +import type { Plates } from "src/Types&Enums/plate.interface"; + +type CarBodyInquiryDetail = Omit & { + plate: Plates | string; +}; @Injectable() export class SandHubService { @@ -44,6 +51,7 @@ export class SandHubService { private readonly externalInquirySettings: ExternalInquirySettingsService, private readonly plateNormalizer: PlateNormalizerService, private readonly offlineInquiryService: OfflineInquiryService, + private readonly lookupsService: LookupsService, ) {} private clientRefFrom(options?: SandHubInquiryOptions): string | undefined { @@ -833,6 +841,81 @@ export class SandHubService { return await this.sandHubDbService.findOneBySandHubId(sandHubId); } + /** + * CAR_BODY is the only ESG product that uses the processed Fanavaran lookup. + * THIRD_PARTY remains on its established provider-specific inquiry path. + */ + async getCarBodyInquiry( + userDetail: CarBodyInquiryDetail, + options?: SandHubInquiryOptions, + ): Promise<{ + source: + | "ESG_CAR_BODY_INQUIRY" + | "ESG_CAR_BODY_VIN_INQUIRY" + | "TEJARAT_CAR_BODY_INQUIRY" + | "TEJARAT_CAR_BODY_VIN_INQUIRY"; + raw: any; + mapped: Record; + }> { + if (!this.shouldUseEsgInquiryProvider()) { + const result = await this.getTejaratCarBodyInquiry( + userDetail as SandHubDetailDto, + options, + ); + return { + source: + typeof userDetail.plate === "string" + ? "TEJARAT_CAR_BODY_VIN_INQUIRY" + : "TEJARAT_CAR_BODY_INQUIRY", + ...result, + }; + } + + const live = await this.isInquiryLive("carBodyPlate", options); + if (!live) { + // Reuse the established local mock contract without sending an HTTP request. + const result = await this.getTejaratCarBodyInquiry( + userDetail as SandHubDetailDto, + options, + ); + return { + source: + typeof userDetail.plate === "string" + ? "ESG_CAR_BODY_VIN_INQUIRY" + : "ESG_CAR_BODY_INQUIRY", + ...result, + }; + } + + const plateOrVin = userDetail.plate; + const query = + typeof plateOrVin === "string" + ? { + nationalCode: String(userDetail.nationalCodeOfInsurer), + vin: plateOrVin, + } + : { + nationalCode: String(userDetail.nationalCodeOfInsurer), + plaqueLeft: String(plateOrVin.leftDigits), + plaqueLetter: String(plateOrVin.centerAlphabet), + plaqueRight: String(plateOrVin.centerDigits), + plaqueSerial: String(plateOrVin.ir), + }; + const raw = await this.lookupsService.findLastProcessedCarPolicy( + "car-body", + query, + ); + + return { + source: + typeof plateOrVin === "string" + ? "ESG_CAR_BODY_VIN_INQUIRY" + : "ESG_CAR_BODY_INQUIRY", + raw, + mapped: mapEsgCarBodyPolicyToInquiry(raw), + }; + } + async getTejaratCarBodyInquiry( userDetail: SandHubDetailDto, options?: SandHubInquiryOptions,