forked from Yara724/api
Compare commits
29 Commits
5942f51b4c
...
cb69b496b5
| Author | SHA1 | Date | |
|---|---|---|---|
| cb69b496b5 | |||
| 1d56326456 | |||
|
|
d5aa9f3f1b | ||
| d8a2be091f | |||
|
|
01f8a5b12c | ||
| e6ea5c2e8a | |||
|
|
875b52d761 | ||
| f5aa25edf8 | |||
|
|
85d7881b2b | ||
| 7259eec949 | |||
|
|
42f4c6e8e3 | ||
| 71b3b1d786 | |||
|
|
baac633443 | ||
| 13231736d8 | |||
|
|
cbed681c8f | ||
| 3e1cde739f | |||
|
|
70d7f34402 | ||
| f57b1b1171 | |||
|
|
6791c71809 | ||
| 6d955cd608 | |||
|
|
ca7200e17d | ||
| 60ed80fc87 | |||
|
|
210e96fcf1 | ||
| 63038f630d | |||
|
|
8e4c794d61 | ||
| 0663b35157 | |||
|
|
d4cd8c9343 | ||
| 2e8a8197a4 | |||
|
|
3821bf36ef |
@@ -20,6 +20,12 @@ export enum ClaimRequiredDocumentType {
|
|||||||
GUILTY_CAR_CARD_FRONT = "guilty_car_card_front",
|
GUILTY_CAR_CARD_FRONT = "guilty_car_card_front",
|
||||||
GUILTY_CAR_CARD_BACK = "guilty_car_card_back",
|
GUILTY_CAR_CARD_BACK = "guilty_car_card_back",
|
||||||
GUILTY_METAL_PLATE = "guilty_metal_plate",
|
GUILTY_METAL_PLATE = "guilty_metal_plate",
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V4/V5 only — a photo of the guilty car's damaged area, captured by the
|
||||||
|
* FileReviewer during the CAPTURE_PART_DAMAGES phase (after all car angles).
|
||||||
|
*/
|
||||||
|
GUILTY_DAMAGE_AREA = "guilty_damage_area",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum CarAngle {
|
export enum CarAngle {
|
||||||
|
|||||||
@@ -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,
|
HttpException,
|
||||||
InternalServerErrorException,
|
InternalServerErrorException,
|
||||||
Param,
|
Param,
|
||||||
StreamableFile,
|
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import {
|
import {
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
ApiParam,
|
ApiParam,
|
||||||
ApiProduces,
|
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
ApiTags,
|
ApiTags,
|
||||||
} from "@nestjs/swagger";
|
} from "@nestjs/swagger";
|
||||||
@@ -21,6 +19,7 @@ import { Roles } from "src/decorators/roles.decorator";
|
|||||||
import { CurrentUser } from "src/decorators/user.decorator";
|
import { CurrentUser } from "src/decorators/user.decorator";
|
||||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
import { CaseExpertReportService } from "./case-expert-report.service";
|
import { CaseExpertReportService } from "./case-expert-report.service";
|
||||||
|
import { InsurerFileReportViewModel } from "./case-expert-report.types";
|
||||||
|
|
||||||
@ApiTags("expert-insurer-panel")
|
@ApiTags("expert-insurer-panel")
|
||||||
@Controller("expert-insurer")
|
@Controller("expert-insurer")
|
||||||
@@ -32,36 +31,30 @@ export class CaseExpertReportInsurerController {
|
|||||||
private readonly caseExpertReportService: CaseExpertReportService,
|
private readonly caseExpertReportService: CaseExpertReportService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get("files/:publicId/report.pdf")
|
@Get("files/:publicId/report")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Download insurer file report PDF",
|
summary: "Get insurer file report data",
|
||||||
description:
|
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" })
|
@ApiParam({ name: "publicId" })
|
||||||
@ApiProduces("application/pdf")
|
@ApiResponse({ status: 200, description: "Report data" })
|
||||||
@ApiResponse({ status: 200, description: "PDF file" })
|
|
||||||
@ApiResponse({ status: 404, description: "File not found for this publicId" })
|
@ApiResponse({ status: 404, description: "File not found for this publicId" })
|
||||||
async downloadInsurerReport(
|
async getInsurerReport(
|
||||||
@CurrentUser() insurer: { clientKey?: string },
|
@CurrentUser() insurer: { clientKey?: string },
|
||||||
@Param("publicId") publicId: string,
|
@Param("publicId") publicId: string,
|
||||||
): Promise<StreamableFile> {
|
): Promise<InsurerFileReportViewModel> {
|
||||||
try {
|
try {
|
||||||
const { buffer, filename } =
|
return await this.caseExpertReportService.generateForInsurer(
|
||||||
await this.caseExpertReportService.generateForInsurer(
|
publicId,
|
||||||
publicId,
|
insurer,
|
||||||
insurer,
|
);
|
||||||
);
|
|
||||||
return new StreamableFile(buffer, {
|
|
||||||
type: "application/pdf",
|
|
||||||
disposition: `attachment; filename="${filename}"`,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
throw new InternalServerErrorException(
|
throw new InternalServerErrorException(
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? 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 { Module } from "@nestjs/common";
|
||||||
import { ExpertInsurerModule } from "src/expert-insurer/expert-insurer.module";
|
import { ExpertInsurerModule } from "src/expert-insurer/expert-insurer.module";
|
||||||
import { CaseExpertReportInsurerController } from "./case-expert-report.controller";
|
import { CaseExpertReportInsurerController } from "./case-expert-report.controller";
|
||||||
import { CaseExpertReportPdfService } from "./case-expert-report-pdf.service";
|
|
||||||
import { CaseExpertReportService } from "./case-expert-report.service";
|
import { CaseExpertReportService } from "./case-expert-report.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ExpertInsurerModule],
|
imports: [ExpertInsurerModule],
|
||||||
controllers: [CaseExpertReportInsurerController],
|
controllers: [CaseExpertReportInsurerController],
|
||||||
providers: [CaseExpertReportService, CaseExpertReportPdfService],
|
providers: [CaseExpertReportService],
|
||||||
exports: [CaseExpertReportService],
|
exports: [CaseExpertReportService],
|
||||||
})
|
})
|
||||||
export class CaseExpertReportModule {}
|
export class CaseExpertReportModule {}
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
import { ExpertInsurerService } from "src/expert-insurer/expert-insurer.service";
|
import { ExpertInsurerService } from "src/expert-insurer/expert-insurer.service";
|
||||||
import { buildInsurerFileReport } from "./case-expert-report.builder";
|
import { buildInsurerFileReport } from "./case-expert-report.builder";
|
||||||
import { CaseExpertReportPdfService } from "./case-expert-report-pdf.service";
|
import { InsurerFileReportViewModel } from "./case-expert-report.types";
|
||||||
import { InsurerFileReportPdfResult } from "./case-expert-report.types";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CaseExpertReportService {
|
export class CaseExpertReportService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly expertInsurerService: ExpertInsurerService,
|
private readonly expertInsurerService: ExpertInsurerService,
|
||||||
private readonly pdfService: CaseExpertReportPdfService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async generateForInsurer(
|
async generateForInsurer(
|
||||||
publicId: string,
|
publicId: string,
|
||||||
actor: { clientKey?: string },
|
actor: { clientKey?: string },
|
||||||
): Promise<InsurerFileReportPdfResult> {
|
): Promise<InsurerFileReportViewModel> {
|
||||||
const clientKey = actor?.clientKey;
|
const clientKey = actor?.clientKey;
|
||||||
if (!clientKey) {
|
if (!clientKey) {
|
||||||
throw new NotFoundException("Insurer context not found");
|
throw new NotFoundException("Insurer context not found");
|
||||||
@@ -24,7 +22,6 @@ export class CaseExpertReportService {
|
|||||||
clientKey,
|
clientKey,
|
||||||
publicId,
|
publicId,
|
||||||
);
|
);
|
||||||
const model = buildInsurerFileReport(file);
|
return buildInsurerFileReport(file);
|
||||||
return this.pdfService.render(model);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,3 @@ export type InsurerFileReportViewModel = {
|
|||||||
requestNo?: string;
|
requestNo?: string;
|
||||||
sections: InsurerFileReportSection[];
|
sections: InsurerFileReportSection[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type InsurerFileReportPdfResult = {
|
|
||||||
buffer: Buffer;
|
|
||||||
filename: string;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ import {
|
|||||||
CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS,
|
CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS,
|
||||||
capturePhaseSequenceMessage,
|
capturePhaseSequenceMessage,
|
||||||
getClaimCaptureProgress,
|
getClaimCaptureProgress,
|
||||||
|
GUILTY_DAMAGE_AREA_DOC_KEY,
|
||||||
isCapturePhaseDamagedPartyDocKey,
|
isCapturePhaseDamagedPartyDocKey,
|
||||||
isClaimCaptureStepComplete,
|
isClaimCaptureStepComplete,
|
||||||
OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5,
|
OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5,
|
||||||
@@ -9073,7 +9074,7 @@ export class ClaimRequestManagementService {
|
|||||||
file: Express.Multer.File,
|
file: Express.Multer.File,
|
||||||
currentUserId: string,
|
currentUserId: string,
|
||||||
actor?: { sub: string; role?: string },
|
actor?: { sub: string; role?: string },
|
||||||
options?: { v3InPersonFlow?: boolean; skipMetalPlate?: boolean; requiresFileMakerApproval?: boolean },
|
options?: { v3InPersonFlow?: boolean; skipMetalPlate?: boolean; requiresFileMakerApproval?: boolean; includeGuiltyDamageArea?: boolean },
|
||||||
): Promise<UploadRequiredDocumentV2ResponseDto> {
|
): Promise<UploadRequiredDocumentV2ResponseDto> {
|
||||||
try {
|
try {
|
||||||
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
@@ -9099,7 +9100,9 @@ export class ClaimRequestManagementService {
|
|||||||
const isResendUpload = step === ClaimWorkflowStep.USER_EXPERT_RESEND;
|
const isResendUpload = step === ClaimWorkflowStep.USER_EXPERT_RESEND;
|
||||||
const isCapturePhaseDocUpload =
|
const isCapturePhaseDocUpload =
|
||||||
step === ClaimWorkflowStep.CAPTURE_PART_DAMAGES &&
|
step === ClaimWorkflowStep.CAPTURE_PART_DAMAGES &&
|
||||||
isCapturePhaseDamagedPartyDocKey(body.documentKey);
|
(isCapturePhaseDamagedPartyDocKey(body.documentKey) ||
|
||||||
|
(options?.includeGuiltyDamageArea &&
|
||||||
|
(body.documentKey as string) === GUILTY_DAMAGE_AREA_DOC_KEY));
|
||||||
|
|
||||||
if (!isResendUpload) {
|
if (!isResendUpload) {
|
||||||
if (
|
if (
|
||||||
@@ -9249,17 +9252,20 @@ export class ClaimRequestManagementService {
|
|||||||
const afterThis = (k: string) =>
|
const afterThis = (k: string) =>
|
||||||
k === body.documentKey ||
|
k === body.documentKey ||
|
||||||
this.isRequiredDocumentUploadedOnClaim(claimCase, k);
|
this.isRequiredDocumentUploadedOnClaim(claimCase, k);
|
||||||
remaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.filter(
|
const captureKeysForCount: string[] = [
|
||||||
(k) => {
|
...CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS,
|
||||||
if (options?.skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(k as any)) return false;
|
...(options?.includeGuiltyDamageArea ? [GUILTY_DAMAGE_AREA_DOC_KEY] : []),
|
||||||
return !afterThis(k);
|
];
|
||||||
},
|
remaining = captureKeysForCount.filter((k) => {
|
||||||
).length;
|
if (options?.skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(k as any)) return false;
|
||||||
|
return !afterThis(k);
|
||||||
|
}).length;
|
||||||
|
|
||||||
if (remaining === 0) {
|
if (remaining === 0) {
|
||||||
const progressAfterDoc = getClaimCaptureProgress(claimCase, {
|
const progressAfterDoc = getClaimCaptureProgress(claimCase, {
|
||||||
assumeCapturePhaseDocKey: body.documentKey,
|
assumeCapturePhaseDocKey: body.documentKey,
|
||||||
skipMetalPlate: options?.skipMetalPlate,
|
skipMetalPlate: options?.skipMetalPlate,
|
||||||
|
includeGuiltyDamageArea: options?.includeGuiltyDamageArea,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -11241,6 +11247,13 @@ export class ClaimRequestManagementService {
|
|||||||
base: GetCaptureRequirementsV2ResponseDto,
|
base: GetCaptureRequirementsV2ResponseDto,
|
||||||
): GetCaptureRequirementsV2ResponseDto {
|
): GetCaptureRequirementsV2ResponseDto {
|
||||||
const skipMetalPlate = !!(blame as any)?.isMadeByFileMaker;
|
const skipMetalPlate = !!(blame as any)?.isMadeByFileMaker;
|
||||||
|
// V4 = FileMaker flow without a secondary FileMaker approval step
|
||||||
|
const isV4 =
|
||||||
|
!!(blame as any)?.isMadeByFileMaker &&
|
||||||
|
!(claimCase as any)?.requiresFileMakerApproval;
|
||||||
|
const isCarBody =
|
||||||
|
blame?.type === BlameRequestType.CAR_BODY ||
|
||||||
|
(blame as any)?.type === "CAR_BODY";
|
||||||
|
|
||||||
// For V4/V5 files strip metal-plate keys from the base response entirely
|
// For V4/V5 files strip metal-plate keys from the base response entirely
|
||||||
// so they never appear in any phase — the front-end should not show them.
|
// so they never appear in any phase — the front-end should not show them.
|
||||||
@@ -11316,6 +11329,40 @@ export class ClaimRequestManagementService {
|
|||||||
d.preferUploadDuringCapture &&
|
d.preferUploadDuringCapture &&
|
||||||
!(skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(d.key as any)),
|
!(skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(d.key as any)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// V4 THIRD_PARTY only: guilty_damage_area is uploaded via upload-document
|
||||||
|
// after all car angles are captured (last item in the documents phase).
|
||||||
|
// It also appears in damagedParts so the expert sees it labelled as a
|
||||||
|
// damaged-part capture.
|
||||||
|
if (isV4 && !isCarBody) {
|
||||||
|
const guiltyAreaUploaded = this.isRequiredDocumentUploadedOnClaim(
|
||||||
|
claimCase,
|
||||||
|
GUILTY_DAMAGE_AREA_DOC_KEY,
|
||||||
|
);
|
||||||
|
const guiltyAreaDoc = {
|
||||||
|
key: GUILTY_DAMAGE_AREA_DOC_KEY,
|
||||||
|
label_fa: "تصویر نقطه آسیبدیده مقصر",
|
||||||
|
label_en: "Guilty Car Damaged Area",
|
||||||
|
category: "guilty_party",
|
||||||
|
uploaded: guiltyAreaUploaded,
|
||||||
|
preferUploadDuringCapture: true,
|
||||||
|
};
|
||||||
|
const captureProgress = getClaimCaptureProgress(claimCase, {
|
||||||
|
skipMetalPlate,
|
||||||
|
includeGuiltyDamageArea: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
requiredDocuments: [...capturePhaseDocs, guiltyAreaDoc],
|
||||||
|
totalRemaining: captureProgress.capturePhaseDocsRemaining,
|
||||||
|
progress: {
|
||||||
|
...base.progress,
|
||||||
|
capturePhaseDocsRemaining: captureProgress.capturePhaseDocsRemaining,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
requiredDocuments: capturePhaseDocs,
|
requiredDocuments: capturePhaseDocs,
|
||||||
@@ -11327,6 +11374,7 @@ export class ClaimRequestManagementService {
|
|||||||
step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS &&
|
step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS &&
|
||||||
capturePartDone
|
capturePartDone
|
||||||
) {
|
) {
|
||||||
|
// car-capture not yet uploaded: next step is the walk-around video.
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
requiredDocuments: [],
|
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;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11355,8 +11419,21 @@ export class ClaimRequestManagementService {
|
|||||||
}
|
}
|
||||||
const blame = await this.assertV3InPersonClaim(claimCase);
|
const blame = await this.assertV3InPersonClaim(claimCase);
|
||||||
const skipMetalPlate = !!(blame as any).isMadeByFileMaker;
|
const skipMetalPlate = !!(blame as any).isMadeByFileMaker;
|
||||||
|
const isV4FileMaker =
|
||||||
|
!!(blame as any)?.isMadeByFileMaker &&
|
||||||
|
!(claimCase as any)?.requiresFileMakerApproval;
|
||||||
|
const isCarBodyBlame =
|
||||||
|
blame?.type === BlameRequestType.CAR_BODY ||
|
||||||
|
(blame as any)?.type === "CAR_BODY";
|
||||||
|
|
||||||
const isCaptureDoc = isCapturePhaseDamagedPartyDocKey(body.documentKey);
|
// guilty_damage_area is an extra capture-phase doc key for V4 THIRD_PARTY
|
||||||
|
const isGuiltyDamageAreaDoc =
|
||||||
|
(body.documentKey as string) === GUILTY_DAMAGE_AREA_DOC_KEY &&
|
||||||
|
isV4FileMaker &&
|
||||||
|
!isCarBodyBlame;
|
||||||
|
|
||||||
|
const isCaptureDoc =
|
||||||
|
isCapturePhaseDamagedPartyDocKey(body.documentKey) || isGuiltyDamageAreaDoc;
|
||||||
|
|
||||||
if (isCaptureDoc) {
|
if (isCaptureDoc) {
|
||||||
this.assertV3ClaimPartsPhase(blame);
|
this.assertV3ClaimPartsPhase(blame);
|
||||||
@@ -11375,7 +11452,10 @@ export class ClaimRequestManagementService {
|
|||||||
"Complete outer and other part selection before capture-phase vehicle documents.",
|
"Complete outer and other part selection before capture-phase vehicle documents.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const captureProgress = getClaimCaptureProgress(claimCase, { skipMetalPlate });
|
const captureProgress = getClaimCaptureProgress(claimCase, {
|
||||||
|
skipMetalPlate,
|
||||||
|
includeGuiltyDamageArea: isGuiltyDamageAreaDoc,
|
||||||
|
});
|
||||||
if (!captureProgress.partsComplete) {
|
if (!captureProgress.partsComplete) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Upload photos for all selected damaged parts before chassis or engine photos.",
|
"Upload photos for all selected damaged parts before chassis or engine photos.",
|
||||||
@@ -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(
|
return this.uploadRequiredDocumentV2(
|
||||||
claimRequestId,
|
claimRequestId,
|
||||||
body,
|
body,
|
||||||
file,
|
file,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
actor,
|
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.",
|
"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(
|
async selectOtherPartsV3(
|
||||||
@@ -11645,7 +11738,34 @@ export class ClaimRequestManagementService {
|
|||||||
// V4/V5 only (isMadeByFileMaker): car-capture is the FINAL FileReviewer step.
|
// V4/V5 only (isMadeByFileMaker): car-capture is the FINAL FileReviewer step.
|
||||||
// For the v3 mirror (FIELD_EXPERT IN_PERSON), car-capture is penultimate —
|
// For the v3 mirror (FIELD_EXPERT IN_PERSON), car-capture is penultimate —
|
||||||
// expertUploadBlameVideoV3 finalises the blame with the accident video next.
|
// 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.
|
// Both V4 and V5 land at WAITING_FOR_DAMAGE_EXPERT here.
|
||||||
// For V5 (requiresFileMakerApproval=true), autoSubmitToFanavaranV2OnClaimCompleted
|
// For V5 (requiresFileMakerApproval=true), autoSubmitToFanavaranV2OnClaimCompleted
|
||||||
// intercepts COMPLETED → WAITING_FOR_FILE_MAKER_APPROVAL after expert review.
|
// 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,
|
CreateFileMakerByInsurerDto,
|
||||||
CreateFileReviewerByInsurerDto,
|
CreateFileReviewerByInsurerDto,
|
||||||
} from "./dto/create-insurer-expert.dto";
|
} 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")
|
@Controller("expert-insurer")
|
||||||
@ApiTags("expert-insurer-panel")
|
@ApiTags("expert-insurer-panel")
|
||||||
@@ -46,25 +53,18 @@ import {
|
|||||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||||
@Roles(RoleEnum.COMPANY)
|
@Roles(RoleEnum.COMPANY)
|
||||||
export class ExpertInsurerController {
|
export class ExpertInsurerController {
|
||||||
constructor(private readonly expertInsurerService: ExpertInsurerService) {}
|
constructor(
|
||||||
|
private readonly expertInsurerService: ExpertInsurerService,
|
||||||
|
private readonly reportsService: ReportsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ─── Branch management ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Get("branches")
|
@Get("branches")
|
||||||
@ApiQuery({ name: "search", required: false, type: String })
|
@ApiQuery({ name: "search", required: false, type: String })
|
||||||
@ApiQuery({
|
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" })
|
||||||
name: "from",
|
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
|
||||||
required: false,
|
@ApiQuery({ name: "isActive", required: false, description: "Filter active state (true/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(
|
async getInsuranceBranches(
|
||||||
@CurrentUser() insurer,
|
@CurrentUser() insurer,
|
||||||
@Query("search") search?: string,
|
@Query("search") search?: string,
|
||||||
@@ -72,9 +72,7 @@ export class ExpertInsurerController {
|
|||||||
@Query("to") to?: string,
|
@Query("to") to?: string,
|
||||||
@Query("isActive") isActive?: string,
|
@Query("isActive") isActive?: string,
|
||||||
) {
|
) {
|
||||||
if (!insurer) {
|
if (!insurer) throw new UnauthorizedException("Could not identify the current user.");
|
||||||
throw new UnauthorizedException("Could not identify the current user.");
|
|
||||||
}
|
|
||||||
return await this.expertInsurerService.retrieveInsuranceBranches(
|
return await this.expertInsurerService.retrieveInsuranceBranches(
|
||||||
insurer.clientKey,
|
insurer.clientKey,
|
||||||
{ search, from, to, isActive },
|
{ search, from, to, isActive },
|
||||||
@@ -83,18 +81,9 @@ export class ExpertInsurerController {
|
|||||||
|
|
||||||
@Post("branches")
|
@Post("branches")
|
||||||
@ApiBody({ type: CreateBranchDto })
|
@ApiBody({ type: CreateBranchDto })
|
||||||
async addBranch(
|
async addBranch(@CurrentUser() insurer, @Body() createBranchDto: CreateBranchDto) {
|
||||||
@CurrentUser() insurer,
|
if (!insurer) throw new UnauthorizedException("Could not identify the current user.");
|
||||||
@Body() createBranchDto: CreateBranchDto,
|
return await this.expertInsurerService.addBranch(insurer.clientKey, createBranchDto);
|
||||||
) {
|
|
||||||
if (!insurer) {
|
|
||||||
throw new UnauthorizedException("Could not identify the current user.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return await this.expertInsurerService.addBranch(
|
|
||||||
insurer.clientKey,
|
|
||||||
createBranchDto,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put("branches/:branchId/status")
|
@Put("branches/:branchId/status")
|
||||||
@@ -111,10 +100,7 @@ export class ExpertInsurerController {
|
|||||||
@Param("branchId") branchId: string,
|
@Param("branchId") branchId: string,
|
||||||
@Body("isActive") isActive: unknown,
|
@Body("isActive") isActive: unknown,
|
||||||
) {
|
) {
|
||||||
if (!insurer) {
|
if (!insurer) throw new UnauthorizedException("Could not identify the current user.");
|
||||||
throw new UnauthorizedException("Could not identify the current user.");
|
|
||||||
}
|
|
||||||
// Accept native boolean (JSON body) or string coercion (legacy query/form usage)
|
|
||||||
let active: boolean;
|
let active: boolean;
|
||||||
if (typeof isActive === "boolean") {
|
if (typeof isActive === "boolean") {
|
||||||
active = isActive;
|
active = isActive;
|
||||||
@@ -125,60 +111,38 @@ export class ExpertInsurerController {
|
|||||||
}
|
}
|
||||||
active = ["true", "1", "yes"].includes(normalized);
|
active = ["true", "1", "yes"].includes(normalized);
|
||||||
}
|
}
|
||||||
return this.expertInsurerService.setBranchActive(
|
return this.expertInsurerService.setBranchActive(insurer.clientKey, branchId, active);
|
||||||
insurer.clientKey,
|
|
||||||
branchId,
|
|
||||||
active,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Expert management ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Post("experts/blame")
|
@Post("experts/blame")
|
||||||
@ApiBody({ type: CreateBlameExpertByInsurerDto })
|
@ApiBody({ type: CreateBlameExpertByInsurerDto })
|
||||||
async addBlameExpert(
|
async addBlameExpert(@CurrentUser() insurer, @Body() body: CreateBlameExpertByInsurerDto) {
|
||||||
@CurrentUser() insurer,
|
if (!insurer) throw new UnauthorizedException("Could not identify the current user.");
|
||||||
@Body() body: CreateBlameExpertByInsurerDto,
|
|
||||||
) {
|
|
||||||
if (!insurer) {
|
|
||||||
throw new UnauthorizedException("Could not identify the current user.");
|
|
||||||
}
|
|
||||||
return this.expertInsurerService.addBlameExpert(insurer.clientKey, body);
|
return this.expertInsurerService.addBlameExpert(insurer.clientKey, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("experts/claim")
|
@Post("experts/claim")
|
||||||
@ApiBody({ type: CreateClaimExpertByInsurerDto })
|
@ApiBody({ type: CreateClaimExpertByInsurerDto })
|
||||||
async addClaimExpert(
|
async addClaimExpert(@CurrentUser() insurer, @Body() body: CreateClaimExpertByInsurerDto) {
|
||||||
@CurrentUser() insurer,
|
if (!insurer) throw new UnauthorizedException("Could not identify the current user.");
|
||||||
@Body() body: CreateClaimExpertByInsurerDto,
|
|
||||||
) {
|
|
||||||
if (!insurer) {
|
|
||||||
throw new UnauthorizedException("Could not identify the current user.");
|
|
||||||
}
|
|
||||||
return this.expertInsurerService.addClaimExpert(insurer.clientKey, body);
|
return this.expertInsurerService.addClaimExpert(insurer.clientKey, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("experts/file-maker")
|
@Post("experts/file-maker")
|
||||||
@ApiBody({ type: CreateFileMakerByInsurerDto })
|
@ApiBody({ type: CreateFileMakerByInsurerDto })
|
||||||
@ApiOperation({ summary: "Create a FileMaker account under this insurer" })
|
@ApiOperation({ summary: "Create a FileMaker account under this insurer" })
|
||||||
async addFileMaker(
|
async addFileMaker(@CurrentUser() insurer, @Body() body: CreateFileMakerByInsurerDto) {
|
||||||
@CurrentUser() insurer,
|
if (!insurer) throw new UnauthorizedException("Could not identify the current user.");
|
||||||
@Body() body: CreateFileMakerByInsurerDto,
|
|
||||||
) {
|
|
||||||
if (!insurer) {
|
|
||||||
throw new UnauthorizedException("Could not identify the current user.");
|
|
||||||
}
|
|
||||||
return this.expertInsurerService.addFileMaker(insurer.clientKey, body);
|
return this.expertInsurerService.addFileMaker(insurer.clientKey, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("experts/file-reviewer")
|
@Post("experts/file-reviewer")
|
||||||
@ApiBody({ type: CreateFileReviewerByInsurerDto })
|
@ApiBody({ type: CreateFileReviewerByInsurerDto })
|
||||||
@ApiOperation({ summary: "Create a FileReviewer account under this insurer" })
|
@ApiOperation({ summary: "Create a FileReviewer account under this insurer" })
|
||||||
async addFileReviewer(
|
async addFileReviewer(@CurrentUser() insurer, @Body() body: CreateFileReviewerByInsurerDto) {
|
||||||
@CurrentUser() insurer,
|
if (!insurer) throw new UnauthorizedException("Could not identify the current user.");
|
||||||
@Body() body: CreateFileReviewerByInsurerDto,
|
|
||||||
) {
|
|
||||||
if (!insurer) {
|
|
||||||
throw new UnauthorizedException("Could not identify the current user.");
|
|
||||||
}
|
|
||||||
return this.expertInsurerService.addFileReviewer(insurer.clientKey, body);
|
return this.expertInsurerService.addFileReviewer(insurer.clientKey, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,37 +154,140 @@ export class ExpertInsurerController {
|
|||||||
@Query("response_count") count: number,
|
@Query("response_count") count: number,
|
||||||
@CurrentUser() actor,
|
@CurrentUser() actor,
|
||||||
) {
|
) {
|
||||||
return await this.expertInsurerService.retrieveAllExpertsOfClient(
|
return await this.expertInsurerService.retrieveAllExpertsOfClient(actor, page, count);
|
||||||
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")
|
@Get("experts/top")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Top blame vs claim experts for this insurer",
|
summary: "Top blame vs claim experts for this insurer",
|
||||||
description:
|
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) {
|
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" })
|
||||||
return await this.expertInsurerService.getTopExpertsForClient(actor);
|
@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")
|
@Get("files")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "List insurer files (blame + claim merged by publicId)",
|
summary: "List insurer files (blame + claim merged by publicId)",
|
||||||
description:
|
description:
|
||||||
"Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`, `unifiedStatus`, `fileType` (THIRD_PARTY | CAR_BODY).",
|
"Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`, `unifiedStatus`, `fileType` (THIRD_PARTY | CAR_BODY).",
|
||||||
})
|
})
|
||||||
async getAllFiles(
|
async getAllFiles(@CurrentUser() insurer, @Query() query: ListQueryV2Dto) {
|
||||||
@CurrentUser() insurer,
|
return await this.expertInsurerService.retrieveAllFilesOfClient(insurer.clientKey, query);
|
||||||
@Query() query: ListQueryV2Dto,
|
|
||||||
) {
|
|
||||||
return await this.expertInsurerService.retrieveAllFilesOfClient(
|
|
||||||
insurer.clientKey,
|
|
||||||
query,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("report/unified-file-statuses")
|
@Get("report/unified-file-statuses")
|
||||||
@@ -234,10 +301,7 @@ export class ExpertInsurerController {
|
|||||||
@CurrentUser() actor,
|
@CurrentUser() actor,
|
||||||
@Query() query: UnifiedFileStatusReportQueryDto,
|
@Query() query: UnifiedFileStatusReportQueryDto,
|
||||||
): Promise<UnifiedFileStatusReportDto> {
|
): Promise<UnifiedFileStatusReportDto> {
|
||||||
return await this.expertInsurerService.getInsurerUnifiedFileStatusReport(
|
return await this.expertInsurerService.getInsurerUnifiedFileStatusReport(actor, query);
|
||||||
actor,
|
|
||||||
query,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("files/:publicId/timeline")
|
@Get("files/:publicId/timeline")
|
||||||
@@ -247,26 +311,14 @@ export class ExpertInsurerController {
|
|||||||
description:
|
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.",
|
"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(
|
async getFileTimeline(@CurrentUser() insurer, @Param("publicId") publicId: string) {
|
||||||
@CurrentUser() insurer,
|
return await this.expertInsurerService.getFileTimeline(insurer.clientKey, publicId);
|
||||||
@Param("publicId") publicId: string,
|
|
||||||
) {
|
|
||||||
return await this.expertInsurerService.getFileTimeline(
|
|
||||||
insurer.clientKey,
|
|
||||||
publicId,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("files/:publicId")
|
@Get("files/:publicId")
|
||||||
@ApiParam({ name: "publicId" })
|
@ApiParam({ name: "publicId" })
|
||||||
async getFileDetailsByPublicId(
|
async getFileDetailsByPublicId(@CurrentUser() insurer, @Param("publicId") publicId: string) {
|
||||||
@CurrentUser() insurer,
|
return await this.expertInsurerService.retrieveFileDetailsByPublicId(insurer.clientKey, publicId);
|
||||||
@Param("publicId") publicId: string,
|
|
||||||
) {
|
|
||||||
return await this.expertInsurerService.retrieveFileDetailsByPublicId(
|
|
||||||
insurer.clientKey,
|
|
||||||
publicId,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiBody({
|
@ApiBody({
|
||||||
@@ -275,56 +327,14 @@ export class ExpertInsurerController {
|
|||||||
schema: {
|
schema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
collisionMethodAccuracy: {
|
collisionMethodAccuracy: { type: "number", minimum: 0, maximum: 5, example: 4, description: "تشخیص درست نحوه برخورد" },
|
||||||
type: "number",
|
evaluationTimeliness: { type: "number", minimum: 0, maximum: 5, example: 3, description: "زمان ارزیابی" },
|
||||||
minimum: 0,
|
accidentCauseAccuracy: { type: "number", minimum: 0, maximum: 5, example: 5, description: "تشخیص درست علت تصادف" },
|
||||||
maximum: 5,
|
guiltyVehicleIdentification: { type: "number", minimum: 0, maximum: 5, example: 4, description: "تشخیص درست وسیله نقلیه مقصر" },
|
||||||
example: 4,
|
botRating: { type: "number", minimum: 0, maximum: 5, example: 4, description: "برای امتیاز دادن به عملکرد بات" },
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
|
required: ["collisionMethodAccuracy", "evaluationTimeliness", "accidentCauseAccuracy", "guiltyVehicleIdentification", "botRating"],
|
||||||
|
example: { collisionMethodAccuracy: 4, evaluationTimeliness: 3, accidentCauseAccuracy: 5, guiltyVehicleIdentification: 4, botRating: 4 },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ApiParam({ name: "publicId" })
|
@ApiParam({ name: "publicId" })
|
||||||
@@ -334,23 +344,7 @@ export class ExpertInsurerController {
|
|||||||
@Param("publicId") publicId: string,
|
@Param("publicId") publicId: string,
|
||||||
@Body() rating: FileRating,
|
@Body() rating: FileRating,
|
||||||
) {
|
) {
|
||||||
return await this.expertInsurerService.rateExpertByPublicId(
|
return await this.expertInsurerService.rateExpertByPublicId(publicId, rating, insurer.clientKey);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("report/status-counts")
|
@Get("report/status-counts")
|
||||||
@@ -359,28 +353,18 @@ export class ExpertInsurerController {
|
|||||||
deprecated: true,
|
deprecated: true,
|
||||||
description: "Prefer GET report/unified-file-statuses for full calculatable blame+claim statuses.",
|
description: "Prefer GET report/unified-file-statuses for full calculatable blame+claim statuses.",
|
||||||
})
|
})
|
||||||
@ApiQuery({
|
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" })
|
||||||
name: "from",
|
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
|
||||||
required: false,
|
|
||||||
description: "Optional start datetime (ISO string)",
|
|
||||||
})
|
|
||||||
@ApiQuery({
|
|
||||||
name: "to",
|
|
||||||
required: false,
|
|
||||||
description: "Optional end datetime (ISO string)",
|
|
||||||
})
|
|
||||||
async getInsurerStatusReport(
|
async getInsurerStatusReport(
|
||||||
@CurrentUser() actor,
|
@CurrentUser() actor,
|
||||||
@Query("from") from?: string,
|
@Query("from") from?: string,
|
||||||
@Query("to") to?: string,
|
@Query("to") to?: string,
|
||||||
) {
|
) {
|
||||||
return await this.expertInsurerService.getInsurerFileStatusCounts(
|
return await this.expertInsurerService.getInsurerFileStatusCounts(actor, from, to);
|
||||||
actor,
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Expert detail ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ApiParam({ name: "expertId" })
|
@ApiParam({ name: "expertId" })
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Files handled by one roster expert (summary rows)",
|
summary: "Files handled by one roster expert (summary rows)",
|
||||||
@@ -389,13 +373,7 @@ export class ExpertInsurerController {
|
|||||||
})
|
})
|
||||||
@Get("/:expertId")
|
@Get("/:expertId")
|
||||||
async requestDetail(@CurrentUser() insurer, @Param("expertId") id: string) {
|
async requestDetail(@CurrentUser() insurer, @Param("expertId") id: string) {
|
||||||
if (!Types.ObjectId.isValid(id)) {
|
if (!Types.ObjectId.isValid(id)) throw new BadRequestException("Invalid expert ID");
|
||||||
throw new BadRequestException("Invalid expert ID");
|
return await this.expertInsurerService.getAllFilesForInsurerExpert(id, insurer.clientKey);
|
||||||
}
|
|
||||||
|
|
||||||
return await this.expertInsurerService.getAllFilesForInsurerExpert(
|
|
||||||
id,
|
|
||||||
insurer.clientKey,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { MongooseModule } from "@nestjs/mongoose";
|
import { MongooseModule } from "@nestjs/mongoose";
|
||||||
import { AuthModule } from "src/auth/auth.module";
|
import { AuthModule } from "src/auth/auth.module";
|
||||||
|
import { ReportsModule } from "src/reports/reports.module";
|
||||||
import {
|
import {
|
||||||
ClaimRequestManagementModel,
|
ClaimRequestManagementModel,
|
||||||
ClaimRequestManagementSchema,
|
ClaimRequestManagementSchema,
|
||||||
@@ -29,6 +30,7 @@ import { HashModule } from "src/utils/hash/hash.module";
|
|||||||
HashModule,
|
HashModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
ClientModule,
|
ClientModule,
|
||||||
|
ReportsModule,
|
||||||
MongooseModule.forFeature([
|
MongooseModule.forFeature([
|
||||||
{
|
{
|
||||||
name: ClaimRequestManagementModel.name,
|
name: ClaimRequestManagementModel.name,
|
||||||
|
|||||||
@@ -971,24 +971,38 @@ export class ExpertInsurerService {
|
|||||||
* excluding `botRating`), optionally blend with the file’s user rating, average those
|
* excluding `botRating`), optionally blend with the file’s user rating, average those
|
||||||
* combined scores per file, then average across that expert’s files (`overallAverageRating`).
|
* combined scores per file, then average across that expert’s files (`overallAverageRating`).
|
||||||
*/
|
*/
|
||||||
async getTopExpertsForClient(actor): Promise<{
|
async getTopExpertsForClient(
|
||||||
blameExperts: any[];
|
actor,
|
||||||
claimExperts: any[];
|
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 result = await this.retrieveAllExpertsOfClient(actor, 1, 1000);
|
||||||
const rows = result?.experts || [];
|
const rows = result?.experts || [];
|
||||||
|
|
||||||
const byRatingDesc = (a: any, b: any) =>
|
const byRatingDesc = (a: any, b: any) =>
|
||||||
(b.overallAverageRating ?? 0) - (a.overallAverageRating ?? 0);
|
(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
|
const blameExperts = rows
|
||||||
.filter((e) => e.expertKind === "blame")
|
.filter((e) => e.expertKind === "blame")
|
||||||
.sort(byRatingDesc)
|
.sort(byRatingDesc)
|
||||||
.slice(0, 10);
|
.slice(0, 10)
|
||||||
|
.map(slim);
|
||||||
const claimExperts = rows
|
const claimExperts = rows
|
||||||
.filter((e) => e.expertKind === "claim")
|
.filter((e) => e.expertKind === "claim")
|
||||||
.sort(byRatingDesc)
|
.sort(byRatingDesc)
|
||||||
.slice(0, 10);
|
.slice(0, 10)
|
||||||
|
.map(slim);
|
||||||
|
|
||||||
return { blameExperts, claimExperts };
|
return { blameExperts, claimExperts };
|
||||||
}
|
}
|
||||||
@@ -997,18 +1011,47 @@ export class ExpertInsurerService {
|
|||||||
* Returns top 10 claim files for the current insurer client based on
|
* Returns top 10 claim files for the current insurer client based on
|
||||||
* combined insurer + user ratings.
|
* combined insurer + user ratings.
|
||||||
*/
|
*/
|
||||||
async getTopFilesForClient(insurerId: string): Promise<any[]> {
|
async getTopFilesForClient(
|
||||||
const claimFiles = await this.getClientClaimFiles(
|
insurerId: string,
|
||||||
this.getClientId(insurerId),
|
opts: { from?: string; to?: string } = {},
|
||||||
);
|
): Promise<Array<{
|
||||||
const scored = claimFiles
|
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) => {
|
.map((file) => {
|
||||||
const combinedScore = this.getCombinedFileScore(file);
|
const combinedScore = this.getCombinedFileScore(file);
|
||||||
if (combinedScore === null) return null;
|
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);
|
.filter((f): f is NonNullable<typeof f> => f !== null)
|
||||||
return scored
|
|
||||||
.sort((a, b) => b.combinedScore - a.combinedScore)
|
.sort((a, b) => b.combinedScore - a.combinedScore)
|
||||||
.slice(0, 10);
|
.slice(0, 10);
|
||||||
}
|
}
|
||||||
@@ -1775,29 +1818,59 @@ export class ExpertInsurerService {
|
|||||||
* - Percentage of files that have objection
|
* - Percentage of files that have objection
|
||||||
* - Number of files created in the current month
|
* - 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 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 now = new Date();
|
||||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
const monthEnd = new Date(
|
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59);
|
||||||
now.getFullYear(),
|
const filesThisMonth = claimFiles.filter((f) => {
|
||||||
now.getMonth() + 1,
|
const d = new Date(f.createdAt);
|
||||||
0,
|
return d >= monthStart && d <= monthEnd;
|
||||||
23,
|
|
||||||
59,
|
|
||||||
59,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Filter files created this month
|
|
||||||
const filesThisMonth = claimFiles.filter((file) => {
|
|
||||||
const createdAt = new Date(file.createdAt);
|
|
||||||
return createdAt >= monthStart && createdAt <= 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 totalInsurerRatings = 0;
|
||||||
let totalBotRatings = 0;
|
let totalBotRatings = 0;
|
||||||
let filesWithInsurerRating = 0;
|
let filesWithInsurerRating = 0;
|
||||||
@@ -1805,103 +1878,88 @@ export class ExpertInsurerService {
|
|||||||
let filesWithUserRating = 0;
|
let filesWithUserRating = 0;
|
||||||
let filesWithObjection = 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 insurerRating = file?.rating;
|
||||||
const userRating = file?.userRating;
|
const userRating = file?.userRating;
|
||||||
const objection = file?.objection;
|
|
||||||
|
|
||||||
// Check for insurer rating (excluding botRating)
|
|
||||||
if (insurerRating) {
|
if (insurerRating) {
|
||||||
const insurerValues = [
|
const insurerValues = [
|
||||||
insurerRating.collisionMethodAccuracy,
|
insurerRating.collisionMethodAccuracy,
|
||||||
insurerRating.evaluationTimeliness,
|
insurerRating.evaluationTimeliness,
|
||||||
insurerRating.accidentCauseAccuracy,
|
insurerRating.accidentCauseAccuracy,
|
||||||
insurerRating.guiltyVehicleIdentification,
|
insurerRating.guiltyVehicleIdentification,
|
||||||
].filter(
|
].filter((v): v is number => typeof v === "number" && !isNaN(v));
|
||||||
(val): val is number => typeof val === "number" && !isNaN(val),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (insurerValues.length > 0) {
|
if (insurerValues.length > 0) {
|
||||||
filesWithInsurerRating++;
|
filesWithInsurerRating++;
|
||||||
const insurerAvg =
|
totalInsurerRatings += insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length;
|
||||||
insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length;
|
}
|
||||||
totalInsurerRatings += insurerAvg;
|
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) {
|
if (userRating) {
|
||||||
filesWithUserRating++;
|
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 (file?.objection) filesWithObjection++;
|
||||||
if (objection) {
|
|
||||||
filesWithObjection++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate percentages
|
const totalFiles = rangedClaimFiles.length;
|
||||||
const totalFiles = claimFiles.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
|
// averageUserRatingPercentage: (mean score / 5) * 100; mean score is avg of the three
|
||||||
const averageInsurerRating =
|
// per-file dimension averages across all rated files.
|
||||||
filesWithInsurerRating > 0
|
const averageUserRatingPercentage =
|
||||||
? totalInsurerRatings / filesWithInsurerRating
|
userRatingDimensionCount > 0
|
||||||
|
? parseFloat(((userRatingDimensionSum / userRatingDimensionCount / 5) * 100).toFixed(2))
|
||||||
: 0;
|
: 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 =
|
const insurerToBotPercentage =
|
||||||
averageBotRating > 0 && averageInsurerRating > 0
|
averageBotRating > 0 && averageInsurerRating > 0
|
||||||
? parseFloat(
|
? parseFloat(((averageInsurerRating / averageBotRating) * 100).toFixed(2))
|
||||||
((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))
|
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
insurerToBotRatingPercentage: insurerToBotPercentage,
|
// New / corrected KPI fields
|
||||||
userRatingPercentage: userRatingPercentage,
|
totalFilesReviewed,
|
||||||
objectionPercentage: objectionPercentage,
|
averageUserRatingPercentage,
|
||||||
|
inPersonAccompaniedCount,
|
||||||
|
// Unchanged
|
||||||
filesCreatedThisMonth: filesThisMonth.length,
|
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: {
|
breakdown: {
|
||||||
filesWithInsurerRating,
|
filesWithInsurerRating,
|
||||||
filesWithBotRating,
|
filesWithBotRating,
|
||||||
filesWithUserRating,
|
filesWithUserRating,
|
||||||
filesWithObjection,
|
filesWithObjection,
|
||||||
averageInsurerRating:
|
averageInsurerRating: parseFloat(averageInsurerRating.toFixed(2)),
|
||||||
filesWithInsurerRating > 0
|
averageBotRating: parseFloat(averageBotRating.toFixed(2)),
|
||||||
? parseFloat(
|
|
||||||
(totalInsurerRatings / filesWithInsurerRating).toFixed(2),
|
|
||||||
)
|
|
||||||
: 0,
|
|
||||||
averageBotRating:
|
|
||||||
filesWithBotRating > 0
|
|
||||||
? parseFloat((totalBotRatings / filesWithBotRating).toFixed(2))
|
|
||||||
: 0,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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(
|
async getFileTimeline(
|
||||||
insurerId: string,
|
insurerId: string,
|
||||||
publicId: string,
|
publicId: string,
|
||||||
@@ -2129,6 +2201,7 @@ export class ExpertInsurerService {
|
|||||||
faLabel: getEventFaLabel(ev),
|
faLabel: getEventFaLabel(ev),
|
||||||
timestamp: ev.timestamp,
|
timestamp: ev.timestamp,
|
||||||
actor: ev.actor ?? null,
|
actor: ev.actor ?? null,
|
||||||
|
performedBy: ExpertInsurerService.resolveTimelinePerformedBy(ev.actor?.actorType),
|
||||||
metadata: ev.metadata ?? null,
|
metadata: ev.metadata ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2148,6 +2221,7 @@ export class ExpertInsurerService {
|
|||||||
faLabel: getEventFaLabel(ev),
|
faLabel: getEventFaLabel(ev),
|
||||||
timestamp: ev.timestamp,
|
timestamp: ev.timestamp,
|
||||||
actor: ev.actor ?? null,
|
actor: ev.actor ?? null,
|
||||||
|
performedBy: ExpertInsurerService.resolveTimelinePerformedBy(ev.actor?.actorType),
|
||||||
metadata: ev.metadata ?? null,
|
metadata: ev.metadata ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ export const OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5 = [
|
|||||||
"guilty_metal_plate",
|
"guilty_metal_plate",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extra capture-phase document key present only in V4/V5 (FileMaker) flows.
|
||||||
|
* Uploaded via `upload-document` after all car angles are captured.
|
||||||
|
*/
|
||||||
|
export const GUILTY_DAMAGE_AREA_DOC_KEY = "guilty_damage_area" as const;
|
||||||
|
|
||||||
export type CapturePhaseSequence =
|
export type CapturePhaseSequence =
|
||||||
| "parts"
|
| "parts"
|
||||||
| "angles"
|
| "angles"
|
||||||
@@ -58,7 +64,12 @@ function isRequiredDocumentUploadedOnClaim(
|
|||||||
|
|
||||||
export function getClaimCaptureProgress(
|
export function getClaimCaptureProgress(
|
||||||
claimCase: any,
|
claimCase: any,
|
||||||
options?: { assumeCapturePhaseDocKey?: string; skipMetalPlate?: boolean },
|
options?: {
|
||||||
|
assumeCapturePhaseDocKey?: string;
|
||||||
|
skipMetalPlate?: boolean;
|
||||||
|
/** V4/V5 only — include `guilty_damage_area` in capture-phase doc count. */
|
||||||
|
includeGuiltyDamageArea?: boolean;
|
||||||
|
},
|
||||||
): ClaimCaptureProgress {
|
): ClaimCaptureProgress {
|
||||||
const carType = claimCase?.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
|
const carType = claimCase?.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
|
||||||
const selectedNorm = normalizeDamageSelectedParts(
|
const selectedNorm = normalizeDamageSelectedParts(
|
||||||
@@ -87,13 +98,15 @@ export function getClaimCaptureProgress(
|
|||||||
const anglesTotal = CLAIM_CAR_ANGLE_KEYS.length;
|
const anglesTotal = CLAIM_CAR_ANGLE_KEYS.length;
|
||||||
const anglesComplete = anglesCaptured >= anglesTotal;
|
const anglesComplete = anglesCaptured >= anglesTotal;
|
||||||
|
|
||||||
const capturePhaseDocsRemaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.filter(
|
const capturePhaseKeys: string[] = [
|
||||||
(k) => {
|
...CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS,
|
||||||
if (options?.skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(k as any)) return false;
|
...(options?.includeGuiltyDamageArea ? [GUILTY_DAMAGE_AREA_DOC_KEY] : []),
|
||||||
if (k === options?.assumeCapturePhaseDocKey) return false;
|
];
|
||||||
return !isRequiredDocumentUploadedOnClaim(claimCase, k);
|
const capturePhaseDocsRemaining = capturePhaseKeys.filter((k) => {
|
||||||
},
|
if (options?.skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(k as any)) return false;
|
||||||
).length;
|
if (k === options?.assumeCapturePhaseDocKey) return false;
|
||||||
|
return !isRequiredDocumentUploadedOnClaim(claimCase, k);
|
||||||
|
}).length;
|
||||||
const capturePhaseDocsComplete = capturePhaseDocsRemaining === 0;
|
const capturePhaseDocsComplete = capturePhaseDocsRemaining === 0;
|
||||||
|
|
||||||
let sequencePhase: CapturePhaseSequence = "parts";
|
let sequencePhase: CapturePhaseSequence = "parts";
|
||||||
|
|||||||
@@ -13,5 +13,6 @@ import { ReportsService } from "./reports.service";
|
|||||||
],
|
],
|
||||||
controllers: [ReportsController],
|
controllers: [ReportsController],
|
||||||
providers: [ReportsService],
|
providers: [ReportsService],
|
||||||
|
exports: [ReportsService],
|
||||||
})
|
})
|
||||||
export class ReportsModule {}
|
export class ReportsModule {}
|
||||||
|
|||||||
@@ -436,19 +436,47 @@ export class ReportsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getInsurerExpertWorkLog(actor: {
|
async getInsurerExpertWorkLog(
|
||||||
clientKey?: string;
|
actor: { clientKey?: string },
|
||||||
}): Promise<InsurerExpertWorkLogResponseDtoRs> {
|
opts: {
|
||||||
|
expertKind?: "all" | "expert" | "damage_expert";
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
} = {},
|
||||||
|
): Promise<{ experts: InsurerExpertWorkLogEntryDtoRs[]; total: number }> {
|
||||||
const clientKey = requireActorClientKey(actor);
|
const clientKey = requireActorClientKey(actor);
|
||||||
const { events, expertRows } =
|
const { events, expertRows } =
|
||||||
await this.loadTenantExpertsAndActivities(clientKey);
|
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 now = new Date();
|
||||||
const epoch = new Date(0);
|
|
||||||
const entries = this.buildWorkLogEntries(expertRows, events, now, {
|
// When from/to supplied, distinctFilesCheckedInPeriod is restricted to that window;
|
||||||
from: epoch,
|
// totalHandled and currentlyChecking are snapshot-at-'to' (or now when to is absent).
|
||||||
to: now,
|
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: {
|
async getInsurerExpertWorkLogPerMonth(actor: {
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ import { Roles } from "src/decorators/roles.decorator";
|
|||||||
import { CurrentUser } from "src/decorators/user.decorator";
|
import { CurrentUser } from "src/decorators/user.decorator";
|
||||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
import { RequestManagementService } from "./request-management.service";
|
import { RequestManagementService } from "./request-management.service";
|
||||||
import { RunCallCenterInquiryV6Dto } from "./dto/run-call-center-inquiry-v6.dto";
|
import {
|
||||||
|
RunCallCenterInquiryV6Dto,
|
||||||
|
RunCallCenterInquiryVinV6Dto,
|
||||||
|
} from "./dto/run-call-center-inquiry-v6.dto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V6 call-center blame API.
|
* V6 call-center blame API.
|
||||||
@@ -105,6 +108,27 @@ export class CallCenterBlameV6Controller {
|
|||||||
return this.requestManagementService.runCallCenterInquiryV6(agent, requestId, dto);
|
return this.requestManagementService.runCallCenterInquiryV6(agent, requestId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("run-inquiry-vin/:requestId")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "[V6] Run VIN/chassis inquiry for the guilty party",
|
||||||
|
description:
|
||||||
|
"VIN alternative to `run-inquiry`. " +
|
||||||
|
"The agent supplies the chassis number and personal data collected from the caller. " +
|
||||||
|
"ESG chassis lookup (`policyByChassis`) is executed and the result is stored on the " +
|
||||||
|
"blame document under `vehicle.vin` (plateId is left empty). " +
|
||||||
|
"Identical eligibility guards and insurer-company validation as the plate variant. " +
|
||||||
|
"After this call, proceed to `send-link` exactly as in the plate flow.",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "requestId", description: "Blame request ID from `create`" })
|
||||||
|
@ApiBody({ type: RunCallCenterInquiryVinV6Dto })
|
||||||
|
runInquiryVin(
|
||||||
|
@CurrentUser() agent: any,
|
||||||
|
@Param("requestId") requestId: string,
|
||||||
|
@Body() dto: RunCallCenterInquiryVinV6Dto,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.runCallCenterInquiryVinV6(agent, requestId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Post("send-link/:requestId")
|
@Post("send-link/:requestId")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "[V6] Send blame link to the guilty party via SMS",
|
summary: "[V6] Send blame link to the guilty party via SMS",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
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 { Types } from "mongoose";
|
||||||
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
|
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
|
||||||
import { StepsEnum } from "src/Types&Enums/blame-request-management/steps.enum";
|
import { StepsEnum } from "src/Types&Enums/blame-request-management/steps.enum";
|
||||||
@@ -87,6 +87,37 @@ export class DescriptionDto {
|
|||||||
lightCondition?: LightCondition;
|
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.
|
* THIRD_PARTY description step: only desc is accepted.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
|
MaxLength,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { Type } from "class-transformer";
|
import { Type } from "class-transformer";
|
||||||
@@ -83,3 +84,60 @@ export class RunCallCenterInquiryV6Dto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
insurerLicense?: string;
|
insurerLicense?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VIN / chassis variant of the V6 call-center inquiry.
|
||||||
|
* Identical to `RunCallCenterInquiryV6Dto` but replaces `plate` with `vin`.
|
||||||
|
* Sheba (IBAN) is intentionally absent — the user provides it themselves via the link.
|
||||||
|
*/
|
||||||
|
export class RunCallCenterInquiryVinV6Dto {
|
||||||
|
@ApiProperty({
|
||||||
|
example: "NAAM01E15HK123456",
|
||||||
|
description: "17-character VIN / chassis number (شماره شاسی)",
|
||||||
|
maxLength: 17,
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(17)
|
||||||
|
vin: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: "1234567890", description: "National code of the policyholder (insurer)" })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
nationalCodeOfInsurer: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: "1234567890", description: "National code of the driver" })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
nationalCodeOfDriver: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: true, description: "Whether the driver is the same person as the insurer" })
|
||||||
|
@IsBoolean()
|
||||||
|
driverIsInsurer: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 13780624, description: "Insurer birth date (Jalali)" })
|
||||||
|
insurerBirthday: number | string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 13780624,
|
||||||
|
description: "Driver birth date (Jalali). Required when driverIsInsurer is false.",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
driverBirthday?: number | string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: "123456789",
|
||||||
|
description: "Driver license (required when driverIsInsurer is false).",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
driverLicense?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: "123456789",
|
||||||
|
description: "Insurer license (required when driverIsInsurer is true).",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
insurerLicense?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -89,6 +89,16 @@ export class RunInquiriesV3Dto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
insurerLicense?: string;
|
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()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
insurerLicense?: string;
|
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() nationalCodeOfDriver?: string;
|
||||||
@Prop() insurerLicense?: string;
|
@Prop() insurerLicense?: string;
|
||||||
@Prop() driverLicense?: string;
|
@Prop() driverLicense?: string;
|
||||||
|
/** Driving licence type code from the Fanavaran lookup (GET /lookups/driving-licence-types). */
|
||||||
|
@Prop() licenseType?: string;
|
||||||
|
|
||||||
@Prop({ type: Boolean })
|
@Prop({ type: Boolean })
|
||||||
driverIsInsurer?: boolean;
|
driverIsInsurer?: boolean;
|
||||||
@@ -51,6 +53,8 @@ export const PersonSchema = SchemaFactory.createForClass(Person);
|
|||||||
@Schema({ _id: false })
|
@Schema({ _id: false })
|
||||||
export class Vehicle {
|
export class Vehicle {
|
||||||
@Prop() plateId?: string;
|
@Prop() plateId?: string;
|
||||||
|
/** VIN / chassis number — populated only when the inquiry was performed via VIN lookup. */
|
||||||
|
@Prop() vin?: string;
|
||||||
@Prop() name?: string;
|
@Prop() name?: string;
|
||||||
@Prop() model?: string;
|
@Prop() model?: string;
|
||||||
@Prop() type?: 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 { RunInquiriesV3Dto, RunInquiriesVinV3Dto } from "./dto/run-inquiries-v3.dto";
|
||||||
import {
|
import {
|
||||||
CarBodyFormDto,
|
CarBodyFormDto,
|
||||||
DescriptionDto,
|
DescriptionV4Dto,
|
||||||
LocationDto,
|
LocationDto,
|
||||||
} from "./dto/create-request-management.dto";
|
} from "./dto/create-request-management.dto";
|
||||||
import { RequestManagementService } from "./request-management.service";
|
import { RequestManagementService } from "./request-management.service";
|
||||||
@@ -260,15 +260,20 @@ export class FileMakerBlameV4Controller {
|
|||||||
|
|
||||||
@Post("add-detail-description/:requestId")
|
@Post("add-detail-description/:requestId")
|
||||||
@ApiParam({ name: "requestId" })
|
@ApiParam({ name: "requestId" })
|
||||||
@ApiBody({ type: DescriptionDto })
|
@ApiBody({ type: DescriptionV4Dto })
|
||||||
@ApiOperation({ summary: "Add description for current party" })
|
@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(
|
async addDescription(
|
||||||
@Param("requestId") requestId: string,
|
@Param("requestId") requestId: string,
|
||||||
@Body() body: DescriptionDto,
|
@Body() body: DescriptionV4Dto,
|
||||||
@CurrentUser() fileMaker: any,
|
@CurrentUser() fileMaker: any,
|
||||||
@Body("partyRole") partyRole?: string,
|
@Body("partyRole") partyRole?: string,
|
||||||
) {
|
) {
|
||||||
return this.requestManagementService.addPartyDescriptionV3(
|
return this.requestManagementService.addPartyDescriptionV4(
|
||||||
requestId,
|
requestId,
|
||||||
fileMaker,
|
fileMaker,
|
||||||
body,
|
body,
|
||||||
|
|||||||
@@ -501,6 +501,9 @@ export class InquiryRefreshService {
|
|||||||
if (memParty.vehicle?.plateId !== undefined) {
|
if (memParty.vehicle?.plateId !== undefined) {
|
||||||
party.vehicle.plateId = memParty.vehicle.plateId;
|
party.vehicle.plateId = memParty.vehicle.plateId;
|
||||||
}
|
}
|
||||||
|
if (memParty.vehicle?.vin !== undefined) {
|
||||||
|
party.vehicle.vin = memParty.vehicle.vin;
|
||||||
|
}
|
||||||
blameDoc.markModified(`parties.${docIdx}.vehicle`);
|
blameDoc.markModified(`parties.${docIdx}.vehicle`);
|
||||||
|
|
||||||
if (memParty.insurance) {
|
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 { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
|
||||||
import {
|
import {
|
||||||
DescriptionDto,
|
DescriptionDto,
|
||||||
|
DescriptionV4Dto,
|
||||||
LocationDto,
|
LocationDto,
|
||||||
RequestManagementDtoRs,
|
RequestManagementDtoRs,
|
||||||
} from "./dto/create-request-management.dto";
|
} from "./dto/create-request-management.dto";
|
||||||
@@ -109,6 +110,7 @@ import {
|
|||||||
collectUserIdVariants,
|
collectUserIdVariants,
|
||||||
} from "src/helpers/party-access-queries";
|
} from "src/helpers/party-access-queries";
|
||||||
import { resolveLinkedUserIdStrings } from "src/helpers/user-access-resolver";
|
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.
|
* 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`);
|
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 */
|
/** Convert plate (string or { ir, leftDigits, centerAlphabet, centerDigits }) to string for vehicle.plateId */
|
||||||
private plateToPlateIdString(plate: any): string {
|
private plateToPlateIdString(plate: any): string {
|
||||||
if (plate == null) return "";
|
if (plate == null) return "";
|
||||||
@@ -1841,8 +1871,18 @@ export class RequestManagementService {
|
|||||||
|
|
||||||
if (!party.vehicle) party.vehicle = {} as any;
|
if (!party.vehicle) party.vehicle = {} as any;
|
||||||
party.vehicle.isNew = body.isNewCar;
|
party.vehicle.isNew = body.isNewCar;
|
||||||
// Store VIN as the vehicle identifier instead of plate
|
// Derive plateId from the plate parts returned by the VIN inquiry.
|
||||||
party.vehicle.plateId = body.vin;
|
// 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.name = inquiryMapped?.MapTypNam;
|
||||||
party.vehicle.type = `${inquiryMapped?.UsageField} / ${inquiryMapped?.MapUsageName || "-"}`;
|
party.vehicle.type = `${inquiryMapped?.UsageField} / ${inquiryMapped?.MapUsageName || "-"}`;
|
||||||
party.vehicle.inquiry = {
|
party.vehicle.inquiry = {
|
||||||
@@ -8689,6 +8729,8 @@ export class RequestManagementService {
|
|||||||
party.person.insurerLicense = partyData.insurerLicense;
|
party.person.insurerLicense = partyData.insurerLicense;
|
||||||
if (partyData.driverLicense)
|
if (partyData.driverLicense)
|
||||||
party.person.driverLicense = 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;
|
if (!party.vehicle) party.vehicle = {} as any;
|
||||||
party.vehicle.plateId = this.plateToPlateIdString(partyData.plate);
|
party.vehicle.plateId = this.plateToPlateIdString(partyData.plate);
|
||||||
@@ -9501,9 +9543,22 @@ export class RequestManagementService {
|
|||||||
party.person.insurerLicense = partyData.insurerLicense;
|
party.person.insurerLicense = partyData.insurerLicense;
|
||||||
if (partyData.driverLicense)
|
if (partyData.driverLicense)
|
||||||
party.person.driverLicense = 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;
|
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.name = inquiryMapped?.MapTypNam;
|
||||||
party.vehicle.type = `${inquiryMapped?.UsageField ?? ""} / ${inquiryMapped?.UsageName ?? inquiryMapped?.MapUsageName ?? "-"}`;
|
party.vehicle.type = `${inquiryMapped?.UsageField ?? ""} / ${inquiryMapped?.UsageName ?? inquiryMapped?.MapUsageName ?? "-"}`;
|
||||||
party.vehicle.inquiry = {
|
party.vehicle.inquiry = {
|
||||||
@@ -9994,7 +10049,9 @@ export class RequestManagementService {
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
claim.workflow?.currentStep !==
|
claim.workflow?.currentStep !==
|
||||||
ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS
|
ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS &&
|
||||||
|
claim.workflow?.currentStep !==
|
||||||
|
ClaimWorkflowStep.USER_SUBMISSION_COMPLETE
|
||||||
) {
|
) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Complete capture-part (parts, angles, and capture-phase documents) before the blame accident video.",
|
"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 };
|
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(
|
async carBodyAccidentTypeFormV3(
|
||||||
requestId: string,
|
requestId: string,
|
||||||
body: CarBodyFormDto,
|
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.
|
* V6: Send the blame link to the guilty party's phone number.
|
||||||
* Registers/looks up the user by phone and stores them as the first party, then
|
* Registers/looks up the user by phone and stores them as the first party, then
|
||||||
@@ -11050,6 +11262,7 @@ export class RequestManagementService {
|
|||||||
clientId: p.person?.clientId
|
clientId: p.person?.clientId
|
||||||
? String(p.person.clientId)
|
? String(p.person.clientId)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
licenseType: p.person?.licenseType ?? undefined,
|
||||||
},
|
},
|
||||||
insurance: p.insurance
|
insurance: p.insurance
|
||||||
? {
|
? {
|
||||||
@@ -11063,6 +11276,7 @@ export class RequestManagementService {
|
|||||||
vehicle: p.vehicle
|
vehicle: p.vehicle
|
||||||
? {
|
? {
|
||||||
plateId: p.vehicle.plateId,
|
plateId: p.vehicle.plateId,
|
||||||
|
vin: p.vehicle.vin,
|
||||||
name: p.vehicle.name,
|
name: p.vehicle.name,
|
||||||
type: p.vehicle.type,
|
type: p.vehicle.type,
|
||||||
}
|
}
|
||||||
@@ -11144,6 +11358,7 @@ export class RequestManagementService {
|
|||||||
driverIsInsurer: p.person?.driverIsInsurer,
|
driverIsInsurer: p.person?.driverIsInsurer,
|
||||||
userId: p.person?.userId ? String(p.person.userId) : undefined,
|
userId: p.person?.userId ? String(p.person.userId) : undefined,
|
||||||
clientId: p.person?.clientId ? String(p.person.clientId) : undefined,
|
clientId: p.person?.clientId ? String(p.person.clientId) : undefined,
|
||||||
|
licenseType: p.person?.licenseType ?? undefined,
|
||||||
},
|
},
|
||||||
insurance: p.insurance ? {
|
insurance: p.insurance ? {
|
||||||
company: p.insurance.company,
|
company: p.insurance.company,
|
||||||
@@ -11250,6 +11465,7 @@ export class RequestManagementService {
|
|||||||
driverIsInsurer: p.person?.driverIsInsurer,
|
driverIsInsurer: p.person?.driverIsInsurer,
|
||||||
userId: p.person?.userId ? String(p.person.userId) : undefined,
|
userId: p.person?.userId ? String(p.person.userId) : undefined,
|
||||||
clientId: p.person?.clientId ? String(p.person.clientId) : undefined,
|
clientId: p.person?.clientId ? String(p.person.clientId) : undefined,
|
||||||
|
licenseType: p.person?.licenseType ?? undefined,
|
||||||
},
|
},
|
||||||
insurance: p.insurance ? {
|
insurance: p.insurance ? {
|
||||||
company: p.insurance.company,
|
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
|
* 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 {
|
private mapNewApiResponseToOldFormat(newResponse: any): any {
|
||||||
if (!newResponse) return newResponse;
|
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
|
// Map the new field names to the old field names
|
||||||
return {
|
return {
|
||||||
...newResponse,
|
...newResponse,
|
||||||
|
...(plkParts ? {
|
||||||
|
Plk1: plkParts.Plk1,
|
||||||
|
Plk2: plkParts.Plk2,
|
||||||
|
Plk3: plkParts.Plk3,
|
||||||
|
PlkSrl: plkParts.PlkSrl,
|
||||||
|
plateLetterid: plkParts.Plk2,
|
||||||
|
} : {}),
|
||||||
// Company information
|
// Company information
|
||||||
CompanyCode: newResponse.companyId || newResponse.CompanyCode,
|
CompanyCode: newResponse.companyId || newResponse.CompanyCode,
|
||||||
CompanyName: newResponse.companyPersianName || newResponse.CompanyName,
|
CompanyName: newResponse.companyPersianName || newResponse.CompanyName,
|
||||||
|
|||||||
Reference in New Issue
Block a user