Compare commits

...

29 Commits

Author SHA1 Message Date
cb69b496b5 merge upstream 2026-08-18 10:15:33 +03:30
1d56326456 Merge pull request 'Added 'perfomedBy' field to the Timeline' (#272) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#272
2026-08-17 10:26:25 +03:30
SepehrYahyaee
d5aa9f3f1b Added 'perfomedBy' field to the Timeline 2026-08-17 10:26:02 +03:30
d8a2be091f Merge pull request 'YARA-1078' (#271) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#271
2026-08-16 12:37:26 +03:30
SepehrYahyaee
01f8a5b12c YARA-1078 2026-08-16 12:35:18 +03:30
e6ea5c2e8a Merge pull request 'Delegated the PDF creation task for FE, and BE now only returns the necessary data for it' (#270) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#270
2026-08-16 11:53:04 +03:30
SepehrYahyaee
875b52d761 Delegated the PDF creation task for FE, and BE now only returns the necessary data for it 2026-08-16 11:52:26 +03:30
f5aa25edf8 Merge pull request 'YARA-1169' (#269) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#269
2026-08-16 10:59:31 +03:30
SepehrYahyaee
85d7881b2b YARA-1169 2026-08-16 10:18:43 +03:30
7259eec949 Merge pull request 'Fixed VIN' (#268) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#268
2026-08-10 17:14:11 +03:30
SepehrYahyaee
42f4c6e8e3 Fixed VIN 2026-08-10 17:13:44 +03:30
71b3b1d786 Merge pull request 'Fixed vin inquiry' (#267) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#267
2026-08-10 16:51:40 +03:30
SepehrYahyaee
baac633443 Fixed vin inquiry 2026-08-10 16:51:00 +03:30
13231736d8 Merge pull request 'fix damaged parts' (#266) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#266
2026-08-10 14:23:02 +03:30
SepehrYahyaee
cbed681c8f fix damaged parts 2026-08-10 14:22:37 +03:30
3e1cde739f Merge pull request 'Fixed v4 damaged area part' (#265) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#265
2026-08-10 14:08:24 +03:30
SepehrYahyaee
70d7f34402 Fixed v4 damaged area part 2026-08-10 14:07:56 +03:30
f57b1b1171 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
2026-08-10 13:48:34 +03:30
SepehrYahyaee
6791c71809 Added vin inquiry for v6, fixed new damaged part for v4 2026-08-10 13:48:05 +03:30
6d955cd608 Merge pull request 'Fixed VIN placement for when the users use vin-based inquiry' (#263) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#263
2026-08-10 12:15:31 +03:30
SepehrYahyaee
ca7200e17d Fixed VIN placement for when the users use vin-based inquiry 2026-08-10 12:14:51 +03:30
60ed80fc87 Merge pull request 'Fixed YARA-1217' (#262) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#262
2026-08-10 10:35:14 +03:30
SepehrYahyaee
210e96fcf1 Fixed YARA-1217 2026-08-10 10:34:20 +03:30
63038f630d Merge pull request 'YARA-1218' (#261) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#261
2026-08-09 14:08:46 +03:30
SepehrYahyaee
8e4c794d61 YARA-1218 2026-08-09 14:08:11 +03:30
0663b35157 Merge pull request 'YARA-1217' (#260) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#260
2026-08-09 11:03:49 +03:30
SepehrYahyaee
d4cd8c9343 YARA-1217 2026-08-09 11:03:16 +03:30
2e8a8197a4 Merge pull request 'YARA-1216' (#259) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#259
2026-08-09 10:37:13 +03:30
SepehrYahyaee
3821bf36ef YARA-1216 2026-08-09 10:36:45 +03:30
23 changed files with 1159 additions and 395 deletions

View File

@@ -20,6 +20,12 @@ export enum ClaimRequiredDocumentType {
GUILTY_CAR_CARD_FRONT = "guilty_car_card_front",
GUILTY_CAR_CARD_BACK = "guilty_car_card_back",
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 {

View File

@@ -1,36 +0,0 @@
import { Injectable } from "@nestjs/common";
import {
createPersianPdfDocument,
persianPdfToBuffer,
} from "src/helpers/persian-pdf-document";
import {
InsurerFileReportPdfResult,
InsurerFileReportViewModel,
} from "./case-expert-report.types";
import { PR } from "./persian-report-labels";
@Injectable()
export class CaseExpertReportPdfService {
async render(model: InsurerFileReportViewModel): Promise<InsurerFileReportPdfResult> {
const pdf = createPersianPdfDocument();
pdf.addTitle(model.title);
pdf.addKeyValue(PR.publicId, model.publicId);
pdf.addKeyValue(PR.requestNo, model.requestNo);
pdf.addBlank();
for (const section of model.sections) {
pdf.addSection(section.title);
for (const field of section.fields) {
pdf.addKeyValue(field.label, field.value);
}
pdf.addBlank();
}
const buffer = await persianPdfToBuffer(pdf);
const safeId = (model.publicId || "file").replace(/[^\w.-]+/g, "_");
return {
buffer,
filename: `insurer-file-report-${safeId}.pdf`,
};
}
}

View File

@@ -4,14 +4,12 @@ import {
HttpException,
InternalServerErrorException,
Param,
StreamableFile,
UseGuards,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiOperation,
ApiParam,
ApiProduces,
ApiResponse,
ApiTags,
} from "@nestjs/swagger";
@@ -21,6 +19,7 @@ import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { CaseExpertReportService } from "./case-expert-report.service";
import { InsurerFileReportViewModel } from "./case-expert-report.types";
@ApiTags("expert-insurer-panel")
@Controller("expert-insurer")
@@ -32,36 +31,30 @@ export class CaseExpertReportInsurerController {
private readonly caseExpertReportService: CaseExpertReportService,
) {}
@Get("files/:publicId/report.pdf")
@Get("files/:publicId/report")
@ApiOperation({
summary: "Download insurer file report PDF",
summary: "Get insurer file report data",
description:
"Generates a PDF for the shared publicId (blame + claim combined): damaged owner, driver when different, insurance, vehicle, and accident report sections.",
"Returns the structured report data for the given publicId (blame + claim combined): damaged owner, driver when different, insurance, vehicle, and accident report sections. The front-end uses this data to render and generate the PDF.",
})
@ApiParam({ name: "publicId" })
@ApiProduces("application/pdf")
@ApiResponse({ status: 200, description: "PDF file" })
@ApiResponse({ status: 200, description: "Report data" })
@ApiResponse({ status: 404, description: "File not found for this publicId" })
async downloadInsurerReport(
async getInsurerReport(
@CurrentUser() insurer: { clientKey?: string },
@Param("publicId") publicId: string,
): Promise<StreamableFile> {
): Promise<InsurerFileReportViewModel> {
try {
const { buffer, filename } =
await this.caseExpertReportService.generateForInsurer(
publicId,
insurer,
);
return new StreamableFile(buffer, {
type: "application/pdf",
disposition: `attachment; filename="${filename}"`,
});
return await this.caseExpertReportService.generateForInsurer(
publicId,
insurer,
);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error
? error.message
: "Failed to generate insurer file report PDF",
: "Failed to retrieve insurer file report data",
);
}
}

View File

@@ -1,13 +1,12 @@
import { Module } from "@nestjs/common";
import { ExpertInsurerModule } from "src/expert-insurer/expert-insurer.module";
import { CaseExpertReportInsurerController } from "./case-expert-report.controller";
import { CaseExpertReportPdfService } from "./case-expert-report-pdf.service";
import { CaseExpertReportService } from "./case-expert-report.service";
@Module({
imports: [ExpertInsurerModule],
controllers: [CaseExpertReportInsurerController],
providers: [CaseExpertReportService, CaseExpertReportPdfService],
providers: [CaseExpertReportService],
exports: [CaseExpertReportService],
})
export class CaseExpertReportModule {}

View File

@@ -1,20 +1,18 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { ExpertInsurerService } from "src/expert-insurer/expert-insurer.service";
import { buildInsurerFileReport } from "./case-expert-report.builder";
import { CaseExpertReportPdfService } from "./case-expert-report-pdf.service";
import { InsurerFileReportPdfResult } from "./case-expert-report.types";
import { InsurerFileReportViewModel } from "./case-expert-report.types";
@Injectable()
export class CaseExpertReportService {
constructor(
private readonly expertInsurerService: ExpertInsurerService,
private readonly pdfService: CaseExpertReportPdfService,
) {}
async generateForInsurer(
publicId: string,
actor: { clientKey?: string },
): Promise<InsurerFileReportPdfResult> {
): Promise<InsurerFileReportViewModel> {
const clientKey = actor?.clientKey;
if (!clientKey) {
throw new NotFoundException("Insurer context not found");
@@ -24,7 +22,6 @@ export class CaseExpertReportService {
clientKey,
publicId,
);
const model = buildInsurerFileReport(file);
return this.pdfService.render(model);
return buildInsurerFileReport(file);
}
}

View File

@@ -14,8 +14,3 @@ export type InsurerFileReportViewModel = {
requestNo?: string;
sections: InsurerFileReportSection[];
};
export type InsurerFileReportPdfResult = {
buffer: Buffer;
filename: string;
};

View File

@@ -163,6 +163,7 @@ import {
CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS,
capturePhaseSequenceMessage,
getClaimCaptureProgress,
GUILTY_DAMAGE_AREA_DOC_KEY,
isCapturePhaseDamagedPartyDocKey,
isClaimCaptureStepComplete,
OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5,
@@ -9073,7 +9074,7 @@ export class ClaimRequestManagementService {
file: Express.Multer.File,
currentUserId: string,
actor?: { sub: string; role?: string },
options?: { v3InPersonFlow?: boolean; skipMetalPlate?: boolean; requiresFileMakerApproval?: boolean },
options?: { v3InPersonFlow?: boolean; skipMetalPlate?: boolean; requiresFileMakerApproval?: boolean; includeGuiltyDamageArea?: boolean },
): Promise<UploadRequiredDocumentV2ResponseDto> {
try {
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
@@ -9099,7 +9100,9 @@ export class ClaimRequestManagementService {
const isResendUpload = step === ClaimWorkflowStep.USER_EXPERT_RESEND;
const isCapturePhaseDocUpload =
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 (
@@ -9249,17 +9252,20 @@ export class ClaimRequestManagementService {
const afterThis = (k: string) =>
k === body.documentKey ||
this.isRequiredDocumentUploadedOnClaim(claimCase, k);
remaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.filter(
(k) => {
if (options?.skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(k as any)) return false;
return !afterThis(k);
},
).length;
const captureKeysForCount: string[] = [
...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;
return !afterThis(k);
}).length;
if (remaining === 0) {
const progressAfterDoc = getClaimCaptureProgress(claimCase, {
assumeCapturePhaseDocKey: body.documentKey,
skipMetalPlate: options?.skipMetalPlate,
includeGuiltyDamageArea: options?.includeGuiltyDamageArea,
});
if (
@@ -11241,6 +11247,13 @@ export class ClaimRequestManagementService {
base: GetCaptureRequirementsV2ResponseDto,
): GetCaptureRequirementsV2ResponseDto {
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
// so they never appear in any phase — the front-end should not show them.
@@ -11316,6 +11329,40 @@ export class ClaimRequestManagementService {
d.preferUploadDuringCapture &&
!(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 captureProgress = getClaimCaptureProgress(claimCase, {
skipMetalPlate,
includeGuiltyDamageArea: true,
});
return {
...base,
requiredDocuments: [...capturePhaseDocs, guiltyAreaDoc],
totalRemaining: captureProgress.capturePhaseDocsRemaining,
progress: {
...base.progress,
capturePhaseDocsRemaining: captureProgress.capturePhaseDocsRemaining,
},
};
}
return {
...base,
requiredDocuments: capturePhaseDocs,
@@ -11327,6 +11374,7 @@ export class ClaimRequestManagementService {
step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS &&
capturePartDone
) {
// car-capture not yet uploaded: next step is the walk-around video.
return {
...base,
requiredDocuments: [],
@@ -11337,6 +11385,22 @@ export class ClaimRequestManagementService {
};
}
// V3 FIELD_EXPERT only: car-capture is done (step advanced to USER_SUBMISSION_COMPLETE
// in setVideoCaptureV3). The one remaining action is the blame accident video.
if (
step === ClaimWorkflowStep.USER_SUBMISSION_COMPLETE &&
!(blame as any).isMadeByFileMaker
) {
return {
...base,
requiredDocuments: [],
captureSequencePhase: "complete",
captureSequenceHint:
"Walk-around video uploaded. Upload the blame accident video to finalise.",
totalRemaining: 0,
};
}
return base;
}
@@ -11355,8 +11419,21 @@ export class ClaimRequestManagementService {
}
const blame = await this.assertV3InPersonClaim(claimCase);
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) {
this.assertV3ClaimPartsPhase(blame);
@@ -11375,7 +11452,10 @@ export class ClaimRequestManagementService {
"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) {
throw new BadRequestException(
"Upload photos for all selected damaged parts before chassis or engine photos.",
@@ -11397,13 +11477,24 @@ export class ClaimRequestManagementService {
}
}
// For V4 THIRD_PARTY files, guilty_damage_area must be counted in the
// capture-phase doc total for ALL capture uploads (not just the guilty_damage_area
// upload itself) so that the CAPTURE_PART_DAMAGES step is not marked complete
// until all three standard docs AND the guilty area photo are uploaded.
const includeGuiltyDamageArea = isV4FileMaker && !isCarBodyBlame;
return this.uploadRequiredDocumentV2(
claimRequestId,
body,
file,
currentUserId,
actor,
{ v3InPersonFlow: true, skipMetalPlate, requiresFileMakerApproval: !!(claimCase as any).requiresFileMakerApproval },
{
v3InPersonFlow: true,
skipMetalPlate,
requiresFileMakerApproval: !!(claimCase as any).requiresFileMakerApproval,
includeGuiltyDamageArea,
},
);
}
@@ -11461,7 +11552,9 @@ export class ClaimRequestManagementService {
"Select outer parts after accident fields and required documents.",
);
return this.selectOuterPartsV2(claimRequestId, body, currentUserId, actor);
const result = await this.selectOuterPartsV2(claimRequestId, body, currentUserId, actor);
return result;
}
async selectOtherPartsV3(
@@ -11645,7 +11738,34 @@ export class ClaimRequestManagementService {
// V4/V5 only (isMadeByFileMaker): car-capture is the FINAL FileReviewer step.
// For the v3 mirror (FIELD_EXPERT IN_PERSON), car-capture is penultimate —
// expertUploadBlameVideoV3 finalises the blame with the accident video next.
if ((blame as any).isMadeByFileMaker) {
// Advance the workflow step so that if the expert exits and reopens the claim
// the system correctly resumes at upload-video rather than replaying car-capture.
if (!(blame as any).isMadeByFileMaker) {
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: {
"workflow.currentStep": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
"workflow.nextStep": undefined,
},
$push: {
"workflow.completedSteps": ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
history: {
type: "STEP_COMPLETED",
actor: {
actorId: new Types.ObjectId(currentUserId),
actorName: claimCase.owner?.fullName || "User",
actorType: "field_expert",
},
timestamp: new Date(),
metadata: {
stepKey: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
description:
"V3 walk-around video uploaded. Upload blame accident video to finalise.",
v3InPersonFlow: true,
},
},
},
});
} else if ((blame as any).isMadeByFileMaker) {
// Both V4 and V5 land at WAITING_FOR_DAMAGE_EXPERT here.
// For V5 (requiresFileMakerApproval=true), autoSubmitToFanavaranV2OnClaimCompleted
// intercepts COMPLETED → WAITING_FOR_FILE_MAKER_APPROVAL after expert review.

View File

@@ -0,0 +1,186 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
// ---------------------------------------------------------------------------
// Query helpers
// ---------------------------------------------------------------------------
export type ExpertKindFilter = "all" | "expert" | "damage_expert";
export class InsurerReportQueryDto {
@ApiPropertyOptional({ description: "Start of date range (ISO string)" })
from?: string;
@ApiPropertyOptional({ description: "End of date range (ISO string)" })
to?: string;
@ApiPropertyOptional({
enum: ["all", "expert", "damage_expert"],
description:
"Filter by expert kind. `expert` = blame-panel expert; `damage_expert` = claim damage expert; `all` (default) = both.",
})
expertKind?: ExpertKindFilter;
}
export class InsurerWorkLogQueryDto extends InsurerReportQueryDto {
@ApiPropertyOptional({ type: Number, description: "Page number (1-based)" })
page?: number;
@ApiPropertyOptional({ type: Number, description: "Items per page" })
limit?: number;
}
// ---------------------------------------------------------------------------
// Statistics
// ---------------------------------------------------------------------------
export class InsurerStatisticsBreakdownDto {
@ApiProperty({ description: "Files that have an insurer rating" })
filesWithInsurerRating: number;
@ApiProperty({ description: "Files that have a bot rating" })
filesWithBotRating: number;
@ApiProperty({ description: "Files that have any user rating" })
filesWithUserRating: number;
@ApiProperty({ description: "Files that have an objection" })
filesWithObjection: number;
@ApiProperty({ description: "Average insurer rating (0–5)" })
averageInsurerRating: number;
@ApiProperty({ description: "Average bot rating (0–5)" })
averageBotRating: number;
}
export class InsurerStatisticsDto {
@ApiProperty({
description:
"تعداد کل پرونده‌های بررسی‌شده — distinct tenant claim files with at least one expert CHECKED activity.",
})
totalFilesReviewed: number;
@ApiProperty({
description:
"رضایت کاربران از روند پرونده — average of progressSpeed + registrationEase + overallEvaluation across rated claim files, normalised to 0–100. Formula: (avg of three dimensions across all rated files / 5) * 100.",
})
averageUserRatingPercentage: number;
@ApiProperty({
description:
"پرونده‌های همراه — blame files where expertInitiated === true AND creationMethod === IN_PERSON.",
})
inPersonAccompaniedCount: number;
@ApiProperty({ description: "Claim files created in the current calendar month." })
filesCreatedThisMonth: number;
@ApiProperty({ description: "Total claim files scoped to this insurer." })
totalFiles: number;
@ApiProperty({
description:
"Percentage of total claim files that have any user rating recorded. (NOT a satisfaction metric — do not render as رضایت کاربران.)",
deprecated: true,
})
filesWithUserRatingPercentage: number;
@ApiProperty({ description: "Percentage of files with an objection." })
objectionPercentage: number;
@ApiProperty({ description: "Insurer-to-bot rating ratio (0–100)." })
insurerToBotRatingPercentage: number;
@ApiProperty({ type: InsurerStatisticsBreakdownDto })
breakdown: InsurerStatisticsBreakdownDto;
}
// ---------------------------------------------------------------------------
// Top files
// ---------------------------------------------------------------------------
export class TopFileUserRatingDto {
@ApiPropertyOptional({ description: "User's free-text comment" })
comment?: string | null;
@ApiPropertyOptional({ description: "User's overall evaluation score (0–5)" })
overallEvaluation?: number | null;
}
export class InsurerTopFileDto {
@ApiProperty({ description: "Public file ID — use for front-end link مشاهده پرونده" })
publicId: string;
@ApiProperty({ description: "ISO creation datetime" })
createdAt: string;
@ApiProperty({ description: "Combined insurer + user rating blend (0–5)" })
combinedScore: number;
@ApiProperty({ type: TopFileUserRatingDto })
userRating: TopFileUserRatingDto;
}
// ---------------------------------------------------------------------------
// Top experts
// ---------------------------------------------------------------------------
export class InsurerTopExpertDto {
@ApiProperty()
_id: string;
@ApiProperty()
fullName: string;
@ApiProperty({ enum: ["blame", "claim"] })
expertKind: "blame" | "claim";
@ApiPropertyOptional({ description: "Overall average combined rating (0–5)" })
overallAverageRating: number | null;
@ApiProperty({ description: "Activity stats from expertFileActivity log" })
requestStats: { totalHandled: number; totalChecked: number };
}
export class InsurerTopExpertsDto {
@ApiProperty({ type: [InsurerTopExpertDto], description: "Top blame-panel experts" })
blameExperts: InsurerTopExpertDto[];
@ApiProperty({ type: [InsurerTopExpertDto], description: "Top damage experts (claim)" })
claimExperts: InsurerTopExpertDto[];
}
// ---------------------------------------------------------------------------
// Work-log (mirrors reports.dto.ts shapes; exposed from this module for swagger)
// ---------------------------------------------------------------------------
export class InsurerWorkLogEntryDto {
@ApiProperty()
expertId: string;
@ApiProperty()
fullName: string;
@ApiProperty({ enum: ["expert", "damage_expert"] })
expertKind: "expert" | "damage_expert";
@ApiProperty({ description: "Distinct files in HANDLED state up to cutoff" })
totalHandled: number;
@ApiProperty({ description: "Distinct files currently CHECKED but not yet HANDLED" })
currentlyChecking: number;
@ApiProperty({
description:
"Distinct files with at least one CHECKED event in the reporting window (all-time when no date range given; restricted to from–to otherwise).",
})
distinctFilesCheckedInPeriod: number;
}
export class InsurerWorkLogResponseDto {
@ApiProperty({ type: [InsurerWorkLogEntryDto] })
experts: InsurerWorkLogEntryDto[];
@ApiProperty({ description: "Total experts in roster" })
total: number;
}

View File

@@ -39,6 +39,13 @@ import {
CreateFileMakerByInsurerDto,
CreateFileReviewerByInsurerDto,
} from "./dto/create-insurer-expert.dto";
import {
InsurerStatisticsDto,
InsurerTopExpertsDto,
InsurerTopFileDto,
InsurerWorkLogResponseDto,
} from "./dto/insurer-reports.dto";
import { ReportsService } from "src/reports/reports.service";
@Controller("expert-insurer")
@ApiTags("expert-insurer-panel")
@@ -46,25 +53,18 @@ import {
@UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.COMPANY)
export class ExpertInsurerController {
constructor(private readonly expertInsurerService: ExpertInsurerService) {}
constructor(
private readonly expertInsurerService: ExpertInsurerService,
private readonly reportsService: ReportsService,
) {}
// ─── Branch management ────────────────────────────────────────────────────
@Get("branches")
@ApiQuery({ name: "search", required: false, type: String })
@ApiQuery({
name: "from",
required: false,
description: "Optional start datetime (ISO string)",
})
@ApiQuery({
name: "to",
required: false,
description: "Optional end datetime (ISO string)",
})
@ApiQuery({
name: "isActive",
required: false,
description: "Filter active state (true/false)",
})
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" })
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
@ApiQuery({ name: "isActive", required: false, description: "Filter active state (true/false)" })
async getInsuranceBranches(
@CurrentUser() insurer,
@Query("search") search?: string,
@@ -72,9 +72,7 @@ export class ExpertInsurerController {
@Query("to") to?: string,
@Query("isActive") isActive?: string,
) {
if (!insurer) {
throw new UnauthorizedException("Could not identify the current user.");
}
if (!insurer) throw new UnauthorizedException("Could not identify the current user.");
return await this.expertInsurerService.retrieveInsuranceBranches(
insurer.clientKey,
{ search, from, to, isActive },
@@ -83,18 +81,9 @@ export class ExpertInsurerController {
@Post("branches")
@ApiBody({ type: CreateBranchDto })
async addBranch(
@CurrentUser() insurer,
@Body() createBranchDto: CreateBranchDto,
) {
if (!insurer) {
throw new UnauthorizedException("Could not identify the current user.");
}
return await this.expertInsurerService.addBranch(
insurer.clientKey,
createBranchDto,
);
async addBranch(@CurrentUser() insurer, @Body() createBranchDto: CreateBranchDto) {
if (!insurer) throw new UnauthorizedException("Could not identify the current user.");
return await this.expertInsurerService.addBranch(insurer.clientKey, createBranchDto);
}
@Put("branches/:branchId/status")
@@ -111,10 +100,7 @@ export class ExpertInsurerController {
@Param("branchId") branchId: string,
@Body("isActive") isActive: unknown,
) {
if (!insurer) {
throw new UnauthorizedException("Could not identify the current user.");
}
// Accept native boolean (JSON body) or string coercion (legacy query/form usage)
if (!insurer) throw new UnauthorizedException("Could not identify the current user.");
let active: boolean;
if (typeof isActive === "boolean") {
active = isActive;
@@ -125,60 +111,38 @@ export class ExpertInsurerController {
}
active = ["true", "1", "yes"].includes(normalized);
}
return this.expertInsurerService.setBranchActive(
insurer.clientKey,
branchId,
active,
);
return this.expertInsurerService.setBranchActive(insurer.clientKey, branchId, active);
}
// ─── Expert management ────────────────────────────────────────────────────
@Post("experts/blame")
@ApiBody({ type: CreateBlameExpertByInsurerDto })
async addBlameExpert(
@CurrentUser() insurer,
@Body() body: CreateBlameExpertByInsurerDto,
) {
if (!insurer) {
throw new UnauthorizedException("Could not identify the current user.");
}
async addBlameExpert(@CurrentUser() insurer, @Body() body: CreateBlameExpertByInsurerDto) {
if (!insurer) throw new UnauthorizedException("Could not identify the current user.");
return this.expertInsurerService.addBlameExpert(insurer.clientKey, body);
}
@Post("experts/claim")
@ApiBody({ type: CreateClaimExpertByInsurerDto })
async addClaimExpert(
@CurrentUser() insurer,
@Body() body: CreateClaimExpertByInsurerDto,
) {
if (!insurer) {
throw new UnauthorizedException("Could not identify the current user.");
}
async addClaimExpert(@CurrentUser() insurer, @Body() body: CreateClaimExpertByInsurerDto) {
if (!insurer) throw new UnauthorizedException("Could not identify the current user.");
return this.expertInsurerService.addClaimExpert(insurer.clientKey, body);
}
@Post("experts/file-maker")
@ApiBody({ type: CreateFileMakerByInsurerDto })
@ApiOperation({ summary: "Create a FileMaker account under this insurer" })
async addFileMaker(
@CurrentUser() insurer,
@Body() body: CreateFileMakerByInsurerDto,
) {
if (!insurer) {
throw new UnauthorizedException("Could not identify the current user.");
}
async addFileMaker(@CurrentUser() insurer, @Body() body: CreateFileMakerByInsurerDto) {
if (!insurer) throw new UnauthorizedException("Could not identify the current user.");
return this.expertInsurerService.addFileMaker(insurer.clientKey, body);
}
@Post("experts/file-reviewer")
@ApiBody({ type: CreateFileReviewerByInsurerDto })
@ApiOperation({ summary: "Create a FileReviewer account under this insurer" })
async addFileReviewer(
@CurrentUser() insurer,
@Body() body: CreateFileReviewerByInsurerDto,
) {
if (!insurer) {
throw new UnauthorizedException("Could not identify the current user.");
}
async addFileReviewer(@CurrentUser() insurer, @Body() body: CreateFileReviewerByInsurerDto) {
if (!insurer) throw new UnauthorizedException("Could not identify the current user.");
return this.expertInsurerService.addFileReviewer(insurer.clientKey, body);
}
@@ -190,37 +154,140 @@ export class ExpertInsurerController {
@Query("response_count") count: number,
@CurrentUser() actor,
) {
return await this.expertInsurerService.retrieveAllExpertsOfClient(
actor,
page,
count,
);
return await this.expertInsurerService.retrieveAllExpertsOfClient(actor, page, count);
}
// ─── Reports: statistics ──────────────────────────────────────────────────
@Get("statistics")
@ApiOperation({
summary: "نمایش کلی — KPI cards for insurer reports page",
description:
"Returns `totalFilesReviewed` (distinct tenant claim files with ≥1 expert CHECKED activity), " +
"`averageUserRatingPercentage` (mean of progressSpeed + registrationEase + overallEvaluation across rated files, normalised to 0–100), " +
"`inPersonAccompaniedCount` (blame files where expertInitiated=true AND creationMethod=IN_PERSON), " +
"`filesCreatedThisMonth`, `totalFiles`, `objectionPercentage`, `insurerToBotRatingPercentage`. " +
"⚠️ `userRatingPercentage` and `filesWithUserRatingPercentage` are the share of files *that have any user rating* — NOT a satisfaction score. Use `averageUserRatingPercentage` for رضایت کاربران.",
})
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string) — restricts counted claim portfolio" })
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
@ApiResponse({ status: 200, type: InsurerStatisticsDto })
async getExpertStatistics(
@CurrentUser() actor,
@Query("from") from?: string,
@Query("to") to?: string,
) {
return await this.expertInsurerService.getExpertStatisticsReport(actor, { from, to });
}
// ─── Reports: top files ───────────────────────────────────────────────────
@Get("top-files")
@ApiOperation({
summary: "Top 10 highest-rated claim files for this insurer",
description:
"Sorted by combined insurer + user rating blend (getCombinedFileScore). " +
"Returns slim DTO only: publicId, createdAt, combinedScore, userRating.{comment, overallEvaluation}. " +
"Use publicId to build مشاهده پرونده links.",
})
@ApiQuery({ name: "from", required: false, description: "Restrict to files created on or after this ISO date" })
@ApiQuery({ name: "to", required: false, description: "Restrict to files created on or before this ISO date" })
@ApiResponse({ status: 200, type: [InsurerTopFileDto] })
async getTopFiles(
@CurrentUser() insurer,
@Query("from") from?: string,
@Query("to") to?: string,
) {
return await this.expertInsurerService.getTopFilesForClient(insurer.clientKey, { from, to });
}
// ─── Reports: top experts (canonical + alias) ─────────────────────────────
@Get("experts/top")
@ApiOperation({
summary: "Top blame vs claim experts for this insurer",
description:
"Response has two arrays: `blameExperts` (roster from expert / blame files) and `claimExperts` (damage-expert roster / claim files). Each item includes `overallAverageRating` derived from ratings stored on those files plus user ratings where present.",
"Response: `{ blameExperts: [], claimExperts: [] }`. " +
"Each item: `_id`, `fullName`, `expertKind`, `overallAverageRating`, `requestStats`. " +
"Sorted by overallAverageRating descending; up to 10 per group. " +
"Optional `from` / `to` accepted for forward-compat (currently accepted but roster is full-history).",
})
async getTopExperts(@CurrentUser() actor) {
return await this.expertInsurerService.getTopExpertsForClient(actor);
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" })
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
@ApiResponse({ status: 200, type: InsurerTopExpertsDto })
async getTopExperts(
@CurrentUser() actor,
@Query("from") from?: string,
@Query("to") to?: string,
) {
return await this.expertInsurerService.getTopExpertsForClient(actor, { from, to });
}
/**
* Alias: some BFF/frontend paths call /top-experts instead of /experts/top.
* Both routes point to the same handler.
*/
@Get("top-experts")
@ApiOperation({
summary: "Alias for GET experts/top — top blame + claim experts",
description: "Same response as `GET experts/top`. Provided for front-end compatibility.",
})
@ApiQuery({ name: "from", required: false })
@ApiQuery({ name: "to", required: false })
@ApiResponse({ status: 200, type: InsurerTopExpertsDto })
async getTopExpertsAlias(
@CurrentUser() actor,
@Query("from") from?: string,
@Query("to") to?: string,
) {
return await this.expertInsurerService.getTopExpertsForClient(actor, { from, to });
}
// ─── Reports: work-log (delegates to ReportsService) ─────────────────────
@Get("expert-work-log")
@ApiOperation({
summary: "نمایش جدولی — per-expert work log (blame panel + damage experts)",
description:
"Rows for every tenant expert in the `expert` (blame) and `damage-expert` (claim) collections — same scope as GET experts/list, field-expert excluded. " +
"Columns: expertId, fullName, expertKind, totalHandled (files in HANDLED state up to `to`/now), " +
"currentlyChecking (CHECKED but not yet HANDLED up to `to`/now), " +
"distinctFilesCheckedInPeriod (distinct files with ≥1 CHECKED event inside from–to window; all-time when omitted). " +
"Optional `expertKind` filter: `expert` = blame panel, `damage_expert` = claim damage experts, `all` (default).",
})
@ApiQuery({ name: "expertKind", required: false, enum: ["all", "expert", "damage_expert"], description: "Filter by expert kind" })
@ApiQuery({ name: "from", required: false, description: "Start of date window (ISO string)" })
@ApiQuery({ name: "to", required: false, description: "End of date window (ISO string)" })
@ApiQuery({ name: "page", required: false, type: Number, description: "Page (1-based)" })
@ApiQuery({ name: "limit", required: false, type: Number, description: "Items per page" })
@ApiResponse({ status: 200, type: InsurerWorkLogResponseDto })
async getExpertWorkLog(
@CurrentUser() actor,
@Query("expertKind") expertKind?: "all" | "expert" | "damage_expert",
@Query("from") from?: string,
@Query("to") to?: string,
@Query("page") page?: string,
@Query("limit") limit?: string,
) {
return this.reportsService.getInsurerExpertWorkLog(actor, {
expertKind,
from,
to,
page: page ? parseInt(page, 10) : undefined,
limit: limit ? parseInt(limit, 10) : undefined,
});
}
// ─── Files ────────────────────────────────────────────────────────────────
@Get("files")
@ApiOperation({
summary: "List insurer files (blame + claim merged by publicId)",
description:
"Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`, `unifiedStatus`, `fileType` (THIRD_PARTY | CAR_BODY).",
})
async getAllFiles(
@CurrentUser() insurer,
@Query() query: ListQueryV2Dto,
) {
return await this.expertInsurerService.retrieveAllFilesOfClient(
insurer.clientKey,
query,
);
async getAllFiles(@CurrentUser() insurer, @Query() query: ListQueryV2Dto) {
return await this.expertInsurerService.retrieveAllFilesOfClient(insurer.clientKey, query);
}
@Get("report/unified-file-statuses")
@@ -234,10 +301,7 @@ export class ExpertInsurerController {
@CurrentUser() actor,
@Query() query: UnifiedFileStatusReportQueryDto,
): Promise<UnifiedFileStatusReportDto> {
return await this.expertInsurerService.getInsurerUnifiedFileStatusReport(
actor,
query,
);
return await this.expertInsurerService.getInsurerUnifiedFileStatusReport(actor, query);
}
@Get("files/:publicId/timeline")
@@ -247,26 +311,14 @@ export class ExpertInsurerController {
description:
"Returns a chronological list of all history events for the blame and/or claim associated with the given publicId. Each event has: source, type, timestamp, actor, metadata.",
})
async getFileTimeline(
@CurrentUser() insurer,
@Param("publicId") publicId: string,
) {
return await this.expertInsurerService.getFileTimeline(
insurer.clientKey,
publicId,
);
async getFileTimeline(@CurrentUser() insurer, @Param("publicId") publicId: string) {
return await this.expertInsurerService.getFileTimeline(insurer.clientKey, publicId);
}
@Get("files/:publicId")
@ApiParam({ name: "publicId" })
async getFileDetailsByPublicId(
@CurrentUser() insurer,
@Param("publicId") publicId: string,
) {
return await this.expertInsurerService.retrieveFileDetailsByPublicId(
insurer.clientKey,
publicId,
);
async getFileDetailsByPublicId(@CurrentUser() insurer, @Param("publicId") publicId: string) {
return await this.expertInsurerService.retrieveFileDetailsByPublicId(insurer.clientKey, publicId);
}
@ApiBody({
@@ -275,56 +327,14 @@ export class ExpertInsurerController {
schema: {
type: "object",
properties: {
collisionMethodAccuracy: {
type: "number",
minimum: 0,
maximum: 5,
example: 4,
description: "تشخیص درست نحوه برخورد",
},
evaluationTimeliness: {
type: "number",
minimum: 0,
maximum: 5,
example: 3,
description: "زمان ارزیابی",
},
accidentCauseAccuracy: {
type: "number",
minimum: 0,
maximum: 5,
example: 5,
description: "تشخیص درست علت تصادف",
},
guiltyVehicleIdentification: {
type: "number",
minimum: 0,
maximum: 5,
example: 4,
description: "تشخیص درست وسیله نقلیه مقصر",
},
botRating: {
type: "number",
minimum: 0,
maximum: 5,
example: 4,
description: "برای امتیاز دادن به عملکرد بات",
},
},
required: [
"collisionMethodAccuracy",
"evaluationTimeliness",
"accidentCauseAccuracy",
"guiltyVehicleIdentification",
"botRating",
],
example: {
collisionMethodAccuracy: 4,
evaluationTimeliness: 3,
accidentCauseAccuracy: 5,
guiltyVehicleIdentification: 4,
botRating: 4,
collisionMethodAccuracy: { type: "number", minimum: 0, maximum: 5, example: 4, description: "تشخیص درست نحوه برخورد" },
evaluationTimeliness: { type: "number", minimum: 0, maximum: 5, example: 3, description: "زمان ارزیابی" },
accidentCauseAccuracy: { type: "number", minimum: 0, maximum: 5, example: 5, description: "تشخیص درست علت تصادف" },
guiltyVehicleIdentification: { type: "number", minimum: 0, maximum: 5, example: 4, description: "تشخیص درست وسیله نقلیه مقصر" },
botRating: { type: "number", minimum: 0, maximum: 5, example: 4, description: "برای امتیاز دادن به عملکرد بات" },
},
required: ["collisionMethodAccuracy", "evaluationTimeliness", "accidentCauseAccuracy", "guiltyVehicleIdentification", "botRating"],
example: { collisionMethodAccuracy: 4, evaluationTimeliness: 3, accidentCauseAccuracy: 5, guiltyVehicleIdentification: 4, botRating: 4 },
},
})
@ApiParam({ name: "publicId" })
@@ -334,23 +344,7 @@ export class ExpertInsurerController {
@Param("publicId") publicId: string,
@Body() rating: FileRating,
) {
return await this.expertInsurerService.rateExpertByPublicId(
publicId,
rating,
insurer.clientKey,
);
}
@Get("top-files")
async getTopFiles(@CurrentUser() insurer) {
return await this.expertInsurerService.getTopFilesForClient(
insurer.clientKey,
);
}
@Get("statistics")
async getExpertStatistics(@CurrentUser() actor) {
return await this.expertInsurerService.getExpertStatisticsReport(actor);
return await this.expertInsurerService.rateExpertByPublicId(publicId, rating, insurer.clientKey);
}
@Get("report/status-counts")
@@ -359,28 +353,18 @@ export class ExpertInsurerController {
deprecated: true,
description: "Prefer GET report/unified-file-statuses for full calculatable blame+claim statuses.",
})
@ApiQuery({
name: "from",
required: false,
description: "Optional start datetime (ISO string)",
})
@ApiQuery({
name: "to",
required: false,
description: "Optional end datetime (ISO string)",
})
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" })
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
async getInsurerStatusReport(
@CurrentUser() actor,
@Query("from") from?: string,
@Query("to") to?: string,
) {
return await this.expertInsurerService.getInsurerFileStatusCounts(
actor,
from,
to,
);
return await this.expertInsurerService.getInsurerFileStatusCounts(actor, from, to);
}
// ─── Expert detail ────────────────────────────────────────────────────────
@ApiParam({ name: "expertId" })
@ApiOperation({
summary: "Files handled by one roster expert (summary rows)",
@@ -389,13 +373,7 @@ export class ExpertInsurerController {
})
@Get("/:expertId")
async requestDetail(@CurrentUser() insurer, @Param("expertId") id: string) {
if (!Types.ObjectId.isValid(id)) {
throw new BadRequestException("Invalid expert ID");
}
return await this.expertInsurerService.getAllFilesForInsurerExpert(
id,
insurer.clientKey,
);
if (!Types.ObjectId.isValid(id)) throw new BadRequestException("Invalid expert ID");
return await this.expertInsurerService.getAllFilesForInsurerExpert(id, insurer.clientKey);
}
}

View File

@@ -1,6 +1,7 @@
import { Module } from "@nestjs/common";
import { MongooseModule } from "@nestjs/mongoose";
import { AuthModule } from "src/auth/auth.module";
import { ReportsModule } from "src/reports/reports.module";
import {
ClaimRequestManagementModel,
ClaimRequestManagementSchema,
@@ -29,6 +30,7 @@ import { HashModule } from "src/utils/hash/hash.module";
HashModule,
UsersModule,
ClientModule,
ReportsModule,
MongooseModule.forFeature([
{
name: ClaimRequestManagementModel.name,

View File

@@ -971,24 +971,38 @@ export class ExpertInsurerService {
* excluding `botRating`), optionally blend with the file’s user rating, average those
* combined scores per file, then average across that expert’s files (`overallAverageRating`).
*/
async getTopExpertsForClient(actor): Promise<{
blameExperts: any[];
claimExperts: any[];
}> {
async getTopExpertsForClient(
actor,
opts: { from?: string; to?: string } = {},
): Promise<{ blameExperts: any[]; claimExperts: any[] }> {
// When date opts are passed we re-use the full list; date filtering on file-based
// ratings would require per-file date awareness — for now we surface the roster
// as-is and document that from/to are accepted for forward-compat.
const result = await this.retrieveAllExpertsOfClient(actor, 1, 1000);
const rows = result?.experts || [];
const byRatingDesc = (a: any, b: any) =>
(b.overallAverageRating ?? 0) - (a.overallAverageRating ?? 0);
// Return only the fields the spec mandates — keep shape minimal
const slim = (e: any) => ({
_id: e._id,
fullName: e.fullName,
expertKind: e.expertKind,
overallAverageRating: e.overallAverageRating ?? null,
requestStats: e.requestStats ?? { totalHandled: 0, totalChecked: 0 },
});
const blameExperts = rows
.filter((e) => e.expertKind === "blame")
.sort(byRatingDesc)
.slice(0, 10);
.slice(0, 10)
.map(slim);
const claimExperts = rows
.filter((e) => e.expertKind === "claim")
.sort(byRatingDesc)
.slice(0, 10);
.slice(0, 10)
.map(slim);
return { blameExperts, claimExperts };
}
@@ -997,18 +1011,47 @@ export class ExpertInsurerService {
* Returns top 10 claim files for the current insurer client based on
* combined insurer + user ratings.
*/
async getTopFilesForClient(insurerId: string): Promise<any[]> {
const claimFiles = await this.getClientClaimFiles(
this.getClientId(insurerId),
);
const scored = claimFiles
async getTopFilesForClient(
insurerId: string,
opts: { from?: string; to?: string } = {},
): Promise<Array<{
publicId: string;
createdAt: string;
combinedScore: number;
userRating: { comment: string | null; overallEvaluation: number | null };
}>> {
const fromDate = opts.from ? new Date(opts.from) : undefined;
const toDate = opts.to ? new Date(opts.to) : undefined;
let claimFiles = await this.getClientClaimFiles(this.getClientId(insurerId));
if (fromDate || toDate) {
claimFiles = claimFiles.filter((f) => {
const d = new Date(f.createdAt);
if (fromDate && d < fromDate) return false;
if (toDate && d > toDate) return false;
return true;
});
}
return claimFiles
.map((file) => {
const combinedScore = this.getCombinedFileScore(file);
if (combinedScore === null) return null;
return { ...file, combinedScore };
const ur = file?.userRating;
return {
publicId: String(file.publicId ?? ""),
createdAt: file.createdAt instanceof Date
? file.createdAt.toISOString()
: String(file.createdAt ?? ""),
combinedScore,
userRating: {
comment: ur?.comment ?? null,
overallEvaluation: typeof ur?.overallEvaluation === "number" ? ur.overallEvaluation : null,
},
};
})
.filter((f) => f !== null);
return scored
.filter((f): f is NonNullable<typeof f> => f !== null)
.sort((a, b) => b.combinedScore - a.combinedScore)
.slice(0, 10);
}
@@ -1775,29 +1818,59 @@ export class ExpertInsurerService {
* - Percentage of files that have objection
* - Number of files created in the current month
*/
async getExpertStatisticsReport(actor: any) {
async getExpertStatisticsReport(
actor: any,
opts: { from?: string; to?: string } = {},
) {
const clientObjectId = this.getClientId(actor);
const claimFiles = await this.getClientClaimFiles(clientObjectId);
const [claimFiles, blameFiles, activityEvents] = await Promise.all([
this.getClientClaimFiles(clientObjectId),
this.getClientBlameFiles(clientObjectId),
this.expertFileActivityDbService.findByTenant(clientObjectId),
]);
// Calculate current month date range
// Optional date range filter for portfolio counts
const fromDate = opts.from ? new Date(opts.from) : undefined;
const toDate = opts.to ? new Date(opts.to) : undefined;
const inRange = (date: unknown) => {
if (!fromDate && !toDate) return true;
if (!date) return false;
const d = new Date(date as string);
if (fromDate && d < fromDate) return false;
if (toDate && d > toDate) return false;
return true;
};
const rangedClaimFiles = claimFiles.filter((f) => inRange(f.createdAt));
// Current calendar month
const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const monthEnd = new Date(
now.getFullYear(),
now.getMonth() + 1,
0,
23,
59,
59,
);
// Filter files created this month
const filesThisMonth = claimFiles.filter((file) => {
const createdAt = new Date(file.createdAt);
return createdAt >= monthStart && createdAt <= monthEnd;
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59);
const filesThisMonth = claimFiles.filter((f) => {
const d = new Date(f.createdAt);
return d >= monthStart && d <= monthEnd;
});
// Calculate statistics
// totalFilesReviewed: distinct claim files with at least one CHECKED activity
const checkedFileIds = new Set<string>();
for (const ev of activityEvents) {
if (ev.eventType === ExpertFileActivityType.CHECKED) {
checkedFileIds.add(String(ev.fileId));
}
}
// Intersect with this insurer's claim file ids
const claimFileIds = new Set(claimFiles.map((f) => String(f._id)));
let totalFilesReviewed = 0;
for (const id of checkedFileIds) {
if (claimFileIds.has(id)) totalFilesReviewed++;
}
// inPersonAccompaniedCount: blame files where expertInitiated === true AND creationMethod === IN_PERSON
const inPersonAccompaniedCount = blameFiles.filter(
(b) => !!(b as any).expertInitiated && (b as any).creationMethod === "IN_PERSON",
).length;
// Per-file statistics over the (optionally date-ranged) claim portfolio
let totalInsurerRatings = 0;
let totalBotRatings = 0;
let filesWithInsurerRating = 0;
@@ -1805,103 +1878,88 @@ export class ExpertInsurerService {
let filesWithUserRating = 0;
let filesWithObjection = 0;
for (const file of claimFiles) {
// averageUserRatingPercentage: mean of (progressSpeed + registrationEase + overallEvaluation) / 5 * 100
let userRatingDimensionSum = 0;
let userRatingDimensionCount = 0;
for (const file of rangedClaimFiles) {
const insurerRating = file?.rating;
const userRating = file?.userRating;
const objection = file?.objection;
// Check for insurer rating (excluding botRating)
if (insurerRating) {
const insurerValues = [
insurerRating.collisionMethodAccuracy,
insurerRating.evaluationTimeliness,
insurerRating.accidentCauseAccuracy,
insurerRating.guiltyVehicleIdentification,
].filter(
(val): val is number => typeof val === "number" && !isNaN(val),
);
].filter((v): v is number => typeof v === "number" && !isNaN(v));
if (insurerValues.length > 0) {
filesWithInsurerRating++;
const insurerAvg =
insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length;
totalInsurerRatings += insurerAvg;
totalInsurerRatings += insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length;
}
const botRating = (insurerRating as any)?.botRating;
if (typeof botRating === "number" && !isNaN(botRating)) {
filesWithBotRating++;
totalBotRatings += botRating;
}
}
// Check for bot rating (if botRating field exists in rating object)
const botRating = (insurerRating as any)?.botRating;
if (
botRating !== undefined &&
!isNaN(botRating) &&
typeof botRating === "number"
) {
filesWithBotRating++;
totalBotRatings += botRating;
}
// Check for user rating
if (userRating) {
filesWithUserRating++;
const dims = [
userRating.progressSpeed,
userRating.registrationEase,
userRating.overallEvaluation,
].filter((v): v is number => typeof v === "number" && !isNaN(v));
if (dims.length > 0) {
userRatingDimensionSum += dims.reduce((a, b) => a + b, 0) / dims.length;
userRatingDimensionCount++;
}
}
// Check for objection
if (objection) {
filesWithObjection++;
}
if (file?.objection) filesWithObjection++;
}
// Calculate percentages
const totalFiles = claimFiles.length;
const totalFiles = rangedClaimFiles.length;
const averageInsurerRating = filesWithInsurerRating > 0 ? totalInsurerRatings / filesWithInsurerRating : 0;
const averageBotRating = filesWithBotRating > 0 ? totalBotRatings / filesWithBotRating : 0;
// Calculate average insurer rating (excluding botRating) and average bot rating
const averageInsurerRating =
filesWithInsurerRating > 0
? totalInsurerRatings / filesWithInsurerRating
// averageUserRatingPercentage: (mean score / 5) * 100; mean score is avg of the three
// per-file dimension averages across all rated files.
const averageUserRatingPercentage =
userRatingDimensionCount > 0
? parseFloat(((userRatingDimensionSum / userRatingDimensionCount / 5) * 100).toFixed(2))
: 0;
const averageBotRating =
filesWithBotRating > 0 ? totalBotRatings / filesWithBotRating : 0;
// Calculate percentage: (averageInsurerRating / averageBotRating) * 100
// This shows what percentage the insurer rating is compared to bot rating
const insurerToBotPercentage =
averageBotRating > 0 && averageInsurerRating > 0
? parseFloat(
((averageInsurerRating / averageBotRating) * 100).toFixed(2),
)
: 0;
const userRatingPercentage =
totalFiles > 0
? parseFloat(((filesWithUserRating / totalFiles) * 100).toFixed(2))
: 0;
const objectionPercentage =
totalFiles > 0
? parseFloat(((filesWithObjection / totalFiles) * 100).toFixed(2))
? parseFloat(((averageInsurerRating / averageBotRating) * 100).toFixed(2))
: 0;
return {
insurerToBotRatingPercentage: insurerToBotPercentage,
userRatingPercentage: userRatingPercentage,
objectionPercentage: objectionPercentage,
// New / corrected KPI fields
totalFilesReviewed,
averageUserRatingPercentage,
inPersonAccompaniedCount,
// Unchanged
filesCreatedThisMonth: filesThisMonth.length,
totalFiles: totalFiles,
totalFiles,
objectionPercentage:
totalFiles > 0 ? parseFloat(((filesWithObjection / totalFiles) * 100).toFixed(2)) : 0,
insurerToBotRatingPercentage: insurerToBotPercentage,
// Deprecated: percentage of files that HAVE a user rating; not a satisfaction metric
filesWithUserRatingPercentage:
totalFiles > 0 ? parseFloat(((filesWithUserRating / totalFiles) * 100).toFixed(2)) : 0,
/** @deprecated use filesWithUserRatingPercentage */
userRatingPercentage:
totalFiles > 0 ? parseFloat(((filesWithUserRating / totalFiles) * 100).toFixed(2)) : 0,
breakdown: {
filesWithInsurerRating,
filesWithBotRating,
filesWithUserRating,
filesWithObjection,
averageInsurerRating:
filesWithInsurerRating > 0
? parseFloat(
(totalInsurerRatings / filesWithInsurerRating).toFixed(2),
)
: 0,
averageBotRating:
filesWithBotRating > 0
? parseFloat((totalBotRatings / filesWithBotRating).toFixed(2))
: 0,
averageInsurerRating: parseFloat(averageInsurerRating.toFixed(2)),
averageBotRating: parseFloat(averageBotRating.toFixed(2)),
},
};
}
@@ -2090,6 +2148,20 @@ export class ExpertInsurerService {
};
}
/**
* Maps a raw history `actorType` string to either `"user"` or `"expert"`.
* - `"user"` → the action was taken by the car-owner / damaged party
* - `"expert"` → taken by any expert, system, or back-office actor
* - `null` → actorType was absent (unknown)
*/
private static resolveTimelinePerformedBy(
actorType: string | undefined,
): "user" | "expert" | null {
if (!actorType) return null;
if (actorType === "user") return "user";
return "expert";
}
async getFileTimeline(
insurerId: string,
publicId: string,
@@ -2129,6 +2201,7 @@ export class ExpertInsurerService {
faLabel: getEventFaLabel(ev),
timestamp: ev.timestamp,
actor: ev.actor ?? null,
performedBy: ExpertInsurerService.resolveTimelinePerformedBy(ev.actor?.actorType),
metadata: ev.metadata ?? null,
});
}
@@ -2148,6 +2221,7 @@ export class ExpertInsurerService {
faLabel: getEventFaLabel(ev),
timestamp: ev.timestamp,
actor: ev.actor ?? null,
performedBy: ExpertInsurerService.resolveTimelinePerformedBy(ev.actor?.actorType),
metadata: ev.metadata ?? null,
});
}

View File

@@ -22,6 +22,12 @@ export const OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5 = [
"guilty_metal_plate",
] 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 =
| "parts"
| "angles"
@@ -58,7 +64,12 @@ function isRequiredDocumentUploadedOnClaim(
export function getClaimCaptureProgress(
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 {
const carType = claimCase?.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const selectedNorm = normalizeDamageSelectedParts(
@@ -87,13 +98,15 @@ export function getClaimCaptureProgress(
const anglesTotal = CLAIM_CAR_ANGLE_KEYS.length;
const anglesComplete = anglesCaptured >= anglesTotal;
const capturePhaseDocsRemaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.filter(
(k) => {
if (options?.skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(k as any)) return false;
if (k === options?.assumeCapturePhaseDocKey) return false;
return !isRequiredDocumentUploadedOnClaim(claimCase, k);
},
).length;
const capturePhaseKeys: string[] = [
...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 (k === options?.assumeCapturePhaseDocKey) return false;
return !isRequiredDocumentUploadedOnClaim(claimCase, k);
}).length;
const capturePhaseDocsComplete = capturePhaseDocsRemaining === 0;
let sequencePhase: CapturePhaseSequence = "parts";

View File

@@ -13,5 +13,6 @@ import { ReportsService } from "./reports.service";
],
controllers: [ReportsController],
providers: [ReportsService],
exports: [ReportsService],
})
export class ReportsModule {}

View File

@@ -436,19 +436,47 @@ export class ReportsService {
});
}
async getInsurerExpertWorkLog(actor: {
clientKey?: string;
}): Promise<InsurerExpertWorkLogResponseDtoRs> {
async getInsurerExpertWorkLog(
actor: { clientKey?: string },
opts: {
expertKind?: "all" | "expert" | "damage_expert";
from?: string;
to?: string;
page?: number;
limit?: number;
} = {},
): Promise<{ experts: InsurerExpertWorkLogEntryDtoRs[]; total: number }> {
const clientKey = requireActorClientKey(actor);
const { events, expertRows } =
await this.loadTenantExpertsAndActivities(clientKey);
// expertKind filter
const filteredRows =
!opts.expertKind || opts.expertKind === "all"
? expertRows
: expertRows.filter((r) => r.kind === opts.expertKind);
const now = new Date();
const epoch = new Date(0);
const entries = this.buildWorkLogEntries(expertRows, events, now, {
from: epoch,
to: now,
// When from/to supplied, distinctFilesCheckedInPeriod is restricted to that window;
// totalHandled and currentlyChecking are snapshot-at-'to' (or now when to is absent).
const { fromDate, toDate } = this.parseDateRange(opts.from, opts.to);
const cutoff = toDate ?? now;
const checkedFrom = fromDate ?? new Date(0);
const checkedTo = toDate ?? now;
const entries = this.buildWorkLogEntries(filteredRows, events, cutoff, {
from: checkedFrom,
to: checkedTo,
});
return new InsurerExpertWorkLogResponseDtoRs(entries);
// Optional pagination
const page = Number(opts.page) > 0 ? Number(opts.page) : 1;
const limit = Number(opts.limit) > 0 ? Number(opts.limit) : entries.length;
const start = (page - 1) * limit;
const paged = entries.slice(start, start + limit);
return { experts: paged, total: entries.length };
}
async getInsurerExpertWorkLogPerMonth(actor: {

View File

@@ -20,7 +20,10 @@ import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator";
import { RoleEnum } from "src/Types&Enums/role.enum";
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.
@@ -105,6 +108,27 @@ export class CallCenterBlameV6Controller {
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")
@ApiOperation({
summary: "[V6] Send blame link to the guilty party via SMS",

View File

@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsNotEmpty, IsString, MaxLength } from "class-validator";
import { IsNotEmpty, IsOptional, 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";
@@ -87,6 +87,37 @@ export class DescriptionDto {
lightCondition?: LightCondition;
}
/**
* V4 FileMaker description step (THIRD_PARTY and CAR_BODY alike).
* Accident date and time are required for all file types in V4 so the
* front-end can display them consistently regardless of blame type.
*/
export class DescriptionV4Dto {
@ApiProperty({ description: "Accident description" })
@IsString()
@IsNotEmpty()
desc: string;
@ApiPropertyOptional({
description:
"Date of the accident. Required for the first (guilty) party; " +
"optional for the second (damaged) party.",
example: "2025-12-08",
})
@IsOptional()
accidentDate?: Date;
@ApiPropertyOptional({
description:
"Time of the accident. Required for the first (guilty) party; " +
"optional for the second (damaged) party.",
example: "14:30",
})
@IsOptional()
@IsString()
accidentTime?: string;
}
/**
* THIRD_PARTY description step: only desc is accepted.
*/

View File

@@ -4,6 +4,7 @@ import {
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
ValidateNested,
} from "class-validator";
import { Type } from "class-transformer";
@@ -83,3 +84,60 @@ export class RunCallCenterInquiryV6Dto {
@IsString()
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

@@ -89,6 +89,16 @@ export class RunInquiriesV3Dto {
@IsOptional()
@IsString()
insurerLicense?: string;
@ApiPropertyOptional({
example: "1",
description:
"Driving licence type code from GET /lookups/driving-licence-types. " +
"Optional — used in V4 FileMaker flow.",
})
@IsOptional()
@IsString()
licenseType?: string;
}
/**
@@ -152,4 +162,14 @@ export class RunInquiriesVinV3Dto {
@IsOptional()
@IsString()
insurerLicense?: string;
@ApiPropertyOptional({
example: "1",
description:
"Driving licence type code from GET /lookups/driving-licence-types. " +
"Optional — used in V4 FileMaker flow.",
})
@IsOptional()
@IsString()
licenseType?: string;
}

View File

@@ -23,6 +23,8 @@ export class Person {
@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;
@@ -51,6 +53,8 @@ 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;

View File

@@ -33,7 +33,7 @@ import { SendPartyOtpDto, VerifyPartyOtpDto } from "./dto/party-otp.dto";
import { RunInquiriesV3Dto, RunInquiriesVinV3Dto } from "./dto/run-inquiries-v3.dto";
import {
CarBodyFormDto,
DescriptionDto,
DescriptionV4Dto,
LocationDto,
} from "./dto/create-request-management.dto";
import { RequestManagementService } from "./request-management.service";
@@ -260,15 +260,20 @@ export class FileMakerBlameV4Controller {
@Post("add-detail-description/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: DescriptionDto })
@ApiOperation({ summary: "Add description for current party" })
@ApiBody({ type: DescriptionV4Dto })
@ApiOperation({
summary: "Add description for current party (V4)",
description:
"Saves description, accidentDate, and accidentTime on the party statement. " +
"accidentDate and accidentTime are required for all V4 files (THIRD_PARTY and CAR_BODY alike).",
})
async addDescription(
@Param("requestId") requestId: string,
@Body() body: DescriptionDto,
@Body() body: DescriptionV4Dto,
@CurrentUser() fileMaker: any,
@Body("partyRole") partyRole?: string,
) {
return this.requestManagementService.addPartyDescriptionV3(
return this.requestManagementService.addPartyDescriptionV4(
requestId,
fileMaker,
body,

View File

@@ -501,6 +501,9 @@ export class InquiryRefreshService {
if (memParty.vehicle?.plateId !== undefined) {
party.vehicle.plateId = memParty.vehicle.plateId;
}
if (memParty.vehicle?.vin !== undefined) {
party.vehicle.vin = memParty.vehicle.vin;
}
blameDoc.markModified(`parties.${docIdx}.vehicle`);
if (memParty.insurance) {

View File

@@ -57,6 +57,7 @@ import { AutoCloseRequestService } from "src/utils/cron/cron.service";
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
import {
DescriptionDto,
DescriptionV4Dto,
LocationDto,
RequestManagementDtoRs,
} from "./dto/create-request-management.dto";
@@ -109,6 +110,7 @@ import {
collectUserIdVariants,
} from "src/helpers/party-access-queries";
import { resolveLinkedUserIdStrings } from "src/helpers/user-access-resolver";
import { normalizePlateText } from "src/utils/plate-normalizer/plate-normalizer.service";
/**
* Formats a compact Jalali date (number or string like 13780624) as YYYY/MM/DD.
@@ -133,6 +135,34 @@ export class RequestManagementService {
throw new BadRequestException(`Step ${stepKey} is not a party-scoped step`);
}
/**
* Reverse map: Fanavaran/Tejarat numeric letter code → Persian plate letter.
* Tejarat inquiry stores Plk2 as a numeric code (e.g. 12 → "م", 5 → "د").
* 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<number, string> = {
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:
* - already a Persian string → returned as-is (after normalization)
* - a numeric code (number or numeric string) → looked up in the reverse map
*/
private resolvePlk2Letter(plk2: any): string {
if (plk2 == null) return "";
const code = Number(plk2);
if (Number.isFinite(code) && code > 0) {
return RequestManagementService.PLATE_LETTER_CODE_TO_LETTER[code] ?? "";
}
return normalizePlateText(String(plk2).trim());
}
/** Convert plate (string or { ir, leftDigits, centerAlphabet, centerDigits }) to string for vehicle.plateId */
private plateToPlateIdString(plate: any): string {
if (plate == null) return "";
@@ -1841,8 +1871,18 @@ export class RequestManagementService {
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;
// Derive plateId from the plate parts returned by the VIN inquiry.
// 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.name = inquiryMapped?.MapTypNam;
party.vehicle.type = `${inquiryMapped?.UsageField} / ${inquiryMapped?.MapUsageName || "-"}`;
party.vehicle.inquiry = {
@@ -8689,6 +8729,8 @@ export class RequestManagementService {
party.person.insurerLicense = partyData.insurerLicense;
if (partyData.driverLicense)
party.person.driverLicense = partyData.driverLicense;
if ((partyData as any).licenseType)
(party.person as any).licenseType = (partyData as any).licenseType;
if (!party.vehicle) party.vehicle = {} as any;
party.vehicle.plateId = this.plateToPlateIdString(partyData.plate);
@@ -9501,9 +9543,22 @@ export class RequestManagementService {
party.person.insurerLicense = partyData.insurerLicense;
if (partyData.driverLicense)
party.person.driverLicense = partyData.driverLicense;
if ((partyData as any).licenseType)
(party.person as any).licenseType = (partyData as any).licenseType;
if (!party.vehicle) party.vehicle = {} as any;
party.vehicle.plateId = partyData.vin; // store VIN as vehicle identifier
// Derive plateId from the plate parts returned by the VIN inquiry.
// 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.name = inquiryMapped?.MapTypNam;
party.vehicle.type = `${inquiryMapped?.UsageField ?? ""} / ${inquiryMapped?.UsageName ?? inquiryMapped?.MapUsageName ?? "-"}`;
party.vehicle.inquiry = {
@@ -9994,7 +10049,9 @@ export class RequestManagementService {
}
if (
claim.workflow?.currentStep !==
ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS
ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS &&
claim.workflow?.currentStep !==
ClaimWorkflowStep.USER_SUBMISSION_COMPLETE
) {
throw new BadRequestException(
"Complete capture-part (parts, angles, and capture-phase documents) before the blame accident video.",
@@ -10260,6 +10317,70 @@ export class RequestManagementService {
return { requestId: req._id, publicId: req.publicId, partyRole: role };
}
/**
* V4 description step.
*
* Identical to {@link addPartyDescriptionV3} but additionally requires
* `accidentDate` and `accidentTime` for all blame types (THIRD_PARTY and
* CAR_BODY alike) and persists them on `party.statement`. This lets the
* front-end display the accident time consistently for every V4 file without
* branching on blame type.
*/
async addPartyDescriptionV4(
requestId: string,
actor: any,
body: DescriptionV4Dto,
partyRole?: string,
) {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
await this.verifyExpertAccessForBlameV2(req, actor);
const role = this.resolvePartyRoleV3(req, partyRole);
this.assertBlameV3PartyDetailPhase(req, role);
// accidentDate and accidentTime are only required for the first (guilty)
// party — the second (damaged) party can skip them.
if (role === PartyRole.FIRST) {
if (body.accidentDate == null || body.accidentDate === ("" as any)) {
throw new BadRequestException(
"accidentDate is required for the first party in V4 files.",
);
}
if (!body.accidentTime || !String(body.accidentTime).trim()) {
throw new BadRequestException(
"accidentTime is required for the first party in V4 files.",
);
}
}
const idx = this.getPartyIndex(req, role);
if (idx === -1) throw new BadRequestException(`${role} party not found`);
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.accidentTime) party.statement.accidentTime = body.accidentTime;
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: "V4_PARTY_DESCRIPTION_SAVED",
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
actorType: "field_expert",
},
metadata: {
partyRole: role,
accidentDate: body.accidentDate,
accidentTime: body.accidentTime,
},
} as any);
await (req as any).save();
return { requestId: req._id, publicId: req.publicId, partyRole: role };
}
async carBodyAccidentTypeFormV3(
requestId: string,
body: CarBodyFormDto,
@@ -10883,6 +11004,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.
* Registers/looks up the user by phone and stores them as the first party, then
@@ -11050,6 +11262,7 @@ export class RequestManagementService {
clientId: p.person?.clientId
? String(p.person.clientId)
: undefined,
licenseType: p.person?.licenseType ?? undefined,
},
insurance: p.insurance
? {
@@ -11063,6 +11276,7 @@ export class RequestManagementService {
vehicle: p.vehicle
? {
plateId: p.vehicle.plateId,
vin: p.vehicle.vin,
name: p.vehicle.name,
type: p.vehicle.type,
}
@@ -11144,6 +11358,7 @@ export class RequestManagementService {
driverIsInsurer: p.person?.driverIsInsurer,
userId: p.person?.userId ? String(p.person.userId) : undefined,
clientId: p.person?.clientId ? String(p.person.clientId) : undefined,
licenseType: p.person?.licenseType ?? undefined,
},
insurance: p.insurance ? {
company: p.insurance.company,
@@ -11250,6 +11465,7 @@ export class RequestManagementService {
driverIsInsurer: p.person?.driverIsInsurer,
userId: p.person?.userId ? String(p.person.userId) : undefined,
clientId: p.person?.clientId ? String(p.person.clientId) : undefined,
licenseType: p.person?.licenseType ?? undefined,
},
insurance: p.insurance ? {
company: p.insurance.company,

View File

@@ -1027,12 +1027,59 @@ export class SandHubService {
/**
* Maps the new SandHub API response format to the old format expected by the application
*/
/**
* Parse the `plk` string returned by the ESG VIN inquiry into the four
* numeric/text plate parts used everywhere in the system.
*
* ESG format example: `"782د29 60"`
* → Plk3=782 (center digits) Plk2="د" (center letter)
* Plk1=29 (left digits) PlkSrl=60 (IR region code)
*
* Returns null if the string does not match the expected pattern.
*/
private parsePlkString(
plk: string,
): { Plk1: number; Plk2: string; Plk3: number; PlkSrl: number } | null {
if (!plk || typeof plk !== "string") return null;
// Pattern: {centerDigits}{centerLetter(s)}{leftDigits}<space>{ir}
const m = plk.trim().match(/^(\d+)([^\d\s]+)(\d+)\s+(\d+)$/);
if (!m) return null;
const Plk3 = parseInt(m[1], 10); // center digits
const Plk2 = this.plateNormalizer.normalizePlateText(m[2]);
const Plk1 = parseInt(m[3], 10); // left digits
const PlkSrl = parseInt(m[4], 10); // IR region code
if (!Number.isFinite(Plk3) || !Number.isFinite(Plk1) || !Number.isFinite(PlkSrl) || !Plk2) {
return null;
}
return { Plk1, Plk2, Plk3, PlkSrl };
}
private mapNewApiResponseToOldFormat(newResponse: any): any {
if (!newResponse) return newResponse;
// If the response carries a `plk` plate string (VIN inquiry) but lacks the
// individual Plk1/Plk2/Plk3/PlkSrl fields, parse and inject them so that
// all downstream plate-handling code works identically to the plate flow.
let plkParts: { Plk1: number; Plk2: string; Plk3: number; PlkSrl: number } | null = null;
if (
newResponse.plk &&
newResponse.Plk1 == null &&
newResponse.Plk3 == null &&
newResponse.PlkSrl == null
) {
plkParts = this.parsePlkString(String(newResponse.plk));
}
// Map the new field names to the old field names
return {
...newResponse,
...(plkParts ? {
Plk1: plkParts.Plk1,
Plk2: plkParts.Plk2,
Plk3: plkParts.Plk3,
PlkSrl: plkParts.PlkSrl,
plateLetterid: plkParts.Plk2,
} : {}),
// Company information
CompanyCode: newResponse.companyId || newResponse.CompanyCode,
CompanyName: newResponse.companyPersianName || newResponse.CompanyName,