From 0dcb2cf2ca087a0d40ec22ebfad796bf3d0bb006 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Sat, 11 Jul 2026 15:34:01 +0330 Subject: [PATCH] YARA-947, YARA-1038 --- .../dto/client-external-inquiries.dto.ts | 8 + src/common/types/external-inquiry.types.ts | 2 + .../dto/create-request-management.dto.ts | 47 +++ .../request-management.service.ts | 308 ++++++++++++++++++ .../request-management.v2.controller.ts | 21 ++ src/sand-hub/sand-hub.service.ts | 42 +++ 6 files changed, 428 insertions(+) diff --git a/src/client/dto/client-external-inquiries.dto.ts b/src/client/dto/client-external-inquiries.dto.ts index 5b8d781..01abad2 100644 --- a/src/client/dto/client-external-inquiries.dto.ts +++ b/src/client/dto/client-external-inquiries.dto.ts @@ -49,6 +49,14 @@ export class ExternalInquiryFlagsDto implements ExternalInquiryFlags { }) @IsBoolean() carOwnership: boolean; + + @ApiProperty({ + description: + "ESG VIN/chassis-number inquiry (`/inquiry/policyByChassis`). Required for the VIN initial-form path.", + example: false, + }) + @IsBoolean() + vinChassis: boolean; } export class UpdateClientExternalInquiriesDto extends PartialType( diff --git a/src/common/types/external-inquiry.types.ts b/src/common/types/external-inquiry.types.ts index 5e5e415..4241f7d 100644 --- a/src/common/types/external-inquiry.types.ts +++ b/src/common/types/external-inquiry.types.ts @@ -6,6 +6,7 @@ export const EXTERNAL_INQUIRY_TYPES = [ "sheba", "drivingLicense", "carOwnership", + "vinChassis", ] as const; export type ExternalInquiryType = (typeof EXTERNAL_INQUIRY_TYPES)[number]; @@ -21,6 +22,7 @@ export const DEFAULT_EXTERNAL_INQUIRY_FLAGS: Record< sheba: false, drivingLicense: false, carOwnership: false, + vinChassis: false, }; export type ExternalInquiryFlags = Record; diff --git a/src/request-management/dto/create-request-management.dto.ts b/src/request-management/dto/create-request-management.dto.ts index 9e28ec3..06f72a3 100644 --- a/src/request-management/dto/create-request-management.dto.ts +++ b/src/request-management/dto/create-request-management.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsNotEmpty, IsString, MaxLength } 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"; @@ -190,6 +191,52 @@ export class BlameConfessionDtoV2 { imGuilty?: boolean; } +/** + * V2 initial-form step submitted with a VIN/chassis number instead of a plate. + * 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({ + type: String, + required: true, + description: "17-character VIN / chassis number (شماره شاسی)", + example: "NAAM01E15HK123456", + maxLength: 17, + }) + @IsString() + @IsNotEmpty() + @MaxLength(17) + vin: string; + + @ApiProperty({ type: String, required: true }) + nationalCodeOfInsurer: string; + + @ApiProperty({ type: String, required: true }) + nationalCodeOfDriver: string; + + @ApiProperty({ type: String, required: true }) + insurerLicense: string; + + @ApiProperty({ type: String, required: true }) + driverLicense: string; + + @ApiProperty({ type: Boolean, required: true }) + driverIsInsurer: boolean; + + @ApiProperty({ type: Boolean, required: true, default: false }) + isNewCar: boolean; + + @ApiProperty({ type: Boolean, required: true }) + userNoCertificate: boolean; + + @ApiProperty({ type: Number, required: true }) + insurerBirthday: number; + + @ApiPropertyOptional({ type: String, required: false }) + driverBirthday: string | null; +} + // export class DocsOfThisFile { // @ApiProperty() // firstPartyFile?: FirstPartyFileDto; diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 0bfb5d2..7f68917 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -18,6 +18,7 @@ import { CarBodyFormDto, CarBodySecondForm, InitialFormDto, + InitialFormVinDto, } from "src/request-management/dto/create-request-management.dto"; import { BlameVideoDbService } from "src/request-management/entities/db-service/blame-video.db.service"; import { BlameVoiceDbService } from "src/request-management/entities/db-service/blame.voice.db.service"; @@ -1475,6 +1476,313 @@ export class RequestManagementService { } } + /** + * V2 initial-form submitted with a VIN/chassis number instead of a plate. + * + * Performs: + * 1. ESG `/inquiry/policyByChassis` (or its mock when `vinChassis` is off) + * 2. Personal identity inquiry (same as the plate path) + * + * The inquiry result is mapped through the same `mapEsgPolicyByPlateToOldFormat` + * normaliser used by `getTejaratBlockInquiry`, so all downstream party/vehicle + * /insurance field assignments are identical. + */ + async initialFormVinV2( + requestId: string, + body: InitialFormVinDto, + user: any, + expectedRole?: string, + ) { + try { + const req = await this.blameRequestDbService.findById(requestId); + if (!req) throw new NotFoundException("Request not found"); + + if (!req.workflow?.currentStep) { + throw new BadRequestException("Request workflow is not initialized"); + } + + const stepKey = req.workflow.currentStep as WorkflowStep; + if ( + stepKey !== WorkflowStep.FIRST_INITIAL_FORM && + stepKey !== WorkflowStep.SECOND_INITIAL_FORM + ) { + throw new BadRequestException( + `Invalid step. Expected FIRST_INITIAL_FORM or SECOND_INITIAL_FORM but got ${stepKey}`, + ); + } + + const role = this.stepKeyToPartyRole(stepKey); + const idx = this.getPartyIndex(req, role); + if (idx === -1) + throw new BadRequestException(`${role} party not found on request`); + + const party = req.parties[idx]; + this.assertPartyOwner( + party, + user, + `Only ${role} party can submit this step`, + req, + ); + this.assertExpectedPartyRole( + expectedRole, + role, + this.isBlameOnBehalfActor(req, user), + ); + + if (!party.person) party.person = {} as any; + if ( + !party.person.userId && + user?.sub && + !this.isBlameOnBehalfActor(req, user) + ) { + party.person.userId = Types.ObjectId.isValid(user.sub) + ? new Types.ObjectId(user.sub) + : undefined; + } + + // Validation: driver/insurer sameness rules (identical to plate path) + if (body.driverIsInsurer === false) { + if ( + body.nationalCodeOfInsurer === body.nationalCodeOfDriver || + body.insurerLicense === body.driverLicense + ) { + throw new BadRequestException( + "Insurer and Driver should be two different persons in this mode.", + ); + } + } else if (body.driverIsInsurer === true) { + const sameNat = + body.nationalCodeOfInsurer === body.nationalCodeOfDriver; + const sameLic = body.insurerLicense === body.driverLicense; + const sameBirthday = + body.driverBirthday == null || + String(body.driverBirthday) === String(body.insurerBirthday); + if (!sameNat || !sameLic || !sameBirthday) { + throw new BadRequestException( + "When driverIsInsurer is true, insurer and driver data must be the same.", + ); + } + } + + // ---- External inquiry: ESG policyByChassis ---- + const inquiryClientId = party.person?.clientId + ? String(party.person.clientId) + : undefined; + const inquiryOptions = inquiryClientId + ? { clientId: inquiryClientId } + : undefined; + + let inquiryRaw: any; + let inquiryMapped: any; + try { + const inquiry = await this.sandHubService.getPolicyByChassisInquiry( + body.vin, + inquiryOptions, + ); + inquiryRaw = inquiry.raw; + inquiryMapped = inquiry.mapped; + this.logger.log( + `[ESG] policyByChassis raw for request=${req._id}: ${JSON.stringify(inquiryRaw)}`, + ); + this.logger.log( + `[ESG] policyByChassis mapped for request=${req._id}: ${JSON.stringify(inquiryMapped)}`, + ); + this.recordPartyCaseInquiryStatus(req, "thirdParty", role, true, { + source: "ESG_VIN_INQUIRY", + raw: inquiryRaw, + mapped: inquiryMapped, + }); + } catch (err: any) { + this.logger.error( + `[ESG] policyByChassis failed for request=${req._id}: ${err?.message || err}`, + ); + if (err?.response) { + this.logger.error( + `[ESG] policyByChassis response for request=${req._id}: status=${ + err.response.status + }, data=${JSON.stringify(err.response.data)}`, + ); + } + this.recordPartyCaseInquiryStatus( + req, + "thirdParty", + role, + false, + {}, + err, + ); + await this.blameRequestDbService.findByIdAndUpdate(req._id, { + $set: { inquiries: req.inquiries }, + }); + throw new HttpException("VIN inquiry failed", HttpStatus.BAD_REQUEST); + } + + if (inquiryMapped?.Error) { + this.logger.warn( + `[ESG] policyByChassis error for request=${req._id}: ${JSON.stringify(inquiryMapped.Error)}`, + ); + this.recordPartyCaseInquiryStatus(req, "thirdParty", role, false, { + source: "ESG_VIN_INQUIRY", + raw: inquiryRaw, + mapped: inquiryMapped, + }); + await this.blameRequestDbService.findByIdAndUpdate(req._id, { + $set: { inquiries: req.inquiries }, + }); + throw new HttpException( + inquiryMapped.Error.Message || "VIN inquiry returned error", + HttpStatus.BAD_REQUEST, + ); + } + + // ---- 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, + ); + } + + // Resolve insurer client from inquiry response + const clientName = inquiryMapped?.CompanyName; + if (!clientName) { + throw new BadRequestException( + "CompanyName missing from VIN inquiry response", + ); + } + const companyCode = inquiryMapped?.CompanyCode; + const client = await this.clientService.findOrCreateClientByCompanyCode( + companyCode, + clientName, + ); + if (!client) { + throw new BadRequestException( + "CompanyCode missing or invalid in VIN inquiry response", + ); + } + + // Persist party data + const resolvedClientId = + (client as any)?._id ?? (client as any)?._doc?._id; + party.person.clientId = resolvedClientId; + party.person.nationalCodeOfInsurer = body.nationalCodeOfInsurer; + party.person.nationalCodeOfDriver = body.nationalCodeOfDriver; + party.person.insurerLicense = body.insurerLicense; + party.person.driverLicense = body.driverLicense; + party.person.driverIsInsurer = body.driverIsInsurer; + party.person.isNewCar = body.isNewCar; + party.person.userNoCertificate = body.userNoCertificate; + party.person.insurerBirthday = body.insurerBirthday; + party.person.driverBirthday = + body.driverBirthday ?? + (body.driverIsInsurer ? String(body.insurerBirthday) : null); + + if (!party.vehicle) party.vehicle = {} as any; + party.vehicle.isNew = body.isNewCar; + // Store VIN as the vehicle identifier instead of plate + party.vehicle.plateId = body.vin; + party.vehicle.name = inquiryMapped?.MapTypNam; + party.vehicle.type = `${inquiryMapped?.UsageField} / ${inquiryMapped?.MapUsageName || "-"}`; + party.vehicle.inquiry = { + source: "ESG_VIN_INQUIRY", + raw: inquiryRaw, + mapped: inquiryMapped, + }; + + if (!party.insurance) party.insurance = {} as any; + party.insurance.policyNumber = inquiryMapped?.PrntPlcyCmpDocNo; + party.insurance.company = clientName; + party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl; + party.insurance.startDate = inquiryMapped?.IssueDate; + party.insurance.endDate = inquiryMapped?.EndDate; + + // Advance workflow + await this.advanceWorkflowToNext(req, stepKey); + + const historyEntry: any = { + type: `${stepKey}_SUBMITTED`, + actor: { + actorId: Types.ObjectId.isValid(user?.sub) + ? new Types.ObjectId(user.sub) + : undefined, + actorName: user?.fullName, + actorType: "user", + }, + metadata: { + role, + companyCode, + companyName: clientName, + inquiryType: "VIN", + vin: body.vin, + }, + }; + + await this.blameRequestDbService.findByIdAndUpdate(req._id, { + $set: { + [`parties.${idx}.person`]: party.person, + [`parties.${idx}.vehicle`]: party.vehicle, + [`parties.${idx}.insurance`]: party.insurance, + inquiries: req.inquiries, + "workflow.completedSteps": req.workflow.completedSteps, + "workflow.currentStep": req.workflow.currentStep, + "workflow.nextStep": req.workflow.nextStep, + status: req.status, + }, + $push: { history: historyEntry }, + }); + + return { + requestId: req._id, + publicId: req.publicId, + workflow: req.workflow, + blameStatus: req.blameStatus, + status: req.status, + }; + } catch (error) { + throw error; + } + } + + async addDetailLocationV2( requestId: string, body: LocationDto, diff --git a/src/request-management/request-management.v2.controller.ts b/src/request-management/request-management.v2.controller.ts index e183685..d3671ac 100644 --- a/src/request-management/request-management.v2.controller.ts +++ b/src/request-management/request-management.v2.controller.ts @@ -40,6 +40,7 @@ import { BlameConfessionDtoV2, CreateBlameRequestDtoV2, DescriptionDto, + InitialFormVinDto, LocationDto, CarBodyFormDto, } from "./dto/create-request-management.dto"; @@ -197,6 +198,26 @@ export class RequestManagementV2Controller { return this.requestManagementService.initialFormV2(requestId, body, user); } + @Post("/initial-form-vin/:requestId") + @ApiParam({ name: "requestId", description: "Blame request ID" }) + @ApiBody({ + type: InitialFormVinDto, + description: + "Same identity/license fields as the plate form, but with `vin` (17-char chassis number) instead of `plate`.", + }) + @UseGuards(GlobalGuard) + async initialFormVinV2( + @Param("requestId") requestId: string, + @Body() body: InitialFormVinDto, + @CurrentUser() user, + ) { + return this.requestManagementService.initialFormVinV2( + requestId, + body, + user, + ); + } + @Post("/add-detail-location/:requestId") @ApiParam({ name: "requestId" }) @ApiBody({ type: LocationDto }) diff --git a/src/sand-hub/sand-hub.service.ts b/src/sand-hub/sand-hub.service.ts index 99d342b..9bad94b 100644 --- a/src/sand-hub/sand-hub.service.ts +++ b/src/sand-hub/sand-hub.service.ts @@ -893,6 +893,48 @@ export class SandHubService { }; } + + /** + * ESG VIN/chassis-number inquiry (`/inquiry/policyByChassis`). + * + * When `vinChassis` inquiry is disabled (mock mode) the response shape mirrors + * `buildMockPlateInquiryRaw` so the downstream mapper (`mapEsgPolicyByPlateToOldFormat`) + * can normalise it into the same `{ raw, mapped }` contract used by the plate path. + * + * @param chassisNo - 17-character VIN / chassis number + * @param options - optional per-tenant client scope + */ + async getPolicyByChassisInquiry( + chassisNo: string, + options?: SandHubInquiryOptions, + ): Promise<{ raw: any; mapped: any }> { + const baseUrl = process.env.ESG_URL ?? "http://192.168.20.22:8085"; + const requestUrl = `${baseUrl}/inquiry/policyByChassis`; + const requestPayload = { chassisNo }; + + const live = await this.isInquiryLive("vinChassis", options); + + if (!live) { + const ctx = await this.mockCompanyContext(options); + const raw = this.buildMockPlateInquiryRaw(ctx); + this.logger.debug( + `[MOCK] getPolicyByChassisInquiry chassisNo=${chassisNo}`, + ); + const mapped = this.mapEsgPolicyByPlateToOldFormat(raw); + return { raw, mapped }; + } + + const raw = await this.makeEsgRequest( + requestUrl, + requestPayload, + "vinChassis", + options, + ); + const mapped = this.mapEsgPolicyByPlateToOldFormat(raw); + return { raw, mapped }; + } + + private async makeSandHubRequest( url: string, payload: any,