diff --git a/docs/inquiry-participants-proposal.fa.md b/docs/inquiry-participants-proposal.fa.md index 382b58c..7185953 100644 --- a/docs/inquiry-participants-proposal.fa.md +++ b/docs/inquiry-participants-proposal.fa.md @@ -34,6 +34,7 @@ "driver": { "nationalCode": "0012345678", "birthday": "1370/01/01", + "hasDrivingLicense": true, "licenseNumber": "123456789", "licenseType": "1" }, @@ -48,6 +49,8 @@ فیلد `carBodyPolicyholder` برای `THIRD_PARTY` مجاز نیست و برای `CAR_BODY` الزامی است. هر نقش باید فقط یکی از دو حالت «اطلاعات شخص» یا `sameAs` را داشته باشد. بک‌اند این ورودی را به فهرست اشخاص یکتا و اتصال نقش‌ها به آن‌ها تبدیل می‌کند. +فقط برای طرف زیان‌دیده در پرونده `THIRD_PARTY` می‌توان نامشخص بودن بیمه‌گذار را به‌صورت صریح با `"thirdPartyPolicyholder": { "unknown": true }` ارسال کرد. این حالت برای طرف مقصر پذیرفته نمی‌شود. در مسیر پلاک، استعلام بیمه شخص ثالث ــ چون به هویت بیمه‌گذار نیاز دارد ــ به‌شکل قابل‌ممیزی «عمداً اجرا نشد» ثبت می‌شود؛ استعلام مبتنی بر VIN همچنان بدون حدس‌زدن هویت قابل اجرا است. اعتبارسنجی راننده و مالک و استعلام‌های هویت، مالکیت و گواهینامه همچنان انجام می‌شوند. + برای راننده، `hasDrivingLicense` الزامی است. اگر مقدار آن `true` باشد، هر دو فیلد `licenseNumber` و `licenseType` نیز الزامی هستند؛ اگر مقدار آن `false` باشد، استعلام گواهینامه عمداً اجرا نمی‌شود. ## انتقال مالکیت اخیر و پلاک قبلی diff --git a/docs/inquiry-participants-proposal.md b/docs/inquiry-participants-proposal.md index 723a264..594e498 100644 --- a/docs/inquiry-participants-proposal.md +++ b/docs/inquiry-participants-proposal.md @@ -34,6 +34,7 @@ Use explicit role references instead: "driver": { "nationalCode": "0012345678", "birthday": "1370/01/01", + "hasDrivingLicense": true, "licenseNumber": "123456789", "licenseType": "1" }, @@ -48,6 +49,8 @@ Use explicit role references instead: `carBodyPolicyholder` is forbidden for `THIRD_PARTY` and required for `CAR_BODY`. A role is either a new person's identity or a `sameAs` reference, never both. The backend should normalize this input into unique participants plus role assignments. +For the damaged party of a `THIRD_PARTY` case only, the policyholder can be explicitly unresolved with `"thirdPartyPolicyholder": { "unknown": true }`. This is not accepted for the guilty party. Plate-based third-party-policy inquiry is recorded as intentionally skipped because it requires that identity; VIN-based lookup can still run without guessing a person. The driver, owner, personal, ownership, and licence rules remain enforced. + For Driver, `hasDrivingLicense` is required. When it is `true`, both `licenseNumber` and `licenseType` are required; when it is `false`, the licence inquiry is intentionally skipped. ## Recent ownership transfer and previous plate diff --git a/src/request-management/dto/inquiry-participants.dto.ts b/src/common/dto/inquiry-participants.dto.ts similarity index 89% rename from src/request-management/dto/inquiry-participants.dto.ts rename to src/common/dto/inquiry-participants.dto.ts index ae80299..d9969b3 100644 --- a/src/request-management/dto/inquiry-participants.dto.ts +++ b/src/common/dto/inquiry-participants.dto.ts @@ -50,6 +50,14 @@ export class InquiryParticipantInputDto { @IsEnum(InquiryParticipantRole) sameAs?: InquiryParticipantRole; + @ApiPropertyOptional({ + description: + "Only for the damaged party's third-party policyholder when the identity is genuinely unknown.", + }) + @IsOptional() + @IsBoolean() + unknown?: boolean; + @ApiPropertyOptional({ example: "0012345678" }) @IsOptional() @IsString() @@ -88,9 +96,13 @@ export class InquiryParticipantInputDto { } export class InquiryVehicleInputDto { - @ApiProperty({ enum: VehicleRegistrationState }) + @ApiPropertyOptional({ + enum: VehicleRegistrationState, + default: VehicleRegistrationState.CURRENT, + }) + @IsOptional() @IsEnum(VehicleRegistrationState) - registrationState: VehicleRegistrationState; + registrationState?: VehicleRegistrationState; @ApiProperty({ type: InquiryPlateDto }) @ValidateNested() diff --git a/src/profile/dto/user/AddPlateDto.ts b/src/profile/dto/user/AddPlateDto.ts index 1fc2ac6..3e9f8bd 100644 --- a/src/profile/dto/user/AddPlateDto.ts +++ b/src/profile/dto/user/AddPlateDto.ts @@ -1,7 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { PlatesDto } from "src/plates/dto/plate.dto"; import { Plates } from "src/Types&Enums/plate.interface"; -import { InquiryParticipantFieldsDto } from "src/request-management/dto/inquiry-participants.dto"; +import { InquiryParticipantFieldsDto } from "src/common/dto/inquiry-participants.dto"; export class AddPlateDto extends InquiryParticipantFieldsDto { plateId: string; diff --git a/src/request-management/dto/create-request-management.dto.ts b/src/request-management/dto/create-request-management.dto.ts index 6de2d45..6d80bef 100644 --- a/src/request-management/dto/create-request-management.dto.ts +++ b/src/request-management/dto/create-request-management.dto.ts @@ -16,7 +16,7 @@ import { } from "src/Types&Enums/blame-request-management/accident-conditions.enum"; import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; import { IsEnum } from "class-validator"; -import { InquiryParticipantFieldsDto } from "./inquiry-participants.dto"; +import { InquiryParticipantFieldsDto } from "src/common/dto/inquiry-participants.dto"; export class InitialFormDto { @ApiProperty({ required: false, default: false }) diff --git a/src/request-management/dto/run-call-center-inquiry-v6.dto.ts b/src/request-management/dto/run-call-center-inquiry-v6.dto.ts index 512f0a1..0b331f3 100644 --- a/src/request-management/dto/run-call-center-inquiry-v6.dto.ts +++ b/src/request-management/dto/run-call-center-inquiry-v6.dto.ts @@ -9,7 +9,7 @@ import { ValidateNested, } from "class-validator"; import { Type } from "class-transformer"; -import { InquiryParticipantFieldsDto } from "./inquiry-participants.dto"; +import { InquiryParticipantFieldsDto } from "src/common/dto/inquiry-participants.dto"; class PlateV6Dto { @ApiProperty({ example: "44", description: "Left two digits" }) diff --git a/src/request-management/dto/run-inquiries-v3.dto.ts b/src/request-management/dto/run-inquiries-v3.dto.ts index c658b57..71e0922 100644 --- a/src/request-management/dto/run-inquiries-v3.dto.ts +++ b/src/request-management/dto/run-inquiries-v3.dto.ts @@ -9,7 +9,7 @@ import { ValidateNested, } from "class-validator"; import { Type } from "class-transformer"; -import { InquiryParticipantFieldsDto } from "./inquiry-participants.dto"; +import { InquiryParticipantFieldsDto } from "src/common/dto/inquiry-participants.dto"; class PlateV3Dto { @ApiProperty({ example: "44", description: "Left two digits" }) diff --git a/src/request-management/entities/schema/partyRole.enum.ts b/src/request-management/entities/schema/partyRole.enum.ts index 6a1ea5c..2915653 100644 --- a/src/request-management/entities/schema/partyRole.enum.ts +++ b/src/request-management/entities/schema/partyRole.enum.ts @@ -66,6 +66,7 @@ export class InquiryParticipant { @Prop({ type: Boolean }) hasDrivingLicense?: boolean; @Prop() licenseNumber?: string; @Prop() licenseType?: string; + @Prop({ type: Boolean }) unknown?: boolean; } export const InquiryParticipantSchema = SchemaFactory.createForClass(InquiryParticipant); diff --git a/src/request-management/inquiry-participant-resolver.spec.ts b/src/request-management/inquiry-participant-resolver.spec.ts index 7e08e2b..062090d 100644 --- a/src/request-management/inquiry-participant-resolver.spec.ts +++ b/src/request-management/inquiry-participant-resolver.spec.ts @@ -3,9 +3,10 @@ import { BlameRequestType } from "src/Types&Enums/blame-request-management/blame import { InquiryParticipantRole, VehicleRegistrationState, -} from "./dto/inquiry-participants.dto"; +} from "src/common/dto/inquiry-participants.dto"; import { assertPreviousPlateInquiryMatchesVin, + isMappedPolicyCurrent, normalizeInquirySubmission, participantForRole, resolveInquiryParticipants, @@ -52,6 +53,19 @@ describe("inquiry participant resolver", () => { ).toThrow(BadRequestException); }); + it("defaults an omitted registration state to CURRENT", () => { + expect( + resolveInquiryVehicle({ + currentPlate: { + leftDigits: "44", + centerAlphabet: "ب", + centerDigits: "111", + ir: "22", + }, + }).registrationState, + ).toBe(VehicleRegistrationState.CURRENT); + }); + it("keeps legacy driver/policyholder payloads working during rollout", () => { const resolved = resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, { nationalCodeOfDriver: "0012345678", @@ -261,4 +275,177 @@ describe("inquiry participant resolver", () => { }), ).toThrow(BadRequestException); }); + + it("rejects sameAs together with any person-specific fields", () => { + expect(() => + resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, { + driver: { + nationalCode: "0012345678", + birthday: "1370/01/01", + hasDrivingLicense: false, + }, + vehicleOwner: { + sameAs: InquiryParticipantRole.DRIVER, + fullName: "Duplicate details must not be silently discarded", + }, + thirdPartyPolicyholder: { + sameAs: InquiryParticipantRole.DRIVER, + }, + }), + ).toThrow(BadRequestException); + }); + + it("rejects conflicting legacy and structured current plates", () => { + expect(() => + normalizeInquirySubmission(BlameRequestType.THIRD_PARTY, { + plate: { + leftDigits: "44", + centerAlphabet: "ب", + centerDigits: "111", + ir: "22", + }, + vehicle: { + registrationState: VehicleRegistrationState.CURRENT, + currentPlate: { + leftDigits: "55", + centerAlphabet: "ج", + centerDigits: "222", + ir: "33", + }, + }, + driver: { + nationalCode: "0012345678", + birthday: "1370/01/01", + hasDrivingLicense: false, + }, + vehicleOwner: { sameAs: InquiryParticipantRole.DRIVER }, + thirdPartyPolicyholder: { + sameAs: InquiryParticipantRole.DRIVER, + }, + }), + ).toThrow(BadRequestException); + }); + + it("treats an expired policy result as stale", () => { + expect(isMappedPolicyCurrent({ EndDate: "1404/01/01" }, "2026-09-13")).toBe( + false, + ); + expect(isMappedPolicyCurrent({ EndDate: "1406/01/01" }, "2026-09-13")).toBe( + true, + ); + }); + + it("falls back from a stale current policy to a current previous-plate policy", async () => { + const currentPlate = { + leftDigits: "44", + centerAlphabet: "ب", + centerDigits: "111", + ir: "22", + }; + const previousPlate = { + leftDigits: "55", + centerAlphabet: "ج", + centerDigits: "222", + ir: "33", + }; + const query = jest + .fn() + .mockResolvedValueOnce({ + mapped: { CompanyName: "پارسیان", EndDate: "1404/01/01" }, + }) + .mockResolvedValueOnce({ + mapped: { + CompanyName: "پارسیان", + EndDate: "1406/01/01", + VinNumberField: "NAAM01E15HK123456", + }, + }); + + const result = await runPlateInquiryWithFallback<{ + mapped: Record; + }>({ + vehicle: resolveInquiryVehicle({ + registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED, + currentPlate, + previousPlate, + vin: "NAAM01E15HK123456", + }), + fallbackCurrentPlate: currentPlate, + query, + isUsable: (value) => + !!value.mapped.CompanyName && + isMappedPolicyCurrent(value.mapped, "2026-09-13"), + mappedValue: (value) => value.mapped, + }); + + expect(result.plateKind).toBe("PREVIOUS"); + expect(result.attempts[0]).toMatchObject({ + plateKind: "CURRENT", + succeeded: true, + usable: false, + }); + }); + + it("rejects the result and retains audit attempts when every plate is unusable", async () => { + const currentPlate = { + leftDigits: "44", + centerAlphabet: "ب", + centerDigits: "111", + ir: "22", + }; + const previousPlate = { + leftDigits: "55", + centerAlphabet: "ج", + centerDigits: "222", + ir: "33", + }; + + await expect( + runPlateInquiryWithFallback({ + vehicle: resolveInquiryVehicle({ + registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED, + currentPlate, + previousPlate, + vin: "NAAM01E15HK123456", + }), + fallbackCurrentPlate: currentPlate, + query: async () => ({ mapped: { CompanyName: "پارسیان" } }), + isUsable: () => false, + mappedValue: (value) => value.mapped, + }), + ).rejects.toMatchObject({ + attempts: [ + { plateKind: "CURRENT", succeeded: true, usable: false }, + { plateKind: "PREVIOUS", succeeded: true, usable: false }, + ], + }); + }); + + it("allows an explicitly unknown policyholder only for a damaged-party submission", () => { + const input = { + driver: { + nationalCode: "0012345678", + birthday: "1370/01/01", + hasDrivingLicense: false, + }, + vehicleOwner: { sameAs: InquiryParticipantRole.DRIVER }, + thirdPartyPolicyholder: { unknown: true }, + }; + + expect(() => + resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, input), + ).toThrow(BadRequestException); + + const resolved = resolveInquiryParticipants( + BlameRequestType.THIRD_PARTY, + input, + { allowUnknownThirdPartyPolicyholder: true }, + ); + expect( + participantForRole( + resolved, + InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER, + ), + ).toMatchObject({ unknown: true }); + }); }); diff --git a/src/request-management/inquiry-participant-resolver.ts b/src/request-management/inquiry-participant-resolver.ts index f1f022e..0fd7303 100644 --- a/src/request-management/inquiry-participant-resolver.ts +++ b/src/request-management/inquiry-participant-resolver.ts @@ -1,12 +1,14 @@ import { BadRequestException } from "@nestjs/common"; import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; +import { jalaliToGregorianDate } from "src/helpers/date-jalali"; +import { gregorianDateInIran } from "src/helpers/iran-datetime"; import { InquiryParticipantFieldsDto, InquiryParticipantInputDto, InquiryParticipantRole, InquiryVehicleInputDto, VehicleRegistrationState, -} from "./dto/inquiry-participants.dto"; +} from "src/common/dto/inquiry-participants.dto"; export interface ResolvedInquiryParticipant { participantId: string; @@ -17,6 +19,11 @@ export interface ResolvedInquiryParticipant { hasDrivingLicense?: boolean; licenseNumber?: string; licenseType?: string; + unknown?: boolean; +} + +export interface InquiryParticipantResolutionOptions { + allowUnknownThirdPartyPolicyholder?: boolean; } export interface InquiryParticipantRoleAssignments { @@ -49,9 +56,13 @@ export interface NormalizedInquirySubmission> { vehicleOwner?: ResolvedInquiryParticipant; thirdPartyPolicyholder: ResolvedInquiryParticipant; carBodyPolicyholder?: ResolvedInquiryParticipant; - vehicle?: InquiryVehicleInputDto; + vehicle?: ResolvedInquiryVehicle; } +export type ResolvedInquiryVehicle = InquiryVehicleInputDto & { + registrationState: VehicleRegistrationState; +}; + export function participantForRole( resolved: ResolvedInquiryParticipants, role: InquiryParticipantRole, @@ -119,6 +130,7 @@ function requiredIdentity( export function resolveInquiryParticipants( caseType: BlameRequestType, input: InquiryParticipantFieldsDto & Record, + options: InquiryParticipantResolutionOptions = {}, ): ResolvedInquiryParticipants { const hasRoleCompleteInput = Object.values(ROLE_FIELDS).some( (field) => input[field] != null, @@ -200,8 +212,40 @@ export function resolveInquiryParticipants( resolving.add(role); let participantId: string; - if (value.sameAs) { - if (value.nationalCode != null || value.birthday != null) { + if ( + role === InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER && + value.unknown === true + ) { + if (!options.allowUnknownThirdPartyPolicyholder) { + throw new BadRequestException( + "An unknown third-party policyholder is allowed only for the damaged party.", + ); + } + const hasIdentityFields = Object.entries(value).some( + ([field, fieldValue]) => field !== "unknown" && fieldValue != null, + ); + if (hasIdentityFields) { + throw new BadRequestException( + `${role} must contain either unknown=true or person fields, not both.`, + ); + } + const participant: ResolvedInquiryParticipant = { + participantId: role, + nationalCode: "", + birthday: "", + unknown: true, + }; + participants.set(participant.participantId, participant); + participantId = participant.participantId; + } else if (value.unknown != null) { + throw new BadRequestException( + `${role} does not support the unknown option.`, + ); + } else if (value.sameAs) { + const hasPersonSpecificFields = Object.entries(value).some( + ([field, fieldValue]) => field !== "sameAs" && fieldValue != null, + ); + if (hasPersonSpecificFields) { throw new BadRequestException( `${role} must contain either sameAs or identity fields, not both.`, ); @@ -249,7 +293,7 @@ export function resolveInquiryParticipants( export function resolveInquiryVehicle( input: InquiryVehicleInputDto, -): InquiryVehicleInputDto { +): ResolvedInquiryVehicle { const registrationState = input.registrationState ?? VehicleRegistrationState.CURRENT; if (!input.currentPlate) { @@ -274,7 +318,7 @@ export function resolveInquiryVehicle( return { ...input, registrationState }; } -export function vehiclePlateCandidates(input?: InquiryVehicleInputDto): Array<{ +export function vehiclePlateCandidates(input?: ResolvedInquiryVehicle): Array<{ kind: "CURRENT" | "PREVIOUS"; plate: InquiryVehicleInputDto["currentPlate"]; }> { @@ -294,6 +338,35 @@ function normalizeVehicleSerial(value: unknown): string { .replace(/[^A-Z0-9]/g, ""); } +/** A dated result is usable only while the returned policy has not expired. */ +export function isMappedPolicyCurrent( + mapped: Record, + todayGregorian: string = gregorianDateInIran(new Date()), +): boolean { + const rawEndDate = + mapped?.EndDate ?? + mapped?.HEndDte ?? + mapped?.persianEndDate ?? + mapped?.PolicyEndDate ?? + mapped?.endDate; + if (rawEndDate == null || String(rawEndDate).trim() === "") return true; + const endDate = jalaliToGregorianDate(rawEndDate); + return endDate != null && endDate >= todayGregorian; +} + +function normalizePlateForComparison( + plate: InquiryVehicleInputDto["currentPlate"], +): string { + return [ + plate?.ir, + plate?.leftDigits, + plate?.centerAlphabet, + plate?.centerDigits, + ] + .map((part) => String(part ?? "").trim()) + .join("|"); +} + export function assertPreviousPlateInquiryMatchesVin( expectedVin: string, mapped: Record, @@ -319,7 +392,7 @@ export function assertPreviousPlateInquiryMatchesVin( } export async function runPlateInquiryWithFallback(options: { - vehicle?: InquiryVehicleInputDto; + vehicle?: ResolvedInquiryVehicle; fallbackCurrentPlate: InquiryVehicleInputDto["currentPlate"]; query: (plate: InquiryVehicleInputDto["currentPlate"]) => Promise; isUsable: (value: T) => boolean; @@ -358,7 +431,11 @@ export async function runPlateInquiryWithFallback(options: { usable: false, }); if (!isLast) continue; - return { value, plateKind: candidate.kind, attempts }; + const error = new BadRequestException( + "No current usable policy was found for the submitted vehicle identifiers.", + ) as BadRequestException & { attempts?: typeof attempts }; + error.attempts = attempts; + throw error; } if (candidate.kind === "PREVIOUS" && usable) { assertPreviousPlateInquiryMatchesVin( @@ -374,12 +451,23 @@ export async function runPlateInquiryWithFallback(options: { return { value, plateKind: candidate.kind, attempts }; } catch (error) { lastError = error; - attempts.push({ - plateKind: candidate.kind, - succeeded: false, - error: error instanceof Error ? error.message : String(error), - }); - if (isLast) throw error; + const alreadyRecorded = + typeof error === "object" && + error != null && + Array.isArray((error as { attempts?: unknown }).attempts); + if (!alreadyRecorded) { + attempts.push({ + plateKind: candidate.kind, + succeeded: false, + error: error instanceof Error ? error.message : String(error), + }); + } + if (isLast) { + if (typeof error === "object" && error != null) { + (error as { attempts?: typeof attempts }).attempts = attempts; + } + throw error; + } } } @@ -389,8 +477,9 @@ export async function runPlateInquiryWithFallback(options: { export function normalizeInquirySubmission>( caseType: BlameRequestType, input: T, + options: InquiryParticipantResolutionOptions = {}, ): NormalizedInquirySubmission { - const participants = resolveInquiryParticipants(caseType, input); + const participants = resolveInquiryParticipants(caseType, input, options); const driver = participantForRole( participants, InquiryParticipantRole.DRIVER, @@ -415,6 +504,16 @@ export function normalizeInquirySubmission>( const vehicle = input.vehicle ? resolveInquiryVehicle(input.vehicle as InquiryVehicleInputDto) : undefined; + if ( + vehicle && + input.plate && + normalizePlateForComparison(vehicle.currentPlate) !== + normalizePlateForComparison(input.plate) + ) { + throw new BadRequestException( + "plate and vehicle.currentPlate must identify the same current plate.", + ); + } const sameDriverAndPolicyholder = participants.roles.driver === participants.roles.thirdPartyPolicyholder; diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index b7f1aee..3294b1d 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -124,6 +124,7 @@ import { resolveLinkedUserIdStrings } from "src/helpers/user-access-resolver"; import { normalizePlateText } from "src/utils/plate-normalizer/plate-normalizer.service"; import { fanavaranClaimReferences } from "src/claim-request-management/fanavaran-claim-references"; import { + isMappedPolicyCurrent, NormalizedInquirySubmission, normalizeInquirySubmission, runPlateInquiryWithFallback, @@ -308,8 +309,12 @@ export class RequestManagementService { private normalizeInquiryInput>( type: BlameRequestType, input: T, + partyRole?: PartyRole, ): NormalizedInquirySubmission { - return normalizeInquirySubmission(type, input); + return normalizeInquirySubmission(type, input, { + allowUnknownThirdPartyPolicyholder: + type === BlameRequestType.THIRD_PARTY && partyRole === PartyRole.SECOND, + }); } private applyInquiryParticipantsToParty( @@ -335,6 +340,15 @@ export class RequestManagementService { submission: NormalizedInquirySubmission>, options?: Record, ): Promise { + if (submission.thirdPartyPolicyholder.unknown) { + return { + raw: null, + mapped: {}, + skipped: true, + plateKind: "CURRENT", + attempts: [], + }; + } const result = await runPlateInquiryWithFallback({ vehicle: submission.vehicle, fallbackCurrentPlate: submission.dto.plate, @@ -348,7 +362,9 @@ export class RequestManagementService { options, ), isUsable: (value) => - !value?.mapped?.Error && !!value?.mapped?.CompanyName, + !value?.mapped?.Error && + !!value?.mapped?.CompanyName && + isMappedPolicyCurrent(value.mapped), mappedValue: (value) => value?.mapped ?? {}, }); return { @@ -373,6 +389,7 @@ export class RequestManagementService { }), isUsable: (value) => !!value?.mapped && + isMappedPolicyCurrent(value.mapped) && !!( value.mapped.policyNumber || value.mapped.CompanyName || @@ -395,7 +412,9 @@ export class RequestManagementService { ): Promise { const participants = submission.participants.legacy ? [submission.thirdPartyPolicyholder] - : submission.participants.participants; + : submission.participants.participants.filter( + (participant) => !participant.unknown, + ); const results: Record = {}; for (const participant of participants) { @@ -535,6 +554,7 @@ export class RequestManagementService { message: err?.message || String(err), status: err?.response?.status, data: err?.response?.data, + ...(Array.isArray(err?.attempts) ? { attempts: err.attempts } : {}), }; } @@ -1575,9 +1595,15 @@ export class RequestManagementService { : undefined; } - const inquirySubmission = this.normalizeInquiryInput(req.type, body); + const inquirySubmission = this.normalizeInquiryInput( + req.type, + body, + role, + ); body = inquirySubmission.dto as unknown as AddPlateDto; this.applyInquiryParticipantsToParty(party, inquirySubmission); + const policyholderUnknown = + inquirySubmission.thirdPartyPolicyholder.unknown === true; // Validation: driver/insurer sameness rules if (body.driverIsInsurer === false) { @@ -1637,13 +1663,25 @@ export class RequestManagementService { this.logger.log( `[TEJARAT] block inquiry mapped for request=${req._id}: ${JSON.stringify(inquiryMapped)}`, ); - this.recordPartyCaseInquiryStatus(req, "thirdParty", role, true, { - source: inquirySource, - plateKind: inquiry.plateKind, - attempts: inquiry.attempts, - raw: inquiryRaw, - mapped: inquiryMapped, - }); + this.recordPartyCaseInquiryStatus( + req, + "thirdParty", + role, + !inquiry.skipped, + { + source: inquirySource, + plateKind: inquiry.plateKind, + attempts: inquiry.attempts, + raw: inquiryRaw, + mapped: inquiryMapped, + ...(inquiry.skipped + ? { + skipped: true, + reason: "Third-party policyholder is unknown", + } + : {}), + }, + ); if (inquiry.offline?.fanavaranDriverId != null) { if (!party.person) party.person = {} as any; party.person.fanavaranDriverId = inquiry.offline.fanavaranDriverId; @@ -1714,17 +1752,19 @@ export class RequestManagementService { // Find client by company code const clientName = inquiryMapped?.CompanyName; - if (!clientName) { + if (!clientName && !policyholderUnknown) { throw new BadRequestException( `CompanyName missing from inquiry response`, ); } const companyCode = inquiryMapped?.CompanyCode; - const client = await this.clientService.findOrCreateClientByCompanyCode( - companyCode, - clientName, - ); - if (!client) { + const client = clientName + ? await this.clientService.findOrCreateClientByCompanyCode( + companyCode, + clientName, + ) + : null; + if (clientName && !client) { throw new BadRequestException( `CompanyCode missing or invalid in inquiry response`, ); @@ -1734,7 +1774,7 @@ export class RequestManagementService { if (!party.person) party.person = {} as any; const resolvedClientId = (client as any)?._id ?? (client as any)?._doc?._id; - party.person.clientId = resolvedClientId; + if (resolvedClientId) party.person.clientId = resolvedClientId; party.person.nationalCodeOfInsurer = body.nationalCodeOfInsurer; party.person.nationalCodeOfDriver = body.nationalCodeOfDriver; party.person.insurerLicense = body.insurerLicense; @@ -1980,7 +2020,11 @@ export class RequestManagementService { : undefined; } - const inquirySubmission = this.normalizeInquiryInput(req.type, body); + const inquirySubmission = this.normalizeInquiryInput( + req.type, + body, + role, + ); body = inquirySubmission.dto as unknown as InitialFormVinDto; this.applyInquiryParticipantsToParty(party, inquirySubmission); @@ -2123,7 +2167,7 @@ export class RequestManagementService { // Persist party data const resolvedClientId = (client as any)?._id ?? (client as any)?._doc?._id; - party.person.clientId = resolvedClientId; + if (resolvedClientId) party.person.clientId = resolvedClientId; party.person.nationalCodeOfInsurer = body.nationalCodeOfInsurer; party.person.nationalCodeOfDriver = body.nationalCodeOfDriver; party.person.insurerLicense = body.insurerLicense; @@ -6578,6 +6622,7 @@ export class RequestManagementService { const inquirySubmission = this.normalizeInquiryInput( req.type, formData.firstPartyPlate, + PartyRole.FIRST, ); const firstPartyPlate = inquirySubmission.dto; let sandHubReport: any; @@ -6586,8 +6631,7 @@ export class RequestManagementService { ? await (async () => { const response = await this.sandHubService.getSandHubResponse({ plate: firstPartyPlate.plate, - nationalCodeOfInsurer: - firstPartyPlate.nationalCodeOfInsurer, + nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer, }); const mapped = response["_doc"] || response; return { @@ -6857,8 +6901,14 @@ export class RequestManagementService { desc: string, role: PartyRole, ) => { - const inquirySubmission = this.normalizeInquiryInput(req.type, plateDto); + const inquirySubmission = this.normalizeInquiryInput( + req.type, + plateDto, + role, + ); plateDto = inquirySubmission.dto; + const policyholderUnknown = + inquirySubmission.thirdPartyPolicyholder.unknown === true; let inquiry: any; try { if (inquirySubmission.participants.legacy) { @@ -6871,9 +6921,7 @@ export class RequestManagementService { raw: mapped, mapped, plateKind: "CURRENT", - attempts: [ - { plateKind: "CURRENT", succeeded: true, usable: true }, - ], + attempts: [{ plateKind: "CURRENT", succeeded: true, usable: true }], }; } else { inquiry = await this.getThirdPartyPlateInquiry(inquirySubmission); @@ -6897,23 +6945,37 @@ export class RequestManagementService { "THIRD_PARTY", ); } - this.recordPartyCaseInquiryStatus(req, "thirdParty", role, true, { + this.recordPartyCaseInquiryStatus( + req, + "thirdParty", + role, + !inquiry.skipped, + { source: inquiry.offline?.source ?? "TEJARAT_BLOCK_INQUIRY", plateKind: inquiry.plateKind, attempts: inquiry.attempts, - raw: inquiry.raw, - mapped: inquiry.mapped, - }); + raw: inquiry.raw, + mapped: inquiry.mapped, + ...(inquiry.skipped + ? { + skipped: true, + reason: "Third-party policyholder is unknown", + } + : {}), + }, + ); const clientName = sandHubReport?.CompanyName || sandHubReport?.LastCompanyName; const companyCode = sandHubReport?.CompanyCode; - const client = companyCode - ? await this.clientService.findOrCreateClientByCompanyCode( - companyCode, - clientName, - ) - : await this.clientService.findOne({ clientName }); - if (!client) { + const client = clientName + ? companyCode + ? await this.clientService.findOrCreateClientByCompanyCode( + companyCode, + clientName, + ) + : await this.clientService.findOne({ clientName }) + : null; + if (!client && !policyholderUnknown) { throw new NotFoundException( `Client not found for company: ${clientName}`, ); @@ -9112,9 +9174,15 @@ export class RequestManagementService { partyRole: PartyRole, party: any, ): Promise<{ clientId?: string }> { - const inquirySubmission = this.normalizeInquiryInput(req.type, partyData); + const inquirySubmission = this.normalizeInquiryInput( + req.type, + partyData, + partyRole, + ); partyData = inquirySubmission.dto as RunInquiriesV3Dto; this.applyInquiryParticipantsToParty(party, inquirySubmission); + const policyholderUnknown = + inquirySubmission.thirdPartyPolicyholder.unknown === true; const roleLabel = partyRole === PartyRole.FIRST ? "FIRST" : "SECOND"; let clientId: string | undefined; const inquiryOptions = (cid?: string) => @@ -9137,13 +9205,25 @@ export class RequestManagementService { if (inquiry.offline?.source) { inquirySource = inquiry.offline.source; } - this.recordPartyCaseInquiryStatus(req, "thirdParty", partyRole, true, { - source: inquirySource, - plateKind: inquiry.plateKind, - attempts: inquiry.attempts, - raw: inquiryRaw, - mapped: inquiryMapped, - }); + this.recordPartyCaseInquiryStatus( + req, + "thirdParty", + partyRole, + !inquiry.skipped, + { + source: inquirySource, + plateKind: inquiry.plateKind, + attempts: inquiry.attempts, + raw: inquiryRaw, + mapped: inquiryMapped, + ...(inquiry.skipped + ? { + skipped: true, + reason: "Third-party policyholder is unknown", + } + : {}), + }, + ); if (inquiry.offline?.fanavaranDriverId != null) { if (!party.person) party.person = {} as any; party.person.fanavaranDriverId = inquiry.offline.fanavaranDriverId; @@ -9174,16 +9254,18 @@ export class RequestManagementService { const clientName = inquiryMapped?.CompanyName; const companyCode = inquiryMapped?.CompanyCode; - if (!clientName) { + if (!clientName && !policyholderUnknown) { throw new BadRequestException( `CompanyName missing from ${roleLabel} party inquiry response`, ); } - const client = await this.clientService.findOrCreateClientByCompanyCode( - companyCode, - clientName, - ); - if (!client) { + const client = clientName + ? await this.clientService.findOrCreateClientByCompanyCode( + companyCode, + clientName, + ) + : null; + if (clientName && !client) { throw new BadRequestException( `CompanyCode missing or invalid in ${roleLabel} party inquiry response`, ); @@ -9192,7 +9274,7 @@ export class RequestManagementService { clientId = resolvedClientId ? String(resolvedClientId) : undefined; if (!party.person) party.person = {} as any; - party.person.clientId = resolvedClientId; + if (resolvedClientId) party.person.clientId = resolvedClientId; party.person.nationalCodeOfInsurer = partyData.nationalCodeOfInsurer; party.person.nationalCodeOfDriver = partyData.nationalCodeOfDriver; party.person.driverIsInsurer = partyData.driverIsInsurer; @@ -9337,7 +9419,22 @@ export class RequestManagementService { ? partyData.insurerLicense : partyData.driverLicense; - if (!licenseNumber) { + if ( + !inquirySubmission.participants.legacy && + inquirySubmission.driver.hasDrivingLicense === false + ) { + this.recordPartyCaseInquiryStatus( + req, + "drivingLicence", + partyRole, + true, + { + participantId: inquirySubmission.driver.participantId, + skipped: true, + reason: "Driver declared no driving licence", + }, + ); + } else if (!licenseNumber) { this.recordPartyCaseInquiryStatus( req, "drivingLicence", @@ -9683,12 +9780,12 @@ export class RequestManagementService { if (!req) throw new NotFoundException("Blame request not found"); this.assertBlameV3ExpertInPerson(req); await this.verifyExpertAccessForBlameV2(req, actor); - dto = this.normalizeInquiryInput(req.type, dto).dto as RunInquiriesV3Dto; - const partyRole = this.resolveV3InquiryPartyRole(req); if (!partyRole) { throw new ConflictException("All party inquiries are already complete."); } + dto = this.normalizeInquiryInput(req.type, dto, partyRole) + .dto as RunInquiriesV3Dto; if (partyRole === PartyRole.FIRST) { const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); @@ -9820,12 +9917,12 @@ export class RequestManagementService { if (!req) throw new NotFoundException("Blame request not found"); this.assertBlameV3ExpertInPerson(req); await this.verifyExpertAccessForBlameV2(req, actor); - dto = this.normalizeInquiryInput(req.type, dto).dto as RunInquiriesVinV3Dto; - const partyRole = this.resolveV3InquiryPartyRole(req); if (!partyRole) { throw new ConflictException("All party inquiries are already complete."); } + dto = this.normalizeInquiryInput(req.type, dto, partyRole) + .dto as RunInquiriesVinV3Dto; if (partyRole === PartyRole.FIRST) { const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); @@ -9952,7 +10049,11 @@ export class RequestManagementService { partyRole: PartyRole, party: any, ): Promise<{ clientId?: string }> { - const inquirySubmission = this.normalizeInquiryInput(req.type, partyData); + const inquirySubmission = this.normalizeInquiryInput( + req.type, + partyData, + partyRole, + ); partyData = inquirySubmission.dto as RunInquiriesVinV3Dto; this.applyInquiryParticipantsToParty(party, inquirySubmission); const roleLabel = partyRole === PartyRole.FIRST ? "FIRST" : "SECOND"; @@ -10018,7 +10119,7 @@ export class RequestManagementService { clientId = resolvedClientId ? String(resolvedClientId) : undefined; if (!party.person) party.person = {} as any; - party.person.clientId = resolvedClientId; + if (resolvedClientId) party.person.clientId = resolvedClientId; party.person.nationalCodeOfInsurer = partyData.nationalCodeOfInsurer; party.person.nationalCodeOfDriver = partyData.nationalCodeOfDriver; party.person.driverIsInsurer = partyData.driverIsInsurer; @@ -10174,7 +10275,22 @@ export class RequestManagementService { ? partyData.insurerLicense : partyData.driverLicense; - if (!licenseNumber) { + if ( + !inquirySubmission.participants.legacy && + inquirySubmission.driver.hasDrivingLicense === false + ) { + this.recordPartyCaseInquiryStatus( + req, + "drivingLicence", + partyRole, + true, + { + participantId: inquirySubmission.driver.participantId, + skipped: true, + reason: "Driver declared no driving licence", + }, + ); + } else if (!licenseNumber) { this.recordPartyCaseInquiryStatus( req, "drivingLicence", @@ -11403,7 +11519,7 @@ export class RequestManagementService { if (firstIdx === -1) throw new BadRequestException("First party not found"); const firstParty = req.parties[firstIdx]; - dto = this.normalizeInquiryInput(req.type, dto).dto; + dto = this.normalizeInquiryInput(req.type, dto, PartyRole.FIRST).dto; await this.runPartyInquiriesV3Internal( req, @@ -11526,7 +11642,7 @@ export class RequestManagementService { if (firstIdx === -1) throw new BadRequestException("First party not found"); const firstParty = req.parties[firstIdx]; - dto = this.normalizeInquiryInput(req.type, dto).dto; + dto = this.normalizeInquiryInput(req.type, dto, PartyRole.FIRST).dto; await this.runPartyInquiriesVinV3Internal( req,