Implement role-complete inquiry participants

This commit is contained in:
SepehrYahyaee
2026-09-13 10:59:00 +03:30
parent 401ad6a143
commit c64f23091a
12 changed files with 2179 additions and 690 deletions

View File

@@ -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` باشد، استعلام گواهینامه عمداً اجرا نمی‌شود.
## انتقال مالکیت اخیر و پلاک قبلی
نقش اشخاص و شناسه‌های خودرو دو موضوع جدا هستند. اگر خودرو به‌تازگی فروخته یا خریداری شده باشد، ممکن است اطلاعات رسمی یا بیمه‌نامه هنوز به پلاک قبلی متصل باشد. این وضعیت باید صریح ثبت شود و پلاک قبلی نباید جایگزین پلاک فعلی شود:

View File

@@ -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:

View File

@@ -30,5 +30,8 @@ export class CaseInquiries {
@Prop({ type: InquiryStatusSchema })
drivingLicence?: InquiryStatus;
@Prop({ type: InquiryStatusSchema })
ownership?: InquiryStatus;
}
export const CaseInquiriesSchema = SchemaFactory.createForClass(CaseInquiries);

View File

@@ -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({

View File

@@ -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 })

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 })
claimsDamage?: boolean;
@Prop({ default: false })
admitsGuilt?: boolean;
@Prop({ default: false })
admitsGuilt?: boolean;
@Prop({ type: String })
description?: string;
@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;
/** 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({ type: [String] })
voices?: string[];
@Prop() videoId?: 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;
/** Distinct people involved with this vehicle; roles below reference participantId. */
@Prop({ type: [InquiryParticipantSchema], default: undefined })
participants?: InquiryParticipant[];
/**
* 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 };
@Prop({ type: InquiryParticipantRolesSchema })
participantRoles?: InquiryParticipantRoles;
/**
* Party-submitted location (step-driven: FIRST_LOCATION / SECOND_LOCATION).
*/
@Prop({ type: LocationSchema })
location?: Location;
/**
* 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 };
@Prop({ type: VehicleSchema })
vehicle?: Vehicle;
/**
* Party-submitted location (step-driven: FIRST_LOCATION / SECOND_LOCATION).
*/
@Prop({ type: LocationSchema })
location?: Location;
@Prop({ type: InsuranceSchema })
insurance?: Insurance;
@Prop({ type: VehicleSchema })
vehicle?: Vehicle;
@Prop({ type: PartyStatementSchema })
statement?: PartyStatement;
@Prop({ type: InsuranceSchema })
insurance?: Insurance;
@Prop({ type: EvidenceBundleSchema })
evidence?: EvidenceBundle;
@Prop({ type: PartyStatementSchema })
statement?: PartyStatement;
@Prop({ type: PartyConfirmationSchema })
confirmation?: PartyConfirmation;
@Prop({ type: EvidenceBundleSchema })
evidence?: EvidenceBundle;
@Prop({ type: PartyConfirmationSchema })
confirmation?: PartyConfirmation;
}
export const PartySchema = SchemaFactory.createForClass(Party);

View File

@@ -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);
});
});

View File

@@ -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<T extends Record<string, any>> {
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<string, any>,
): 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<string, ResolvedInquiryParticipant>();
const resolvedRoleIds = new Map<InquiryParticipantRole, string>();
const resolving = new Set<InquiryParticipantRole>();
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<string, any>,
): 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<T>(options: {
vehicle?: InquiryVehicleInputDto;
fallbackCurrentPlate: InquiryVehicleInputDto["currentPlate"];
query: (plate: InquiryVehicleInputDto["currentPlate"]) => Promise<T>;
isUsable: (value: T) => boolean;
mappedValue: (value: T) => Record<string, any>;
}): 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<T extends Record<string, any>>(
caseType: BlameRequestType,
input: T,
): NormalizedInquirySubmission<T> {
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<T>["dto"],
participants,
driver,
vehicleOwner,
thirdPartyPolicyholder,
carBodyPolicyholder,
vehicle,
};
}

File diff suppressed because it is too large Load Diff