diff --git a/docs/inquiry-participants-proposal.fa.md b/docs/inquiry-participants-proposal.fa.md index 43545c3..382b58c 100644 --- a/docs/inquiry-participants-proposal.fa.md +++ b/docs/inquiry-participants-proposal.fa.md @@ -1,19 +1,25 @@ # پیشنهاد مدل اشخاص در مرحله استعلام -وضعیت: پیشنهادی +وضعیت: پیاده‌سازی‌شده در ۱۴۰۵/۰۶/۲۲ دامنه: تمام جریان‌های استعلام کاربر، کارشناس، پرونده‌ساز و مرکز تماس نسخه انگلیسی: [inquiry-participants-proposal.md](./inquiry-participants-proposal.md) +## interface پیاده‌سازی‌شده + +فیلدهای نقش‌ها و آبجکت اختیاری `vehicle` که در ادامه آمده‌اند، مستقیماً در body تمام درخواست‌های استعلام فعلی پذیرفته می‌شوند. این تغییر شامل فرم اولیه کاربر و mirror کارشناس/ثبت‌کننده در V2، جریان کارشناس V3، جریان‌های پرونده‌ساز V4/V5، مرکز تماس V6 و مسیرهای تک‌درخواستی حضوری است. routeهای پلاک و VIN از قوانین مشترک اشخاص استفاده می‌کنند. + +در دوره مهاجرت، فیلدهای قدیمی و تخت همچنان پذیرفته می‌شوند. پاسخ‌ها و جزئیات پرونده برای نقش‌های عملیاتی، در صورت وجود داده، فیلدهای نرمال‌شده `participants`، `participantRoles`، `vehicle.registrationState` و `vehicle.previousPlateId` را نمایش می‌دهند. + ## مسئله قرارداد فعلی استعلام عمدتاً فقط راننده و شخصی با عنوان `insurer` را نگه می‌دارد. این مدل کامل نیست و نام‌گذاری نیز دقیق نیست: شخص، **بیمه‌گذار** است و **بیمه‌گر** شرکت بیمه است. برای هر وسیله نقلیه در یک `Party` نقش‌های هویتی زیر وجود دارد: -| نوع پرونده | نقش‌های الزامی | -| --- | --- | -| `THIRD_PARTY` | راننده، مالک وسیله نقلیه، بیمه‌گذار شخص ثالث | -| `CAR_BODY` | راننده، مالک وسیله نقلیه، بیمه‌گذار شخص ثالث، بیمه‌گذار بدنه | +| نوع پرونده | نقش‌های الزامی | +| ------------- | ------------------------------------------------------------ | +| `THIRD_PARTY` | راننده، مالک وسیله نقلیه، بیمه‌گذار شخص ثالث | +| `CAR_BODY` | راننده، مالک وسیله نقلیه، بیمه‌گذار شخص ثالث، بیمه‌گذار بدنه | ممکن است یک شخص چند نقش را داشته باشد، اما سیستم نباید یکسان بودن آن‌ها را فرض کند. @@ -42,6 +48,8 @@ فیلد `carBodyPolicyholder` برای `THIRD_PARTY` مجاز نیست و برای `CAR_BODY` الزامی است. هر نقش باید فقط یکی از دو حالت «اطلاعات شخص» یا `sameAs` را داشته باشد. بک‌اند این ورودی را به فهرست اشخاص یکتا و اتصال نقش‌ها به آن‌ها تبدیل می‌کند. +برای راننده، `hasDrivingLicense` الزامی است. اگر مقدار آن `true` باشد، هر دو فیلد `licenseNumber` و `licenseType` نیز الزامی هستند؛ اگر مقدار آن `false` باشد، استعلام گواهینامه عمداً اجرا نمی‌شود. + ## انتقال مالکیت اخیر و پلاک قبلی نقش اشخاص و شناسه‌های خودرو دو موضوع جدا هستند. اگر خودرو به‌تازگی فروخته یا خریداری شده باشد، ممکن است اطلاعات رسمی یا بیمه‌نامه هنوز به پلاک قبلی متصل باشد. این وضعیت باید صریح ثبت شود و پلاک قبلی نباید جایگزین پلاک فعلی شود: diff --git a/docs/inquiry-participants-proposal.md b/docs/inquiry-participants-proposal.md index 5899881..723a264 100644 --- a/docs/inquiry-participants-proposal.md +++ b/docs/inquiry-participants-proposal.md @@ -1,19 +1,25 @@ # Inquiry participant identity proposal -Status: proposed +Status: implemented on 2026-09-13 Scope: every user, expert, FileMaker, and call-center inquiry flow Persian version: [inquiry-participants-proposal.fa.md](./inquiry-participants-proposal.fa.md) +## Implemented interface + +The role fields and optional `vehicle` object shown below are accepted directly in every existing inquiry request body. This covers V2 user and expert/registrar mirror initial forms, V3 expert flow, V4/V5 FileMaker flows, V6 call-center flow, and the one-shot in-person completion paths. Plate and VIN routes share the same participant rules. + +Existing flat fields remain accepted during migration. Responses and file-detail views for operational actors expose normalized `participants`, `participantRoles`, `vehicle.registrationState`, and `vehicle.previousPlateId` where available. + ## Problem The current inquiry contract mainly models a driver and a value named `insurer`. That is incomplete and the name is misleading: a person is the **policyholder**; the **insurer** is the insurance company. Each vehicle-side `Party` can have these identity roles: -| Case type | Required roles | -| --- | --- | -| `THIRD_PARTY` | Driver, vehicle owner, third-party policyholder | -| `CAR_BODY` | Driver, vehicle owner, third-party policyholder, car-body policyholder | +| Case type | Required roles | +| ------------- | ---------------------------------------------------------------------- | +| `THIRD_PARTY` | Driver, vehicle owner, third-party policyholder | +| `CAR_BODY` | Driver, vehicle owner, third-party policyholder, car-body policyholder | One person may hold several roles, but the system must not assume that they do. @@ -42,6 +48,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 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 Participant roles and vehicle identifiers are separate concerns. When a vehicle has recently been sold or purchased, the current official record or policy may still be connected to its previous plate. Model this explicitly instead of replacing the current plate: diff --git a/src/common/schema/case-inquiries.schema.ts b/src/common/schema/case-inquiries.schema.ts index f172b92..9db197b 100644 --- a/src/common/schema/case-inquiries.schema.ts +++ b/src/common/schema/case-inquiries.schema.ts @@ -30,5 +30,8 @@ export class CaseInquiries { @Prop({ type: InquiryStatusSchema }) drivingLicence?: InquiryStatus; + + @Prop({ type: InquiryStatusSchema }) + ownership?: InquiryStatus; } export const CaseInquiriesSchema = SchemaFactory.createForClass(CaseInquiries); diff --git a/src/profile/dto/user/AddPlateDto.ts b/src/profile/dto/user/AddPlateDto.ts index bd090dd..1fc2ac6 100644 --- a/src/profile/dto/user/AddPlateDto.ts +++ b/src/profile/dto/user/AddPlateDto.ts @@ -1,36 +1,53 @@ -import { ApiProperty } from "@nestjs/swagger"; +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"; -export class AddPlateDto { +export class AddPlateDto extends InquiryParticipantFieldsDto { plateId: string; - @ApiProperty({ type: String, required: true }) + @ApiPropertyOptional({ + type: String, + description: "Legacy third-party policyholder national code", + }) nationalCodeOfInsurer: string; - @ApiProperty({ type: String, required: true }) + @ApiPropertyOptional({ + type: String, + description: "Legacy driver national code", + }) nationalCodeOfDriver: string; - @ApiProperty({ type: String, required: true }) + @ApiPropertyOptional({ type: String }) insurerLicense: string; - @ApiProperty({ type: String, required: true }) + @ApiPropertyOptional({ type: String }) driverLicense: string; - @ApiProperty({ type: PlatesDto, required: true }) + @ApiPropertyOptional({ + type: PlatesDto, + description: + "Legacy/current plate; may be supplied as vehicle.currentPlate", + }) plate: Plates; - @ApiProperty({ type: Boolean, required: true }) + @ApiPropertyOptional({ + type: Boolean, + description: "Legacy role relationship", + }) driverIsInsurer: boolean; @ApiProperty({ type: Boolean, required: true, default: false }) isNewCar: boolean; - @ApiProperty({ type: Boolean, required: true }) + @ApiPropertyOptional({ + type: Boolean, + description: "Legacy; use driver.hasDrivingLicense", + }) userNoCertificate: boolean; - @ApiProperty({ + @ApiPropertyOptional({ type: Number, - required: true, + description: "Legacy third-party policyholder birthday", }) insurerBirthday: number; @ApiProperty({ diff --git a/src/request-management/dto/create-request-management.dto.ts b/src/request-management/dto/create-request-management.dto.ts index ea37777..6de2d45 100644 --- a/src/request-management/dto/create-request-management.dto.ts +++ b/src/request-management/dto/create-request-management.dto.ts @@ -1,5 +1,11 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator"; +import { + IsNotEmpty, + IsOptional, + IsString, + MaxLength, + ValidateIf, +} from "class-validator"; import { Types } from "mongoose"; import { AddPlateDto } from "src/profile/dto/user/AddPlateDto"; import { StepsEnum } from "src/Types&Enums/blame-request-management/steps.enum"; @@ -10,6 +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"; export class InitialFormDto { @ApiProperty({ required: false, default: false }) @@ -227,10 +234,10 @@ export class BlameConfessionDtoV2 { * All identity and license fields from {@link AddPlateDto} are preserved; only * `plate` is replaced by `vin` (the 17-character chassis / VIN string). */ -export class InitialFormVinDto { - @ApiProperty({ +export class InitialFormVinDto extends InquiryParticipantFieldsDto { + @ApiPropertyOptional({ type: String, - required: true, + required: false, description: "17-character VIN / chassis number (شماره شاسی)", example: "NAAM01E15HK123456", maxLength: 17, @@ -238,30 +245,49 @@ export class InitialFormVinDto { @IsString() @IsNotEmpty() @MaxLength(17) + @ValidateIf((dto) => !dto.vehicle?.vin) vin: string; - @ApiProperty({ type: String, required: true }) + @ApiPropertyOptional({ + type: String, + description: "Legacy third-party policyholder national code", + }) + @ValidateIf((dto) => !dto.thirdPartyPolicyholder) nationalCodeOfInsurer: string; - @ApiProperty({ type: String, required: true }) + @ApiPropertyOptional({ + type: String, + description: "Legacy driver national code", + }) + @ValidateIf((dto) => !dto.driver) nationalCodeOfDriver: string; - @ApiProperty({ type: String, required: true }) + @ApiPropertyOptional({ type: String }) insurerLicense: string; - @ApiProperty({ type: String, required: true }) + @ApiPropertyOptional({ type: String }) driverLicense: string; - @ApiProperty({ type: Boolean, required: true }) + @ApiPropertyOptional({ + type: Boolean, + description: "Legacy role relationship", + }) + @ValidateIf((dto) => !dto.driver) driverIsInsurer: boolean; - @ApiProperty({ type: Boolean, required: true, default: false }) + @ApiPropertyOptional({ type: Boolean, default: false }) isNewCar: boolean; - @ApiProperty({ type: Boolean, required: true }) + @ApiPropertyOptional({ + type: Boolean, + description: "Legacy; use driver.hasDrivingLicense", + }) userNoCertificate: boolean; - @ApiProperty({ type: Number, required: true }) + @ApiPropertyOptional({ + type: Number, + description: "Legacy policyholder birthday", + }) insurerBirthday: number; @ApiPropertyOptional({ type: String, required: false }) diff --git a/src/request-management/dto/inquiry-participants.dto.ts b/src/request-management/dto/inquiry-participants.dto.ts new file mode 100644 index 0000000..ae80299 --- /dev/null +++ b/src/request-management/dto/inquiry-participants.dto.ts @@ -0,0 +1,144 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsBoolean, + IsEnum, + IsNotEmpty, + IsOptional, + IsString, + MaxLength, + ValidateNested, +} from "class-validator"; + +export enum InquiryParticipantRole { + DRIVER = "DRIVER", + VEHICLE_OWNER = "VEHICLE_OWNER", + THIRD_PARTY_POLICYHOLDER = "THIRD_PARTY_POLICYHOLDER", + CAR_BODY_POLICYHOLDER = "CAR_BODY_POLICYHOLDER", +} + +export enum VehicleRegistrationState { + CURRENT = "CURRENT", + RECENTLY_TRANSFERRED = "RECENTLY_TRANSFERRED", +} + +export class InquiryPlateDto { + @ApiProperty({ example: "44" }) + @IsString() + @IsNotEmpty() + leftDigits: string; + + @ApiProperty({ example: "ب" }) + @IsString() + @IsNotEmpty() + centerAlphabet: string; + + @ApiProperty({ example: "111" }) + @IsString() + @IsNotEmpty() + centerDigits: string; + + @ApiProperty({ example: "22" }) + @IsString() + @IsNotEmpty() + ir: string; +} + +export class InquiryParticipantInputDto { + @ApiPropertyOptional({ enum: InquiryParticipantRole }) + @IsOptional() + @IsEnum(InquiryParticipantRole) + sameAs?: InquiryParticipantRole; + + @ApiPropertyOptional({ example: "0012345678" }) + @IsOptional() + @IsString() + nationalCode?: string; + + @ApiPropertyOptional({ example: "1370/01/01" }) + @IsOptional() + birthday?: string | number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + fullName?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + phoneNumber?: string; + + @ApiPropertyOptional({ + description: "Required for a driver who has a licence.", + }) + @IsOptional() + @IsBoolean() + hasDrivingLicense?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + licenseNumber?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + licenseType?: string; +} + +export class InquiryVehicleInputDto { + @ApiProperty({ enum: VehicleRegistrationState }) + @IsEnum(VehicleRegistrationState) + registrationState: VehicleRegistrationState; + + @ApiProperty({ type: InquiryPlateDto }) + @ValidateNested() + @Type(() => InquiryPlateDto) + currentPlate: InquiryPlateDto; + + @ApiPropertyOptional({ type: InquiryPlateDto }) + @IsOptional() + @ValidateNested() + @Type(() => InquiryPlateDto) + previousPlate?: InquiryPlateDto; + + @ApiPropertyOptional({ maxLength: 17, example: "NAAM01E15HK123456" }) + @IsOptional() + @IsString() + @MaxLength(17) + vin?: string; +} + +/** New role-complete contract mixed into every inquiry DTO. Legacy fields remain during rollout. */ +export class InquiryParticipantFieldsDto { + @ApiPropertyOptional({ type: InquiryParticipantInputDto }) + @IsOptional() + @ValidateNested() + @Type(() => InquiryParticipantInputDto) + driver?: InquiryParticipantInputDto; + + @ApiPropertyOptional({ type: InquiryParticipantInputDto }) + @IsOptional() + @ValidateNested() + @Type(() => InquiryParticipantInputDto) + vehicleOwner?: InquiryParticipantInputDto; + + @ApiPropertyOptional({ type: InquiryParticipantInputDto }) + @IsOptional() + @ValidateNested() + @Type(() => InquiryParticipantInputDto) + thirdPartyPolicyholder?: InquiryParticipantInputDto; + + @ApiPropertyOptional({ type: InquiryParticipantInputDto }) + @IsOptional() + @ValidateNested() + @Type(() => InquiryParticipantInputDto) + carBodyPolicyholder?: InquiryParticipantInputDto; + + @ApiPropertyOptional({ type: InquiryVehicleInputDto }) + @IsOptional() + @ValidateNested() + @Type(() => InquiryVehicleInputDto) + vehicle?: InquiryVehicleInputDto; +} 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 a201467..512f0a1 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 @@ -5,9 +5,11 @@ import { IsOptional, IsString, MaxLength, + ValidateIf, ValidateNested, } from "class-validator"; import { Type } from "class-transformer"; +import { InquiryParticipantFieldsDto } from "./inquiry-participants.dto"; class PlateV6Dto { @ApiProperty({ example: "44", description: "Left two digits" }) @@ -25,7 +27,10 @@ class PlateV6Dto { @IsNotEmpty() centerDigits: string; - @ApiProperty({ example: "22", description: "Right two digits (Iran region code)" }) + @ApiProperty({ + example: "22", + description: "Right two digits (Iran region code)", + }) @IsString() @IsNotEmpty() ir: string; @@ -35,35 +40,52 @@ class PlateV6Dto { * Inquiry body for the V6 call-center flow. * Same as V3 but without `sheba` — the user adds their own IBAN later via the link. */ -export class RunCallCenterInquiryV6Dto { - @ApiProperty({ +export class RunCallCenterInquiryV6Dto extends InquiryParticipantFieldsDto { + @ApiPropertyOptional({ type: PlateV6Dto, description: "Plate segments — Tejarat block / third-party inquiry.", }) @ValidateNested() @Type(() => PlateV6Dto) + @ValidateIf((dto) => !dto.vehicle?.currentPlate) plate: PlateV6Dto; - @ApiProperty({ example: "1234567890", description: "National code of the policyholder (insurer)" }) + @ApiPropertyOptional({ + example: "1234567890", + description: "Legacy third-party policyholder national code", + }) @IsString() @IsNotEmpty() + @ValidateIf((dto) => !dto.thirdPartyPolicyholder) nationalCodeOfInsurer: string; - @ApiProperty({ example: "1234567890", description: "National code of the driver" }) + @ApiPropertyOptional({ + example: "1234567890", + description: "Legacy driver national code", + }) @IsString() @IsNotEmpty() + @ValidateIf((dto) => !dto.driver) nationalCodeOfDriver: string; - @ApiProperty({ example: true, description: "Whether the driver is the same person as the insurer" }) + @ApiPropertyOptional({ + example: true, + description: "Legacy role relationship", + }) @IsBoolean() + @ValidateIf((dto) => !dto.driver) driverIsInsurer: boolean; - @ApiProperty({ example: 13780624, description: "Insurer birth date (Jalali)" }) + @ApiPropertyOptional({ + example: 13780624, + description: "Legacy policyholder birth date (Jalali)", + }) insurerBirthday: number | string; @ApiPropertyOptional({ example: 13780624, - description: "Driver birth date (Jalali). Required when driverIsInsurer is false.", + description: + "Driver birth date (Jalali). Required when driverIsInsurer is false.", }) @IsOptional() driverBirthday?: number | string | null; @@ -90,8 +112,8 @@ export class RunCallCenterInquiryV6Dto { * Identical to `RunCallCenterInquiryV6Dto` but replaces `plate` with `vin`. * Sheba (IBAN) is intentionally absent — the user provides it themselves via the link. */ -export class RunCallCenterInquiryVinV6Dto { - @ApiProperty({ +export class RunCallCenterInquiryVinV6Dto extends InquiryParticipantFieldsDto { + @ApiPropertyOptional({ example: "NAAM01E15HK123456", description: "17-character VIN / chassis number (شماره شاسی)", maxLength: 17, @@ -99,28 +121,45 @@ export class RunCallCenterInquiryVinV6Dto { @IsString() @IsNotEmpty() @MaxLength(17) + @ValidateIf((dto) => !dto.vehicle?.vin) vin: string; - @ApiProperty({ example: "1234567890", description: "National code of the policyholder (insurer)" }) + @ApiPropertyOptional({ + example: "1234567890", + description: "Legacy third-party policyholder national code", + }) @IsString() @IsNotEmpty() + @ValidateIf((dto) => !dto.thirdPartyPolicyholder) nationalCodeOfInsurer: string; - @ApiProperty({ example: "1234567890", description: "National code of the driver" }) + @ApiPropertyOptional({ + example: "1234567890", + description: "Legacy driver national code", + }) @IsString() @IsNotEmpty() + @ValidateIf((dto) => !dto.driver) nationalCodeOfDriver: string; - @ApiProperty({ example: true, description: "Whether the driver is the same person as the insurer" }) + @ApiPropertyOptional({ + example: true, + description: "Legacy role relationship", + }) @IsBoolean() + @ValidateIf((dto) => !dto.driver) driverIsInsurer: boolean; - @ApiProperty({ example: 13780624, description: "Insurer birth date (Jalali)" }) + @ApiPropertyOptional({ + example: 13780624, + description: "Legacy policyholder birth date (Jalali)", + }) insurerBirthday: number | string; @ApiPropertyOptional({ example: 13780624, - description: "Driver birth date (Jalali). Required when driverIsInsurer is false.", + description: + "Driver birth date (Jalali). Required when driverIsInsurer is false.", }) @IsOptional() driverBirthday?: number | string | null; diff --git a/src/request-management/dto/run-inquiries-v3.dto.ts b/src/request-management/dto/run-inquiries-v3.dto.ts index 5d9b436..c658b57 100644 --- a/src/request-management/dto/run-inquiries-v3.dto.ts +++ b/src/request-management/dto/run-inquiries-v3.dto.ts @@ -5,9 +5,11 @@ import { IsOptional, IsString, MaxLength, + ValidateIf, ValidateNested, } from "class-validator"; import { Type } from "class-transformer"; +import { InquiryParticipantFieldsDto } from "./inquiry-participants.dto"; class PlateV3Dto { @ApiProperty({ example: "44", description: "Left two digits" }) @@ -25,7 +27,10 @@ class PlateV3Dto { @IsNotEmpty() centerDigits: string; - @ApiProperty({ example: "22", description: "Right two digits (Iran region code)" }) + @ApiProperty({ + example: "22", + description: "Right two digits (Iran region code)", + }) @IsString() @IsNotEmpty() ir: string; @@ -35,33 +40,53 @@ class PlateV3Dto { * Body for `POST run-inquiries/:requestId`. * First call = guilty party (+ auto claim). Second call = damaged party (THIRD_PARTY only). */ -export class RunInquiriesV3Dto { - @ApiProperty({ +export class RunInquiriesV3Dto extends InquiryParticipantFieldsDto { + @ApiPropertyOptional({ type: PlateV3Dto, description: "Plate segments — Tejarat block / third-party inquiry.", }) @ValidateNested() @Type(() => PlateV3Dto) + @ValidateIf((dto) => !dto.vehicle?.currentPlate) plate: PlateV3Dto; - @ApiProperty({ example: "1234567890", description: "National code of the policyholder (insurer)" }) + @ApiPropertyOptional({ + example: "1234567890", + description: "Legacy third-party policyholder national code", + }) @IsString() @IsNotEmpty() + @ValidateIf((dto) => !dto.thirdPartyPolicyholder) nationalCodeOfInsurer: string; - @ApiProperty({ example: "1234567890", description: "National code of the driver" }) + @ApiPropertyOptional({ + example: "1234567890", + description: "Legacy driver national code", + }) @IsString() @IsNotEmpty() + @ValidateIf((dto) => !dto.driver) nationalCodeOfDriver: string; - @ApiProperty({ example: true, description: "Whether the driver is the same person as the insurer" }) + @ApiPropertyOptional({ + example: true, + description: "Legacy role relationship", + }) @IsBoolean() + @ValidateIf((dto) => !dto.driver) driverIsInsurer: boolean; - @ApiProperty({ example: 13780624, description: "Insurer birth date (Jalali)" }) + @ApiPropertyOptional({ + example: 13780624, + description: "Legacy policyholder birth date (Jalali)", + }) insurerBirthday: number | string; - @ApiPropertyOptional({ example: 13780624, description: "Driver birth date (Jalali). Required when driverIsInsurer is false." }) + @ApiPropertyOptional({ + example: 13780624, + description: + "Driver birth date (Jalali). Required when driverIsInsurer is false.", + }) @IsOptional() driverBirthday?: number | string | null; @@ -106,8 +131,8 @@ export class RunInquiriesV3Dto { * Identical to RunInquiriesV3Dto but uses `vin` (17-char chassis number) instead of `plate`. * First call = guilty party (+ auto claim). Second call = damaged party (THIRD_PARTY only). */ -export class RunInquiriesVinV3Dto { - @ApiProperty({ +export class RunInquiriesVinV3Dto extends InquiryParticipantFieldsDto { + @ApiPropertyOptional({ example: "NAAM01E15HK123456", description: "17-character VIN / chassis number (شماره شاسی)", maxLength: 17, @@ -115,26 +140,46 @@ export class RunInquiriesVinV3Dto { @IsString() @IsNotEmpty() @MaxLength(17) + @ValidateIf((dto) => !dto.vehicle?.vin) vin: string; - @ApiProperty({ example: "1234567890", description: "National code of the policyholder (insurer)" }) + @ApiPropertyOptional({ + example: "1234567890", + description: "Legacy third-party policyholder national code", + }) @IsString() @IsNotEmpty() + @ValidateIf((dto) => !dto.thirdPartyPolicyholder) nationalCodeOfInsurer: string; - @ApiProperty({ example: "1234567890", description: "National code of the driver" }) + @ApiPropertyOptional({ + example: "1234567890", + description: "Legacy driver national code", + }) @IsString() @IsNotEmpty() + @ValidateIf((dto) => !dto.driver) nationalCodeOfDriver: string; - @ApiProperty({ example: true, description: "Whether the driver is the same person as the insurer" }) + @ApiPropertyOptional({ + example: true, + description: "Legacy role relationship", + }) @IsBoolean() + @ValidateIf((dto) => !dto.driver) driverIsInsurer: boolean; - @ApiProperty({ example: 13780624, description: "Insurer birth date (Jalali)" }) + @ApiPropertyOptional({ + example: 13780624, + description: "Legacy policyholder birth date (Jalali)", + }) insurerBirthday: number | string; - @ApiPropertyOptional({ example: 13780624, description: "Driver birth date (Jalali). Required when driverIsInsurer is false." }) + @ApiPropertyOptional({ + example: 13780624, + description: + "Driver birth date (Jalali). Required when driverIsInsurer is false.", + }) @IsOptional() driverBirthday?: number | string | null; diff --git a/src/request-management/entities/schema/partyRole.enum.ts b/src/request-management/entities/schema/partyRole.enum.ts index 61fb12e..6a1ea5c 100644 --- a/src/request-management/entities/schema/partyRole.enum.ts +++ b/src/request-management/entities/schema/partyRole.enum.ts @@ -4,213 +4,253 @@ import { Schema as MongooseSchema, Types } from "mongoose"; import { Location, LocationSchema } from "./accidentInformation.type"; export enum PartyRole { - FIRST = "FIRST", - SECOND = "SECOND", - } + FIRST = "FIRST", + SECOND = "SECOND", +} @Schema({ _id: false }) export class Person { - @Prop({ type: Types.ObjectId }) - userId?: Types.ObjectId; + @Prop({ type: Types.ObjectId }) + userId?: Types.ObjectId; - @Prop() fullName?: string; - @Prop() phoneNumber?: string; - @Prop({ type: Types.ObjectId }) clientId?: Types.ObjectId; - @Prop() birthday?: string; + @Prop() fullName?: string; + @Prop() phoneNumber?: string; + @Prop({ type: Types.ObjectId }) clientId?: Types.ObjectId; + @Prop() birthday?: string; - // ---- FIRST_INITIAL_FORM (step-manager driven) ---- - @Prop() nationalCodeOfInsurer?: string; - @Prop() nationalCodeOfDriver?: string; - @Prop() insurerLicense?: string; - @Prop() driverLicense?: string; - /** Driving licence type code from the Fanavaran lookup (GET /lookups/driving-licence-types). */ - @Prop() licenseType?: string; + // ---- FIRST_INITIAL_FORM (step-manager driven) ---- + @Prop() nationalCodeOfInsurer?: string; + @Prop() nationalCodeOfDriver?: string; + @Prop() insurerLicense?: string; + @Prop() driverLicense?: string; + /** Driving licence type code from the Fanavaran lookup (GET /lookups/driving-licence-types). */ + @Prop() licenseType?: string; - @Prop({ type: Boolean }) - driverIsInsurer?: boolean; + @Prop({ type: Boolean }) + driverIsInsurer?: boolean; - @Prop({ type: Boolean }) - isNewCar?: boolean; + @Prop({ type: Boolean }) + isNewCar?: boolean; - /** - * Mirrors existing DTO `userNoCertificate` (true means user has NO certificate). - */ - @Prop({ type: Boolean }) - userNoCertificate?: boolean; + /** + * Mirrors existing DTO `userNoCertificate` (true means user has NO certificate). + */ + @Prop({ type: Boolean }) + userNoCertificate?: boolean; - @Prop({ type: Number }) - insurerBirthday?: number; + @Prop({ type: Number }) + insurerBirthday?: number; - @Prop({ type: String }) - driverBirthday?: string | null; + @Prop({ type: String }) + driverBirthday?: string | null; - /** Cached Fanavaran party ID resolved from nationalCodeOfDriver + driverBirthday + driverIsInsurer */ - @Prop({ type: Number }) - fanavaranDriverId?: number; + /** Cached Fanavaran party ID resolved from nationalCodeOfDriver + driverBirthday + driverIsInsurer */ + @Prop({ type: Number }) + fanavaranDriverId?: number; } export const PersonSchema = SchemaFactory.createForClass(Person); @Schema({ _id: false }) -export class Vehicle { - @Prop() plateId?: string; - /** VIN / chassis number — populated only when the inquiry was performed via VIN lookup. */ - @Prop() vin?: string; - @Prop() name?: string; - @Prop() model?: string; - @Prop() type?: string; - @Prop() isNew?: boolean; +export class InquiryParticipant { + @Prop({ required: true }) + participantId: string; - /** - * Full external inquiry payload (Tejarat/SandHub) stored as-is - * so we never lose fields that are not mapped yet. - */ - @Prop({ type: MongooseSchema.Types.Mixed }) - inquiry?: any; + @Prop() + nationalCode: string; + + @Prop() + birthday: string; + + @Prop() fullName?: string; + @Prop() phoneNumber?: string; + @Prop({ type: Boolean }) hasDrivingLicense?: boolean; + @Prop() licenseNumber?: string; + @Prop() licenseType?: string; +} +export const InquiryParticipantSchema = + SchemaFactory.createForClass(InquiryParticipant); + +@Schema({ _id: false }) +export class InquiryParticipantRoles { + @Prop() driver?: string; + @Prop() vehicleOwner?: string; + @Prop() thirdPartyPolicyholder?: string; + @Prop() carBodyPolicyholder?: string; +} +export const InquiryParticipantRolesSchema = SchemaFactory.createForClass( + InquiryParticipantRoles, +); + +@Schema({ _id: false }) +export class Vehicle { + @Prop() plateId?: string; + /** VIN / chassis number — populated only when the inquiry was performed via VIN lookup. */ + @Prop() vin?: string; + @Prop() name?: string; + @Prop() model?: string; + @Prop() type?: string; + @Prop() isNew?: boolean; + @Prop({ enum: ["CURRENT", "RECENTLY_TRANSFERRED"] }) + registrationState?: "CURRENT" | "RECENTLY_TRANSFERRED"; + @Prop() previousPlateId?: string; + + /** + * Full external inquiry payload (Tejarat/SandHub) stored as-is + * so we never lose fields that are not mapped yet. + */ + @Prop({ type: MongooseSchema.Types.Mixed }) + inquiry?: any; } export const VehicleSchema = SchemaFactory.createForClass(Vehicle); @Schema({ _id: false }) export class Insurance { - @Prop() policyNumber?: string; - @Prop() company?: string; - @Prop() startDate?: string; - @Prop() endDate?: string; - @Prop() financialCeiling?: string; - @Prop({ type: [String] }) - coverages?: string[]; + @Prop() policyNumber?: string; + @Prop() company?: string; + @Prop() startDate?: string; + @Prop() endDate?: string; + @Prop() financialCeiling?: string; + @Prop({ type: [String] }) + coverages?: string[]; - /** 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; - }; + /** 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); @Schema({ _id: false }) export class PartyStatement { + @Prop({ default: false }) + acceptsExpertOpinion?: boolean; - @Prop({ default: false }) - acceptsExpertOpinion?: boolean; - - @Prop({ default: false }) - claimsDamage?: boolean; - - @Prop({ default: false }) - admitsGuilt?: boolean; - - @Prop({ type: String }) - description?: string; + @Prop({ default: false }) + claimsDamage?: boolean; - /** CAR_BODY: accident conditions from description step */ - @Prop() accidentDate?: Date; - @Prop() accidentTime?: string; - @Prop() weatherCondition?: string; - @Prop() roadCondition?: string; - @Prop() lightCondition?: string; + @Prop({ default: false }) + admitsGuilt?: boolean; + + @Prop({ type: String }) + description?: string; + + /** CAR_BODY: accident conditions from description step */ + @Prop() accidentDate?: Date; + @Prop() accidentTime?: string; + @Prop() weatherCondition?: string; + @Prop() roadCondition?: string; + @Prop() lightCondition?: string; } -export const PartyStatementSchema = SchemaFactory.createForClass(PartyStatement); +export const PartyStatementSchema = + SchemaFactory.createForClass(PartyStatement); @Schema({ _id: false }) export class EvidenceBundle { + @Prop({ type: [String] }) + images?: string[]; - @Prop({ type: [String] }) - images?: string[]; - - @Prop({ type: [String] }) - voices?: string[]; - - @Prop() videoId?: string; + @Prop({ type: [String] }) + voices?: string[]; + + @Prop() videoId?: string; } -export const EvidenceBundleSchema = SchemaFactory.createForClass(EvidenceBundle); +export const EvidenceBundleSchema = + SchemaFactory.createForClass(EvidenceBundle); @Schema({ _id: false }) export class Signature { - @Prop() fileId: string; - @Prop() fileName: string; - @Prop() fileUrl: string; + @Prop() fileId: string; + @Prop() fileName: string; + @Prop() fileUrl: string; } export const SignatureSchema = SchemaFactory.createForClass(Signature); @Schema({ _id: false }) export class PartyConfirmation { - @Prop() partyRole: PartyRole; - @Prop() accepted: boolean; - @Prop({ type: SignatureSchema }) - signature?: Signature; + @Prop() partyRole: PartyRole; + @Prop() accepted: boolean; + @Prop({ type: SignatureSchema }) + signature?: Signature; } export const PartyConfirmationSchema = SchemaFactory.createForClass(PartyConfirmation); @Schema({ _id: false }) export class Party { + @Prop({ enum: PartyRole }) + role: PartyRole; - @Prop({ enum: PartyRole }) - role: PartyRole; - - @Prop({ type: PersonSchema }) - person: Person; + @Prop({ type: PersonSchema }) + person: Person; - /** - * CAR_BODY only: first form – accident with car vs object. - * Second form (guilty/damaged) may be added later as carBodySecondForm. - */ - @Prop({ type: MongooseSchema.Types.Mixed }) - carBodyFirstForm?: { car?: boolean; object?: boolean }; + /** Distinct people involved with this vehicle; roles below reference participantId. */ + @Prop({ type: [InquiryParticipantSchema], default: undefined }) + participants?: InquiryParticipant[]; - /** - * Party-submitted location (step-driven: FIRST_LOCATION / SECOND_LOCATION). - */ - @Prop({ type: LocationSchema }) - location?: Location; - - @Prop({ type: VehicleSchema }) - vehicle?: Vehicle; - - @Prop({ type: InsuranceSchema }) - insurance?: Insurance; - - @Prop({ type: PartyStatementSchema }) - statement?: PartyStatement; - - @Prop({ type: EvidenceBundleSchema }) - evidence?: EvidenceBundle; - - @Prop({ type: PartyConfirmationSchema }) - confirmation?: PartyConfirmation; + @Prop({ type: InquiryParticipantRolesSchema }) + participantRoles?: InquiryParticipantRoles; + + /** + * CAR_BODY only: first form – accident with car vs object. + * Second form (guilty/damaged) may be added later as carBodySecondForm. + */ + @Prop({ type: MongooseSchema.Types.Mixed }) + carBodyFirstForm?: { car?: boolean; object?: boolean }; + + /** + * Party-submitted location (step-driven: FIRST_LOCATION / SECOND_LOCATION). + */ + @Prop({ type: LocationSchema }) + location?: Location; + + @Prop({ type: VehicleSchema }) + vehicle?: Vehicle; + + @Prop({ type: InsuranceSchema }) + insurance?: Insurance; + + @Prop({ type: PartyStatementSchema }) + statement?: PartyStatement; + + @Prop({ type: EvidenceBundleSchema }) + evidence?: EvidenceBundle; + + @Prop({ type: PartyConfirmationSchema }) + confirmation?: PartyConfirmation; } export const PartySchema = SchemaFactory.createForClass(Party); diff --git a/src/request-management/inquiry-participant-resolver.spec.ts b/src/request-management/inquiry-participant-resolver.spec.ts new file mode 100644 index 0000000..7e08e2b --- /dev/null +++ b/src/request-management/inquiry-participant-resolver.spec.ts @@ -0,0 +1,264 @@ +import { BadRequestException } from "@nestjs/common"; +import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; +import { + InquiryParticipantRole, + VehicleRegistrationState, +} from "./dto/inquiry-participants.dto"; +import { + assertPreviousPlateInquiryMatchesVin, + normalizeInquirySubmission, + participantForRole, + resolveInquiryParticipants, + resolveInquiryVehicle, + runPlateInquiryWithFallback, + vehiclePlateCandidates, +} from "./inquiry-participant-resolver"; + +describe("inquiry participant resolver", () => { + it("links several roles to one explicitly shared person", () => { + const resolved = resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, { + driver: { + nationalCode: "0012345678", + birthday: "1370/01/01", + hasDrivingLicense: true, + licenseNumber: "123456789", + licenseType: "1", + }, + vehicleOwner: { sameAs: InquiryParticipantRole.DRIVER }, + thirdPartyPolicyholder: { + sameAs: InquiryParticipantRole.VEHICLE_OWNER, + }, + }); + + expect(resolved.participants).toHaveLength(1); + expect(resolved.roles).toEqual({ + driver: "DRIVER", + vehicleOwner: "DRIVER", + thirdPartyPolicyholder: "DRIVER", + }); + }); + + it("requires both previous plate and VIN for a recent transfer", () => { + expect(() => + resolveInquiryVehicle({ + registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED, + currentPlate: { + leftDigits: "44", + centerAlphabet: "ب", + centerDigits: "111", + ir: "22", + }, + }), + ).toThrow(BadRequestException); + }); + + it("keeps legacy driver/policyholder payloads working during rollout", () => { + const resolved = resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, { + nationalCodeOfDriver: "0012345678", + driverBirthday: "1370/01/01", + driverLicense: "123456789", + nationalCodeOfInsurer: "0098765432", + insurerBirthday: "1360/02/02", + driverIsInsurer: false, + } as any); + + expect(resolved.legacy).toBe(true); + expect(resolved.roles.driver).toBe("DRIVER"); + expect(resolved.roles.thirdPartyPolicyholder).toBe( + "THIRD_PARTY_POLICYHOLDER", + ); + expect(resolved.roles.vehicleOwner).toBeUndefined(); + }); + + it("keeps third-party and car-body policyholders distinct", () => { + const resolved = resolveInquiryParticipants(BlameRequestType.CAR_BODY, { + driver: { + nationalCode: "0012345678", + birthday: "1370/01/01", + hasDrivingLicense: false, + }, + vehicleOwner: { sameAs: InquiryParticipantRole.DRIVER }, + thirdPartyPolicyholder: { + nationalCode: "0023456789", + birthday: "1360/02/02", + }, + carBodyPolicyholder: { + nationalCode: "0034567890", + birthday: "1350/03/03", + }, + }); + + expect( + participantForRole( + resolved, + InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER, + )?.nationalCode, + ).toBe("0023456789"); + expect( + participantForRole(resolved, InquiryParticipantRole.CAR_BODY_POLICYHOLDER) + ?.nationalCode, + ).toBe("0034567890"); + }); + + it("projects role-complete input onto the existing inquiry fields", () => { + const normalized = normalizeInquirySubmission(BlameRequestType.CAR_BODY, { + plate: { + leftDigits: "44", + centerAlphabet: "ب", + centerDigits: "111", + ir: "22", + }, + driver: { + nationalCode: "0012345678", + birthday: "1370/01/01", + hasDrivingLicense: true, + licenseNumber: "D-1", + licenseType: "1", + }, + vehicleOwner: { sameAs: InquiryParticipantRole.DRIVER }, + thirdPartyPolicyholder: { + nationalCode: "0023456789", + birthday: "1360/02/02", + }, + carBodyPolicyholder: { + nationalCode: "0034567890", + birthday: "1350/03/03", + }, + }); + + expect(normalized.dto.nationalCodeOfInsurer).toBe("0023456789"); + expect(normalized.dto.nationalCodeOfDriver).toBe("0012345678"); + expect(normalized.carBodyPolicyholder?.nationalCode).toBe("0034567890"); + expect(normalized.vehicleOwner?.nationalCode).toBe("0012345678"); + }); + + it("orders the current plate before the previous-plate fallback", () => { + const currentPlate = { + leftDigits: "44", + centerAlphabet: "ب", + centerDigits: "111", + ir: "22", + }; + const previousPlate = { + leftDigits: "55", + centerAlphabet: "ج", + centerDigits: "222", + ir: "33", + }; + + expect( + vehiclePlateCandidates({ + registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED, + currentPlate, + previousPlate, + vin: "NAAM01E15HK123456", + }), + ).toEqual([ + { kind: "CURRENT", plate: currentPlate }, + { kind: "PREVIOUS", plate: previousPlate }, + ]); + }); + + it("rejects a previous-plate result for another chassis", () => { + expect(() => + assertPreviousPlateInquiryMatchesVin("NAAM01E15HK123456", { + VinNumberField: "DIFFERENTVIN00001", + }), + ).toThrow(BadRequestException); + }); + + it("requires the driver's licence status in the new contract", () => { + expect(() => + resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, { + driver: { nationalCode: "0012345678", birthday: "1370/01/01" }, + vehicleOwner: { sameAs: InquiryParticipantRole.DRIVER }, + thirdPartyPolicyholder: { + sameAs: InquiryParticipantRole.VEHICLE_OWNER, + }, + }), + ).toThrow(BadRequestException); + }); + + it("falls back to the previous plate and accepts only a matching VIN", async () => { + const currentPlate = { + leftDigits: "44", + centerAlphabet: "ب", + centerDigits: "111", + ir: "22", + }; + const previousPlate = { + leftDigits: "55", + centerAlphabet: "ج", + centerDigits: "222", + ir: "33", + }; + const query = jest + .fn() + .mockRejectedValueOnce(new Error("not found")) + .mockResolvedValueOnce({ + mapped: { VinNumberField: "NAAM01E15HK123456", CompanyName: "پارسیان" }, + }); + + const result = await runPlateInquiryWithFallback<{ + mapped: { VinNumberField?: string; CompanyName?: string }; + }>({ + vehicle: { + registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED, + currentPlate, + previousPlate, + vin: "NAAM01E15HK123456", + }, + fallbackCurrentPlate: currentPlate, + query, + isUsable: (value) => !!value.mapped.CompanyName, + mappedValue: (value) => value.mapped, + }); + + expect(query).toHaveBeenCalledTimes(2); + expect(result.plateKind).toBe("PREVIOUS"); + expect(result.attempts).toEqual([ + { plateKind: "CURRENT", succeeded: false, error: "not found" }, + { plateKind: "PREVIOUS", succeeded: true, usable: true }, + ]); + }); + + it("rejects a car-body policyholder on a third-party case", () => { + expect(() => + resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, { + driver: { + nationalCode: "0012345678", + birthday: "1370/01/01", + hasDrivingLicense: false, + }, + vehicleOwner: { sameAs: InquiryParticipantRole.DRIVER }, + thirdPartyPolicyholder: { + sameAs: InquiryParticipantRole.VEHICLE_OWNER, + }, + carBodyPolicyholder: { + nationalCode: "0034567890", + birthday: "1350/03/03", + }, + }), + ).toThrow(BadRequestException); + }); + + it("does not accept a previous plate unless recent transfer is declared", () => { + expect(() => + resolveInquiryVehicle({ + registrationState: VehicleRegistrationState.CURRENT, + currentPlate: { + leftDigits: "44", + centerAlphabet: "ب", + centerDigits: "111", + ir: "22", + }, + previousPlate: { + leftDigits: "55", + centerAlphabet: "ج", + centerDigits: "222", + ir: "33", + }, + }), + ).toThrow(BadRequestException); + }); +}); diff --git a/src/request-management/inquiry-participant-resolver.ts b/src/request-management/inquiry-participant-resolver.ts new file mode 100644 index 0000000..f1f022e --- /dev/null +++ b/src/request-management/inquiry-participant-resolver.ts @@ -0,0 +1,450 @@ +import { BadRequestException } from "@nestjs/common"; +import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; +import { + InquiryParticipantFieldsDto, + InquiryParticipantInputDto, + InquiryParticipantRole, + InquiryVehicleInputDto, + VehicleRegistrationState, +} from "./dto/inquiry-participants.dto"; + +export interface ResolvedInquiryParticipant { + participantId: string; + nationalCode: string; + birthday: string; + fullName?: string; + phoneNumber?: string; + hasDrivingLicense?: boolean; + licenseNumber?: string; + licenseType?: string; +} + +export interface InquiryParticipantRoleAssignments { + driver: string; + vehicleOwner?: string; + thirdPartyPolicyholder: string; + carBodyPolicyholder?: string; +} + +export interface ResolvedInquiryParticipants { + participants: ResolvedInquiryParticipant[]; + roles: InquiryParticipantRoleAssignments; + legacy: boolean; +} + +export interface NormalizedInquirySubmission> { + dto: T & { + nationalCodeOfDriver: string; + driverBirthday: string; + driverLicense?: string; + licenseType?: string; + userNoCertificate?: boolean; + nationalCodeOfInsurer: string; + insurerBirthday: string; + driverIsInsurer: boolean; + insurerLicense?: string; + }; + participants: ResolvedInquiryParticipants; + driver: ResolvedInquiryParticipant; + vehicleOwner?: ResolvedInquiryParticipant; + thirdPartyPolicyholder: ResolvedInquiryParticipant; + carBodyPolicyholder?: ResolvedInquiryParticipant; + vehicle?: InquiryVehicleInputDto; +} + +export function participantForRole( + resolved: ResolvedInquiryParticipants, + role: InquiryParticipantRole, +): ResolvedInquiryParticipant | undefined { + const roleField = ROLE_FIELDS[ + role + ] as keyof InquiryParticipantRoleAssignments; + const participantId = resolved.roles[roleField]; + return resolved.participants.find( + (participant) => participant.participantId === participantId, + ); +} + +const ROLE_FIELDS: Record< + InquiryParticipantRole, + keyof InquiryParticipantFieldsDto +> = { + [InquiryParticipantRole.DRIVER]: "driver", + [InquiryParticipantRole.VEHICLE_OWNER]: "vehicleOwner", + [InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER]: "thirdPartyPolicyholder", + [InquiryParticipantRole.CAR_BODY_POLICYHOLDER]: "carBodyPolicyholder", +}; + +function requiredIdentity( + role: InquiryParticipantRole, + input: InquiryParticipantInputDto, +): ResolvedInquiryParticipant { + const nationalCode = String(input.nationalCode ?? "").trim(); + const birthday = String(input.birthday ?? "").trim(); + if (!nationalCode || !birthday) { + throw new BadRequestException( + `${role} requires nationalCode and birthday.`, + ); + } + if ( + role === InquiryParticipantRole.DRIVER && + typeof input.hasDrivingLicense !== "boolean" + ) { + throw new BadRequestException("DRIVER requires hasDrivingLicense."); + } + if ( + role === InquiryParticipantRole.DRIVER && + input.hasDrivingLicense === true && + (!String(input.licenseNumber ?? "").trim() || + !String(input.licenseType ?? "").trim()) + ) { + throw new BadRequestException( + "DRIVER requires licenseNumber and licenseType when hasDrivingLicense is true.", + ); + } + return { + participantId: role, + nationalCode, + birthday, + ...(input.fullName ? { fullName: input.fullName } : {}), + ...(input.phoneNumber ? { phoneNumber: input.phoneNumber } : {}), + ...(input.hasDrivingLicense != null + ? { hasDrivingLicense: input.hasDrivingLicense } + : {}), + ...(input.licenseNumber ? { licenseNumber: input.licenseNumber } : {}), + ...(input.licenseType ? { licenseType: input.licenseType } : {}), + }; +} + +export function resolveInquiryParticipants( + caseType: BlameRequestType, + input: InquiryParticipantFieldsDto & Record, +): ResolvedInquiryParticipants { + const hasRoleCompleteInput = Object.values(ROLE_FIELDS).some( + (field) => input[field] != null, + ); + if (!hasRoleCompleteInput) { + const driverId = InquiryParticipantRole.DRIVER; + const policyholderId = input.driverIsInsurer + ? driverId + : InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER; + const participants: ResolvedInquiryParticipant[] = [ + { + participantId: driverId, + nationalCode: String(input.nationalCodeOfDriver ?? ""), + birthday: String( + input.driverBirthday ?? + (input.driverIsInsurer ? input.insurerBirthday : "") ?? + "", + ), + ...(input.driverLicense + ? { licenseNumber: String(input.driverLicense) } + : {}), + ...(input.licenseType + ? { licenseType: String(input.licenseType) } + : {}), + ...(input.userNoCertificate != null + ? { hasDrivingLicense: !input.userNoCertificate } + : {}), + }, + ]; + if (policyholderId !== driverId) { + participants.push({ + participantId: policyholderId, + nationalCode: String(input.nationalCodeOfInsurer ?? ""), + birthday: String(input.insurerBirthday ?? ""), + }); + } + return { + participants, + roles: { + driver: driverId, + thirdPartyPolicyholder: policyholderId, + }, + legacy: true, + }; + } + if ( + caseType === BlameRequestType.THIRD_PARTY && + input.carBodyPolicyholder != null + ) { + throw new BadRequestException( + "CAR_BODY_POLICYHOLDER is not allowed for a THIRD_PARTY case.", + ); + } + + const requiredRoles = [ + InquiryParticipantRole.DRIVER, + InquiryParticipantRole.VEHICLE_OWNER, + InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER, + ...(caseType === BlameRequestType.CAR_BODY + ? [InquiryParticipantRole.CAR_BODY_POLICYHOLDER] + : []), + ]; + const participants = new Map(); + const resolvedRoleIds = new Map(); + const resolving = new Set(); + + const resolveRole = (role: InquiryParticipantRole): string => { + const existing = resolvedRoleIds.get(role); + if (existing) return existing; + if (resolving.has(role)) { + throw new BadRequestException( + "Participant sameAs references cannot be circular.", + ); + } + const value = input[ROLE_FIELDS[role]] as + | InquiryParticipantInputDto + | undefined; + if (!value) throw new BadRequestException(`${role} is required.`); + + resolving.add(role); + let participantId: string; + if (value.sameAs) { + if (value.nationalCode != null || value.birthday != null) { + throw new BadRequestException( + `${role} must contain either sameAs or identity fields, not both.`, + ); + } + participantId = resolveRole(value.sameAs); + } else { + const participant = requiredIdentity(role, value); + const duplicate = [...participants.values()].find( + (item) => item.nationalCode === participant.nationalCode, + ); + if (duplicate) { + throw new BadRequestException( + `${role} duplicates an existing nationalCode; use sameAs instead.`, + ); + } + participants.set(participant.participantId, participant); + participantId = participant.participantId; + } + resolving.delete(role); + resolvedRoleIds.set(role, participantId); + return participantId; + }; + + for (const role of requiredRoles) resolveRole(role); + + return { + participants: [...participants.values()], + roles: { + driver: resolvedRoleIds.get(InquiryParticipantRole.DRIVER)!, + vehicleOwner: resolvedRoleIds.get(InquiryParticipantRole.VEHICLE_OWNER)!, + thirdPartyPolicyholder: resolvedRoleIds.get( + InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER, + )!, + ...(caseType === BlameRequestType.CAR_BODY + ? { + carBodyPolicyholder: resolvedRoleIds.get( + InquiryParticipantRole.CAR_BODY_POLICYHOLDER, + )!, + } + : {}), + }, + legacy: false, + }; +} + +export function resolveInquiryVehicle( + input: InquiryVehicleInputDto, +): InquiryVehicleInputDto { + const registrationState = + input.registrationState ?? VehicleRegistrationState.CURRENT; + if (!input.currentPlate) { + throw new BadRequestException("vehicle.currentPlate is required."); + } + if ( + registrationState === VehicleRegistrationState.RECENTLY_TRANSFERRED && + (!input.previousPlate || !String(input.vin ?? "").trim()) + ) { + throw new BadRequestException( + "RECENTLY_TRANSFERRED requires previousPlate and vin.", + ); + } + if ( + registrationState !== VehicleRegistrationState.RECENTLY_TRANSFERRED && + input.previousPlate + ) { + throw new BadRequestException( + "previousPlate is only allowed for RECENTLY_TRANSFERRED vehicles.", + ); + } + return { ...input, registrationState }; +} + +export function vehiclePlateCandidates(input?: InquiryVehicleInputDto): Array<{ + kind: "CURRENT" | "PREVIOUS"; + plate: InquiryVehicleInputDto["currentPlate"]; +}> { + if (!input) return []; + return [ + { kind: "CURRENT" as const, plate: input.currentPlate }, + ...(input.registrationState === + VehicleRegistrationState.RECENTLY_TRANSFERRED && input.previousPlate + ? [{ kind: "PREVIOUS" as const, plate: input.previousPlate }] + : []), + ]; +} + +function normalizeVehicleSerial(value: unknown): string { + return String(value ?? "") + .toUpperCase() + .replace(/[^A-Z0-9]/g, ""); +} + +export function assertPreviousPlateInquiryMatchesVin( + expectedVin: string, + mapped: Record, +): void { + const expected = normalizeVehicleSerial(expectedVin); + const candidates = [ + mapped?.VinNumberField, + mapped?.vin, + mapped?.VIN, + mapped?.ChassisNumberField, + mapped?.chassisNumber, + mapped?.ChassisNo, + mapped?.vehicle?.VIN, + mapped?.vehicle?.ChassisNo, + ] + .map(normalizeVehicleSerial) + .filter(Boolean); + if (!expected || !candidates.includes(expected)) { + throw new BadRequestException( + "Previous-plate inquiry does not match the submitted VIN/chassis; manual review is required.", + ); + } +} + +export async function runPlateInquiryWithFallback(options: { + vehicle?: InquiryVehicleInputDto; + fallbackCurrentPlate: InquiryVehicleInputDto["currentPlate"]; + query: (plate: InquiryVehicleInputDto["currentPlate"]) => Promise; + isUsable: (value: T) => boolean; + mappedValue: (value: T) => Record; +}): Promise<{ + value: T; + plateKind: "CURRENT" | "PREVIOUS"; + attempts: Array<{ + plateKind: "CURRENT" | "PREVIOUS"; + succeeded: boolean; + usable?: boolean; + error?: string; + }>; +}> { + const candidates = options.vehicle + ? vehiclePlateCandidates(options.vehicle) + : [{ kind: "CURRENT" as const, plate: options.fallbackCurrentPlate }]; + let lastError: unknown; + const attempts: Array<{ + plateKind: "CURRENT" | "PREVIOUS"; + succeeded: boolean; + usable?: boolean; + error?: string; + }> = []; + + for (let index = 0; index < candidates.length; index += 1) { + const candidate = candidates[index]; + const isLast = index === candidates.length - 1; + try { + const value = await options.query(candidate.plate); + const usable = options.isUsable(value); + if (!usable) { + attempts.push({ + plateKind: candidate.kind, + succeeded: true, + usable: false, + }); + if (!isLast) continue; + return { value, plateKind: candidate.kind, attempts }; + } + if (candidate.kind === "PREVIOUS" && usable) { + assertPreviousPlateInquiryMatchesVin( + options.vehicle!.vin!, + options.mappedValue(value), + ); + } + attempts.push({ + plateKind: candidate.kind, + succeeded: true, + usable: true, + }); + 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; + } + } + + throw lastError ?? new BadRequestException("Inquiry failed for all plates."); +} + +export function normalizeInquirySubmission>( + caseType: BlameRequestType, + input: T, +): NormalizedInquirySubmission { + const participants = resolveInquiryParticipants(caseType, input); + const driver = participantForRole( + participants, + InquiryParticipantRole.DRIVER, + ); + const thirdPartyPolicyholder = participantForRole( + participants, + InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER, + ); + if (!driver || !thirdPartyPolicyholder) { + throw new BadRequestException( + "Driver and third-party policyholder identities are required.", + ); + } + const vehicleOwner = participantForRole( + participants, + InquiryParticipantRole.VEHICLE_OWNER, + ); + const carBodyPolicyholder = participantForRole( + participants, + InquiryParticipantRole.CAR_BODY_POLICYHOLDER, + ); + const vehicle = input.vehicle + ? resolveInquiryVehicle(input.vehicle as InquiryVehicleInputDto) + : undefined; + const sameDriverAndPolicyholder = + participants.roles.driver === participants.roles.thirdPartyPolicyholder; + + return { + dto: { + ...input, + ...(vehicle?.currentPlate && !input.plate + ? { plate: vehicle.currentPlate } + : {}), + ...(vehicle?.vin && !input.vin ? { vin: vehicle.vin } : {}), + nationalCodeOfDriver: driver.nationalCode, + driverBirthday: driver.birthday, + driverLicense: driver.licenseNumber, + licenseType: driver.licenseType, + userNoCertificate: + driver.hasDrivingLicense == null + ? input.userNoCertificate + : !driver.hasDrivingLicense, + nationalCodeOfInsurer: thirdPartyPolicyholder.nationalCode, + insurerBirthday: thirdPartyPolicyholder.birthday, + driverIsInsurer: sameDriverAndPolicyholder, + insurerLicense: sameDriverAndPolicyholder + ? driver.licenseNumber + : input.insurerLicense, + } as NormalizedInquirySubmission["dto"], + participants, + driver, + vehicleOwner, + thirdPartyPolicyholder, + carBodyPolicyholder, + vehicle, + }; +} diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index adffb84..b7f1aee 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -42,7 +42,10 @@ import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatu import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum"; import { resolveClaimOwnerFieldsFromBlame } from "src/helpers/blame-damaged-party"; import { ExpertAccidentFieldsDto } from "./dto/expert-accident-fields.dto"; -import { RunInquiriesV3Dto, RunInquiriesVinV3Dto } from "./dto/run-inquiries-v3.dto"; +import { + RunInquiriesV3Dto, + RunInquiriesVinV3Dto, +} from "./dto/run-inquiries-v3.dto"; import { SandHubService } from "src/sand-hub/sand-hub.service"; import { ReqBlameStatus } from "src/Types&Enums/blame-request-management/status.enum"; import { StepsEnum } from "src/Types&Enums/blame-request-management/steps.enum"; @@ -86,7 +89,10 @@ import { WorkflowStepDbService } from "src/workflow-step-management/entities/db- import { WorkflowStepModel } from "src/workflow-step-management/entities/schema/workflow-step.schema"; import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatus.enum"; import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum"; -import { BusinessRuleException, BusinessErrorCode } from "src/expert-claim/exceptions/business-rule.exception"; +import { + BusinessRuleException, + BusinessErrorCode, +} from "src/expert-claim/exceptions/business-rule.exception"; import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service"; import { ExpertFileActivityType, @@ -117,12 +123,19 @@ import { 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 { + NormalizedInquirySubmission, + normalizeInquirySubmission, + runPlateInquiryWithFallback, +} from "./inquiry-participant-resolver"; /** * Formats a compact Jalali date (number or string like 13780624) as YYYY/MM/DD. * Returns the original value as a string if it cannot be parsed. */ -function formatJalaliCompact(raw: number | string | null | undefined): string | null | undefined { +function formatJalaliCompact( + raw: number | string | null | undefined, +): string | null | undefined { if (raw == null) return raw as null | undefined; const s = String(raw).replace(/\D/g, ""); if (s.length === 8) { @@ -163,10 +176,7 @@ export class RequestManagementService { private throwCarBodyInquiryFailure(err: unknown): never { if (err instanceof ForbiddenException) throw err; - throw new HttpException( - "Car body inquiry failed", - HttpStatus.BAD_REQUEST, - ); + throw new HttpException("Car body inquiry failed", HttpStatus.BAD_REQUEST); } /** @@ -238,13 +248,33 @@ export class RequestManagementService { * ESG VIN inquiry via parsePlkString already returns the actual letter string, * but the mock and some Tejarat responses use the numeric code form. */ - private static readonly PLATE_LETTER_CODE_TO_LETTER: Record = { - 1: "الف", 2: "ب", 3: "پ", 4: "ج", 5: "د", - 6: "س", 7: "ص", 8: "ط", 9: "ع", 10: "ق", - 11: "ل", 12: "م", 13: "ن", 14: "و", 15: "ه", - 16: "ی", 17: "ک", 18: "ژ", 19: "ت", 20: "ث", - 21: "ز", 22: "ش", 23: "ف", 24: "گ", - }; + private static readonly PLATE_LETTER_CODE_TO_LETTER: Record = + { + 1: "الف", + 2: "ب", + 3: "پ", + 4: "ج", + 5: "د", + 6: "س", + 7: "ص", + 8: "ط", + 9: "ع", + 10: "ق", + 11: "ل", + 12: "م", + 13: "ن", + 14: "و", + 15: "ه", + 16: "ی", + 17: "ک", + 18: "ژ", + 19: "ت", + 20: "ث", + 21: "ز", + 22: "ش", + 23: "ف", + 24: "گ", + }; /** * Resolve a plate center letter from whatever shape Plk2 arrives in: @@ -275,6 +305,224 @@ export class RequestManagementService { .join("-"); } + private normalizeInquiryInput>( + type: BlameRequestType, + input: T, + ): NormalizedInquirySubmission { + return normalizeInquirySubmission(type, input); + } + + private applyInquiryParticipantsToParty( + party: any, + submission: NormalizedInquirySubmission>, + ): void { + party.participants = submission.participants.participants; + party.participantRoles = submission.participants.roles; + if (!party.vehicle) party.vehicle = {}; + if (submission.vehicle) { + party.vehicle.registrationState = submission.vehicle.registrationState; + party.vehicle.plateId = this.plateToPlateIdString( + submission.vehicle.currentPlate, + ); + party.vehicle.previousPlateId = submission.vehicle.previousPlate + ? this.plateToPlateIdString(submission.vehicle.previousPlate) + : undefined; + if (submission.vehicle.vin) party.vehicle.vin = submission.vehicle.vin; + } + } + + private async getThirdPartyPlateInquiry( + submission: NormalizedInquirySubmission>, + options?: Record, + ): Promise { + const result = await runPlateInquiryWithFallback({ + vehicle: submission.vehicle, + fallbackCurrentPlate: submission.dto.plate, + query: (plate) => + this.sandHubService.getTejaratBlockInquiry( + { + plate: plate as any, + nationalCodeOfInsurer: + submission.thirdPartyPolicyholder.nationalCode, + }, + options, + ), + isUsable: (value) => + !value?.mapped?.Error && !!value?.mapped?.CompanyName, + mappedValue: (value) => value?.mapped ?? {}, + }); + return { + ...result.value, + plateKind: result.plateKind, + attempts: result.attempts, + }; + } + + private async getCarBodyPlateInquiry( + submission: NormalizedInquirySubmission>, + ): Promise { + const policyholder = + submission.carBodyPolicyholder ?? submission.thirdPartyPolicyholder; + const result = await runPlateInquiryWithFallback({ + vehicle: submission.vehicle, + fallbackCurrentPlate: submission.dto.plate, + query: (plate) => + this.sandHubService.getCarBodyInquiry({ + nationalCodeOfInsurer: policyholder.nationalCode, + plate: plate as any, + }), + isUsable: (value) => + !!value?.mapped && + !!( + value.mapped.policyNumber || + value.mapped.CompanyName || + value.mapped.companyId + ), + mappedValue: (value) => value?.mapped ?? {}, + }); + return { + ...result.value, + plateKind: result.plateKind, + attempts: result.attempts, + }; + } + + private async runParticipantPersonalInquiries( + req: any, + role: PartyRole, + submission: NormalizedInquirySubmission>, + options?: Record, + ): Promise { + const participants = submission.participants.legacy + ? [submission.thirdPartyPolicyholder] + : submission.participants.participants; + const results: Record = {}; + + for (const participant of participants) { + if (!participant.nationalCode || !participant.birthday) { + throw new BadRequestException( + `${participant.participantId} requires nationalCode and birthday for personal inquiry.`, + ); + } + try { + results[participant.participantId] = + await this.sandHubService.getPersonalInquiry( + participant.nationalCode, + participant.birthday, + options, + ); + } catch (error: any) { + this.recordPartyCaseInquiryStatus( + req, + "person", + role, + false, + { + participantId: participant.participantId, + completedParticipants: results, + }, + error, + ); + throw new BadRequestException( + `${participant.participantId} personal identity inquiry failed: ${error?.message || "استعلام در دسترس نیست"}`, + ); + } + } + + this.recordPartyCaseInquiryStatus( + req, + "person", + role, + true, + submission.participants.legacy + ? results[submission.thirdPartyPolicyholder.participantId] + : { participants: results }, + ); + } + + private async runVehicleOwnershipInquiry( + req: any, + role: PartyRole, + submission: NormalizedInquirySubmission>, + options?: Record, + ): Promise { + if (submission.participants.legacy || !submission.vehicleOwner) return; + const plate = submission.vehicle?.currentPlate ?? submission.dto.plate; + if (!plate) { + throw new BadRequestException( + "Current plate is required for vehicle ownership inquiry.", + ); + } + try { + const result = await this.sandHubService.getCarOwnershipInfo( + plate, + submission.vehicleOwner.nationalCode, + options, + ); + this.recordPartyCaseInquiryStatus(req, "ownership", role, true, { + participantId: submission.vehicleOwner.participantId, + result, + }); + } catch (error: any) { + this.recordPartyCaseInquiryStatus( + req, + "ownership", + role, + false, + { participantId: submission.vehicleOwner.participantId }, + error, + ); + throw new BadRequestException( + `Vehicle ownership inquiry failed: ${error?.message || "استعلام در دسترس نیست"}`, + ); + } + } + + private async runRoleCompleteDriverLicenceInquiry( + req: any, + role: PartyRole, + submission: NormalizedInquirySubmission>, + options?: Record, + ): Promise { + if (submission.participants.legacy) return; + if (submission.driver.hasDrivingLicense === false) { + this.recordPartyCaseInquiryStatus(req, "drivingLicence", role, true, { + participantId: submission.driver.participantId, + skipped: true, + reason: "Driver declared no driving licence", + }); + return; + } + if (!submission.driver.licenseNumber) { + throw new BadRequestException( + "Driver licence number is required when the driver has a licence.", + ); + } + try { + const result = await this.sandHubService.getDrivingLicenseInfo( + submission.driver.nationalCode, + submission.driver.licenseNumber, + options, + ); + this.recordPartyCaseInquiryStatus(req, "drivingLicence", role, true, { + participantId: submission.driver.participantId, + result, + }); + } catch (error: any) { + this.recordPartyCaseInquiryStatus( + req, + "drivingLicence", + role, + false, + { participantId: submission.driver.participantId }, + error, + ); + throw new BadRequestException( + `Driver licence inquiry failed: ${error?.message || "استعلام در دسترس نیست"}`, + ); + } + } + private getPartyIndex(req: any, role: PartyRole): number { return Array.isArray(req.parties) ? req.parties.findIndex((p) => p?.role === role) @@ -410,7 +658,8 @@ export class RequestManagementService { const completed = Array.isArray(req.workflow.completedSteps) ? req.workflow.completedSteps : []; - if (!completed.includes(submittedStepKey)) completed.push(submittedStepKey); + if (!completed.includes(submittedStepKey)) + completed.push(submittedStepKey); // Also mark FIRST_INITIAL_FORM as completed so the step chain is consistent if (!completed.includes(WorkflowStep.FIRST_INITIAL_FORM)) { completed.push(WorkflowStep.FIRST_INITIAL_FORM); @@ -481,10 +730,11 @@ export class RequestManagementService { // V6 call-center THIRD_PARTY: guilt is pre-decided (first party is always guilty), // so skip the WAITING_FOR_GUILT_DECISION waiting state just like IN_PERSON. const isCallCenterThirdParty = - req.type === BlameRequestType.THIRD_PARTY && - !!req.callCenterInitiated; + req.type === BlameRequestType.THIRD_PARTY && !!req.callCenterInitiated; - const resolveStep = (step: WorkflowStep | undefined): WorkflowStep | undefined => { + const resolveStep = ( + step: WorkflowStep | undefined, + ): WorkflowStep | undefined => { if ( (isInPersonThirdParty || isCallCenterThirdParty) && step === WorkflowStep.WAITING_FOR_GUILT_DECISION @@ -516,8 +766,9 @@ export class RequestManagementService { ); const nextStepKey = resolveStep( - (sequentialNextStep?.stepKey ?? - submittedStep.nextPossibleSteps?.[0]) as WorkflowStep | undefined, + (sequentialNextStep?.stepKey ?? submittedStep.nextPossibleSteps?.[0]) as + | WorkflowStep + | undefined, ); if (!nextStepKey) { @@ -1324,11 +1575,17 @@ export class RequestManagementService { : undefined; } + const inquirySubmission = this.normalizeInquiryInput(req.type, body); + body = inquirySubmission.dto as unknown as AddPlateDto; + this.applyInquiryParticipantsToParty(party, inquirySubmission); + // Validation: driver/insurer sameness rules if (body.driverIsInsurer === false) { if ( body.nationalCodeOfInsurer === body.nationalCodeOfDriver || - body.insurerLicense === body.driverLicense + (body.insurerLicense && + body.driverLicense && + body.insurerLicense === body.driverLicense) ) { throw new BadRequestException( "Insurer and Driver should be two different persons in this mode.", @@ -1352,6 +1609,8 @@ export class RequestManagementService { let inquiryRaw: any; let inquiryMapped: any; let inquirySource = "TEJARAT_BLOCK_INQUIRY"; + let inquiryPlateKind = "CURRENT"; + let inquiryAttempts: any[] = []; const inquiryClientId = party.person?.clientId ? String(party.person.clientId) : undefined; @@ -1361,15 +1620,14 @@ export class RequestManagementService { inquiryClientId, ); try { - const inquiry = await this.sandHubService.getTejaratBlockInquiry( - { - plate: body.plate, - nationalCodeOfInsurer: body.nationalCodeOfInsurer, - }, + const inquiry = await this.getThirdPartyPlateInquiry( + inquirySubmission, inquiryOptions, ); inquiryRaw = inquiry.raw; inquiryMapped = inquiry.mapped; + inquiryPlateKind = inquiry.plateKind; + inquiryAttempts = inquiry.attempts; if (inquiry.offline?.source) { inquirySource = inquiry.offline.source; } @@ -1381,6 +1639,8 @@ export class RequestManagementService { ); this.recordPartyCaseInquiryStatus(req, "thirdParty", role, true, { source: inquirySource, + plateKind: inquiry.plateKind, + attempts: inquiry.attempts, raw: inquiryRaw, mapped: inquiryMapped, }); @@ -1433,87 +1693,24 @@ export class RequestManagementService { ); } - // ---- External inquiry 2: personal identity check (insurer/driver nationalCode + birthDate) ---- - // The form sends the birthday as a Jalali date in any of these shapes: - // - number 13770624 (packed YYYYMMDD) - // - string "1377-06-24" / "1377/06/24" / "13770624" - // We forward it as-is; sandHubService.getPersonalInquiry() handles the - // Jalali → Gregorian conversion required by the external API. - const personalNationalCode = - body.nationalCodeOfInsurer || body.nationalCodeOfDriver; - const personalBirthDate: number | string | null = (body.insurerBirthday ?? - body.driverBirthday ?? - null) as number | string | null; - if ( - !personalNationalCode || - personalBirthDate === null || - personalBirthDate === undefined || - String(personalBirthDate).trim() === "" - ) { - throw new BadRequestException( - "Valid nationalCode and birthDate are required for personal inquiry.", - ); - } - - try { - const personalInquiry = await this.sandHubService.getPersonalInquiry( - personalNationalCode, - personalBirthDate, - inquiryOptions, - ); - this.recordPartyCaseInquiryStatus( - req, - "person", - role, - true, - personalInquiry, - ); - this.logger.log( - `[SANDHUB] personal inquiry success request=${req._id} nationalCode=${personalNationalCode}: ${JSON.stringify( - personalInquiry, - )}`, - ); - } catch (err: any) { - this.logger.error( - `[SANDHUB] personal inquiry failed request=${req._id} nationalCode=${personalNationalCode}: ${err?.message || err}`, - ); - this.recordPartyCaseInquiryStatus(req, "person", role, false, {}, err); - await this.blameRequestDbService.findByIdAndUpdate(req._id, { - $set: { inquiries: req.inquiries }, - }); - throw new HttpException( - "Personal identity inquiry failed", - HttpStatus.BAD_REQUEST, - ); - } - - // ---- External inquiry 3: driving license check (insurerLicense + nationalCode) ---- - // const licenseNationalCode = body.nationalCodeOfInsurer || body.nationalCodeOfDriver; - // const licenseNumber = body.insurerLicense; - // if (!licenseNationalCode || !licenseNumber) { - // throw new BadRequestException( - // "nationalCode and insurerLicense are required for driving license inquiry.", - // ); - // } - // try { - // const licenseInquiry = await this.sandHubService.getDrivingLicenseInfo( - // licenseNationalCode, - // licenseNumber, - // ); - // this.logger.log( - // `[SANDHUB] license inquiry success request=${req._id} nationalCode=${licenseNationalCode}: ${JSON.stringify( - // licenseInquiry, - // )}`, - // ); - // } catch (err: any) { - // this.logger.error( - // `[SANDHUB] license inquiry failed request=${req._id} nationalCode=${licenseNationalCode}: ${err?.message || err}`, - // ); - // throw new HttpException( - // "Driving license inquiry failed", - // HttpStatus.BAD_REQUEST, - // ); - // } + await this.runParticipantPersonalInquiries( + req, + role, + inquirySubmission, + inquiryOptions, + ); + await this.runVehicleOwnershipInquiry( + req, + role, + inquirySubmission, + inquiryOptions, + ); + await this.runRoleCompleteDriverLicenceInquiry( + req, + role, + inquirySubmission, + inquiryOptions, + ); // Find client by company code const clientName = inquiryMapped?.CompanyName; @@ -1560,6 +1757,8 @@ export class RequestManagementService { party.vehicle.type = `${inquiryMapped?.UsageField} / ${inquiryMapped?.MapUsageName || "-"}`; party.vehicle.inquiry = { source: inquirySource, + plateKind: inquiryPlateKind, + attempts: inquiryAttempts, raw: inquiryRaw, mapped: inquiryMapped, }; @@ -1587,12 +1786,11 @@ export class RequestManagementService { if (req.type === BlameRequestType.CAR_BODY && role === PartyRole.FIRST) { let carBodyInfo: any; try { - carBodyInfo = await this.sandHubService.getCarBodyInquiry({ - nationalCodeOfInsurer: body.nationalCodeOfInsurer, - plate: body.plate, - }); + carBodyInfo = await this.getCarBodyPlateInquiry(inquirySubmission); this.recordPartyCaseInquiryStatus(req, "carBody", role, true, { source: carBodyInfo.source, + plateKind: carBodyInfo.plateKind, + attempts: carBodyInfo.attempts, raw: carBodyInfo.raw, mapped: carBodyInfo.mapped, }); @@ -1619,6 +1817,8 @@ export class RequestManagementService { ...party.vehicle.inquiry, carBody: { source: carBodyInfo.source, + plateKind: carBodyInfo.plateKind, + attempts: carBodyInfo.attempts, raw: carBodyInfo.raw, mapped: carBodyInfo.mapped, }, @@ -1691,6 +1891,8 @@ export class RequestManagementService { await this.blameRequestDbService.findByIdAndUpdate(req._id, { $set: { [`parties.${idx}.person`]: party.person, + [`parties.${idx}.participants`]: party.participants, + [`parties.${idx}.participantRoles`]: party.participantRoles, [`parties.${idx}.vehicle`]: party.vehicle, [`parties.${idx}.insurance`]: party.insurance, inquiries: req.inquiries, @@ -1778,11 +1980,17 @@ export class RequestManagementService { : undefined; } + const inquirySubmission = this.normalizeInquiryInput(req.type, body); + body = inquirySubmission.dto as unknown as InitialFormVinDto; + this.applyInquiryParticipantsToParty(party, inquirySubmission); + // Validation: driver/insurer sameness rules (identical to plate path) if (body.driverIsInsurer === false) { if ( body.nationalCodeOfInsurer === body.nationalCodeOfDriver || - body.insurerLicense === body.driverLicense + (body.insurerLicense && + body.driverLicense && + body.insurerLicense === body.driverLicense) ) { throw new BadRequestException( "Insurer and Driver should be two different persons in this mode.", @@ -1875,52 +2083,24 @@ export class RequestManagementService { ); } - // ---- External inquiry 2: personal identity (same as plate path) ---- - const personalNationalCode = - body.nationalCodeOfInsurer || body.nationalCodeOfDriver; - const personalBirthDate: number | string | null = (body.insurerBirthday ?? - body.driverBirthday ?? - null) as number | string | null; - if ( - !personalNationalCode || - personalBirthDate === null || - personalBirthDate === undefined || - String(personalBirthDate).trim() === "" - ) { - throw new BadRequestException( - "Valid nationalCode and birthDate are required for personal inquiry.", - ); - } - - try { - const personalInquiry = await this.sandHubService.getPersonalInquiry( - personalNationalCode, - personalBirthDate, - inquiryOptions, - ); - this.recordPartyCaseInquiryStatus( - req, - "person", - role, - true, - personalInquiry, - ); - this.logger.log( - `[SANDHUB] personal inquiry success request=${req._id} nationalCode=${personalNationalCode}: ${JSON.stringify(personalInquiry)}`, - ); - } catch (err: any) { - this.logger.error( - `[SANDHUB] personal inquiry failed request=${req._id} nationalCode=${personalNationalCode}: ${err?.message || err}`, - ); - this.recordPartyCaseInquiryStatus(req, "person", role, false, {}, err); - await this.blameRequestDbService.findByIdAndUpdate(req._id, { - $set: { inquiries: req.inquiries }, - }); - throw new HttpException( - "Personal identity inquiry failed", - HttpStatus.BAD_REQUEST, - ); - } + await this.runParticipantPersonalInquiries( + req, + role, + inquirySubmission, + inquiryOptions, + ); + await this.runVehicleOwnershipInquiry( + req, + role, + inquirySubmission, + inquiryOptions, + ); + await this.runRoleCompleteDriverLicenceInquiry( + req, + role, + inquirySubmission, + inquiryOptions, + ); // Resolve insurer client from inquiry response const clientName = inquiryMapped?.CompanyName; @@ -1962,14 +2142,16 @@ export class RequestManagementService { // Plk2 may be a numeric letter code (e.g. 5 → "د") or already a Persian // letter string — resolvePlk2Letter handles both forms. // Falls back to the submitted VIN so plateId is never empty. - party.vehicle.plateId = this.plateToPlateIdString({ - ir: inquiryMapped?.PlkSrl, - leftDigits: inquiryMapped?.Plk1, - centerAlphabet: this.resolvePlk2Letter( - inquiryMapped?.Plk2 ?? inquiryMapped?.plateLetterid, - ), - centerDigits: inquiryMapped?.Plk3, - }) || body.vin; + party.vehicle.plateId = inquirySubmission.vehicle?.currentPlate + ? this.plateToPlateIdString(inquirySubmission.vehicle.currentPlate) + : this.plateToPlateIdString({ + ir: inquiryMapped?.PlkSrl, + leftDigits: inquiryMapped?.Plk1, + centerAlphabet: this.resolvePlk2Letter( + inquiryMapped?.Plk2 ?? inquiryMapped?.plateLetterid, + ), + centerDigits: inquiryMapped?.Plk3, + }) || body.vin; party.vehicle.name = inquiryMapped?.MapTypNam; party.vehicle.type = `${inquiryMapped?.UsageField} / ${inquiryMapped?.MapUsageName || "-"}`; party.vehicle.inquiry = { @@ -2003,7 +2185,9 @@ export class RequestManagementService { if (req.type === BlameRequestType.CAR_BODY && role === PartyRole.FIRST) { try { const carBodyInfo = await this.sandHubService.getCarBodyInquiry({ - nationalCodeOfInsurer: body.nationalCodeOfInsurer, + nationalCodeOfInsurer: + inquirySubmission.carBodyPolicyholder?.nationalCode ?? + body.nationalCodeOfInsurer, plate: body.vin, }); this.recordPartyCaseInquiryStatus(req, "carBody", role, true, { @@ -2080,6 +2264,8 @@ export class RequestManagementService { await this.blameRequestDbService.findByIdAndUpdate(req._id, { $set: { [`parties.${idx}.person`]: party.person, + [`parties.${idx}.participants`]: party.participants, + [`parties.${idx}.participantRoles`]: party.participantRoles, [`parties.${idx}.vehicle`]: party.vehicle, [`parties.${idx}.insurance`]: party.insurance, inquiries: req.inquiries, @@ -2103,7 +2289,6 @@ export class RequestManagementService { } } - async addDetailLocationV2( requestId: string, body: LocationDto, @@ -2378,10 +2563,15 @@ export class RequestManagementService { // V6 call-center initiated THIRD_PARTY: first party is always the guilty party. // If blameStatus is still UNKNOWN here (pre-fix file or race condition), force it to AGREED now. - if (isSecondThirdParty && req.callCenterInitiated && req.blameStatus !== BlameStatus.AGREED) { + if ( + isSecondThirdParty && + req.callCenterInitiated && + req.blameStatus !== BlameStatus.AGREED + ) { const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); if (firstIdx !== -1) { - if (!req.parties[firstIdx].statement) req.parties[firstIdx].statement = {} as any; + if (!req.parties[firstIdx].statement) + req.parties[firstIdx].statement = {} as any; req.parties[firstIdx].statement.admitsGuilt = true; req.parties[firstIdx].statement.claimsDamage = false; req.parties[firstIdx].statement.acceptsExpertOpinion = false; @@ -3162,12 +3352,10 @@ export class RequestManagementService { setFields[ "firstPartyDetails.firstPartyCarBodyInsuranceDetail.coverages" ] = carBodyInfo.coverages; - setFields[ - "firstPartyDetails.firstPartyCarBodyInsuranceDetail.raw" - ] = carBodyInquiry.raw; - setFields[ - "firstPartyDetails.firstPartyCarBodyInsuranceDetail.source" - ] = carBodyInquiry.source; + setFields["firstPartyDetails.firstPartyCarBodyInsuranceDetail.raw"] = + carBodyInquiry.raw; + setFields["firstPartyDetails.firstPartyCarBodyInsuranceDetail.source"] = + carBodyInquiry.source; setFields[`${partyDetails}.${partyType}Client.clientId`] = await this.resolveCarBodyPolicyClientId(carBodyInfo); } @@ -4770,11 +4958,18 @@ export class RequestManagementService { /** * Verify the current user (field expert) can access this BlameRequest (v2). */ - private async verifyExpertAccessForBlameV2(req: any, expert: any): Promise { + private async verifyExpertAccessForBlameV2( + req: any, + expert: any, + ): Promise { // FILE_REVIEWER can access V4/V5 FileMaker-sealed files. // They must be the assigned reviewer (or the file is still open for taking). if (expert?.role === RoleEnum.FILE_REVIEWER) { - if (!req?.isMadeByFileMaker || !req?.expertInitiated || req?.creationMethod !== CreationMethod.IN_PERSON) { + if ( + !req?.isMadeByFileMaker || + !req?.expertInitiated || + req?.creationMethod !== CreationMethod.IN_PERSON + ) { throw new ForbiddenException( "FileReviewer can only access V4/V5 FileMaker files.", ); @@ -4811,12 +5006,14 @@ export class RequestManagementService { if (!assignedId) { // Atomically claim — ignore if another reviewer won the race (they would have // been caught by the assignedId check above on their own first call). - await this.fanavaranLocationService.assertMakerReviewerLocationCompatible({ - fileMakerId: req?.initiatedByFieldExpertId - ? String(req.initiatedByFieldExpertId) - : null, - fileReviewerId: String(expert.sub), - }); + await this.fanavaranLocationService.assertMakerReviewerLocationCompatible( + { + fileMakerId: req?.initiatedByFieldExpertId + ? String(req.initiatedByFieldExpertId) + : null, + fileReviewerId: String(expert.sub), + }, + ); await this.blameRequestDbService.findOneAndUpdate( { _id: req._id, @@ -4825,17 +5022,23 @@ export class RequestManagementService { { assignedFileReviewerId: null }, ], }, - { $set: { assignedFileReviewerId: new Types.ObjectId(String(expert.sub)) } }, + { + $set: { + assignedFileReviewerId: new Types.ObjectId(String(expert.sub)), + }, + }, { new: false }, ); } else { // Already assigned to this reviewer — re-check location compatibility. - await this.fanavaranLocationService.assertMakerReviewerLocationCompatible({ - fileMakerId: req?.initiatedByFieldExpertId - ? String(req.initiatedByFieldExpertId) - : null, - fileReviewerId: String(expert.sub), - }); + await this.fanavaranLocationService.assertMakerReviewerLocationCompatible( + { + fileMakerId: req?.initiatedByFieldExpertId + ? String(req.initiatedByFieldExpertId) + : null, + fileReviewerId: String(expert.sub), + }, + ); } return; } @@ -5130,7 +5333,9 @@ export class RequestManagementService { ...(isFileMakerRole ? { isMadeByFileMaker: true } : {}), // V5 only: flag that causes the resulting claim to require FileMaker // approval before fanavaran submission. V4 files never set this. - ...(dto.requiresFileMakerApproval ? { requiresFileMakerApproval: true } : {}), + ...(dto.requiresFileMakerApproval + ? { requiresFileMakerApproval: true } + : {}), }); const requestId = String((created as any)._id); @@ -5739,7 +5944,8 @@ export class RequestManagementService { type: "AUTO_CONFESSION_SKIPPED", actor: { actorId: new Types.ObjectId(actor.sub), - actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(), + actorName: + `${actor.firstName || ""} ${actor.lastName || ""}`.trim(), actorType: this.resolveBlameHistoryActorType(actor), }, metadata: { @@ -5765,8 +5971,7 @@ export class RequestManagementService { type: "SECOND_PARTY_OTP_VERIFIED_ADVANCED", actor: { actorId: new Types.ObjectId(actor.sub), - actorName: - `${actor.firstName || ""} ${actor.lastName || ""}`.trim(), + actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(), actorType: this.resolveBlameHistoryActorType(actor), }, metadata: { @@ -6370,20 +6575,44 @@ export class RequestManagementService { formData.firstPartyPhoneNumber, ); - const firstPartyPlate = formData.firstPartyPlate; + const inquirySubmission = this.normalizeInquiryInput( + req.type, + formData.firstPartyPlate, + ); + const firstPartyPlate = inquirySubmission.dto; let sandHubReport: any; try { - const sandHubResponse = await this.sandHubService.getSandHubResponse({ - plate: firstPartyPlate.plate, - nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer, - }); - sandHubReport = sandHubResponse["_doc"] || sandHubResponse; + const inquiry = inquirySubmission.participants.legacy + ? await (async () => { + const response = await this.sandHubService.getSandHubResponse({ + plate: firstPartyPlate.plate, + nationalCodeOfInsurer: + firstPartyPlate.nationalCodeOfInsurer, + }); + const mapped = response["_doc"] || response; + return { + raw: mapped, + mapped, + plateKind: "CURRENT", + attempts: [ + { plateKind: "CURRENT", succeeded: true, usable: true }, + ], + }; + })() + : await this.getThirdPartyPlateInquiry(inquirySubmission); + sandHubReport = inquiry.mapped; this.recordPartyCaseInquiryStatus( req, "thirdParty", PartyRole.FIRST, true, - sandHubReport, + { + source: inquiry.offline?.source ?? "TEJARAT_BLOCK_INQUIRY", + plateKind: inquiry.plateKind, + attempts: inquiry.attempts, + raw: inquiry.raw, + mapped: inquiry.mapped, + }, ); } catch (e) { this.logger.error("SandHub error in expertCompleteCarBodyFormV2:", e); @@ -6416,25 +6645,38 @@ export class RequestManagementService { ); } - const carBodyInquiry = await this.sandHubService.getCarBodyInquiry({ - nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer, - plate: firstPartyPlate.plate, - } as any); + const carBodyInquiry = await this.getCarBodyPlateInquiry(inquirySubmission); const carBodyInfo = carBodyInquiry.mapped as any; - this.recordPartyCaseInquiryStatus( - req, - "carBody", - PartyRole.FIRST, - true, - { - source: carBodyInquiry.source, - raw: carBodyInquiry.raw, - mapped: carBodyInfo, - }, - ); - const carBodyClientId = await this.resolveCarBodyPolicyClientId( - carBodyInfo, - ); + this.recordPartyCaseInquiryStatus(req, "carBody", PartyRole.FIRST, true, { + source: carBodyInquiry.source, + raw: carBodyInquiry.raw, + mapped: carBodyInfo, + }); + const carBodyClientId = + await this.resolveCarBodyPolicyClientId(carBodyInfo); + if (!inquirySubmission.participants.legacy) { + const options = carBodyClientId + ? { clientId: String(carBodyClientId) } + : undefined; + await this.runParticipantPersonalInquiries( + req, + PartyRole.FIRST, + inquirySubmission, + options, + ); + await this.runVehicleOwnershipInquiry( + req, + PartyRole.FIRST, + inquirySubmission, + options, + ); + await this.runRoleCompleteDriverLicenceInquiry( + req, + PartyRole.FIRST, + inquirySubmission, + options, + ); + } // Gate stale CAR_BODY submissions against the body-policy insurer, not // the unrelated third-party insurer resolved above. @@ -6448,6 +6690,8 @@ export class RequestManagementService { const firstParty: any = { role: PartyRole.FIRST, + participants: inquirySubmission.participants.participants, + participantRoles: inquirySubmission.participants.roles, person: { userId: firstPartyUserId, phoneNumber: @@ -6472,6 +6716,11 @@ export class RequestManagementService { model: sandHubReport?.MapTypNam, type: `${sandHubReport?.UsageField || ""} / ${sandHubReport?.MapUsageName || "-"}`, isNew: firstPartyPlate.isNewCar, + registrationState: inquirySubmission.vehicle?.registrationState, + previousPlateId: inquirySubmission.vehicle?.previousPlate + ? this.plateToPlateIdString(inquirySubmission.vehicle.previousPlate) + : undefined, + vin: inquirySubmission.vehicle?.vin, inquiry: { ...sandHubReport, carBody: { @@ -6584,9 +6833,7 @@ export class RequestManagementService { "secondParty and guiltyPartyPhoneNumber are required.", ); } - if ( - formData.guiltyPartyPhoneNumber !== formData.firstPartyPhoneNumber - ) { + if (formData.guiltyPartyPhoneNumber !== formData.firstPartyPhoneNumber) { throw new BadRequestException( "The first party is the guilty party in this flow.", ); @@ -6610,12 +6857,27 @@ export class RequestManagementService { desc: string, role: PartyRole, ) => { - let sandHubResponse: any; + const inquirySubmission = this.normalizeInquiryInput(req.type, plateDto); + plateDto = inquirySubmission.dto; + let inquiry: any; try { - sandHubResponse = await this.sandHubService.getSandHubResponse({ - plate: plateDto.plate, - nationalCodeOfInsurer: plateDto.nationalCodeOfInsurer, - }); + if (inquirySubmission.participants.legacy) { + const response = await this.sandHubService.getSandHubResponse({ + plate: plateDto.plate, + nationalCodeOfInsurer: plateDto.nationalCodeOfInsurer, + }); + const mapped = response["_doc"] || response; + inquiry = { + raw: mapped, + mapped, + plateKind: "CURRENT", + attempts: [ + { plateKind: "CURRENT", succeeded: true, usable: true }, + ], + }; + } else { + inquiry = await this.getThirdPartyPlateInquiry(inquirySubmission); + } } catch (err: any) { this.recordPartyCaseInquiryStatus( req, @@ -6628,20 +6890,20 @@ export class RequestManagementService { await (req as any).save(); throw err; } - const sandHubReport = (sandHubResponse["_doc"] || sandHubResponse) as any; + const sandHubReport = inquiry.mapped as any; if (role === PartyRole.FIRST) { this.sandHubService.assertInsuranceMatchesDeployment( sandHubReport, "THIRD_PARTY", ); } - this.recordPartyCaseInquiryStatus( - req, - "thirdParty", - role, - true, - sandHubReport, - ); + this.recordPartyCaseInquiryStatus(req, "thirdParty", role, true, { + source: inquiry.offline?.source ?? "TEJARAT_BLOCK_INQUIRY", + plateKind: inquiry.plateKind, + attempts: inquiry.attempts, + raw: inquiry.raw, + mapped: inquiry.mapped, + }); const clientName = sandHubReport?.CompanyName || sandHubReport?.LastCompanyName; const companyCode = sandHubReport?.CompanyCode; @@ -6658,8 +6920,33 @@ export class RequestManagementService { } const resolvedClientId = (client as any)?._id ?? (client as any)?._doc?._id; + if (!inquirySubmission.participants.legacy) { + const options = resolvedClientId + ? { clientId: String(resolvedClientId) } + : undefined; + await this.runParticipantPersonalInquiries( + req, + role, + inquirySubmission, + options, + ); + await this.runVehicleOwnershipInquiry( + req, + role, + inquirySubmission, + options, + ); + await this.runRoleCompleteDriverLicenceInquiry( + req, + role, + inquirySubmission, + options, + ); + } return { role, + participants: inquirySubmission.participants.participants, + participantRoles: inquirySubmission.participants.roles, person: { userId, phoneNumber: normalizeIranMobile(phoneNumber) ?? phoneNumber, @@ -6682,6 +6969,11 @@ export class RequestManagementService { model: sandHubReport?.MapTypNam, type: `${sandHubReport?.UsageField || ""} / ${sandHubReport?.MapUsageName || "-"}`, isNew: plateDto.isNewCar, + registrationState: inquirySubmission.vehicle?.registrationState, + previousPlateId: inquirySubmission.vehicle?.previousPlate + ? this.plateToPlateIdString(inquirySubmission.vehicle.previousPlate) + : undefined, + vin: inquirySubmission.vehicle?.vin, inquiry: sandHubReport, }, insurance: { @@ -7248,9 +7540,7 @@ export class RequestManagementService { // The first party is the guilty party in every supported THIRD_PARTY flow. if (request.type === "THIRD_PARTY") { - if ( - formData.guiltyPartyPhoneNumber !== formData.firstPartyPhoneNumber - ) { + if (formData.guiltyPartyPhoneNumber !== formData.firstPartyPhoneNumber) { throw new BadRequestException( "The first party is the guilty party in this flow.", ); @@ -8822,6 +9112,9 @@ export class RequestManagementService { partyRole: PartyRole, party: any, ): Promise<{ clientId?: string }> { + const inquirySubmission = this.normalizeInquiryInput(req.type, partyData); + partyData = inquirySubmission.dto as RunInquiriesV3Dto; + this.applyInquiryParticipantsToParty(party, inquirySubmission); const roleLabel = partyRole === PartyRole.FIRST ? "FIRST" : "SECOND"; let clientId: string | undefined; const inquiryOptions = (cid?: string) => @@ -8830,21 +9123,24 @@ export class RequestManagementService { let inquiryRaw: any; let inquiryMapped: any; let inquirySource = "TEJARAT_BLOCK_INQUIRY"; + let inquiryPlateKind = "CURRENT"; + let inquiryAttempts: any[] = []; try { - const inquiry = await this.sandHubService.getTejaratBlockInquiry( - { - plate: partyData.plate as any, - nationalCodeOfInsurer: partyData.nationalCodeOfInsurer, - }, + const inquiry = await this.getThirdPartyPlateInquiry( + inquirySubmission, inquiryOptions(party?.person?.clientId?.toString()), ); inquiryRaw = inquiry.raw; inquiryMapped = inquiry.mapped; + inquiryPlateKind = inquiry.plateKind; + inquiryAttempts = inquiry.attempts; 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, }); @@ -8917,6 +9213,8 @@ export class RequestManagementService { party.vehicle.type = `${inquiryMapped?.UsageField ?? ""} / ${inquiryMapped?.UsageName ?? inquiryMapped?.MapUsageName ?? "-"}`; party.vehicle.inquiry = { source: inquirySource, + plateKind: inquiryPlateKind, + attempts: inquiryAttempts, raw: inquiryRaw, mapped: inquiryMapped, }; @@ -8945,12 +9243,12 @@ export class RequestManagementService { partyRole === PartyRole.FIRST ) { try { - const carBodyInfo = await this.sandHubService.getCarBodyInquiry({ - nationalCodeOfInsurer: partyData.nationalCodeOfInsurer, - plate: partyData.plate as any, - }); + const carBodyInfo = + await this.getCarBodyPlateInquiry(inquirySubmission); this.recordPartyCaseInquiryStatus(req, "carBody", partyRole, true, { source: carBodyInfo.source, + plateKind: carBodyInfo.plateKind, + attempts: carBodyInfo.attempts, raw: carBodyInfo.raw, mapped: carBodyInfo.mapped, }); @@ -8958,6 +9256,8 @@ export class RequestManagementService { ...party.vehicle.inquiry, carBody: { source: carBodyInfo.source, + plateKind: carBodyInfo.plateKind, + attempts: carBodyInfo.attempts, raw: carBodyInfo.raw, mapped: carBodyInfo.mapped, }, @@ -9017,40 +9317,18 @@ export class RequestManagementService { } } - const nationalCode = - partyData.nationalCodeOfInsurer || partyData.nationalCodeOfDriver; - const birthDate = partyData.insurerBirthday ?? partyData.driverBirthday; - if (nationalCode && birthDate != null) { - try { - const personalInquiry = await this.sandHubService.getPersonalInquiry( - nationalCode, - birthDate as string | number, - clientId ? { clientId } : undefined, - ); - this.recordPartyCaseInquiryStatus( - req, - "person", - partyRole, - true, - personalInquiry, - ); - } catch (err: any) { - this.logger.error( - `[V3] personal inquiry failed for ${roleLabel} party request=${req._id}: ${err?.message || err}`, - ); - this.recordPartyCaseInquiryStatus( - req, - "person", - partyRole, - false, - {}, - err, - ); - throw new BadRequestException( - `${roleLabel} party personal identity inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`, - ); - } - } + await this.runParticipantPersonalInquiries( + req, + partyRole, + inquirySubmission, + clientId ? { clientId } : undefined, + ); + await this.runVehicleOwnershipInquiry( + req, + partyRole, + inquirySubmission, + clientId ? { clientId } : undefined, + ); const licenseNationalCode = partyData.driverIsInsurer ? partyData.nationalCodeOfInsurer @@ -9126,11 +9404,19 @@ export class RequestManagementService { if (partyRole === PartyRole.FIRST) { const completedSteps = skipLocation - ? [WorkflowStep.CREATED, WorkflowStep.FIRST_INITIAL_FORM, WorkflowStep.FIRST_LOCATION] + ? [ + WorkflowStep.CREATED, + WorkflowStep.FIRST_INITIAL_FORM, + WorkflowStep.FIRST_LOCATION, + ] : [WorkflowStep.CREATED, WorkflowStep.FIRST_INITIAL_FORM]; this.pushWorkflowSteps(req, completedSteps); - req.workflow.currentStep = skipLocation ? WorkflowStep.FIRST_VOICE : WorkflowStep.FIRST_LOCATION; - req.workflow.nextStep = skipLocation ? WorkflowStep.FIRST_DESCRIPTION : WorkflowStep.FIRST_VOICE; + req.workflow.currentStep = skipLocation + ? WorkflowStep.FIRST_VOICE + : WorkflowStep.FIRST_LOCATION; + req.workflow.nextStep = skipLocation + ? WorkflowStep.FIRST_DESCRIPTION + : WorkflowStep.FIRST_VOICE; const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); const guiltyUserId = req.parties?.[firstIdx]?.person?.userId; @@ -9149,8 +9435,12 @@ export class RequestManagementService { ? [WorkflowStep.SECOND_INITIAL_FORM, WorkflowStep.SECOND_LOCATION] : [WorkflowStep.SECOND_INITIAL_FORM]; this.pushWorkflowSteps(req, completedSteps); - req.workflow.currentStep = skipLocation ? WorkflowStep.SECOND_VOICE : WorkflowStep.SECOND_LOCATION; - req.workflow.nextStep = skipLocation ? WorkflowStep.SECOND_DESCRIPTION : WorkflowStep.SECOND_VOICE; + req.workflow.currentStep = skipLocation + ? WorkflowStep.SECOND_VOICE + : WorkflowStep.SECOND_LOCATION; + req.workflow.nextStep = skipLocation + ? WorkflowStep.SECOND_DESCRIPTION + : WorkflowStep.SECOND_VOICE; } } @@ -9393,6 +9683,7 @@ 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) { @@ -9529,6 +9820,7 @@ 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) { @@ -9660,6 +9952,9 @@ export class RequestManagementService { partyRole: PartyRole, party: any, ): Promise<{ clientId?: string }> { + const inquirySubmission = this.normalizeInquiryInput(req.type, partyData); + partyData = inquirySubmission.dto as RunInquiriesVinV3Dto; + this.applyInquiryParticipantsToParty(party, inquirySubmission); const roleLabel = partyRole === PartyRole.FIRST ? "FIRST" : "SECOND"; let clientId: string | undefined; const inquiryOptions = (cid?: string) => @@ -9743,14 +10038,16 @@ export class RequestManagementService { // Plk2 may be a numeric letter code (e.g. 5 → "د") or already a Persian // letter string — resolvePlk2Letter handles both forms. // Falls back to the submitted VIN so plateId is never empty. - party.vehicle.plateId = this.plateToPlateIdString({ - ir: inquiryMapped?.PlkSrl, - leftDigits: inquiryMapped?.Plk1, - centerAlphabet: this.resolvePlk2Letter( - inquiryMapped?.Plk2 ?? inquiryMapped?.plateLetterid, - ), - centerDigits: inquiryMapped?.Plk3, - }) || partyData.vin; + party.vehicle.plateId = inquirySubmission.vehicle?.currentPlate + ? this.plateToPlateIdString(inquirySubmission.vehicle.currentPlate) + : this.plateToPlateIdString({ + ir: inquiryMapped?.PlkSrl, + leftDigits: inquiryMapped?.Plk1, + centerAlphabet: this.resolvePlk2Letter( + inquiryMapped?.Plk2 ?? inquiryMapped?.plateLetterid, + ), + centerDigits: inquiryMapped?.Plk3, + }) || partyData.vin; party.vehicle.name = inquiryMapped?.MapTypNam; party.vehicle.type = `${inquiryMapped?.UsageField ?? ""} / ${inquiryMapped?.UsageName ?? inquiryMapped?.MapUsageName ?? "-"}`; party.vehicle.inquiry = { @@ -9784,7 +10081,9 @@ export class RequestManagementService { ) { try { const carBodyInfo = await this.sandHubService.getCarBodyInquiry({ - nationalCodeOfInsurer: partyData.nationalCodeOfInsurer, + nationalCodeOfInsurer: + inquirySubmission.carBodyPolicyholder?.nationalCode ?? + partyData.nationalCodeOfInsurer, plate: partyData.vin as any, // VIN used as identifier for CAR_BODY }); this.recordPartyCaseInquiryStatus(req, "carBody", partyRole, true, { @@ -9855,40 +10154,18 @@ export class RequestManagementService { } } - const nationalCode = - partyData.nationalCodeOfInsurer || partyData.nationalCodeOfDriver; - const birthDate = partyData.insurerBirthday ?? partyData.driverBirthday; - if (nationalCode && birthDate != null) { - try { - const personalInquiry = await this.sandHubService.getPersonalInquiry( - nationalCode, - birthDate as string | number, - clientId ? { clientId } : undefined, - ); - this.recordPartyCaseInquiryStatus( - req, - "person", - partyRole, - true, - personalInquiry, - ); - } catch (err: any) { - this.logger.error( - `[V3-VIN] personal inquiry failed for ${roleLabel} party request=${req._id}: ${err?.message || err}`, - ); - this.recordPartyCaseInquiryStatus( - req, - "person", - partyRole, - false, - {}, - err, - ); - throw new BadRequestException( - `${roleLabel} party personal identity inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`, - ); - } - } + await this.runParticipantPersonalInquiries( + req, + partyRole, + inquirySubmission, + clientId ? { clientId } : undefined, + ); + await this.runVehicleOwnershipInquiry( + req, + partyRole, + inquirySubmission, + clientId ? { clientId } : undefined, + ); const licenseNationalCode = partyData.driverIsInsurer ? partyData.nationalCodeOfInsurer @@ -10174,7 +10451,8 @@ export class RequestManagementService { type: "V3_ACCIDENT_FIELDS_SAVED", actor: { actorId: new Types.ObjectId(String(expert.sub)), - actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), + actorName: + `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), actorType: this.resolveBlameHistoryActorType(expert), }, metadata: { accidentWay: fields.accidentWay }, @@ -10255,8 +10533,7 @@ export class RequestManagementService { if ( claim.workflow?.currentStep !== ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS && - claim.workflow?.currentStep !== - ClaimWorkflowStep.USER_SUBMISSION_COMPLETE + claim.workflow?.currentStep !== ClaimWorkflowStep.USER_SUBMISSION_COMPLETE ) { throw new BadRequestException( "Complete capture-part (parts, angles, and capture-phase documents) before the blame accident video.", @@ -10564,7 +10841,8 @@ export class RequestManagementService { const party = req.parties[idx]; if (!party.statement) party.statement = {} as any; party.statement.description = body.desc; - if (body.accidentDate != null) party.statement.accidentDate = body.accidentDate as any; + if (body.accidentDate != null) + party.statement.accidentDate = body.accidentDate as any; if (body.accidentTime) party.statement.accidentTime = body.accidentTime; if (!Array.isArray(req.history)) req.history = []; @@ -10771,7 +11049,8 @@ export class RequestManagementService { type: "V5_BLAME_ACCIDENT_VIDEO_UPLOADED", actor: { actorId: new Types.ObjectId(expert.sub), - actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), + actorName: + `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), actorType: "file_reviewer", }, timestamp: new Date(), @@ -10836,7 +11115,8 @@ export class RequestManagementService { ); } - const actorName = `${fileMaker.firstName || ""} ${fileMaker.lastName || ""}`.trim(); + const actorName = + `${fileMaker.firstName || ""} ${fileMaker.lastName || ""}`.trim(); const historyEntry = { type: "V5_FILE_MAKER_APPROVED", actor: { @@ -10913,7 +11193,8 @@ export class RequestManagementService { } const newRejectionCount = currentRejections + 1; - const actorName = `${fileMaker.firstName || ""} ${fileMaker.lastName || ""}`.trim(); + const actorName = + `${fileMaker.firstName || ""} ${fileMaker.lastName || ""}`.trim(); await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { $set: { @@ -10949,7 +11230,10 @@ export class RequestManagementService { actorType: "file_maker", }, timestamp: new Date(), - metadata: { reason: reason ?? null, rejectionCount: newRejectionCount }, + metadata: { + reason: reason ?? null, + rejectionCount: newRejectionCount, + }, }, }, }); @@ -11023,7 +11307,9 @@ export class RequestManagementService { dto: { type: "THIRD_PARTY" | "CAR_BODY" }, ): Promise<{ requestId: string; publicId: string }> { if (agent?.role !== RoleEnum.CALL_CENTER) { - throw new ForbiddenException("Only call-center agents can use this endpoint."); + throw new ForbiddenException( + "Only call-center agents can use this endpoint.", + ); } const agentId = new Types.ObjectId(agent.sub); const type = @@ -11098,19 +11384,33 @@ export class RequestManagementService { dto: Omit, ): Promise> { if (agent?.role !== RoleEnum.CALL_CENTER) { - throw new ForbiddenException("Only call-center agents can use this endpoint."); + throw new ForbiddenException( + "Only call-center agents can use this endpoint.", + ); } const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Blame request not found"); - if (!req.callCenterInitiated || String(req.initiatedByCallCenterId) !== String(agent.sub)) { - throw new ForbiddenException("You can only access files that you have initiated."); + if ( + !req.callCenterInitiated || + String(req.initiatedByCallCenterId) !== String(agent.sub) + ) { + throw new ForbiddenException( + "You can only access files that you have initiated.", + ); } const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); if (firstIdx === -1) throw new BadRequestException("First party not found"); const firstParty = req.parties[firstIdx]; - await this.runPartyInquiriesV3Internal(req, dto, PartyRole.FIRST, firstParty); + dto = this.normalizeInquiryInput(req.type, dto).dto; + + await this.runPartyInquiriesV3Internal( + req, + dto, + PartyRole.FIRST, + firstParty, + ); // V6 call-center guard: the guilty party's insurance must belong to THIS // deployment's insurer company. Reject the file early so the agent cannot @@ -11152,11 +11452,17 @@ export class RequestManagementService { publicId: (req as any).publicId, type: (req as any).type, status: (req as any).status, - message: "استعلام طرف مقصر با موفقیت انجام شد. اکنون می‌توانید لینک تقصیر را برای کاربر ارسال کنید.", + message: + "استعلام طرف مقصر با موفقیت انجام شد. اکنون می‌توانید لینک تقصیر را برای کاربر ارسال کنید.", guiltyParty: { + participants: firstPartyAfter?.participants, + participantRoles: firstPartyAfter?.participantRoles, vehicle: firstPartyAfter?.vehicle ? { plateId: firstPartyAfter.vehicle.plateId, + previousPlateId: firstPartyAfter.vehicle.previousPlateId, + registrationState: firstPartyAfter.vehicle.registrationState, + vin: firstPartyAfter.vehicle.vin, name: firstPartyAfter.vehicle.name, type: firstPartyAfter.vehicle.type, } @@ -11167,16 +11473,22 @@ export class RequestManagementService { policyNumber: firstPartyAfter.insurance.policyNumber, startDate: firstPartyAfter.insurance.startDate, endDate: firstPartyAfter.insurance.endDate, - financialCeiling: (firstPartyAfter.insurance as any).financialCeiling, + financialCeiling: (firstPartyAfter.insurance as any) + .financialCeiling, } : undefined, person: firstPartyAfter?.person ? { - nationalCodeOfInsurer: firstPartyAfter.person.nationalCodeOfInsurer, + nationalCodeOfInsurer: + firstPartyAfter.person.nationalCodeOfInsurer, nationalCodeOfDriver: firstPartyAfter.person.nationalCodeOfDriver, driverIsInsurer: firstPartyAfter.person.driverIsInsurer, - insurerBirthday: formatJalaliCompact(firstPartyAfter.person.insurerBirthday), - driverBirthday: formatJalaliCompact(firstPartyAfter.person.driverBirthday), + insurerBirthday: formatJalaliCompact( + firstPartyAfter.person.insurerBirthday, + ), + driverBirthday: formatJalaliCompact( + firstPartyAfter.person.driverBirthday, + ), } : undefined, }, @@ -11195,19 +11507,33 @@ export class RequestManagementService { dto: Omit, ): Promise> { if (agent?.role !== RoleEnum.CALL_CENTER) { - throw new ForbiddenException("Only call-center agents can use this endpoint."); + throw new ForbiddenException( + "Only call-center agents can use this endpoint.", + ); } const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Blame request not found"); - if (!req.callCenterInitiated || String(req.initiatedByCallCenterId) !== String(agent.sub)) { - throw new ForbiddenException("You can only access files that you have initiated."); + if ( + !req.callCenterInitiated || + String(req.initiatedByCallCenterId) !== String(agent.sub) + ) { + throw new ForbiddenException( + "You can only access files that you have initiated.", + ); } const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); if (firstIdx === -1) throw new BadRequestException("First party not found"); const firstParty = req.parties[firstIdx]; - await this.runPartyInquiriesVinV3Internal(req, dto as RunInquiriesVinV3Dto, PartyRole.FIRST, firstParty); + dto = this.normalizeInquiryInput(req.type, dto).dto; + + await this.runPartyInquiriesVinV3Internal( + req, + dto as RunInquiriesVinV3Dto, + PartyRole.FIRST, + firstParty, + ); // Same insurer-company guard as the plate variant. const resolvedClientId = req.parties[firstIdx]?.person?.clientId; @@ -11243,11 +11569,17 @@ export class RequestManagementService { publicId: (req as any).publicId, type: (req as any).type, status: (req as any).status, - message: "استعلام VIN طرف مقصر با موفقیت انجام شد. اکنون می‌توانید لینک تقصیر را برای کاربر ارسال کنید.", + message: + "استعلام VIN طرف مقصر با موفقیت انجام شد. اکنون می‌توانید لینک تقصیر را برای کاربر ارسال کنید.", guiltyParty: { + participants: firstPartyAfter?.participants, + participantRoles: firstPartyAfter?.participantRoles, vehicle: firstPartyAfter?.vehicle ? { vin: firstPartyAfter.vehicle.vin, + plateId: firstPartyAfter.vehicle.plateId, + previousPlateId: firstPartyAfter.vehicle.previousPlateId, + registrationState: firstPartyAfter.vehicle.registrationState, name: firstPartyAfter.vehicle.name, type: firstPartyAfter.vehicle.type, } @@ -11258,16 +11590,22 @@ export class RequestManagementService { policyNumber: firstPartyAfter.insurance.policyNumber, startDate: firstPartyAfter.insurance.startDate, endDate: firstPartyAfter.insurance.endDate, - financialCeiling: (firstPartyAfter.insurance as any).financialCeiling, + financialCeiling: (firstPartyAfter.insurance as any) + .financialCeiling, } : undefined, person: firstPartyAfter?.person ? { - nationalCodeOfInsurer: firstPartyAfter.person.nationalCodeOfInsurer, + nationalCodeOfInsurer: + firstPartyAfter.person.nationalCodeOfInsurer, nationalCodeOfDriver: firstPartyAfter.person.nationalCodeOfDriver, driverIsInsurer: firstPartyAfter.person.driverIsInsurer, - insurerBirthday: formatJalaliCompact(firstPartyAfter.person.insurerBirthday), - driverBirthday: formatJalaliCompact(firstPartyAfter.person.driverBirthday), + insurerBirthday: formatJalaliCompact( + firstPartyAfter.person.insurerBirthday, + ), + driverBirthday: formatJalaliCompact( + firstPartyAfter.person.driverBirthday, + ), } : undefined, }, @@ -11283,24 +11621,42 @@ export class RequestManagementService { agent: any, requestId: string, dto: { phoneNumber: string }, - ): Promise<{ sent: boolean; sentTo: { role: string; phoneNumber: string; smsSent: boolean; linkUrl: string }[] }> { + ): Promise<{ + sent: boolean; + sentTo: { + role: string; + phoneNumber: string; + smsSent: boolean; + linkUrl: string; + }[]; + }> { if (agent?.role !== RoleEnum.CALL_CENTER) { - throw new ForbiddenException("Only call-center agents can use this endpoint."); + throw new ForbiddenException( + "Only call-center agents can use this endpoint.", + ); } const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); - if (!req.callCenterInitiated || String(req.initiatedByCallCenterId) !== String(agent.sub)) { - throw new ForbiddenException("You can only access files that you have initiated."); + if ( + !req.callCenterInitiated || + String(req.initiatedByCallCenterId) !== String(agent.sub) + ) { + throw new ForbiddenException( + "You can only access files that you have initiated.", + ); } if (!process.env.URL) { - throw new InternalServerErrorException("URL environment variable is not configured"); + throw new InternalServerErrorException( + "URL environment variable is not configured", + ); } const phone = (dto?.phoneNumber || "").trim(); if (!phone) throw new BadRequestException("phoneNumber is required"); const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); - if (firstIdx === -1) throw new BadRequestException("First party not found on request"); + if (firstIdx === -1) + throw new BadRequestException("First party not found on request"); // Safeguard: verify that run-inquiry was completed and that the resolved // insurance company belongs to this deployment before proceeding. @@ -11326,7 +11682,8 @@ export class RequestManagementService { const userId = await this.getOrCreateUserByPhoneNumber(phone); if (!req.parties[firstIdx].person) req.parties[firstIdx].person = {} as any; - req.parties[firstIdx].person.phoneNumber = normalizeIranMobile(phone) ?? phone; + req.parties[firstIdx].person.phoneNumber = + normalizeIranMobile(phone) ?? phone; req.parties[firstIdx].person.userId = userId; // V6 call-center flow: the first party is always the guilty party. @@ -11338,9 +11695,15 @@ export class RequestManagementService { if (req.type === BlameRequestType.CAR_BODY) { // CAR_BODY: advance CREATED → CAR_BODY_ACCIDENT_TYPE - const carBodyStepDoc = await this.getWorkflowStep({ stepKey: WorkflowStep.CAR_BODY_ACCIDENT_TYPE }); - const afterCarBody = (carBodyStepDoc?.nextPossibleSteps?.[0] as WorkflowStep) ?? WorkflowStep.FIRST_VIDEO; - const completedSteps = Array.isArray(req.workflow.completedSteps) ? req.workflow.completedSteps : []; + const carBodyStepDoc = await this.getWorkflowStep({ + stepKey: WorkflowStep.CAR_BODY_ACCIDENT_TYPE, + }); + const afterCarBody = + (carBodyStepDoc?.nextPossibleSteps?.[0] as WorkflowStep) ?? + WorkflowStep.FIRST_VIDEO; + const completedSteps = Array.isArray(req.workflow.completedSteps) + ? req.workflow.completedSteps + : []; req.workflow.completedSteps = completedSteps; req.workflow.currentStep = WorkflowStep.CAR_BODY_ACCIDENT_TYPE; req.workflow.nextStep = afterCarBody; @@ -11348,8 +11711,12 @@ export class RequestManagementService { // THIRD_PARTY: skip confession — first party is always guilty const step3 = await this.getWorkflowStep({ stepNumber: 3 }); // FIRST_VIDEO const step3Key = step3.stepKey as WorkflowStep; - const nextAfterVideo = (step3.nextPossibleSteps?.[0] as WorkflowStep) ?? WorkflowStep.FIRST_INITIAL_FORM; - const completedSteps = Array.isArray(req.workflow.completedSteps) ? req.workflow.completedSteps : []; + const nextAfterVideo = + (step3.nextPossibleSteps?.[0] as WorkflowStep) ?? + WorkflowStep.FIRST_INITIAL_FORM; + const completedSteps = Array.isArray(req.workflow.completedSteps) + ? req.workflow.completedSteps + : []; if (!completedSteps.includes(step2Key)) completedSteps.push(step2Key); req.workflow.completedSteps = completedSteps; req.workflow.currentStep = step3Key; @@ -11357,7 +11724,8 @@ export class RequestManagementService { } // Auto-guilt: first party is always the guilty party in call-center v6 flow - if (!req.parties[firstIdx].statement) req.parties[firstIdx].statement = {} as any; + if (!req.parties[firstIdx].statement) + req.parties[firstIdx].statement = {} as any; req.parties[firstIdx].statement.admitsGuilt = true; req.parties[firstIdx].statement.claimsDamage = false; req.parties[firstIdx].statement.acceptsExpertOpinion = false; @@ -11372,14 +11740,19 @@ export class RequestManagementService { actorType: RoleEnum.CALL_CENTER, }, metadata: { - reason: "V6 call-center initiated: first party is always guilty; confession auto-resolved", + reason: + "V6 call-center initiated: first party is always guilty; confession auto-resolved", advancedTo: req.workflow.currentStep, }, }); } const expertName = `${agent?.lastName || ""}`.trim() || "اپراتور"; - const firstLink = this.smsOrchestrationService.buildBlamePartyLink(requestId, "FIRST", "v6"); + const firstLink = this.smsOrchestrationService.buildBlamePartyLink( + requestId, + "FIRST", + "v6", + ); const smsSent = await this.smsOrchestrationService.sendFieldExpertLink({ receptor: phone, type: req.type, @@ -11387,7 +11760,14 @@ export class RequestManagementService { link: firstLink, }); - const sentTo = [{ role: PartyRole.FIRST, phoneNumber: phone, smsSent, linkUrl: firstLink }]; + const sentTo = [ + { + role: PartyRole.FIRST, + phoneNumber: phone, + smsSent, + linkUrl: firstLink, + }, + ]; if (!Array.isArray((req as any).history)) (req as any).history = []; (req as any).history.push({ @@ -11407,14 +11787,24 @@ export class RequestManagementService { /** * V6: Get a single blame file detail for the call-center agent who created it. */ - async getCallCenterBlameDetailV6(agent: any, requestId: string): Promise { + async getCallCenterBlameDetailV6( + agent: any, + requestId: string, + ): Promise { if (agent?.role !== RoleEnum.CALL_CENTER) { - throw new ForbiddenException("Only call-center agents can use this endpoint."); + throw new ForbiddenException( + "Only call-center agents can use this endpoint.", + ); } const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Blame request not found"); - if (!req.callCenterInitiated || String(req.initiatedByCallCenterId) !== String(agent.sub)) { - throw new ForbiddenException("You can only access files that you have initiated."); + if ( + !req.callCenterInitiated || + String(req.initiatedByCallCenterId) !== String(agent.sub) + ) { + throw new ForbiddenException( + "You can only access files that you have initiated.", + ); } return { _id: (req as any)._id, @@ -11431,6 +11821,8 @@ export class RequestManagementService { skipInitialFormStep: (req as any).skipInitialFormStep, parties: ((req as any).parties ?? []).map((p: any) => ({ role: p.role, + participants: p.participants, + participantRoles: p.participantRoles, person: { phoneNumber: p.person?.phoneNumber, nationalCodeOfInsurer: p.person?.nationalCodeOfInsurer, @@ -11438,9 +11830,7 @@ export class RequestManagementService { driverIsInsurer: p.person?.driverIsInsurer, insurerBirthday: p.person?.insurerBirthday, driverBirthday: p.person?.driverBirthday, - clientId: p.person?.clientId - ? String(p.person.clientId) - : undefined, + clientId: p.person?.clientId ? String(p.person.clientId) : undefined, licenseType: p.person?.licenseType ?? undefined, }, insurance: p.insurance @@ -11455,12 +11845,14 @@ export class RequestManagementService { vehicle: p.vehicle ? { plateId: p.vehicle.plateId, + previousPlateId: p.vehicle.previousPlateId, + registrationState: p.vehicle.registrationState, vin: p.vehicle.vin, name: p.vehicle.name, type: p.vehicle.type, } : undefined, - inquiryComplete: !!(p.person?.inquiriesCompleted), + inquiryComplete: !!p.person?.inquiriesCompleted, })), createdAt: (req as any).createdAt, updatedAt: (req as any).updatedAt, @@ -11499,7 +11891,10 @@ export class RequestManagementService { })); } - async getMyFileMakerFileDetail(fileMaker: any, requestId: string): Promise { + async getMyFileMakerFileDetail( + fileMaker: any, + requestId: string, + ): Promise { if (fileMaker?.role !== RoleEnum.FILE_MAKER) { throw new ForbiddenException("Only FileMakers can use this endpoint."); } @@ -11509,16 +11904,24 @@ export class RequestManagementService { !req.isMadeByFileMaker || String((req as any).initiatedByFieldExpertId) !== String(fileMaker.sub) ) { - throw new ForbiddenException("You can only access files that you have created."); + throw new ForbiddenException( + "You can only access files that you have created.", + ); } - const plain = typeof (req as any).toObject === "function" - ? (req as any).toObject({ versionKey: false }) - : { ...(req as any) }; + const plain = + typeof (req as any).toObject === "function" + ? (req as any).toObject({ versionKey: false }) + : { ...(req as any) }; // Attach linked claim ID if present - const claim = await this.claimCaseDbService.findOne({ blameRequestId: (req as any)._id }); - const claimPlain = claim && typeof (claim as any).toObject === "function" - ? (claim as any).toObject({ versionKey: false }) - : claim ? { ...(claim as any) } : null; + const claim = await this.claimCaseDbService.findOne({ + blameRequestId: (req as any)._id, + }); + const claimPlain = + claim && typeof (claim as any).toObject === "function" + ? (claim as any).toObject({ versionKey: false }) + : claim + ? { ...(claim as any) } + : null; return { _id: plain._id, publicId: plain.publicId, @@ -11530,6 +11933,8 @@ export class RequestManagementService { requiresFileMakerApproval: plain.requiresFileMakerApproval, parties: (plain.parties ?? []).map((p: any) => ({ role: p.role, + participants: p.participants, + participantRoles: p.participantRoles, person: { fullName: p.person?.fullName, phoneNumber: p.person?.phoneNumber, @@ -11544,37 +11949,48 @@ export class RequestManagementService { clientId: p.person?.clientId ? String(p.person.clientId) : undefined, licenseType: p.person?.licenseType ?? undefined, }, - insurance: p.insurance ? { - company: p.insurance.company, - policyNumber: p.insurance.policyNumber, - startDate: p.insurance.startDate, - endDate: p.insurance.endDate, - financialCeiling: p.insurance.financialCeiling, - } : undefined, - vehicle: p.vehicle ? { - plateId: p.vehicle.plateId, - vin: p.vehicle.vin, - name: p.vehicle.name, - type: p.vehicle.type, - } : undefined, - inquiryComplete: !!(p.person?.inquiriesCompleted), + insurance: p.insurance + ? { + company: p.insurance.company, + policyNumber: p.insurance.policyNumber, + startDate: p.insurance.startDate, + endDate: p.insurance.endDate, + financialCeiling: p.insurance.financialCeiling, + } + : undefined, + vehicle: p.vehicle + ? { + plateId: p.vehicle.plateId, + previousPlateId: p.vehicle.previousPlateId, + registrationState: p.vehicle.registrationState, + vin: p.vehicle.vin, + name: p.vehicle.name, + type: p.vehicle.type, + } + : undefined, + inquiryComplete: !!p.person?.inquiriesCompleted, hasSigned: p.confirmation != null, })), linkedClaimId: claimPlain ? String(claimPlain._id) : null, - ...(claimPlain ? { - claimStatus: claimPlain.status, - claimWorkflow: claimPlain.workflow, - money: claimPlain.money ?? null, - fanavaran: fanavaranClaimReferences(claimPlain), - fileMakerRejectionCount: claimPlain.fileMakerRejectionCount ?? 0, - fileMakerRejectionReason: claimPlain.fileMakerRejectionReason ?? null, - evaluation: claimPlain.evaluation - ? { - damageExpertReply: claimPlain.evaluation.damageExpertReply ?? null, - damageExpertReplyFinal: claimPlain.evaluation.damageExpertReplyFinal ?? null, - } - : null, - } : {}), + ...(claimPlain + ? { + claimStatus: claimPlain.status, + claimWorkflow: claimPlain.workflow, + money: claimPlain.money ?? null, + fanavaran: fanavaranClaimReferences(claimPlain), + fileMakerRejectionCount: claimPlain.fileMakerRejectionCount ?? 0, + fileMakerRejectionReason: + claimPlain.fileMakerRejectionReason ?? null, + evaluation: claimPlain.evaluation + ? { + damageExpertReply: + claimPlain.evaluation.damageExpertReply ?? null, + damageExpertReplyFinal: + claimPlain.evaluation.damageExpertReplyFinal ?? null, + } + : null, + } + : {}), createdAt: plain.createdAt, updatedAt: plain.updatedAt, }; @@ -11638,7 +12054,10 @@ export class RequestManagementService { })); } - async getMyFileReviewerFileDetail(fileReviewer: any, requestId: string): Promise { + async getMyFileReviewerFileDetail( + fileReviewer: any, + requestId: string, + ): Promise { if (fileReviewer?.role !== RoleEnum.FILE_REVIEWER) { throw new ForbiddenException("Only FileReviewers can use this endpoint."); } @@ -11650,7 +12069,9 @@ export class RequestManagementService { !req.expertInitiated || req.creationMethod !== CreationMethod.IN_PERSON ) { - throw new ForbiddenException("FileReviewer can only access V4/V5 FileMaker files."); + throw new ForbiddenException( + "FileReviewer can only access V4/V5 FileMaker files.", + ); } if (!blameCaseTouchesClient(req, clientKey)) { throw new ForbiddenException( @@ -11661,21 +12082,26 @@ export class RequestManagementService { ? String((req as any).assignedFileReviewerId) : null; if (assignedId && assignedId !== String(fileReviewer.sub)) { - throw new ForbiddenException("This file has been taken by another FileReviewer."); + throw new ForbiddenException( + "This file has been taken by another FileReviewer.", + ); } - if ( - !assignedId && - req.status !== CaseStatus.WAITING_FOR_FILE_REVIEWER - ) { + if (!assignedId && req.status !== CaseStatus.WAITING_FOR_FILE_REVIEWER) { throw new ForbiddenException("This file is not available for review."); } - const plain = typeof (req as any).toObject === "function" - ? (req as any).toObject({ versionKey: false }) - : { ...(req as any) }; - const claim = await this.claimCaseDbService.findOne({ blameRequestId: (req as any)._id }); - const claimPlain = claim && typeof (claim as any).toObject === "function" - ? (claim as any).toObject({ versionKey: false }) - : claim ? { ...(claim as any) } : null; + const plain = + typeof (req as any).toObject === "function" + ? (req as any).toObject({ versionKey: false }) + : { ...(req as any) }; + const claim = await this.claimCaseDbService.findOne({ + blameRequestId: (req as any)._id, + }); + const claimPlain = + claim && typeof (claim as any).toObject === "function" + ? (claim as any).toObject({ versionKey: false }) + : claim + ? { ...(claim as any) } + : null; return { _id: plain._id, publicId: plain.publicId, @@ -11690,6 +12116,8 @@ export class RequestManagementService { : null, parties: (plain.parties ?? []).map((p: any) => ({ role: p.role, + participants: p.participants, + participantRoles: p.participantRoles, person: { fullName: p.person?.fullName, phoneNumber: p.person?.phoneNumber, @@ -11704,47 +12132,62 @@ export class RequestManagementService { clientId: p.person?.clientId ? String(p.person.clientId) : undefined, licenseType: p.person?.licenseType ?? undefined, }, - insurance: p.insurance ? { - company: p.insurance.company, - policyNumber: p.insurance.policyNumber, - startDate: p.insurance.startDate, - endDate: p.insurance.endDate, - financialCeiling: p.insurance.financialCeiling, - } : undefined, - vehicle: p.vehicle ? { - plateId: p.vehicle.plateId, - vin: p.vehicle.vin, - name: p.vehicle.name, - type: p.vehicle.type, - } : undefined, - inquiryComplete: !!(p.person?.inquiriesCompleted), + insurance: p.insurance + ? { + company: p.insurance.company, + policyNumber: p.insurance.policyNumber, + startDate: p.insurance.startDate, + endDate: p.insurance.endDate, + financialCeiling: p.insurance.financialCeiling, + } + : undefined, + vehicle: p.vehicle + ? { + plateId: p.vehicle.plateId, + previousPlateId: p.vehicle.previousPlateId, + registrationState: p.vehicle.registrationState, + vin: p.vehicle.vin, + name: p.vehicle.name, + type: p.vehicle.type, + } + : undefined, + inquiryComplete: !!p.person?.inquiriesCompleted, hasSigned: p.confirmation != null, })), - expert: plain.expert ? { - decision: plain.expert.decision ? { - guiltyPartyId: plain.expert.decision.guiltyPartyId - ? String(plain.expert.decision.guiltyPartyId) - : undefined, - description: plain.expert.decision.description, - decidedAt: plain.expert.decision.decidedAt, - } : undefined, - accidentFields: plain.expert.accidentFields, - } : undefined, + expert: plain.expert + ? { + decision: plain.expert.decision + ? { + guiltyPartyId: plain.expert.decision.guiltyPartyId + ? String(plain.expert.decision.guiltyPartyId) + : undefined, + description: plain.expert.decision.description, + decidedAt: plain.expert.decision.decidedAt, + } + : undefined, + accidentFields: plain.expert.accidentFields, + } + : undefined, linkedClaimId: claimPlain ? String(claimPlain._id) : null, - ...(claimPlain ? { - claimStatus: claimPlain.status, - claimWorkflow: claimPlain.workflow, - money: claimPlain.money ?? null, - fanavaran: fanavaranClaimReferences(claimPlain), - fileMakerRejectionCount: claimPlain.fileMakerRejectionCount ?? 0, - fileMakerRejectionReason: claimPlain.fileMakerRejectionReason ?? null, - evaluation: claimPlain.evaluation - ? { - damageExpertReply: claimPlain.evaluation.damageExpertReply ?? null, - damageExpertReplyFinal: claimPlain.evaluation.damageExpertReplyFinal ?? null, - } - : null, - } : {}), + ...(claimPlain + ? { + claimStatus: claimPlain.status, + claimWorkflow: claimPlain.workflow, + money: claimPlain.money ?? null, + fanavaran: fanavaranClaimReferences(claimPlain), + fileMakerRejectionCount: claimPlain.fileMakerRejectionCount ?? 0, + fileMakerRejectionReason: + claimPlain.fileMakerRejectionReason ?? null, + evaluation: claimPlain.evaluation + ? { + damageExpertReply: + claimPlain.evaluation.damageExpertReply ?? null, + damageExpertReplyFinal: + claimPlain.evaluation.damageExpertReplyFinal ?? null, + } + : null, + } + : {}), createdAt: plain.createdAt, updatedAt: plain.updatedAt, }; @@ -11752,7 +12195,9 @@ export class RequestManagementService { async getMyCallCenterFilesV6(agent: any): Promise { if (agent?.role !== RoleEnum.CALL_CENTER) { - throw new ForbiddenException("Only call-center agents can use this endpoint."); + throw new ForbiddenException( + "Only call-center agents can use this endpoint.", + ); } const agentId = new Types.ObjectId(agent.sub); const files = await this.blameRequestDbService.find({