Merge pull request 'Added vin inquiry for v6, fixed new damaged part for v4' (#264) from s.yahyaee/yara724-api:main into main

Reviewed-on: Yara724/api#264
This commit is contained in:
2026-08-10 13:48:34 +03:30
6 changed files with 292 additions and 44 deletions

View File

@@ -20,6 +20,12 @@ export enum ClaimRequiredDocumentType {
GUILTY_CAR_CARD_FRONT = "guilty_car_card_front", GUILTY_CAR_CARD_FRONT = "guilty_car_card_front",
GUILTY_CAR_CARD_BACK = "guilty_car_card_back", GUILTY_CAR_CARD_BACK = "guilty_car_card_back",
GUILTY_METAL_PLATE = "guilty_metal_plate", GUILTY_METAL_PLATE = "guilty_metal_plate",
/**
* V4/V5 only — a photo of the guilty car's damaged area, captured by the
* FileReviewer during the CAPTURE_PART_DAMAGES phase (after all car angles).
*/
GUILTY_DAMAGE_AREA = "guilty_damage_area",
} }
export enum CarAngle { export enum CarAngle {

View File

@@ -163,6 +163,7 @@ import {
CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS, CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS,
capturePhaseSequenceMessage, capturePhaseSequenceMessage,
getClaimCaptureProgress, getClaimCaptureProgress,
GUILTY_DAMAGE_AREA_DOC_KEY,
isCapturePhaseDamagedPartyDocKey, isCapturePhaseDamagedPartyDocKey,
isClaimCaptureStepComplete, isClaimCaptureStepComplete,
OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5, OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5,
@@ -9073,7 +9074,7 @@ export class ClaimRequestManagementService {
file: Express.Multer.File, file: Express.Multer.File,
currentUserId: string, currentUserId: string,
actor?: { sub: string; role?: string }, actor?: { sub: string; role?: string },
options?: { v3InPersonFlow?: boolean; skipMetalPlate?: boolean; requiresFileMakerApproval?: boolean }, options?: { v3InPersonFlow?: boolean; skipMetalPlate?: boolean; requiresFileMakerApproval?: boolean; includeGuiltyDamageArea?: boolean },
): Promise<UploadRequiredDocumentV2ResponseDto> { ): Promise<UploadRequiredDocumentV2ResponseDto> {
try { try {
const claimCase = await this.claimCaseDbService.findById(claimRequestId); const claimCase = await this.claimCaseDbService.findById(claimRequestId);
@@ -9099,7 +9100,9 @@ export class ClaimRequestManagementService {
const isResendUpload = step === ClaimWorkflowStep.USER_EXPERT_RESEND; const isResendUpload = step === ClaimWorkflowStep.USER_EXPERT_RESEND;
const isCapturePhaseDocUpload = const isCapturePhaseDocUpload =
step === ClaimWorkflowStep.CAPTURE_PART_DAMAGES && step === ClaimWorkflowStep.CAPTURE_PART_DAMAGES &&
isCapturePhaseDamagedPartyDocKey(body.documentKey); (isCapturePhaseDamagedPartyDocKey(body.documentKey) ||
(options?.includeGuiltyDamageArea &&
(body.documentKey as string) === GUILTY_DAMAGE_AREA_DOC_KEY));
if (!isResendUpload) { if (!isResendUpload) {
if ( if (
@@ -9249,17 +9252,20 @@ export class ClaimRequestManagementService {
const afterThis = (k: string) => const afterThis = (k: string) =>
k === body.documentKey || k === body.documentKey ||
this.isRequiredDocumentUploadedOnClaim(claimCase, k); this.isRequiredDocumentUploadedOnClaim(claimCase, k);
remaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.filter( const captureKeysForCount: string[] = [
(k) => { ...CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS,
...(options?.includeGuiltyDamageArea ? [GUILTY_DAMAGE_AREA_DOC_KEY] : []),
];
remaining = captureKeysForCount.filter((k) => {
if (options?.skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(k as any)) return false; if (options?.skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(k as any)) return false;
return !afterThis(k); return !afterThis(k);
}, }).length;
).length;
if (remaining === 0) { if (remaining === 0) {
const progressAfterDoc = getClaimCaptureProgress(claimCase, { const progressAfterDoc = getClaimCaptureProgress(claimCase, {
assumeCapturePhaseDocKey: body.documentKey, assumeCapturePhaseDocKey: body.documentKey,
skipMetalPlate: options?.skipMetalPlate, skipMetalPlate: options?.skipMetalPlate,
includeGuiltyDamageArea: options?.includeGuiltyDamageArea,
}); });
if ( if (
@@ -11241,6 +11247,13 @@ export class ClaimRequestManagementService {
base: GetCaptureRequirementsV2ResponseDto, base: GetCaptureRequirementsV2ResponseDto,
): GetCaptureRequirementsV2ResponseDto { ): GetCaptureRequirementsV2ResponseDto {
const skipMetalPlate = !!(blame as any)?.isMadeByFileMaker; const skipMetalPlate = !!(blame as any)?.isMadeByFileMaker;
// V4 = FileMaker flow without a secondary FileMaker approval step
const isV4 =
!!(blame as any)?.isMadeByFileMaker &&
!(claimCase as any)?.requiresFileMakerApproval;
const isCarBody =
blame?.type === BlameRequestType.CAR_BODY ||
(blame as any)?.type === "CAR_BODY";
// For V4/V5 files strip metal-plate keys from the base response entirely // For V4/V5 files strip metal-plate keys from the base response entirely
// so they never appear in any phase — the front-end should not show them. // so they never appear in any phase — the front-end should not show them.
@@ -11316,6 +11329,51 @@ export class ClaimRequestManagementService {
d.preferUploadDuringCapture && d.preferUploadDuringCapture &&
!(skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(d.key as any)), !(skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(d.key as any)),
); );
// V4 THIRD_PARTY only: guilty_damage_area is uploaded via upload-document
// after all car angles are captured (last item in the documents phase).
// It also appears in damagedParts so the expert sees it labelled as a
// damaged-part capture.
if (isV4 && !isCarBody) {
const guiltyAreaUploaded = this.isRequiredDocumentUploadedOnClaim(
claimCase,
GUILTY_DAMAGE_AREA_DOC_KEY,
);
const guiltyAreaDoc = {
key: GUILTY_DAMAGE_AREA_DOC_KEY,
label_fa: "تصویر نقطه آسیب‌دیده مقصر",
label_en: "Guilty Car Damaged Area",
category: "guilty_party",
uploaded: guiltyAreaUploaded,
preferUploadDuringCapture: true,
};
const guiltyAreaPart = {
id: null as any,
key: GUILTY_DAMAGE_AREA_DOC_KEY,
name: GUILTY_DAMAGE_AREA_DOC_KEY,
side: "",
label_fa: "تصویر نقطه آسیب‌دیده مقصر",
label_en: "Guilty Car Damaged Area",
captured: guiltyAreaUploaded,
};
const captureProgress = getClaimCaptureProgress(claimCase, {
skipMetalPlate,
includeGuiltyDamageArea: true,
});
return {
...base,
requiredDocuments: [...capturePhaseDocs, guiltyAreaDoc],
damagedParts: [...base.damagedParts, guiltyAreaPart],
totalRemaining: captureProgress.capturePhaseDocsRemaining,
progress: {
...base.progress,
capturePhaseDocsRemaining: captureProgress.capturePhaseDocsRemaining,
},
};
}
return { return {
...base, ...base,
requiredDocuments: capturePhaseDocs, requiredDocuments: capturePhaseDocs,
@@ -11355,8 +11413,21 @@ export class ClaimRequestManagementService {
} }
const blame = await this.assertV3InPersonClaim(claimCase); const blame = await this.assertV3InPersonClaim(claimCase);
const skipMetalPlate = !!(blame as any).isMadeByFileMaker; const skipMetalPlate = !!(blame as any).isMadeByFileMaker;
const isV4FileMaker =
!!(blame as any)?.isMadeByFileMaker &&
!(claimCase as any)?.requiresFileMakerApproval;
const isCarBodyBlame =
blame?.type === BlameRequestType.CAR_BODY ||
(blame as any)?.type === "CAR_BODY";
const isCaptureDoc = isCapturePhaseDamagedPartyDocKey(body.documentKey); // guilty_damage_area is an extra capture-phase doc key for V4 THIRD_PARTY
const isGuiltyDamageAreaDoc =
(body.documentKey as string) === GUILTY_DAMAGE_AREA_DOC_KEY &&
isV4FileMaker &&
!isCarBodyBlame;
const isCaptureDoc =
isCapturePhaseDamagedPartyDocKey(body.documentKey) || isGuiltyDamageAreaDoc;
if (isCaptureDoc) { if (isCaptureDoc) {
this.assertV3ClaimPartsPhase(blame); this.assertV3ClaimPartsPhase(blame);
@@ -11375,7 +11446,10 @@ export class ClaimRequestManagementService {
"Complete outer and other part selection before capture-phase vehicle documents.", "Complete outer and other part selection before capture-phase vehicle documents.",
); );
} }
const captureProgress = getClaimCaptureProgress(claimCase, { skipMetalPlate }); const captureProgress = getClaimCaptureProgress(claimCase, {
skipMetalPlate,
includeGuiltyDamageArea: isGuiltyDamageAreaDoc,
});
if (!captureProgress.partsComplete) { if (!captureProgress.partsComplete) {
throw new BadRequestException( throw new BadRequestException(
"Upload photos for all selected damaged parts before chassis or engine photos.", "Upload photos for all selected damaged parts before chassis or engine photos.",
@@ -11403,7 +11477,12 @@ export class ClaimRequestManagementService {
file, file,
currentUserId, currentUserId,
actor, actor,
{ v3InPersonFlow: true, skipMetalPlate, requiresFileMakerApproval: !!(claimCase as any).requiresFileMakerApproval }, {
v3InPersonFlow: true,
skipMetalPlate,
requiresFileMakerApproval: !!(claimCase as any).requiresFileMakerApproval,
includeGuiltyDamageArea: isGuiltyDamageAreaDoc,
},
); );
} }
@@ -11463,30 +11542,6 @@ export class ClaimRequestManagementService {
const result = await this.selectOuterPartsV2(claimRequestId, body, currentUserId, actor); const result = await this.selectOuterPartsV2(claimRequestId, body, currentUserId, actor);
// V4 THIRD_PARTY only: append a synthetic "guilty damage area" entry to
// damage.selectedParts so the FileReviewer must capture one photo of the
// guilty party's damaged spot during the CAPTURE_PART_DAMAGES phase.
// selectOuterPartsV2 uses $set on damage.selectedParts, so we append here
// with a separate $push after the V2 write completes.
const isV4 =
!!(blame as any)?.isMadeByFileMaker &&
!(refreshed as any)?.requiresFileMakerApproval;
const isCarBodyBlame =
blame?.type === BlameRequestType.CAR_BODY ||
(blame as any)?.type === "CAR_BODY";
if (isV4 && !isCarBodyBlame) {
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$push: {
"damage.selectedParts": {
id: null,
name: "guilty_damage_area",
side: "",
label_fa: "تصویر نقطه آسیب‌دیده مقصر",
},
},
});
}
return result; return result;
} }

View File

@@ -22,6 +22,12 @@ export const OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5 = [
"guilty_metal_plate", "guilty_metal_plate",
] as const; ] as const;
/**
* Extra capture-phase document key present only in V4/V5 (FileMaker) flows.
* Uploaded via `upload-document` after all car angles are captured.
*/
export const GUILTY_DAMAGE_AREA_DOC_KEY = "guilty_damage_area" as const;
export type CapturePhaseSequence = export type CapturePhaseSequence =
| "parts" | "parts"
| "angles" | "angles"
@@ -58,7 +64,12 @@ function isRequiredDocumentUploadedOnClaim(
export function getClaimCaptureProgress( export function getClaimCaptureProgress(
claimCase: any, claimCase: any,
options?: { assumeCapturePhaseDocKey?: string; skipMetalPlate?: boolean }, options?: {
assumeCapturePhaseDocKey?: string;
skipMetalPlate?: boolean;
/** V4/V5 only — include `guilty_damage_area` in capture-phase doc count. */
includeGuiltyDamageArea?: boolean;
},
): ClaimCaptureProgress { ): ClaimCaptureProgress {
const carType = claimCase?.vehicle?.carType as ClaimVehicleTypeV2 | undefined; const carType = claimCase?.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const selectedNorm = normalizeDamageSelectedParts( const selectedNorm = normalizeDamageSelectedParts(
@@ -87,13 +98,15 @@ export function getClaimCaptureProgress(
const anglesTotal = CLAIM_CAR_ANGLE_KEYS.length; const anglesTotal = CLAIM_CAR_ANGLE_KEYS.length;
const anglesComplete = anglesCaptured >= anglesTotal; const anglesComplete = anglesCaptured >= anglesTotal;
const capturePhaseDocsRemaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.filter( const capturePhaseKeys: string[] = [
(k) => { ...CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS,
...(options?.includeGuiltyDamageArea ? [GUILTY_DAMAGE_AREA_DOC_KEY] : []),
];
const capturePhaseDocsRemaining = capturePhaseKeys.filter((k) => {
if (options?.skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(k as any)) return false; if (options?.skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(k as any)) return false;
if (k === options?.assumeCapturePhaseDocKey) return false; if (k === options?.assumeCapturePhaseDocKey) return false;
return !isRequiredDocumentUploadedOnClaim(claimCase, k); return !isRequiredDocumentUploadedOnClaim(claimCase, k);
}, }).length;
).length;
const capturePhaseDocsComplete = capturePhaseDocsRemaining === 0; const capturePhaseDocsComplete = capturePhaseDocsRemaining === 0;
let sequencePhase: CapturePhaseSequence = "parts"; let sequencePhase: CapturePhaseSequence = "parts";

View File

@@ -20,7 +20,10 @@ import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator"; import { CurrentUser } from "src/decorators/user.decorator";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
import { RequestManagementService } from "./request-management.service"; import { RequestManagementService } from "./request-management.service";
import { RunCallCenterInquiryV6Dto } from "./dto/run-call-center-inquiry-v6.dto"; import {
RunCallCenterInquiryV6Dto,
RunCallCenterInquiryVinV6Dto,
} from "./dto/run-call-center-inquiry-v6.dto";
/** /**
* V6 call-center blame API. * V6 call-center blame API.
@@ -105,6 +108,27 @@ export class CallCenterBlameV6Controller {
return this.requestManagementService.runCallCenterInquiryV6(agent, requestId, dto); return this.requestManagementService.runCallCenterInquiryV6(agent, requestId, dto);
} }
@Post("run-inquiry-vin/:requestId")
@ApiOperation({
summary: "[V6] Run VIN/chassis inquiry for the guilty party",
description:
"VIN alternative to `run-inquiry`. " +
"The agent supplies the chassis number and personal data collected from the caller. " +
"ESG chassis lookup (`policyByChassis`) is executed and the result is stored on the " +
"blame document under `vehicle.vin` (plateId is left empty). " +
"Identical eligibility guards and insurer-company validation as the plate variant. " +
"After this call, proceed to `send-link` exactly as in the plate flow.",
})
@ApiParam({ name: "requestId", description: "Blame request ID from `create`" })
@ApiBody({ type: RunCallCenterInquiryVinV6Dto })
runInquiryVin(
@CurrentUser() agent: any,
@Param("requestId") requestId: string,
@Body() dto: RunCallCenterInquiryVinV6Dto,
) {
return this.requestManagementService.runCallCenterInquiryVinV6(agent, requestId, dto);
}
@Post("send-link/:requestId") @Post("send-link/:requestId")
@ApiOperation({ @ApiOperation({
summary: "[V6] Send blame link to the guilty party via SMS", summary: "[V6] Send blame link to the guilty party via SMS",

View File

@@ -4,6 +4,7 @@ import {
IsNotEmpty, IsNotEmpty,
IsOptional, IsOptional,
IsString, IsString,
MaxLength,
ValidateNested, ValidateNested,
} from "class-validator"; } from "class-validator";
import { Type } from "class-transformer"; import { Type } from "class-transformer";
@@ -83,3 +84,60 @@ export class RunCallCenterInquiryV6Dto {
@IsString() @IsString()
insurerLicense?: string; insurerLicense?: string;
} }
/**
* VIN / chassis variant of the V6 call-center inquiry.
* 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({
example: "NAAM01E15HK123456",
description: "17-character VIN / chassis number (شماره شاسی)",
maxLength: 17,
})
@IsString()
@IsNotEmpty()
@MaxLength(17)
vin: string;
@ApiProperty({ example: "1234567890", description: "National code of the policyholder (insurer)" })
@IsString()
@IsNotEmpty()
nationalCodeOfInsurer: string;
@ApiProperty({ example: "1234567890", description: "National code of the driver" })
@IsString()
@IsNotEmpty()
nationalCodeOfDriver: string;
@ApiProperty({ example: true, description: "Whether the driver is the same person as the insurer" })
@IsBoolean()
driverIsInsurer: boolean;
@ApiProperty({ example: 13780624, description: "Insurer birth date (Jalali)" })
insurerBirthday: number | string;
@ApiPropertyOptional({
example: 13780624,
description: "Driver birth date (Jalali). Required when driverIsInsurer is false.",
})
@IsOptional()
driverBirthday?: number | string | null;
@ApiPropertyOptional({
example: "123456789",
description: "Driver license (required when driverIsInsurer is false).",
})
@IsOptional()
@IsString()
driverLicense?: string;
@ApiPropertyOptional({
example: "123456789",
description: "Insurer license (required when driverIsInsurer is true).",
})
@IsOptional()
@IsString()
insurerLicense?: string;
}

View File

@@ -10953,6 +10953,97 @@ export class RequestManagementService {
}; };
} }
/**
* V6 VIN variant: Run VIN/chassis inquiry for the guilty party on behalf of the caller.
* Identical to `runCallCenterInquiryV6` but calls `runPartyInquiriesVinV3Internal`
* (ESG chassis lookup) instead of the plate-based path.
* Returns `vehicle.vin` in the guiltyParty payload instead of `vehicle.plateId`.
*/
async runCallCenterInquiryVinV6(
agent: any,
requestId: string,
dto: Omit<RunInquiriesVinV3Dto, "sheba">,
): Promise<Record<string, unknown>> {
if (agent?.role !== RoleEnum.CALL_CENTER) {
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.");
}
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);
// Same insurer-company guard as the plate variant.
const resolvedClientId = req.parties[firstIdx]?.person?.clientId;
if (resolvedClientId && process.env.CLIENT_ID) {
const resolvedClient = await this.clientService.findOne({
_id: new Types.ObjectId(String(resolvedClientId)),
});
if (
resolvedClient &&
String(resolvedClient.clientCode) !== String(process.env.CLIENT_ID)
) {
throw new ForbiddenException(
"بیمه‌نامه طرف مقصر متعلق به شرکت بیمه این سامانه نیست. لینک تقصیر فقط برای بیمه‌گذاران همین شرکت قابل ارسال است.",
);
}
}
if (!Array.isArray((req as any).history)) (req as any).history = [];
(req as any).history.push({
type: "CALL_CENTER_INQUIRY_COMPLETED",
actor: {
actorId: new Types.ObjectId(agent.sub),
actorName: `${agent.firstName || ""} ${agent.lastName || ""}`.trim(),
actorType: RoleEnum.CALL_CENTER,
},
metadata: { partyRole: PartyRole.FIRST, inquiryType: "VIN" },
});
await (req as any).save();
const firstPartyAfter = req.parties[firstIdx];
return {
blameRequestId: requestId,
publicId: (req as any).publicId,
type: (req as any).type,
status: (req as any).status,
message: "استعلام VIN طرف مقصر با موفقیت انجام شد. اکنون می‌توانید لینک تقصیر را برای کاربر ارسال کنید.",
guiltyParty: {
vehicle: firstPartyAfter?.vehicle
? {
vin: firstPartyAfter.vehicle.vin,
name: firstPartyAfter.vehicle.name,
type: firstPartyAfter.vehicle.type,
}
: undefined,
insurance: firstPartyAfter?.insurance
? {
company: firstPartyAfter.insurance.company,
policyNumber: firstPartyAfter.insurance.policyNumber,
startDate: firstPartyAfter.insurance.startDate,
endDate: firstPartyAfter.insurance.endDate,
financialCeiling: (firstPartyAfter.insurance as any).financialCeiling,
}
: undefined,
person: firstPartyAfter?.person
? {
nationalCodeOfInsurer: firstPartyAfter.person.nationalCodeOfInsurer,
nationalCodeOfDriver: firstPartyAfter.person.nationalCodeOfDriver,
driverIsInsurer: firstPartyAfter.person.driverIsInsurer,
insurerBirthday: formatJalaliCompact(firstPartyAfter.person.insurerBirthday),
driverBirthday: formatJalaliCompact(firstPartyAfter.person.driverBirthday),
}
: undefined,
},
};
}
/** /**
* V6: Send the blame link to the guilty party's phone number. * V6: Send the blame link to the guilty party's phone number.
* Registers/looks up the user by phone and stores them as the first party, then * Registers/looks up the user by phone and stores them as the first party, then
@@ -11134,6 +11225,7 @@ export class RequestManagementService {
vehicle: p.vehicle vehicle: p.vehicle
? { ? {
plateId: p.vehicle.plateId, plateId: p.vehicle.plateId,
vin: p.vehicle.vin,
name: p.vehicle.name, name: p.vehicle.name,
type: p.vehicle.type, type: p.vehicle.type,
} }