forked from Yara724/api
merge upstream
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
return await this.caseExpertReportService.generateForInsurer(
|
||||
publicId,
|
||||
insurer,
|
||||
);
|
||||
return new StreamableFile(buffer, {
|
||||
type: "application/pdf",
|
||||
disposition: `attachment; filename="${filename}"`,
|
||||
});
|
||||
} 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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,3 @@ export type InsurerFileReportViewModel = {
|
||||
requestNo?: string;
|
||||
sections: InsurerFileReportSection[];
|
||||
};
|
||||
|
||||
export type InsurerFileReportPdfResult = {
|
||||
buffer: Buffer;
|
||||
filename: string;
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
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;
|
||||
}).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.
|
||||
|
||||
186
src/expert-insurer/dto/insurer-reports.dto.ts
Normal file
186
src/expert-insurer/dto/insurer-reports.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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"
|
||||
) {
|
||||
if (typeof botRating === "number" && !isNaN(botRating)) {
|
||||
filesWithBotRating++;
|
||||
totalBotRatings += botRating;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for user rating
|
||||
if (userRating) {
|
||||
filesWithUserRating++;
|
||||
}
|
||||
|
||||
// Check for objection
|
||||
if (objection) {
|
||||
filesWithObjection++;
|
||||
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++;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate percentages
|
||||
const totalFiles = claimFiles.length;
|
||||
if (file?.objection) filesWithObjection++;
|
||||
}
|
||||
|
||||
// Calculate average insurer rating (excluding botRating) and average bot rating
|
||||
const averageInsurerRating =
|
||||
filesWithInsurerRating > 0
|
||||
? totalInsurerRatings / filesWithInsurerRating
|
||||
const totalFiles = rangedClaimFiles.length;
|
||||
const averageInsurerRating = filesWithInsurerRating > 0 ? totalInsurerRatings / filesWithInsurerRating : 0;
|
||||
const averageBotRating = filesWithBotRating > 0 ? totalBotRatings / filesWithBotRating : 0;
|
||||
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
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;
|
||||
}).length;
|
||||
const capturePhaseDocsComplete = capturePhaseDocsRemaining === 0;
|
||||
|
||||
let sequencePhase: CapturePhaseSequence = "parts";
|
||||
|
||||
@@ -13,5 +13,6 @@ import { ReportsService } from "./reports.service";
|
||||
],
|
||||
controllers: [ReportsController],
|
||||
providers: [ReportsService],
|
||||
exports: [ReportsService],
|
||||
})
|
||||
export class ReportsModule {}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user