diff --git a/src/Types&Enums/claim-request-management/required-document-type.enum.ts b/src/Types&Enums/claim-request-management/required-document-type.enum.ts index 5f6312e..841ab1a 100644 --- a/src/Types&Enums/claim-request-management/required-document-type.enum.ts +++ b/src/Types&Enums/claim-request-management/required-document-type.enum.ts @@ -20,6 +20,12 @@ export enum ClaimRequiredDocumentType { GUILTY_CAR_CARD_FRONT = "guilty_car_card_front", GUILTY_CAR_CARD_BACK = "guilty_car_card_back", GUILTY_METAL_PLATE = "guilty_metal_plate", + + /** + * V4/V5 only — a photo of the guilty car's damaged area, captured by the + * FileReviewer during the CAPTURE_PART_DAMAGES phase (after all car angles). + */ + GUILTY_DAMAGE_AREA = "guilty_damage_area", } export enum CarAngle { diff --git a/src/case-expert-report/case-expert-report-pdf.service.ts b/src/case-expert-report/case-expert-report-pdf.service.ts deleted file mode 100644 index ab270ad..0000000 --- a/src/case-expert-report/case-expert-report-pdf.service.ts +++ /dev/null @@ -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 { - 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`, - }; - } -} diff --git a/src/case-expert-report/case-expert-report.controller.ts b/src/case-expert-report/case-expert-report.controller.ts index 362a8dd..41f17cd 100644 --- a/src/case-expert-report/case-expert-report.controller.ts +++ b/src/case-expert-report/case-expert-report.controller.ts @@ -4,14 +4,12 @@ import { HttpException, InternalServerErrorException, Param, - StreamableFile, UseGuards, } from "@nestjs/common"; import { ApiBearerAuth, ApiOperation, ApiParam, - ApiProduces, ApiResponse, ApiTags, } from "@nestjs/swagger"; @@ -21,6 +19,7 @@ import { Roles } from "src/decorators/roles.decorator"; import { CurrentUser } from "src/decorators/user.decorator"; import { RoleEnum } from "src/Types&Enums/role.enum"; import { CaseExpertReportService } from "./case-expert-report.service"; +import { InsurerFileReportViewModel } from "./case-expert-report.types"; @ApiTags("expert-insurer-panel") @Controller("expert-insurer") @@ -32,36 +31,30 @@ export class CaseExpertReportInsurerController { private readonly caseExpertReportService: CaseExpertReportService, ) {} - @Get("files/:publicId/report.pdf") + @Get("files/:publicId/report") @ApiOperation({ - summary: "Download insurer file report PDF", + summary: "Get insurer file report data", description: - "Generates a PDF for the shared publicId (blame + claim combined): damaged owner, driver when different, insurance, vehicle, and accident report sections.", + "Returns the structured report data for the given publicId (blame + claim combined): damaged owner, driver when different, insurance, vehicle, and accident report sections. The front-end uses this data to render and generate the PDF.", }) @ApiParam({ name: "publicId" }) - @ApiProduces("application/pdf") - @ApiResponse({ status: 200, description: "PDF file" }) + @ApiResponse({ status: 200, description: "Report data" }) @ApiResponse({ status: 404, description: "File not found for this publicId" }) - async downloadInsurerReport( + async getInsurerReport( @CurrentUser() insurer: { clientKey?: string }, @Param("publicId") publicId: string, - ): Promise { + ): Promise { try { - const { buffer, filename } = - await this.caseExpertReportService.generateForInsurer( - publicId, - insurer, - ); - return new StreamableFile(buffer, { - type: "application/pdf", - disposition: `attachment; filename="${filename}"`, - }); + return await this.caseExpertReportService.generateForInsurer( + publicId, + insurer, + ); } catch (error) { if (error instanceof HttpException) throw error; throw new InternalServerErrorException( error instanceof Error ? error.message - : "Failed to generate insurer file report PDF", + : "Failed to retrieve insurer file report data", ); } } diff --git a/src/case-expert-report/case-expert-report.module.ts b/src/case-expert-report/case-expert-report.module.ts index 5d8c302..85ec3a2 100644 --- a/src/case-expert-report/case-expert-report.module.ts +++ b/src/case-expert-report/case-expert-report.module.ts @@ -1,13 +1,12 @@ import { Module } from "@nestjs/common"; import { ExpertInsurerModule } from "src/expert-insurer/expert-insurer.module"; import { CaseExpertReportInsurerController } from "./case-expert-report.controller"; -import { CaseExpertReportPdfService } from "./case-expert-report-pdf.service"; import { CaseExpertReportService } from "./case-expert-report.service"; @Module({ imports: [ExpertInsurerModule], controllers: [CaseExpertReportInsurerController], - providers: [CaseExpertReportService, CaseExpertReportPdfService], + providers: [CaseExpertReportService], exports: [CaseExpertReportService], }) export class CaseExpertReportModule {} diff --git a/src/case-expert-report/case-expert-report.service.ts b/src/case-expert-report/case-expert-report.service.ts index 8952c81..b278bc1 100644 --- a/src/case-expert-report/case-expert-report.service.ts +++ b/src/case-expert-report/case-expert-report.service.ts @@ -1,20 +1,18 @@ import { Injectable, NotFoundException } from "@nestjs/common"; import { ExpertInsurerService } from "src/expert-insurer/expert-insurer.service"; import { buildInsurerFileReport } from "./case-expert-report.builder"; -import { CaseExpertReportPdfService } from "./case-expert-report-pdf.service"; -import { InsurerFileReportPdfResult } from "./case-expert-report.types"; +import { InsurerFileReportViewModel } from "./case-expert-report.types"; @Injectable() export class CaseExpertReportService { constructor( private readonly expertInsurerService: ExpertInsurerService, - private readonly pdfService: CaseExpertReportPdfService, ) {} async generateForInsurer( publicId: string, actor: { clientKey?: string }, - ): Promise { + ): Promise { const clientKey = actor?.clientKey; if (!clientKey) { throw new NotFoundException("Insurer context not found"); @@ -24,7 +22,6 @@ export class CaseExpertReportService { clientKey, publicId, ); - const model = buildInsurerFileReport(file); - return this.pdfService.render(model); + return buildInsurerFileReport(file); } } diff --git a/src/case-expert-report/case-expert-report.types.ts b/src/case-expert-report/case-expert-report.types.ts index b98cbb1..e292564 100644 --- a/src/case-expert-report/case-expert-report.types.ts +++ b/src/case-expert-report/case-expert-report.types.ts @@ -14,8 +14,3 @@ export type InsurerFileReportViewModel = { requestNo?: string; sections: InsurerFileReportSection[]; }; - -export type InsurerFileReportPdfResult = { - buffer: Buffer; - filename: string; -}; diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index db11302..5ebbe33 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -163,6 +163,7 @@ import { CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS, capturePhaseSequenceMessage, getClaimCaptureProgress, + GUILTY_DAMAGE_AREA_DOC_KEY, isCapturePhaseDamagedPartyDocKey, isClaimCaptureStepComplete, OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5, @@ -9073,7 +9074,7 @@ export class ClaimRequestManagementService { file: Express.Multer.File, currentUserId: string, actor?: { sub: string; role?: string }, - options?: { v3InPersonFlow?: boolean; skipMetalPlate?: boolean; requiresFileMakerApproval?: boolean }, + options?: { v3InPersonFlow?: boolean; skipMetalPlate?: boolean; requiresFileMakerApproval?: boolean; includeGuiltyDamageArea?: boolean }, ): Promise { try { const claimCase = await this.claimCaseDbService.findById(claimRequestId); @@ -9099,7 +9100,9 @@ export class ClaimRequestManagementService { const isResendUpload = step === ClaimWorkflowStep.USER_EXPERT_RESEND; const isCapturePhaseDocUpload = step === ClaimWorkflowStep.CAPTURE_PART_DAMAGES && - isCapturePhaseDamagedPartyDocKey(body.documentKey); + (isCapturePhaseDamagedPartyDocKey(body.documentKey) || + (options?.includeGuiltyDamageArea && + (body.documentKey as string) === GUILTY_DAMAGE_AREA_DOC_KEY)); if (!isResendUpload) { if ( @@ -9249,17 +9252,20 @@ export class ClaimRequestManagementService { const afterThis = (k: string) => k === body.documentKey || this.isRequiredDocumentUploadedOnClaim(claimCase, k); - remaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.filter( - (k) => { - if (options?.skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(k as any)) return false; - return !afterThis(k); - }, - ).length; + const captureKeysForCount: string[] = [ + ...CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS, + ...(options?.includeGuiltyDamageArea ? [GUILTY_DAMAGE_AREA_DOC_KEY] : []), + ]; + remaining = captureKeysForCount.filter((k) => { + if (options?.skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(k as any)) return false; + return !afterThis(k); + }).length; if (remaining === 0) { const progressAfterDoc = getClaimCaptureProgress(claimCase, { assumeCapturePhaseDocKey: body.documentKey, skipMetalPlate: options?.skipMetalPlate, + includeGuiltyDamageArea: options?.includeGuiltyDamageArea, }); if ( @@ -11241,6 +11247,13 @@ export class ClaimRequestManagementService { base: GetCaptureRequirementsV2ResponseDto, ): GetCaptureRequirementsV2ResponseDto { const skipMetalPlate = !!(blame as any)?.isMadeByFileMaker; + // V4 = FileMaker flow without a secondary FileMaker approval step + const isV4 = + !!(blame as any)?.isMadeByFileMaker && + !(claimCase as any)?.requiresFileMakerApproval; + const isCarBody = + blame?.type === BlameRequestType.CAR_BODY || + (blame as any)?.type === "CAR_BODY"; // For V4/V5 files strip metal-plate keys from the base response entirely // so they never appear in any phase — the front-end should not show them. @@ -11316,6 +11329,40 @@ export class ClaimRequestManagementService { d.preferUploadDuringCapture && !(skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(d.key as any)), ); + + // V4 THIRD_PARTY only: guilty_damage_area is uploaded via upload-document + // after all car angles are captured (last item in the documents phase). + // It also appears in damagedParts so the expert sees it labelled as a + // damaged-part capture. + if (isV4 && !isCarBody) { + const guiltyAreaUploaded = this.isRequiredDocumentUploadedOnClaim( + claimCase, + GUILTY_DAMAGE_AREA_DOC_KEY, + ); + const guiltyAreaDoc = { + key: GUILTY_DAMAGE_AREA_DOC_KEY, + label_fa: "تصویر نقطه آسیب‌دیده مقصر", + label_en: "Guilty Car Damaged Area", + category: "guilty_party", + uploaded: guiltyAreaUploaded, + preferUploadDuringCapture: true, + }; + const captureProgress = getClaimCaptureProgress(claimCase, { + skipMetalPlate, + includeGuiltyDamageArea: true, + }); + + return { + ...base, + requiredDocuments: [...capturePhaseDocs, guiltyAreaDoc], + totalRemaining: captureProgress.capturePhaseDocsRemaining, + progress: { + ...base.progress, + capturePhaseDocsRemaining: captureProgress.capturePhaseDocsRemaining, + }, + }; + } + return { ...base, requiredDocuments: capturePhaseDocs, @@ -11327,6 +11374,7 @@ export class ClaimRequestManagementService { step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS && capturePartDone ) { + // car-capture not yet uploaded: next step is the walk-around video. return { ...base, requiredDocuments: [], @@ -11337,6 +11385,22 @@ export class ClaimRequestManagementService { }; } + // V3 FIELD_EXPERT only: car-capture is done (step advanced to USER_SUBMISSION_COMPLETE + // in setVideoCaptureV3). The one remaining action is the blame accident video. + if ( + step === ClaimWorkflowStep.USER_SUBMISSION_COMPLETE && + !(blame as any).isMadeByFileMaker + ) { + return { + ...base, + requiredDocuments: [], + captureSequencePhase: "complete", + captureSequenceHint: + "Walk-around video uploaded. Upload the blame accident video to finalise.", + totalRemaining: 0, + }; + } + return base; } @@ -11355,8 +11419,21 @@ export class ClaimRequestManagementService { } const blame = await this.assertV3InPersonClaim(claimCase); const skipMetalPlate = !!(blame as any).isMadeByFileMaker; + const isV4FileMaker = + !!(blame as any)?.isMadeByFileMaker && + !(claimCase as any)?.requiresFileMakerApproval; + const isCarBodyBlame = + blame?.type === BlameRequestType.CAR_BODY || + (blame as any)?.type === "CAR_BODY"; - const isCaptureDoc = isCapturePhaseDamagedPartyDocKey(body.documentKey); + // guilty_damage_area is an extra capture-phase doc key for V4 THIRD_PARTY + const isGuiltyDamageAreaDoc = + (body.documentKey as string) === GUILTY_DAMAGE_AREA_DOC_KEY && + isV4FileMaker && + !isCarBodyBlame; + + const isCaptureDoc = + isCapturePhaseDamagedPartyDocKey(body.documentKey) || isGuiltyDamageAreaDoc; if (isCaptureDoc) { this.assertV3ClaimPartsPhase(blame); @@ -11375,7 +11452,10 @@ export class ClaimRequestManagementService { "Complete outer and other part selection before capture-phase vehicle documents.", ); } - const captureProgress = getClaimCaptureProgress(claimCase, { skipMetalPlate }); + const captureProgress = getClaimCaptureProgress(claimCase, { + skipMetalPlate, + includeGuiltyDamageArea: isGuiltyDamageAreaDoc, + }); if (!captureProgress.partsComplete) { throw new BadRequestException( "Upload photos for all selected damaged parts before chassis or engine photos.", @@ -11397,13 +11477,24 @@ export class ClaimRequestManagementService { } } + // For V4 THIRD_PARTY files, guilty_damage_area must be counted in the + // capture-phase doc total for ALL capture uploads (not just the guilty_damage_area + // upload itself) so that the CAPTURE_PART_DAMAGES step is not marked complete + // until all three standard docs AND the guilty area photo are uploaded. + const includeGuiltyDamageArea = isV4FileMaker && !isCarBodyBlame; + return this.uploadRequiredDocumentV2( claimRequestId, body, file, currentUserId, actor, - { v3InPersonFlow: true, skipMetalPlate, requiresFileMakerApproval: !!(claimCase as any).requiresFileMakerApproval }, + { + v3InPersonFlow: true, + skipMetalPlate, + requiresFileMakerApproval: !!(claimCase as any).requiresFileMakerApproval, + includeGuiltyDamageArea, + }, ); } @@ -11461,7 +11552,9 @@ export class ClaimRequestManagementService { "Select outer parts after accident fields and required documents.", ); - return this.selectOuterPartsV2(claimRequestId, body, currentUserId, actor); + const result = await this.selectOuterPartsV2(claimRequestId, body, currentUserId, actor); + + return result; } async selectOtherPartsV3( @@ -11645,7 +11738,34 @@ export class ClaimRequestManagementService { // V4/V5 only (isMadeByFileMaker): car-capture is the FINAL FileReviewer step. // For the v3 mirror (FIELD_EXPERT IN_PERSON), car-capture is penultimate — // expertUploadBlameVideoV3 finalises the blame with the accident video next. - if ((blame as any).isMadeByFileMaker) { + // Advance the workflow step so that if the expert exits and reopens the claim + // the system correctly resumes at upload-video rather than replaying car-capture. + if (!(blame as any).isMadeByFileMaker) { + await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { + $set: { + "workflow.currentStep": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, + "workflow.nextStep": undefined, + }, + $push: { + "workflow.completedSteps": ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, + history: { + type: "STEP_COMPLETED", + actor: { + actorId: new Types.ObjectId(currentUserId), + actorName: claimCase.owner?.fullName || "User", + actorType: "field_expert", + }, + timestamp: new Date(), + metadata: { + stepKey: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, + description: + "V3 walk-around video uploaded. Upload blame accident video to finalise.", + v3InPersonFlow: true, + }, + }, + }, + }); + } else if ((blame as any).isMadeByFileMaker) { // Both V4 and V5 land at WAITING_FOR_DAMAGE_EXPERT here. // For V5 (requiresFileMakerApproval=true), autoSubmitToFanavaranV2OnClaimCompleted // intercepts COMPLETED → WAITING_FOR_FILE_MAKER_APPROVAL after expert review. diff --git a/src/expert-insurer/dto/insurer-reports.dto.ts b/src/expert-insurer/dto/insurer-reports.dto.ts new file mode 100644 index 0000000..588c852 --- /dev/null +++ b/src/expert-insurer/dto/insurer-reports.dto.ts @@ -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; +} diff --git a/src/expert-insurer/expert-insurer.controller.ts b/src/expert-insurer/expert-insurer.controller.ts index 71d9dc6..1cfcb83 100644 --- a/src/expert-insurer/expert-insurer.controller.ts +++ b/src/expert-insurer/expert-insurer.controller.ts @@ -39,6 +39,13 @@ import { CreateFileMakerByInsurerDto, CreateFileReviewerByInsurerDto, } from "./dto/create-insurer-expert.dto"; +import { + InsurerStatisticsDto, + InsurerTopExpertsDto, + InsurerTopFileDto, + InsurerWorkLogResponseDto, +} from "./dto/insurer-reports.dto"; +import { ReportsService } from "src/reports/reports.service"; @Controller("expert-insurer") @ApiTags("expert-insurer-panel") @@ -46,25 +53,18 @@ import { @UseGuards(LocalActorAuthGuard, RolesGuard) @Roles(RoleEnum.COMPANY) export class ExpertInsurerController { - constructor(private readonly expertInsurerService: ExpertInsurerService) {} + constructor( + private readonly expertInsurerService: ExpertInsurerService, + private readonly reportsService: ReportsService, + ) {} + + // ─── Branch management ──────────────────────────────────────────────────── @Get("branches") @ApiQuery({ name: "search", required: false, type: String }) - @ApiQuery({ - name: "from", - required: false, - description: "Optional start datetime (ISO string)", - }) - @ApiQuery({ - name: "to", - required: false, - description: "Optional end datetime (ISO string)", - }) - @ApiQuery({ - name: "isActive", - required: false, - description: "Filter active state (true/false)", - }) + @ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" }) + @ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" }) + @ApiQuery({ name: "isActive", required: false, description: "Filter active state (true/false)" }) async getInsuranceBranches( @CurrentUser() insurer, @Query("search") search?: string, @@ -72,9 +72,7 @@ export class ExpertInsurerController { @Query("to") to?: string, @Query("isActive") isActive?: string, ) { - if (!insurer) { - throw new UnauthorizedException("Could not identify the current user."); - } + if (!insurer) throw new UnauthorizedException("Could not identify the current user."); return await this.expertInsurerService.retrieveInsuranceBranches( insurer.clientKey, { search, from, to, isActive }, @@ -83,18 +81,9 @@ export class ExpertInsurerController { @Post("branches") @ApiBody({ type: CreateBranchDto }) - async addBranch( - @CurrentUser() insurer, - @Body() createBranchDto: CreateBranchDto, - ) { - if (!insurer) { - throw new UnauthorizedException("Could not identify the current user."); - } - - return await this.expertInsurerService.addBranch( - insurer.clientKey, - createBranchDto, - ); + async addBranch(@CurrentUser() insurer, @Body() createBranchDto: CreateBranchDto) { + if (!insurer) throw new UnauthorizedException("Could not identify the current user."); + return await this.expertInsurerService.addBranch(insurer.clientKey, createBranchDto); } @Put("branches/:branchId/status") @@ -111,10 +100,7 @@ export class ExpertInsurerController { @Param("branchId") branchId: string, @Body("isActive") isActive: unknown, ) { - if (!insurer) { - throw new UnauthorizedException("Could not identify the current user."); - } - // Accept native boolean (JSON body) or string coercion (legacy query/form usage) + if (!insurer) throw new UnauthorizedException("Could not identify the current user."); let active: boolean; if (typeof isActive === "boolean") { active = isActive; @@ -125,60 +111,38 @@ export class ExpertInsurerController { } active = ["true", "1", "yes"].includes(normalized); } - return this.expertInsurerService.setBranchActive( - insurer.clientKey, - branchId, - active, - ); + return this.expertInsurerService.setBranchActive(insurer.clientKey, branchId, active); } + // ─── Expert management ──────────────────────────────────────────────────── + @Post("experts/blame") @ApiBody({ type: CreateBlameExpertByInsurerDto }) - async addBlameExpert( - @CurrentUser() insurer, - @Body() body: CreateBlameExpertByInsurerDto, - ) { - if (!insurer) { - throw new UnauthorizedException("Could not identify the current user."); - } + async addBlameExpert(@CurrentUser() insurer, @Body() body: CreateBlameExpertByInsurerDto) { + if (!insurer) throw new UnauthorizedException("Could not identify the current user."); return this.expertInsurerService.addBlameExpert(insurer.clientKey, body); } @Post("experts/claim") @ApiBody({ type: CreateClaimExpertByInsurerDto }) - async addClaimExpert( - @CurrentUser() insurer, - @Body() body: CreateClaimExpertByInsurerDto, - ) { - if (!insurer) { - throw new UnauthorizedException("Could not identify the current user."); - } + async addClaimExpert(@CurrentUser() insurer, @Body() body: CreateClaimExpertByInsurerDto) { + if (!insurer) throw new UnauthorizedException("Could not identify the current user."); return this.expertInsurerService.addClaimExpert(insurer.clientKey, body); } @Post("experts/file-maker") @ApiBody({ type: CreateFileMakerByInsurerDto }) @ApiOperation({ summary: "Create a FileMaker account under this insurer" }) - async addFileMaker( - @CurrentUser() insurer, - @Body() body: CreateFileMakerByInsurerDto, - ) { - if (!insurer) { - throw new UnauthorizedException("Could not identify the current user."); - } + async addFileMaker(@CurrentUser() insurer, @Body() body: CreateFileMakerByInsurerDto) { + if (!insurer) throw new UnauthorizedException("Could not identify the current user."); return this.expertInsurerService.addFileMaker(insurer.clientKey, body); } @Post("experts/file-reviewer") @ApiBody({ type: CreateFileReviewerByInsurerDto }) @ApiOperation({ summary: "Create a FileReviewer account under this insurer" }) - async addFileReviewer( - @CurrentUser() insurer, - @Body() body: CreateFileReviewerByInsurerDto, - ) { - if (!insurer) { - throw new UnauthorizedException("Could not identify the current user."); - } + async addFileReviewer(@CurrentUser() insurer, @Body() body: CreateFileReviewerByInsurerDto) { + if (!insurer) throw new UnauthorizedException("Could not identify the current user."); return this.expertInsurerService.addFileReviewer(insurer.clientKey, body); } @@ -190,37 +154,140 @@ export class ExpertInsurerController { @Query("response_count") count: number, @CurrentUser() actor, ) { - return await this.expertInsurerService.retrieveAllExpertsOfClient( - actor, - page, - count, - ); + return await this.expertInsurerService.retrieveAllExpertsOfClient(actor, page, count); } + // ─── Reports: statistics ────────────────────────────────────────────────── + + @Get("statistics") + @ApiOperation({ + summary: "نمایش کلی — KPI cards for insurer reports page", + description: + "Returns `totalFilesReviewed` (distinct tenant claim files with ≥1 expert CHECKED activity), " + + "`averageUserRatingPercentage` (mean of progressSpeed + registrationEase + overallEvaluation across rated files, normalised to 0–100), " + + "`inPersonAccompaniedCount` (blame files where expertInitiated=true AND creationMethod=IN_PERSON), " + + "`filesCreatedThisMonth`, `totalFiles`, `objectionPercentage`, `insurerToBotRatingPercentage`. " + + "⚠️ `userRatingPercentage` and `filesWithUserRatingPercentage` are the share of files *that have any user rating* — NOT a satisfaction score. Use `averageUserRatingPercentage` for رضایت کاربران.", + }) + @ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string) — restricts counted claim portfolio" }) + @ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" }) + @ApiResponse({ status: 200, type: InsurerStatisticsDto }) + async getExpertStatistics( + @CurrentUser() actor, + @Query("from") from?: string, + @Query("to") to?: string, + ) { + return await this.expertInsurerService.getExpertStatisticsReport(actor, { from, to }); + } + + // ─── Reports: top files ─────────────────────────────────────────────────── + + @Get("top-files") + @ApiOperation({ + summary: "Top 10 highest-rated claim files for this insurer", + description: + "Sorted by combined insurer + user rating blend (getCombinedFileScore). " + + "Returns slim DTO only: publicId, createdAt, combinedScore, userRating.{comment, overallEvaluation}. " + + "Use publicId to build مشاهده پرونده links.", + }) + @ApiQuery({ name: "from", required: false, description: "Restrict to files created on or after this ISO date" }) + @ApiQuery({ name: "to", required: false, description: "Restrict to files created on or before this ISO date" }) + @ApiResponse({ status: 200, type: [InsurerTopFileDto] }) + async getTopFiles( + @CurrentUser() insurer, + @Query("from") from?: string, + @Query("to") to?: string, + ) { + return await this.expertInsurerService.getTopFilesForClient(insurer.clientKey, { from, to }); + } + + // ─── Reports: top experts (canonical + alias) ───────────────────────────── + @Get("experts/top") @ApiOperation({ summary: "Top blame vs claim experts for this insurer", description: - "Response has two arrays: `blameExperts` (roster from expert / blame files) and `claimExperts` (damage-expert roster / claim files). Each item includes `overallAverageRating` derived from ratings stored on those files plus user ratings where present.", + "Response: `{ blameExperts: [], claimExperts: [] }`. " + + "Each item: `_id`, `fullName`, `expertKind`, `overallAverageRating`, `requestStats`. " + + "Sorted by overallAverageRating descending; up to 10 per group. " + + "Optional `from` / `to` accepted for forward-compat (currently accepted but roster is full-history).", }) - async getTopExperts(@CurrentUser() actor) { - return await this.expertInsurerService.getTopExpertsForClient(actor); + @ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" }) + @ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" }) + @ApiResponse({ status: 200, type: InsurerTopExpertsDto }) + async getTopExperts( + @CurrentUser() actor, + @Query("from") from?: string, + @Query("to") to?: string, + ) { + return await this.expertInsurerService.getTopExpertsForClient(actor, { from, to }); } + /** + * Alias: some BFF/frontend paths call /top-experts instead of /experts/top. + * Both routes point to the same handler. + */ + @Get("top-experts") + @ApiOperation({ + summary: "Alias for GET experts/top — top blame + claim experts", + description: "Same response as `GET experts/top`. Provided for front-end compatibility.", + }) + @ApiQuery({ name: "from", required: false }) + @ApiQuery({ name: "to", required: false }) + @ApiResponse({ status: 200, type: InsurerTopExpertsDto }) + async getTopExpertsAlias( + @CurrentUser() actor, + @Query("from") from?: string, + @Query("to") to?: string, + ) { + return await this.expertInsurerService.getTopExpertsForClient(actor, { from, to }); + } + + // ─── Reports: work-log (delegates to ReportsService) ───────────────────── + + @Get("expert-work-log") + @ApiOperation({ + summary: "نمایش جدولی — per-expert work log (blame panel + damage experts)", + description: + "Rows for every tenant expert in the `expert` (blame) and `damage-expert` (claim) collections — same scope as GET experts/list, field-expert excluded. " + + "Columns: expertId, fullName, expertKind, totalHandled (files in HANDLED state up to `to`/now), " + + "currentlyChecking (CHECKED but not yet HANDLED up to `to`/now), " + + "distinctFilesCheckedInPeriod (distinct files with ≥1 CHECKED event inside from–to window; all-time when omitted). " + + "Optional `expertKind` filter: `expert` = blame panel, `damage_expert` = claim damage experts, `all` (default).", + }) + @ApiQuery({ name: "expertKind", required: false, enum: ["all", "expert", "damage_expert"], description: "Filter by expert kind" }) + @ApiQuery({ name: "from", required: false, description: "Start of date window (ISO string)" }) + @ApiQuery({ name: "to", required: false, description: "End of date window (ISO string)" }) + @ApiQuery({ name: "page", required: false, type: Number, description: "Page (1-based)" }) + @ApiQuery({ name: "limit", required: false, type: Number, description: "Items per page" }) + @ApiResponse({ status: 200, type: InsurerWorkLogResponseDto }) + async getExpertWorkLog( + @CurrentUser() actor, + @Query("expertKind") expertKind?: "all" | "expert" | "damage_expert", + @Query("from") from?: string, + @Query("to") to?: string, + @Query("page") page?: string, + @Query("limit") limit?: string, + ) { + return this.reportsService.getInsurerExpertWorkLog(actor, { + expertKind, + from, + to, + page: page ? parseInt(page, 10) : undefined, + limit: limit ? parseInt(limit, 10) : undefined, + }); + } + + // ─── Files ──────────────────────────────────────────────────────────────── + @Get("files") @ApiOperation({ summary: "List insurer files (blame + claim merged by publicId)", description: "Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`, `unifiedStatus`, `fileType` (THIRD_PARTY | CAR_BODY).", }) - async getAllFiles( - @CurrentUser() insurer, - @Query() query: ListQueryV2Dto, - ) { - return await this.expertInsurerService.retrieveAllFilesOfClient( - insurer.clientKey, - query, - ); + async getAllFiles(@CurrentUser() insurer, @Query() query: ListQueryV2Dto) { + return await this.expertInsurerService.retrieveAllFilesOfClient(insurer.clientKey, query); } @Get("report/unified-file-statuses") @@ -234,10 +301,7 @@ export class ExpertInsurerController { @CurrentUser() actor, @Query() query: UnifiedFileStatusReportQueryDto, ): Promise { - return await this.expertInsurerService.getInsurerUnifiedFileStatusReport( - actor, - query, - ); + return await this.expertInsurerService.getInsurerUnifiedFileStatusReport(actor, query); } @Get("files/:publicId/timeline") @@ -247,26 +311,14 @@ export class ExpertInsurerController { description: "Returns a chronological list of all history events for the blame and/or claim associated with the given publicId. Each event has: source, type, timestamp, actor, metadata.", }) - async getFileTimeline( - @CurrentUser() insurer, - @Param("publicId") publicId: string, - ) { - return await this.expertInsurerService.getFileTimeline( - insurer.clientKey, - publicId, - ); + async getFileTimeline(@CurrentUser() insurer, @Param("publicId") publicId: string) { + return await this.expertInsurerService.getFileTimeline(insurer.clientKey, publicId); } @Get("files/:publicId") @ApiParam({ name: "publicId" }) - async getFileDetailsByPublicId( - @CurrentUser() insurer, - @Param("publicId") publicId: string, - ) { - return await this.expertInsurerService.retrieveFileDetailsByPublicId( - insurer.clientKey, - publicId, - ); + async getFileDetailsByPublicId(@CurrentUser() insurer, @Param("publicId") publicId: string) { + return await this.expertInsurerService.retrieveFileDetailsByPublicId(insurer.clientKey, publicId); } @ApiBody({ @@ -275,56 +327,14 @@ export class ExpertInsurerController { schema: { type: "object", properties: { - collisionMethodAccuracy: { - type: "number", - minimum: 0, - maximum: 5, - example: 4, - description: "تشخیص درست نحوه برخورد", - }, - evaluationTimeliness: { - type: "number", - minimum: 0, - maximum: 5, - example: 3, - description: "زمان ارزیابی", - }, - accidentCauseAccuracy: { - type: "number", - minimum: 0, - maximum: 5, - example: 5, - description: "تشخیص درست علت تصادف", - }, - guiltyVehicleIdentification: { - type: "number", - minimum: 0, - maximum: 5, - example: 4, - description: "تشخیص درست وسیله نقلیه مقصر", - }, - botRating: { - type: "number", - minimum: 0, - maximum: 5, - example: 4, - description: "برای امتیاز دادن به عملکرد بات", - }, - }, - required: [ - "collisionMethodAccuracy", - "evaluationTimeliness", - "accidentCauseAccuracy", - "guiltyVehicleIdentification", - "botRating", - ], - example: { - collisionMethodAccuracy: 4, - evaluationTimeliness: 3, - accidentCauseAccuracy: 5, - guiltyVehicleIdentification: 4, - botRating: 4, + collisionMethodAccuracy: { type: "number", minimum: 0, maximum: 5, example: 4, description: "تشخیص درست نحوه برخورد" }, + evaluationTimeliness: { type: "number", minimum: 0, maximum: 5, example: 3, description: "زمان ارزیابی" }, + accidentCauseAccuracy: { type: "number", minimum: 0, maximum: 5, example: 5, description: "تشخیص درست علت تصادف" }, + guiltyVehicleIdentification: { type: "number", minimum: 0, maximum: 5, example: 4, description: "تشخیص درست وسیله نقلیه مقصر" }, + botRating: { type: "number", minimum: 0, maximum: 5, example: 4, description: "برای امتیاز دادن به عملکرد بات" }, }, + required: ["collisionMethodAccuracy", "evaluationTimeliness", "accidentCauseAccuracy", "guiltyVehicleIdentification", "botRating"], + example: { collisionMethodAccuracy: 4, evaluationTimeliness: 3, accidentCauseAccuracy: 5, guiltyVehicleIdentification: 4, botRating: 4 }, }, }) @ApiParam({ name: "publicId" }) @@ -334,23 +344,7 @@ export class ExpertInsurerController { @Param("publicId") publicId: string, @Body() rating: FileRating, ) { - return await this.expertInsurerService.rateExpertByPublicId( - publicId, - rating, - insurer.clientKey, - ); - } - - @Get("top-files") - async getTopFiles(@CurrentUser() insurer) { - return await this.expertInsurerService.getTopFilesForClient( - insurer.clientKey, - ); - } - - @Get("statistics") - async getExpertStatistics(@CurrentUser() actor) { - return await this.expertInsurerService.getExpertStatisticsReport(actor); + return await this.expertInsurerService.rateExpertByPublicId(publicId, rating, insurer.clientKey); } @Get("report/status-counts") @@ -359,28 +353,18 @@ export class ExpertInsurerController { deprecated: true, description: "Prefer GET report/unified-file-statuses for full calculatable blame+claim statuses.", }) - @ApiQuery({ - name: "from", - required: false, - description: "Optional start datetime (ISO string)", - }) - @ApiQuery({ - name: "to", - required: false, - description: "Optional end datetime (ISO string)", - }) + @ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" }) + @ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" }) async getInsurerStatusReport( @CurrentUser() actor, @Query("from") from?: string, @Query("to") to?: string, ) { - return await this.expertInsurerService.getInsurerFileStatusCounts( - actor, - from, - to, - ); + return await this.expertInsurerService.getInsurerFileStatusCounts(actor, from, to); } + // ─── Expert detail ──────────────────────────────────────────────────────── + @ApiParam({ name: "expertId" }) @ApiOperation({ summary: "Files handled by one roster expert (summary rows)", @@ -389,13 +373,7 @@ export class ExpertInsurerController { }) @Get("/:expertId") async requestDetail(@CurrentUser() insurer, @Param("expertId") id: string) { - if (!Types.ObjectId.isValid(id)) { - throw new BadRequestException("Invalid expert ID"); - } - - return await this.expertInsurerService.getAllFilesForInsurerExpert( - id, - insurer.clientKey, - ); + if (!Types.ObjectId.isValid(id)) throw new BadRequestException("Invalid expert ID"); + return await this.expertInsurerService.getAllFilesForInsurerExpert(id, insurer.clientKey); } } diff --git a/src/expert-insurer/expert-insurer.module.ts b/src/expert-insurer/expert-insurer.module.ts index 5d9226b..f0624ca 100644 --- a/src/expert-insurer/expert-insurer.module.ts +++ b/src/expert-insurer/expert-insurer.module.ts @@ -1,6 +1,7 @@ import { Module } from "@nestjs/common"; import { MongooseModule } from "@nestjs/mongoose"; import { AuthModule } from "src/auth/auth.module"; +import { ReportsModule } from "src/reports/reports.module"; import { ClaimRequestManagementModel, ClaimRequestManagementSchema, @@ -29,6 +30,7 @@ import { HashModule } from "src/utils/hash/hash.module"; HashModule, UsersModule, ClientModule, + ReportsModule, MongooseModule.forFeature([ { name: ClaimRequestManagementModel.name, diff --git a/src/expert-insurer/expert-insurer.service.ts b/src/expert-insurer/expert-insurer.service.ts index 8b82424..90f17d6 100644 --- a/src/expert-insurer/expert-insurer.service.ts +++ b/src/expert-insurer/expert-insurer.service.ts @@ -971,24 +971,38 @@ export class ExpertInsurerService { * excluding `botRating`), optionally blend with the file’s user rating, average those * combined scores per file, then average across that expert’s files (`overallAverageRating`). */ - async getTopExpertsForClient(actor): Promise<{ - blameExperts: any[]; - claimExperts: any[]; - }> { + async getTopExpertsForClient( + actor, + opts: { from?: string; to?: string } = {}, + ): Promise<{ blameExperts: any[]; claimExperts: any[] }> { + // When date opts are passed we re-use the full list; date filtering on file-based + // ratings would require per-file date awareness — for now we surface the roster + // as-is and document that from/to are accepted for forward-compat. const result = await this.retrieveAllExpertsOfClient(actor, 1, 1000); const rows = result?.experts || []; const byRatingDesc = (a: any, b: any) => (b.overallAverageRating ?? 0) - (a.overallAverageRating ?? 0); + // Return only the fields the spec mandates — keep shape minimal + const slim = (e: any) => ({ + _id: e._id, + fullName: e.fullName, + expertKind: e.expertKind, + overallAverageRating: e.overallAverageRating ?? null, + requestStats: e.requestStats ?? { totalHandled: 0, totalChecked: 0 }, + }); + const blameExperts = rows .filter((e) => e.expertKind === "blame") .sort(byRatingDesc) - .slice(0, 10); + .slice(0, 10) + .map(slim); const claimExperts = rows .filter((e) => e.expertKind === "claim") .sort(byRatingDesc) - .slice(0, 10); + .slice(0, 10) + .map(slim); return { blameExperts, claimExperts }; } @@ -997,18 +1011,47 @@ export class ExpertInsurerService { * Returns top 10 claim files for the current insurer client based on * combined insurer + user ratings. */ - async getTopFilesForClient(insurerId: string): Promise { - const claimFiles = await this.getClientClaimFiles( - this.getClientId(insurerId), - ); - const scored = claimFiles + async getTopFilesForClient( + insurerId: string, + opts: { from?: string; to?: string } = {}, + ): Promise> { + const fromDate = opts.from ? new Date(opts.from) : undefined; + const toDate = opts.to ? new Date(opts.to) : undefined; + + let claimFiles = await this.getClientClaimFiles(this.getClientId(insurerId)); + + if (fromDate || toDate) { + claimFiles = claimFiles.filter((f) => { + const d = new Date(f.createdAt); + if (fromDate && d < fromDate) return false; + if (toDate && d > toDate) return false; + return true; + }); + } + + return claimFiles .map((file) => { const combinedScore = this.getCombinedFileScore(file); if (combinedScore === null) return null; - return { ...file, combinedScore }; + const ur = file?.userRating; + return { + publicId: String(file.publicId ?? ""), + createdAt: file.createdAt instanceof Date + ? file.createdAt.toISOString() + : String(file.createdAt ?? ""), + combinedScore, + userRating: { + comment: ur?.comment ?? null, + overallEvaluation: typeof ur?.overallEvaluation === "number" ? ur.overallEvaluation : null, + }, + }; }) - .filter((f) => f !== null); - return scored + .filter((f): f is NonNullable => f !== null) .sort((a, b) => b.combinedScore - a.combinedScore) .slice(0, 10); } @@ -1775,29 +1818,59 @@ export class ExpertInsurerService { * - Percentage of files that have objection * - Number of files created in the current month */ - async getExpertStatisticsReport(actor: any) { + async getExpertStatisticsReport( + actor: any, + opts: { from?: string; to?: string } = {}, + ) { const clientObjectId = this.getClientId(actor); - const claimFiles = await this.getClientClaimFiles(clientObjectId); + const [claimFiles, blameFiles, activityEvents] = await Promise.all([ + this.getClientClaimFiles(clientObjectId), + this.getClientBlameFiles(clientObjectId), + this.expertFileActivityDbService.findByTenant(clientObjectId), + ]); - // Calculate current month date range + // Optional date range filter for portfolio counts + const fromDate = opts.from ? new Date(opts.from) : undefined; + const toDate = opts.to ? new Date(opts.to) : undefined; + const inRange = (date: unknown) => { + if (!fromDate && !toDate) return true; + if (!date) return false; + const d = new Date(date as string); + if (fromDate && d < fromDate) return false; + if (toDate && d > toDate) return false; + return true; + }; + const rangedClaimFiles = claimFiles.filter((f) => inRange(f.createdAt)); + + // Current calendar month const now = new Date(); const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); - const monthEnd = new Date( - now.getFullYear(), - now.getMonth() + 1, - 0, - 23, - 59, - 59, - ); - - // Filter files created this month - const filesThisMonth = claimFiles.filter((file) => { - const createdAt = new Date(file.createdAt); - return createdAt >= monthStart && createdAt <= monthEnd; + const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59); + const filesThisMonth = claimFiles.filter((f) => { + const d = new Date(f.createdAt); + return d >= monthStart && d <= monthEnd; }); - // Calculate statistics + // totalFilesReviewed: distinct claim files with at least one CHECKED activity + const checkedFileIds = new Set(); + for (const ev of activityEvents) { + if (ev.eventType === ExpertFileActivityType.CHECKED) { + checkedFileIds.add(String(ev.fileId)); + } + } + // Intersect with this insurer's claim file ids + const claimFileIds = new Set(claimFiles.map((f) => String(f._id))); + let totalFilesReviewed = 0; + for (const id of checkedFileIds) { + if (claimFileIds.has(id)) totalFilesReviewed++; + } + + // inPersonAccompaniedCount: blame files where expertInitiated === true AND creationMethod === IN_PERSON + const inPersonAccompaniedCount = blameFiles.filter( + (b) => !!(b as any).expertInitiated && (b as any).creationMethod === "IN_PERSON", + ).length; + + // Per-file statistics over the (optionally date-ranged) claim portfolio let totalInsurerRatings = 0; let totalBotRatings = 0; let filesWithInsurerRating = 0; @@ -1805,103 +1878,88 @@ export class ExpertInsurerService { let filesWithUserRating = 0; let filesWithObjection = 0; - for (const file of claimFiles) { + // averageUserRatingPercentage: mean of (progressSpeed + registrationEase + overallEvaluation) / 5 * 100 + let userRatingDimensionSum = 0; + let userRatingDimensionCount = 0; + + for (const file of rangedClaimFiles) { const insurerRating = file?.rating; const userRating = file?.userRating; - const objection = file?.objection; - // Check for insurer rating (excluding botRating) if (insurerRating) { const insurerValues = [ insurerRating.collisionMethodAccuracy, insurerRating.evaluationTimeliness, insurerRating.accidentCauseAccuracy, insurerRating.guiltyVehicleIdentification, - ].filter( - (val): val is number => typeof val === "number" && !isNaN(val), - ); - + ].filter((v): v is number => typeof v === "number" && !isNaN(v)); if (insurerValues.length > 0) { filesWithInsurerRating++; - const insurerAvg = - insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length; - totalInsurerRatings += insurerAvg; + totalInsurerRatings += insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length; + } + const botRating = (insurerRating as any)?.botRating; + if (typeof botRating === "number" && !isNaN(botRating)) { + filesWithBotRating++; + totalBotRatings += botRating; } } - // Check for bot rating (if botRating field exists in rating object) - const botRating = (insurerRating as any)?.botRating; - if ( - botRating !== undefined && - !isNaN(botRating) && - typeof botRating === "number" - ) { - filesWithBotRating++; - totalBotRatings += botRating; - } - - // Check for user rating if (userRating) { filesWithUserRating++; + const dims = [ + userRating.progressSpeed, + userRating.registrationEase, + userRating.overallEvaluation, + ].filter((v): v is number => typeof v === "number" && !isNaN(v)); + if (dims.length > 0) { + userRatingDimensionSum += dims.reduce((a, b) => a + b, 0) / dims.length; + userRatingDimensionCount++; + } } - // Check for objection - if (objection) { - filesWithObjection++; - } + if (file?.objection) filesWithObjection++; } - // Calculate percentages - const totalFiles = claimFiles.length; + const totalFiles = rangedClaimFiles.length; + const averageInsurerRating = filesWithInsurerRating > 0 ? totalInsurerRatings / filesWithInsurerRating : 0; + const averageBotRating = filesWithBotRating > 0 ? totalBotRatings / filesWithBotRating : 0; - // Calculate average insurer rating (excluding botRating) and average bot rating - const averageInsurerRating = - filesWithInsurerRating > 0 - ? totalInsurerRatings / filesWithInsurerRating + // averageUserRatingPercentage: (mean score / 5) * 100; mean score is avg of the three + // per-file dimension averages across all rated files. + const averageUserRatingPercentage = + userRatingDimensionCount > 0 + ? parseFloat(((userRatingDimensionSum / userRatingDimensionCount / 5) * 100).toFixed(2)) : 0; - const averageBotRating = - filesWithBotRating > 0 ? totalBotRatings / filesWithBotRating : 0; - // Calculate percentage: (averageInsurerRating / averageBotRating) * 100 - // This shows what percentage the insurer rating is compared to bot rating const insurerToBotPercentage = averageBotRating > 0 && averageInsurerRating > 0 - ? parseFloat( - ((averageInsurerRating / averageBotRating) * 100).toFixed(2), - ) - : 0; - - const userRatingPercentage = - totalFiles > 0 - ? parseFloat(((filesWithUserRating / totalFiles) * 100).toFixed(2)) - : 0; - - const objectionPercentage = - totalFiles > 0 - ? parseFloat(((filesWithObjection / totalFiles) * 100).toFixed(2)) + ? parseFloat(((averageInsurerRating / averageBotRating) * 100).toFixed(2)) : 0; return { - insurerToBotRatingPercentage: insurerToBotPercentage, - userRatingPercentage: userRatingPercentage, - objectionPercentage: objectionPercentage, + // New / corrected KPI fields + totalFilesReviewed, + averageUserRatingPercentage, + inPersonAccompaniedCount, + // Unchanged filesCreatedThisMonth: filesThisMonth.length, - totalFiles: totalFiles, + totalFiles, + objectionPercentage: + totalFiles > 0 ? parseFloat(((filesWithObjection / totalFiles) * 100).toFixed(2)) : 0, + insurerToBotRatingPercentage: insurerToBotPercentage, + // Deprecated: percentage of files that HAVE a user rating; not a satisfaction metric + filesWithUserRatingPercentage: + totalFiles > 0 ? parseFloat(((filesWithUserRating / totalFiles) * 100).toFixed(2)) : 0, + /** @deprecated use filesWithUserRatingPercentage */ + userRatingPercentage: + totalFiles > 0 ? parseFloat(((filesWithUserRating / totalFiles) * 100).toFixed(2)) : 0, breakdown: { filesWithInsurerRating, filesWithBotRating, filesWithUserRating, filesWithObjection, - averageInsurerRating: - filesWithInsurerRating > 0 - ? parseFloat( - (totalInsurerRatings / filesWithInsurerRating).toFixed(2), - ) - : 0, - averageBotRating: - filesWithBotRating > 0 - ? parseFloat((totalBotRatings / filesWithBotRating).toFixed(2)) - : 0, + averageInsurerRating: parseFloat(averageInsurerRating.toFixed(2)), + averageBotRating: parseFloat(averageBotRating.toFixed(2)), }, }; } @@ -2090,6 +2148,20 @@ export class ExpertInsurerService { }; } + /** + * Maps a raw history `actorType` string to either `"user"` or `"expert"`. + * - `"user"` → the action was taken by the car-owner / damaged party + * - `"expert"` → taken by any expert, system, or back-office actor + * - `null` → actorType was absent (unknown) + */ + private static resolveTimelinePerformedBy( + actorType: string | undefined, + ): "user" | "expert" | null { + if (!actorType) return null; + if (actorType === "user") return "user"; + return "expert"; + } + async getFileTimeline( insurerId: string, publicId: string, @@ -2129,6 +2201,7 @@ export class ExpertInsurerService { faLabel: getEventFaLabel(ev), timestamp: ev.timestamp, actor: ev.actor ?? null, + performedBy: ExpertInsurerService.resolveTimelinePerformedBy(ev.actor?.actorType), metadata: ev.metadata ?? null, }); } @@ -2148,6 +2221,7 @@ export class ExpertInsurerService { faLabel: getEventFaLabel(ev), timestamp: ev.timestamp, actor: ev.actor ?? null, + performedBy: ExpertInsurerService.resolveTimelinePerformedBy(ev.actor?.actorType), metadata: ev.metadata ?? null, }); } diff --git a/src/helpers/claim-capture-phase.ts b/src/helpers/claim-capture-phase.ts index 43e5046..89f92fc 100644 --- a/src/helpers/claim-capture-phase.ts +++ b/src/helpers/claim-capture-phase.ts @@ -22,6 +22,12 @@ export const OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5 = [ "guilty_metal_plate", ] as const; +/** + * Extra capture-phase document key present only in V4/V5 (FileMaker) flows. + * Uploaded via `upload-document` after all car angles are captured. + */ +export const GUILTY_DAMAGE_AREA_DOC_KEY = "guilty_damage_area" as const; + export type CapturePhaseSequence = | "parts" | "angles" @@ -58,7 +64,12 @@ function isRequiredDocumentUploadedOnClaim( export function getClaimCaptureProgress( claimCase: any, - options?: { assumeCapturePhaseDocKey?: string; skipMetalPlate?: boolean }, + options?: { + assumeCapturePhaseDocKey?: string; + skipMetalPlate?: boolean; + /** V4/V5 only — include `guilty_damage_area` in capture-phase doc count. */ + includeGuiltyDamageArea?: boolean; + }, ): ClaimCaptureProgress { const carType = claimCase?.vehicle?.carType as ClaimVehicleTypeV2 | undefined; const selectedNorm = normalizeDamageSelectedParts( @@ -87,13 +98,15 @@ export function getClaimCaptureProgress( const anglesTotal = CLAIM_CAR_ANGLE_KEYS.length; const anglesComplete = anglesCaptured >= anglesTotal; - const capturePhaseDocsRemaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.filter( - (k) => { - if (options?.skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(k as any)) return false; - if (k === options?.assumeCapturePhaseDocKey) return false; - return !isRequiredDocumentUploadedOnClaim(claimCase, k); - }, - ).length; + const capturePhaseKeys: string[] = [ + ...CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS, + ...(options?.includeGuiltyDamageArea ? [GUILTY_DAMAGE_AREA_DOC_KEY] : []), + ]; + const capturePhaseDocsRemaining = capturePhaseKeys.filter((k) => { + if (options?.skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(k as any)) return false; + if (k === options?.assumeCapturePhaseDocKey) return false; + return !isRequiredDocumentUploadedOnClaim(claimCase, k); + }).length; const capturePhaseDocsComplete = capturePhaseDocsRemaining === 0; let sequencePhase: CapturePhaseSequence = "parts"; diff --git a/src/reports/reports.module.ts b/src/reports/reports.module.ts index ddcb007..f5840b2 100644 --- a/src/reports/reports.module.ts +++ b/src/reports/reports.module.ts @@ -13,5 +13,6 @@ import { ReportsService } from "./reports.service"; ], controllers: [ReportsController], providers: [ReportsService], + exports: [ReportsService], }) export class ReportsModule {} diff --git a/src/reports/reports.service.ts b/src/reports/reports.service.ts index 55a9e90..4d5e230 100644 --- a/src/reports/reports.service.ts +++ b/src/reports/reports.service.ts @@ -436,19 +436,47 @@ export class ReportsService { }); } - async getInsurerExpertWorkLog(actor: { - clientKey?: string; - }): Promise { + async getInsurerExpertWorkLog( + actor: { clientKey?: string }, + opts: { + expertKind?: "all" | "expert" | "damage_expert"; + from?: string; + to?: string; + page?: number; + limit?: number; + } = {}, + ): Promise<{ experts: InsurerExpertWorkLogEntryDtoRs[]; total: number }> { const clientKey = requireActorClientKey(actor); const { events, expertRows } = await this.loadTenantExpertsAndActivities(clientKey); + + // expertKind filter + const filteredRows = + !opts.expertKind || opts.expertKind === "all" + ? expertRows + : expertRows.filter((r) => r.kind === opts.expertKind); + const now = new Date(); - const epoch = new Date(0); - const entries = this.buildWorkLogEntries(expertRows, events, now, { - from: epoch, - to: now, + + // When from/to supplied, distinctFilesCheckedInPeriod is restricted to that window; + // totalHandled and currentlyChecking are snapshot-at-'to' (or now when to is absent). + const { fromDate, toDate } = this.parseDateRange(opts.from, opts.to); + const cutoff = toDate ?? now; + const checkedFrom = fromDate ?? new Date(0); + const checkedTo = toDate ?? now; + + const entries = this.buildWorkLogEntries(filteredRows, events, cutoff, { + from: checkedFrom, + to: checkedTo, }); - return new InsurerExpertWorkLogResponseDtoRs(entries); + + // Optional pagination + const page = Number(opts.page) > 0 ? Number(opts.page) : 1; + const limit = Number(opts.limit) > 0 ? Number(opts.limit) : entries.length; + const start = (page - 1) * limit; + const paged = entries.slice(start, start + limit); + + return { experts: paged, total: entries.length }; } async getInsurerExpertWorkLogPerMonth(actor: { diff --git a/src/request-management/call-center-blame-v6.controller.ts b/src/request-management/call-center-blame-v6.controller.ts index 4659623..72e0f5e 100644 --- a/src/request-management/call-center-blame-v6.controller.ts +++ b/src/request-management/call-center-blame-v6.controller.ts @@ -20,7 +20,10 @@ import { Roles } from "src/decorators/roles.decorator"; import { CurrentUser } from "src/decorators/user.decorator"; import { RoleEnum } from "src/Types&Enums/role.enum"; import { RequestManagementService } from "./request-management.service"; -import { RunCallCenterInquiryV6Dto } from "./dto/run-call-center-inquiry-v6.dto"; +import { + RunCallCenterInquiryV6Dto, + RunCallCenterInquiryVinV6Dto, +} from "./dto/run-call-center-inquiry-v6.dto"; /** * V6 call-center blame API. @@ -105,6 +108,27 @@ export class CallCenterBlameV6Controller { return this.requestManagementService.runCallCenterInquiryV6(agent, requestId, dto); } + @Post("run-inquiry-vin/:requestId") + @ApiOperation({ + summary: "[V6] Run VIN/chassis inquiry for the guilty party", + description: + "VIN alternative to `run-inquiry`. " + + "The agent supplies the chassis number and personal data collected from the caller. " + + "ESG chassis lookup (`policyByChassis`) is executed and the result is stored on the " + + "blame document under `vehicle.vin` (plateId is left empty). " + + "Identical eligibility guards and insurer-company validation as the plate variant. " + + "After this call, proceed to `send-link` exactly as in the plate flow.", + }) + @ApiParam({ name: "requestId", description: "Blame request ID from `create`" }) + @ApiBody({ type: RunCallCenterInquiryVinV6Dto }) + runInquiryVin( + @CurrentUser() agent: any, + @Param("requestId") requestId: string, + @Body() dto: RunCallCenterInquiryVinV6Dto, + ) { + return this.requestManagementService.runCallCenterInquiryVinV6(agent, requestId, dto); + } + @Post("send-link/:requestId") @ApiOperation({ summary: "[V6] Send blame link to the guilty party via SMS", diff --git a/src/request-management/dto/create-request-management.dto.ts b/src/request-management/dto/create-request-management.dto.ts index 06f72a3..ea37777 100644 --- a/src/request-management/dto/create-request-management.dto.ts +++ b/src/request-management/dto/create-request-management.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { IsNotEmpty, IsString, MaxLength } from "class-validator"; +import { IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator"; import { Types } from "mongoose"; import { AddPlateDto } from "src/profile/dto/user/AddPlateDto"; import { StepsEnum } from "src/Types&Enums/blame-request-management/steps.enum"; @@ -87,6 +87,37 @@ export class DescriptionDto { lightCondition?: LightCondition; } +/** + * V4 FileMaker description step (THIRD_PARTY and CAR_BODY alike). + * Accident date and time are required for all file types in V4 so the + * front-end can display them consistently regardless of blame type. + */ +export class DescriptionV4Dto { + @ApiProperty({ description: "Accident description" }) + @IsString() + @IsNotEmpty() + desc: string; + + @ApiPropertyOptional({ + description: + "Date of the accident. Required for the first (guilty) party; " + + "optional for the second (damaged) party.", + example: "2025-12-08", + }) + @IsOptional() + accidentDate?: Date; + + @ApiPropertyOptional({ + description: + "Time of the accident. Required for the first (guilty) party; " + + "optional for the second (damaged) party.", + example: "14:30", + }) + @IsOptional() + @IsString() + accidentTime?: string; +} + /** * THIRD_PARTY description step: only desc is accepted. */ diff --git a/src/request-management/dto/run-call-center-inquiry-v6.dto.ts b/src/request-management/dto/run-call-center-inquiry-v6.dto.ts index 7e505c5..a201467 100644 --- a/src/request-management/dto/run-call-center-inquiry-v6.dto.ts +++ b/src/request-management/dto/run-call-center-inquiry-v6.dto.ts @@ -4,6 +4,7 @@ import { IsNotEmpty, IsOptional, IsString, + MaxLength, ValidateNested, } from "class-validator"; import { Type } from "class-transformer"; @@ -83,3 +84,60 @@ export class RunCallCenterInquiryV6Dto { @IsString() insurerLicense?: string; } + +/** + * VIN / chassis variant of the V6 call-center inquiry. + * Identical to `RunCallCenterInquiryV6Dto` but replaces `plate` with `vin`. + * Sheba (IBAN) is intentionally absent — the user provides it themselves via the link. + */ +export class RunCallCenterInquiryVinV6Dto { + @ApiProperty({ + example: "NAAM01E15HK123456", + description: "17-character VIN / chassis number (شماره شاسی)", + maxLength: 17, + }) + @IsString() + @IsNotEmpty() + @MaxLength(17) + vin: string; + + @ApiProperty({ example: "1234567890", description: "National code of the policyholder (insurer)" }) + @IsString() + @IsNotEmpty() + nationalCodeOfInsurer: string; + + @ApiProperty({ example: "1234567890", description: "National code of the driver" }) + @IsString() + @IsNotEmpty() + nationalCodeOfDriver: string; + + @ApiProperty({ example: true, description: "Whether the driver is the same person as the insurer" }) + @IsBoolean() + driverIsInsurer: boolean; + + @ApiProperty({ example: 13780624, description: "Insurer birth date (Jalali)" }) + insurerBirthday: number | string; + + @ApiPropertyOptional({ + example: 13780624, + description: "Driver birth date (Jalali). Required when driverIsInsurer is false.", + }) + @IsOptional() + driverBirthday?: number | string | null; + + @ApiPropertyOptional({ + example: "123456789", + description: "Driver license (required when driverIsInsurer is false).", + }) + @IsOptional() + @IsString() + driverLicense?: string; + + @ApiPropertyOptional({ + example: "123456789", + description: "Insurer license (required when driverIsInsurer is true).", + }) + @IsOptional() + @IsString() + insurerLicense?: string; +} diff --git a/src/request-management/dto/run-inquiries-v3.dto.ts b/src/request-management/dto/run-inquiries-v3.dto.ts index b165b37..5d9b436 100644 --- a/src/request-management/dto/run-inquiries-v3.dto.ts +++ b/src/request-management/dto/run-inquiries-v3.dto.ts @@ -89,6 +89,16 @@ export class RunInquiriesV3Dto { @IsOptional() @IsString() insurerLicense?: string; + + @ApiPropertyOptional({ + example: "1", + description: + "Driving licence type code from GET /lookups/driving-licence-types. " + + "Optional — used in V4 FileMaker flow.", + }) + @IsOptional() + @IsString() + licenseType?: string; } /** @@ -152,4 +162,14 @@ export class RunInquiriesVinV3Dto { @IsOptional() @IsString() insurerLicense?: string; + + @ApiPropertyOptional({ + example: "1", + description: + "Driving licence type code from GET /lookups/driving-licence-types. " + + "Optional — used in V4 FileMaker flow.", + }) + @IsOptional() + @IsString() + licenseType?: string; } diff --git a/src/request-management/entities/schema/partyRole.enum.ts b/src/request-management/entities/schema/partyRole.enum.ts index 2b94764..82563f0 100644 --- a/src/request-management/entities/schema/partyRole.enum.ts +++ b/src/request-management/entities/schema/partyRole.enum.ts @@ -23,6 +23,8 @@ export class Person { @Prop() nationalCodeOfDriver?: string; @Prop() insurerLicense?: string; @Prop() driverLicense?: string; + /** Driving licence type code from the Fanavaran lookup (GET /lookups/driving-licence-types). */ + @Prop() licenseType?: string; @Prop({ type: Boolean }) driverIsInsurer?: boolean; @@ -51,6 +53,8 @@ export const PersonSchema = SchemaFactory.createForClass(Person); @Schema({ _id: false }) export class Vehicle { @Prop() plateId?: string; + /** VIN / chassis number — populated only when the inquiry was performed via VIN lookup. */ + @Prop() vin?: string; @Prop() name?: string; @Prop() model?: string; @Prop() type?: string; diff --git a/src/request-management/file-maker-blame-v4.controller.ts b/src/request-management/file-maker-blame-v4.controller.ts index e3a7ba3..cf96baa 100644 --- a/src/request-management/file-maker-blame-v4.controller.ts +++ b/src/request-management/file-maker-blame-v4.controller.ts @@ -33,7 +33,7 @@ import { SendPartyOtpDto, VerifyPartyOtpDto } from "./dto/party-otp.dto"; import { RunInquiriesV3Dto, RunInquiriesVinV3Dto } from "./dto/run-inquiries-v3.dto"; import { CarBodyFormDto, - DescriptionDto, + DescriptionV4Dto, LocationDto, } from "./dto/create-request-management.dto"; import { RequestManagementService } from "./request-management.service"; @@ -260,15 +260,20 @@ export class FileMakerBlameV4Controller { @Post("add-detail-description/:requestId") @ApiParam({ name: "requestId" }) - @ApiBody({ type: DescriptionDto }) - @ApiOperation({ summary: "Add description for current party" }) + @ApiBody({ type: DescriptionV4Dto }) + @ApiOperation({ + summary: "Add description for current party (V4)", + description: + "Saves description, accidentDate, and accidentTime on the party statement. " + + "accidentDate and accidentTime are required for all V4 files (THIRD_PARTY and CAR_BODY alike).", + }) async addDescription( @Param("requestId") requestId: string, - @Body() body: DescriptionDto, + @Body() body: DescriptionV4Dto, @CurrentUser() fileMaker: any, @Body("partyRole") partyRole?: string, ) { - return this.requestManagementService.addPartyDescriptionV3( + return this.requestManagementService.addPartyDescriptionV4( requestId, fileMaker, body, diff --git a/src/request-management/inquiry-refresh.service.ts b/src/request-management/inquiry-refresh.service.ts index e2c1df2..a6e73bb 100644 --- a/src/request-management/inquiry-refresh.service.ts +++ b/src/request-management/inquiry-refresh.service.ts @@ -501,6 +501,9 @@ export class InquiryRefreshService { if (memParty.vehicle?.plateId !== undefined) { party.vehicle.plateId = memParty.vehicle.plateId; } + if (memParty.vehicle?.vin !== undefined) { + party.vehicle.vin = memParty.vehicle.vin; + } blameDoc.markModified(`parties.${docIdx}.vehicle`); if (memParty.insurance) { diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 1e8e2be..f86865e 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -57,6 +57,7 @@ import { AutoCloseRequestService } from "src/utils/cron/cron.service"; import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service"; import { DescriptionDto, + DescriptionV4Dto, LocationDto, RequestManagementDtoRs, } from "./dto/create-request-management.dto"; @@ -109,6 +110,7 @@ import { collectUserIdVariants, } from "src/helpers/party-access-queries"; import { resolveLinkedUserIdStrings } from "src/helpers/user-access-resolver"; +import { normalizePlateText } from "src/utils/plate-normalizer/plate-normalizer.service"; /** * Formats a compact Jalali date (number or string like 13780624) as YYYY/MM/DD. @@ -133,6 +135,34 @@ export class RequestManagementService { throw new BadRequestException(`Step ${stepKey} is not a party-scoped step`); } + /** + * Reverse map: Fanavaran/Tejarat numeric letter code → Persian plate letter. + * Tejarat inquiry stores Plk2 as a numeric code (e.g. 12 → "م", 5 → "د"). + * ESG VIN inquiry via parsePlkString already returns the actual letter string, + * but the mock and some Tejarat responses use the numeric code form. + */ + private static readonly PLATE_LETTER_CODE_TO_LETTER: Record = { + 1: "الف", 2: "ب", 3: "پ", 4: "ج", 5: "د", + 6: "س", 7: "ص", 8: "ط", 9: "ع", 10: "ق", + 11: "ل", 12: "م", 13: "ن", 14: "و", 15: "ه", + 16: "ی", 17: "ک", 18: "ژ", 19: "ت", 20: "ث", + 21: "ز", 22: "ش", 23: "ف", 24: "گ", + }; + + /** + * Resolve a plate center letter from whatever shape Plk2 arrives in: + * - already a Persian string → returned as-is (after normalization) + * - a numeric code (number or numeric string) → looked up in the reverse map + */ + private resolvePlk2Letter(plk2: any): string { + if (plk2 == null) return ""; + const code = Number(plk2); + if (Number.isFinite(code) && code > 0) { + return RequestManagementService.PLATE_LETTER_CODE_TO_LETTER[code] ?? ""; + } + return normalizePlateText(String(plk2).trim()); + } + /** Convert plate (string or { ir, leftDigits, centerAlphabet, centerDigits }) to string for vehicle.plateId */ private plateToPlateIdString(plate: any): string { if (plate == null) return ""; @@ -1841,8 +1871,18 @@ export class RequestManagementService { if (!party.vehicle) party.vehicle = {} as any; party.vehicle.isNew = body.isNewCar; - // Store VIN as the vehicle identifier instead of plate - party.vehicle.plateId = body.vin; + // Derive plateId from the plate parts returned by the VIN inquiry. + // Plk2 may be a numeric letter code (e.g. 5 → "د") or already a Persian + // letter string — resolvePlk2Letter handles both forms. + // Falls back to the submitted VIN so plateId is never empty. + party.vehicle.plateId = this.plateToPlateIdString({ + ir: inquiryMapped?.PlkSrl, + leftDigits: inquiryMapped?.Plk1, + centerAlphabet: this.resolvePlk2Letter( + inquiryMapped?.Plk2 ?? inquiryMapped?.plateLetterid, + ), + centerDigits: inquiryMapped?.Plk3, + }) || body.vin; party.vehicle.name = inquiryMapped?.MapTypNam; party.vehicle.type = `${inquiryMapped?.UsageField} / ${inquiryMapped?.MapUsageName || "-"}`; party.vehicle.inquiry = { @@ -8689,6 +8729,8 @@ export class RequestManagementService { party.person.insurerLicense = partyData.insurerLicense; if (partyData.driverLicense) party.person.driverLicense = partyData.driverLicense; + if ((partyData as any).licenseType) + (party.person as any).licenseType = (partyData as any).licenseType; if (!party.vehicle) party.vehicle = {} as any; party.vehicle.plateId = this.plateToPlateIdString(partyData.plate); @@ -9501,9 +9543,22 @@ export class RequestManagementService { party.person.insurerLicense = partyData.insurerLicense; if (partyData.driverLicense) party.person.driverLicense = partyData.driverLicense; + if ((partyData as any).licenseType) + (party.person as any).licenseType = (partyData as any).licenseType; if (!party.vehicle) party.vehicle = {} as any; - party.vehicle.plateId = partyData.vin; // store VIN as vehicle identifier + // Derive plateId from the plate parts returned by the VIN inquiry. + // Plk2 may be a numeric letter code (e.g. 5 → "د") or already a Persian + // letter string — resolvePlk2Letter handles both forms. + // Falls back to the submitted VIN so plateId is never empty. + party.vehicle.plateId = this.plateToPlateIdString({ + ir: inquiryMapped?.PlkSrl, + leftDigits: inquiryMapped?.Plk1, + centerAlphabet: this.resolvePlk2Letter( + inquiryMapped?.Plk2 ?? inquiryMapped?.plateLetterid, + ), + centerDigits: inquiryMapped?.Plk3, + }) || partyData.vin; party.vehicle.name = inquiryMapped?.MapTypNam; party.vehicle.type = `${inquiryMapped?.UsageField ?? ""} / ${inquiryMapped?.UsageName ?? inquiryMapped?.MapUsageName ?? "-"}`; party.vehicle.inquiry = { @@ -9994,7 +10049,9 @@ export class RequestManagementService { } if ( claim.workflow?.currentStep !== - ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS + ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS && + claim.workflow?.currentStep !== + ClaimWorkflowStep.USER_SUBMISSION_COMPLETE ) { throw new BadRequestException( "Complete capture-part (parts, angles, and capture-phase documents) before the blame accident video.", @@ -10260,6 +10317,70 @@ export class RequestManagementService { return { requestId: req._id, publicId: req.publicId, partyRole: role }; } + /** + * V4 description step. + * + * Identical to {@link addPartyDescriptionV3} but additionally requires + * `accidentDate` and `accidentTime` for all blame types (THIRD_PARTY and + * CAR_BODY alike) and persists them on `party.statement`. This lets the + * front-end display the accident time consistently for every V4 file without + * branching on blame type. + */ + async addPartyDescriptionV4( + requestId: string, + actor: any, + body: DescriptionV4Dto, + partyRole?: string, + ) { + const req = await this.blameRequestDbService.findById(requestId); + if (!req) throw new NotFoundException("Request not found"); + await this.verifyExpertAccessForBlameV2(req, actor); + const role = this.resolvePartyRoleV3(req, partyRole); + this.assertBlameV3PartyDetailPhase(req, role); + + // accidentDate and accidentTime are only required for the first (guilty) + // party — the second (damaged) party can skip them. + if (role === PartyRole.FIRST) { + if (body.accidentDate == null || body.accidentDate === ("" as any)) { + throw new BadRequestException( + "accidentDate is required for the first party in V4 files.", + ); + } + if (!body.accidentTime || !String(body.accidentTime).trim()) { + throw new BadRequestException( + "accidentTime is required for the first party in V4 files.", + ); + } + } + + const idx = this.getPartyIndex(req, role); + if (idx === -1) throw new BadRequestException(`${role} party not found`); + + const party = req.parties[idx]; + if (!party.statement) party.statement = {} as any; + party.statement.description = body.desc; + if (body.accidentDate != null) party.statement.accidentDate = body.accidentDate as any; + if (body.accidentTime) party.statement.accidentTime = body.accidentTime; + + if (!Array.isArray(req.history)) req.history = []; + req.history.push({ + type: "V4_PARTY_DESCRIPTION_SAVED", + actor: { + actorId: new Types.ObjectId(actor.sub), + actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(), + actorType: "field_expert", + }, + metadata: { + partyRole: role, + accidentDate: body.accidentDate, + accidentTime: body.accidentTime, + }, + } as any); + + await (req as any).save(); + return { requestId: req._id, publicId: req.publicId, partyRole: role }; + } + async carBodyAccidentTypeFormV3( requestId: string, body: CarBodyFormDto, @@ -10883,6 +11004,97 @@ export class RequestManagementService { }; } + /** + * V6 VIN variant: Run VIN/chassis inquiry for the guilty party on behalf of the caller. + * Identical to `runCallCenterInquiryV6` but calls `runPartyInquiriesVinV3Internal` + * (ESG chassis lookup) instead of the plate-based path. + * Returns `vehicle.vin` in the guiltyParty payload instead of `vehicle.plateId`. + */ + async runCallCenterInquiryVinV6( + agent: any, + requestId: string, + dto: Omit, + ): Promise> { + if (agent?.role !== RoleEnum.CALL_CENTER) { + throw new ForbiddenException("Only call-center agents can use this endpoint."); + } + const req = await this.blameRequestDbService.findById(requestId); + if (!req) throw new NotFoundException("Blame request not found"); + if (!req.callCenterInitiated || String(req.initiatedByCallCenterId) !== String(agent.sub)) { + throw new ForbiddenException("You can only access files that you have initiated."); + } + + const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); + if (firstIdx === -1) throw new BadRequestException("First party not found"); + const firstParty = req.parties[firstIdx]; + + await this.runPartyInquiriesVinV3Internal(req, dto as RunInquiriesVinV3Dto, PartyRole.FIRST, firstParty); + + // Same insurer-company guard as the plate variant. + const resolvedClientId = req.parties[firstIdx]?.person?.clientId; + if (resolvedClientId && process.env.CLIENT_ID) { + const resolvedClient = await this.clientService.findOne({ + _id: new Types.ObjectId(String(resolvedClientId)), + }); + if ( + resolvedClient && + String(resolvedClient.clientCode) !== String(process.env.CLIENT_ID) + ) { + throw new ForbiddenException( + "بیمه‌نامه طرف مقصر متعلق به شرکت بیمه این سامانه نیست. لینک تقصیر فقط برای بیمه‌گذاران همین شرکت قابل ارسال است.", + ); + } + } + + if (!Array.isArray((req as any).history)) (req as any).history = []; + (req as any).history.push({ + type: "CALL_CENTER_INQUIRY_COMPLETED", + actor: { + actorId: new Types.ObjectId(agent.sub), + actorName: `${agent.firstName || ""} ${agent.lastName || ""}`.trim(), + actorType: RoleEnum.CALL_CENTER, + }, + metadata: { partyRole: PartyRole.FIRST, inquiryType: "VIN" }, + }); + await (req as any).save(); + + const firstPartyAfter = req.parties[firstIdx]; + return { + blameRequestId: requestId, + publicId: (req as any).publicId, + type: (req as any).type, + status: (req as any).status, + message: "استعلام VIN طرف مقصر با موفقیت انجام شد. اکنون می‌توانید لینک تقصیر را برای کاربر ارسال کنید.", + guiltyParty: { + vehicle: firstPartyAfter?.vehicle + ? { + vin: firstPartyAfter.vehicle.vin, + name: firstPartyAfter.vehicle.name, + type: firstPartyAfter.vehicle.type, + } + : undefined, + insurance: firstPartyAfter?.insurance + ? { + company: firstPartyAfter.insurance.company, + policyNumber: firstPartyAfter.insurance.policyNumber, + startDate: firstPartyAfter.insurance.startDate, + endDate: firstPartyAfter.insurance.endDate, + financialCeiling: (firstPartyAfter.insurance as any).financialCeiling, + } + : undefined, + person: firstPartyAfter?.person + ? { + nationalCodeOfInsurer: firstPartyAfter.person.nationalCodeOfInsurer, + nationalCodeOfDriver: firstPartyAfter.person.nationalCodeOfDriver, + driverIsInsurer: firstPartyAfter.person.driverIsInsurer, + insurerBirthday: formatJalaliCompact(firstPartyAfter.person.insurerBirthday), + driverBirthday: formatJalaliCompact(firstPartyAfter.person.driverBirthday), + } + : undefined, + }, + }; + } + /** * V6: Send the blame link to the guilty party's phone number. * Registers/looks up the user by phone and stores them as the first party, then @@ -11050,6 +11262,7 @@ export class RequestManagementService { clientId: p.person?.clientId ? String(p.person.clientId) : undefined, + licenseType: p.person?.licenseType ?? undefined, }, insurance: p.insurance ? { @@ -11063,6 +11276,7 @@ export class RequestManagementService { vehicle: p.vehicle ? { plateId: p.vehicle.plateId, + vin: p.vehicle.vin, name: p.vehicle.name, type: p.vehicle.type, } @@ -11144,6 +11358,7 @@ export class RequestManagementService { driverIsInsurer: p.person?.driverIsInsurer, userId: p.person?.userId ? String(p.person.userId) : undefined, clientId: p.person?.clientId ? String(p.person.clientId) : undefined, + licenseType: p.person?.licenseType ?? undefined, }, insurance: p.insurance ? { company: p.insurance.company, @@ -11250,6 +11465,7 @@ export class RequestManagementService { driverIsInsurer: p.person?.driverIsInsurer, userId: p.person?.userId ? String(p.person.userId) : undefined, clientId: p.person?.clientId ? String(p.person.clientId) : undefined, + licenseType: p.person?.licenseType ?? undefined, }, insurance: p.insurance ? { company: p.insurance.company, diff --git a/src/sand-hub/sand-hub.service.ts b/src/sand-hub/sand-hub.service.ts index 677d030..6f90fb2 100644 --- a/src/sand-hub/sand-hub.service.ts +++ b/src/sand-hub/sand-hub.service.ts @@ -1027,12 +1027,59 @@ export class SandHubService { /** * Maps the new SandHub API response format to the old format expected by the application */ + /** + * Parse the `plk` string returned by the ESG VIN inquiry into the four + * numeric/text plate parts used everywhere in the system. + * + * ESG format example: `"782د29 60"` + * → Plk3=782 (center digits) Plk2="د" (center letter) + * Plk1=29 (left digits) PlkSrl=60 (IR region code) + * + * Returns null if the string does not match the expected pattern. + */ + private parsePlkString( + plk: string, + ): { Plk1: number; Plk2: string; Plk3: number; PlkSrl: number } | null { + if (!plk || typeof plk !== "string") return null; + // Pattern: {centerDigits}{centerLetter(s)}{leftDigits}{ir} + const m = plk.trim().match(/^(\d+)([^\d\s]+)(\d+)\s+(\d+)$/); + if (!m) return null; + const Plk3 = parseInt(m[1], 10); // center digits + const Plk2 = this.plateNormalizer.normalizePlateText(m[2]); + const Plk1 = parseInt(m[3], 10); // left digits + const PlkSrl = parseInt(m[4], 10); // IR region code + if (!Number.isFinite(Plk3) || !Number.isFinite(Plk1) || !Number.isFinite(PlkSrl) || !Plk2) { + return null; + } + return { Plk1, Plk2, Plk3, PlkSrl }; + } + private mapNewApiResponseToOldFormat(newResponse: any): any { if (!newResponse) return newResponse; + // If the response carries a `plk` plate string (VIN inquiry) but lacks the + // individual Plk1/Plk2/Plk3/PlkSrl fields, parse and inject them so that + // all downstream plate-handling code works identically to the plate flow. + let plkParts: { Plk1: number; Plk2: string; Plk3: number; PlkSrl: number } | null = null; + if ( + newResponse.plk && + newResponse.Plk1 == null && + newResponse.Plk3 == null && + newResponse.PlkSrl == null + ) { + plkParts = this.parsePlkString(String(newResponse.plk)); + } + // Map the new field names to the old field names return { ...newResponse, + ...(plkParts ? { + Plk1: plkParts.Plk1, + Plk2: plkParts.Plk2, + Plk3: plkParts.Plk3, + PlkSrl: plkParts.PlkSrl, + plateLetterid: plkParts.Plk2, + } : {}), // Company information CompanyCode: newResponse.companyId || newResponse.CompanyCode, CompanyName: newResponse.companyPersianName || newResponse.CompanyName,