diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index e931595..c22a9c1 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -309,6 +309,44 @@ export class ClaimRequestManagementService { return true; } + /** V3 initial document step — excludes capture-phase vehicle evidence. */ + private v3PreCaptureDocumentKeys(isCarBody: boolean): string[] { + return this.requiredDocumentKeysV2(isCarBody).filter( + (k) => !isCapturePhaseDamagedPartyDocKey(k), + ); + } + + private allV3PreCaptureDocumentsComplete( + claimCase: any, + isCarBody: boolean, + assumeUploadedKey?: string, + ): boolean { + for (const k of this.v3PreCaptureDocumentKeys(isCarBody)) { + const ok = + k === assumeUploadedKey + ? true + : this.isRequiredDocumentUploadedOnClaim(claimCase, k); + if (!ok) return false; + } + return true; + } + + private countRemainingV3PreCaptureDocuments( + claimCase: any, + isCarBody: boolean, + assumeUploadedKey?: string, + ): number { + let n = 0; + for (const k of this.v3PreCaptureDocumentKeys(isCarBody)) { + const ok = + k === assumeUploadedKey + ? true + : this.isRequiredDocumentUploadedOnClaim(claimCase, k); + if (!ok) n++; + } + return n; + } + private countRemainingV2OwnerDocuments( claimCase: any, isCarBody: boolean, @@ -5985,6 +6023,7 @@ export class ClaimRequestManagementService { file: Express.Multer.File, currentUserId: string, actor?: { sub: string; role?: string }, + options?: { v3InPersonFlow?: boolean }, ): Promise { try { const claimCase = await this.claimCaseDbService.findById(claimRequestId); @@ -6042,6 +6081,21 @@ export class ClaimRequestManagementService { `Invalid workflow step. Expected ${ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS}, ${ClaimWorkflowStep.CAPTURE_PART_DAMAGES} (capture-phase documents only), or ${ClaimWorkflowStep.USER_EXPERT_RESEND}, but current step is ${claimCase.workflow?.currentStep}`, ); } + if (options?.v3InPersonFlow) { + if (body.documentKey === ClaimRequiredDocumentType.CAR_GREEN_CARD) { + throw new BadRequestException( + "Car green card must be uploaded during other-parts selection, not in the documents step.", + ); + } + if ( + step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS && + isCapturePhaseDamagedPartyDocKey(body.documentKey) + ) { + throw new BadRequestException( + `Chassis, engine, and metal plate photos must be uploaded during capture (after parts and angles), not in the initial documents step (${CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.join(", ")}).`, + ); + } + } } if (isResendUpload) { @@ -6160,8 +6214,9 @@ export class ClaimRequestManagementService { $set["status"] = ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS; $set["workflow.currentStep"] = ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS; - $set["workflow.nextStep"] = - ClaimWorkflowStep.USER_SUBMISSION_COMPLETE; + $set["workflow.nextStep"] = options?.v3InPersonFlow + ? ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS + : ClaimWorkflowStep.USER_SUBMISSION_COMPLETE; completedStepsEntries.push( ClaimWorkflowStep.CAPTURE_PART_DAMAGES, @@ -6183,30 +6238,46 @@ export class ClaimRequestManagementService { } } } else { - allDocumentsUploaded = this.allV2OwnerDocumentsComplete( - claimCase, - isCarBodyUpload, - body.documentKey, - ); - remaining = this.countRemainingV2OwnerDocuments( - claimCase, - isCarBodyUpload, - body.documentKey, - ); + const v3InitialDocsComplete = + !!options?.v3InPersonFlow && + step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS && + this.allV3PreCaptureDocumentsComplete( + claimCase, + isCarBodyUpload, + body.documentKey, + ); + + allDocumentsUploaded = options?.v3InPersonFlow + ? v3InitialDocsComplete + : this.allV2OwnerDocumentsComplete( + claimCase, + isCarBodyUpload, + body.documentKey, + ); + remaining = options?.v3InPersonFlow + ? this.countRemainingV3PreCaptureDocuments( + claimCase, + isCarBodyUpload, + body.documentKey, + ) + : this.countRemainingV2OwnerDocuments( + claimCase, + isCarBodyUpload, + body.documentKey, + ); if (allDocumentsUploaded) { - $set["status"] = ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT; - $set["claimStatus"] = ClaimStatus.PENDING; - $set["workflow.currentStep"] = - ClaimWorkflowStep.USER_SUBMISSION_COMPLETE; - $set["workflow.nextStep"] = - ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT; + if (options?.v3InPersonFlow) { + $set["status"] = ClaimCaseStatus.SELECTING_OUTER_PARTS; + $set["workflow.currentStep"] = + ClaimWorkflowStep.SELECT_OUTER_PARTS; + $set["workflow.nextStep"] = + ClaimWorkflowStep.SELECT_OTHER_PARTS; - completedStepsEntries.push( - ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, - ); - historyEntries.push( - { + completedStepsEntries.push( + ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, + ); + historyEntries.push({ type: "STEP_COMPLETED", actor: { actorId: new Types.ObjectId(currentUserId), @@ -6216,24 +6287,52 @@ export class ClaimRequestManagementService { timestamp: new Date(), metadata: { stepKey: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, - description: "All required documents uploaded", - }, - }, - { - type: "STEP_COMPLETED", - actor: { - actorId: new Types.ObjectId(currentUserId), - actorName: claimCase.owner?.fullName || "User", - actorType: "user", - }, - timestamp: new Date(), - metadata: { - stepKey: ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, description: - "User submission complete. Claim ready for damage expert review.", + "Initial required documents uploaded. Proceed to outer part selection.", + v3InPersonFlow: true, }, - }, - ); + }); + } else { + $set["status"] = ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT; + $set["claimStatus"] = ClaimStatus.PENDING; + $set["workflow.currentStep"] = + ClaimWorkflowStep.USER_SUBMISSION_COMPLETE; + $set["workflow.nextStep"] = + ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT; + + completedStepsEntries.push( + ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, + ); + historyEntries.push( + { + type: "STEP_COMPLETED", + actor: { + actorId: new Types.ObjectId(currentUserId), + actorName: claimCase.owner?.fullName || "User", + actorType: "user", + }, + timestamp: new Date(), + metadata: { + stepKey: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, + description: "All required documents uploaded", + }, + }, + { + type: "STEP_COMPLETED", + actor: { + actorId: new Types.ObjectId(currentUserId), + actorName: claimCase.owner?.fullName || "User", + actorType: "user", + }, + timestamp: new Date(), + metadata: { + stepKey: ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, + description: + "User submission complete. Claim ready for damage expert review.", + }, + }, + ); + } } } } @@ -6295,7 +6394,9 @@ export class ClaimRequestManagementService { }).sequencePhase, ) : allDocumentsUploaded - ? "All documents uploaded successfully. Your claim is now ready for damage expert review." + ? options?.v3InPersonFlow + ? "Initial required documents uploaded. Proceed to outer part selection." + : "All documents uploaded successfully. Your claim is now ready for damage expert review." : `Document uploaded successfully. ${remaining} documents remaining.`; return { @@ -6308,7 +6409,9 @@ export class ClaimRequestManagementService { ? ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS : ClaimWorkflowStep.CAPTURE_PART_DAMAGES : allDocumentsUploaded - ? ClaimWorkflowStep.USER_SUBMISSION_COMPLETE + ? options?.v3InPersonFlow + ? ClaimWorkflowStep.SELECT_OUTER_PARTS + : ClaimWorkflowStep.USER_SUBMISSION_COMPLETE : ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, message, }; @@ -7852,6 +7955,11 @@ export class ClaimRequestManagementService { } } + private v3PartsFlowNotStarted(claimCase: any): boolean { + const parts = claimCase.damage?.selectedParts; + return !Array.isArray(parts) || parts.length === 0; + } + private async advanceV3ClaimToOuterPartsIfReady( claimCase: any, blame: any, @@ -7859,9 +7967,18 @@ export class ClaimRequestManagementService { if (claimCase.workflow?.currentStep === ClaimWorkflowStep.SELECT_OUTER_PARTS) { return; } - if (claimCase.workflow?.currentStep !== ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS) { + + const step = claimCase.workflow?.currentStep; + const partsNotStarted = this.v3PartsFlowNotStarted(claimCase); + const prematurelyCompleted = + step === ClaimWorkflowStep.USER_SUBMISSION_COMPLETE && partsNotStarted; + const canAdvance = + step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS || + prematurelyCompleted; + + if (!canAdvance) { throw new BadRequestException( - `Complete required documents before selecting outer parts. Current step: ${claimCase.workflow?.currentStep}`, + `Complete required documents before selecting outer parts. Current step: ${step}`, ); } if (!(blame.expert?.decision as any)?.fields?.accidentWay) { @@ -7869,9 +7986,11 @@ export class ClaimRequestManagementService { "Submit accident fields before selecting outer parts.", ); } + await this.claimCaseDbService.findByIdAndUpdate(String(claimCase._id), { $set: { status: ClaimCaseStatus.SELECTING_OUTER_PARTS, + claimStatus: ClaimStatus.PENDING, "workflow.currentStep": ClaimWorkflowStep.SELECT_OUTER_PARTS, "workflow.nextStep": ClaimWorkflowStep.SELECT_OTHER_PARTS, }, @@ -7906,22 +8025,13 @@ export class ClaimRequestManagementService { const blame = await this.assertV3InPersonClaim(claimCase); this.assertV3ClaimDocumentsPhase(blame); - const step = claimCase.workflow?.currentStep; - if (step !== ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS) { - await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { - $set: { - status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS, - "workflow.currentStep": ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, - }, - }); - } - return this.uploadRequiredDocumentV2( claimRequestId, body, file, currentUserId, actor, + { v3InPersonFlow: true }, ); } diff --git a/src/common/dto/list-query-v2.dto.ts b/src/common/dto/list-query-v2.dto.ts index 0b97123..2e33072 100644 --- a/src/common/dto/list-query-v2.dto.ts +++ b/src/common/dto/list-query-v2.dto.ts @@ -9,6 +9,7 @@ import { MaxLength, Min, } from "class-validator"; +import { UNIFIED_FILE_STATUS_KEYS } from "src/helpers/unified-file-status"; /** Allowed sort keys for V2 list endpoints (claims / blame). */ export const LIST_SORT_BY_V2 = [ @@ -75,4 +76,13 @@ export class ListQueryV2Dto { @IsString() @MaxLength(64) search?: string; + + @ApiPropertyOptional({ + enum: UNIFIED_FILE_STATUS_KEYS, + description: + "Filter by calculated unified file status (blame + claim combined).", + }) + @IsOptional() + @IsIn([...UNIFIED_FILE_STATUS_KEYS]) + unifiedStatus?: (typeof UNIFIED_FILE_STATUS_KEYS)[number]; } diff --git a/src/common/dto/unified-file-status-report.dto.ts b/src/common/dto/unified-file-status-report.dto.ts new file mode 100644 index 0000000..3fee153 --- /dev/null +++ b/src/common/dto/unified-file-status-report.dto.ts @@ -0,0 +1,36 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { + UNIFIED_FILE_STATUS_KEYS, + UnifiedFileStatusDefinition, + UnifiedFileStatusKey, +} from "src/helpers/unified-file-status"; + +export class UnifiedFileStatusDefinitionDto implements UnifiedFileStatusDefinition { + @ApiProperty({ enum: UNIFIED_FILE_STATUS_KEYS }) + key: UnifiedFileStatusKey; + + @ApiProperty() + labelEn: string; + + @ApiProperty() + labelFa: string; + + @ApiProperty() + description: string; +} + +export class UnifiedFileStatusReportDto { + @ApiProperty({ type: [UnifiedFileStatusDefinitionDto] }) + statuses: UnifiedFileStatusDefinitionDto[]; + + @ApiProperty({ + description: "Counts per unified status key plus `all`", + example: { + all: 42, + IN_PROGRESS: 10, + WAITING_FOR_DAMAGE_EXPERT: 5, + COMPLETED: 12, + }, + }) + counts: Record; +} diff --git a/src/expert-blame/dto/all-request.dto.ts b/src/expert-blame/dto/all-request.dto.ts index 0d045a4..0664bc3 100644 --- a/src/expert-blame/dto/all-request.dto.ts +++ b/src/expert-blame/dto/all-request.dto.ts @@ -117,6 +117,8 @@ export class AllRequestDtoV2 { lockTime: string | null; type: string; blameStatus: string; + /** Calculated blame + linked claim lifecycle status */ + unifiedFileStatus?: string; partiesInitialForms: { firstParty: string; secondParty: string }; partiesVehicles: { firstPartyVehicle: string; secondPartyVehicle: string }; } diff --git a/src/expert-blame/expert-blame.module.ts b/src/expert-blame/expert-blame.module.ts index 55c1d5a..0471094 100644 --- a/src/expert-blame/expert-blame.module.ts +++ b/src/expert-blame/expert-blame.module.ts @@ -7,6 +7,7 @@ import { ExpertBlameV2Controller } from "./expert-blame.v2.controller"; import { ExpertBlameService } from "./expert-blame.service"; import { RequestManagementModule } from "src/request-management/request-management.module"; import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module"; +import { ClaimRequestManagementModule } from "src/claim-request-management/claim-request-management.module"; @Module({ imports: [ @@ -15,6 +16,7 @@ import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration. RequestManagementModule, PlatesModule, SmsOrchestrationModule, + ClaimRequestManagementModule, ], controllers: [ExpertBlameController, ExpertBlameV2Controller], providers: [ExpertBlameService], diff --git a/src/expert-blame/expert-blame.service.ts b/src/expert-blame/expert-blame.service.ts index ed3927e..19d18e4 100644 --- a/src/expert-blame/expert-blame.service.ts +++ b/src/expert-blame/expert-blame.service.ts @@ -23,7 +23,13 @@ import { expertPortfolioFileIdsFromActivityEvents, objectIdsFromStringSet, } from "src/helpers/expert-portfolio"; +import { + countUnifiedFileStatuses, + getUnifiedFileStatusCatalog, + resolveUnifiedFileStatus, +} from "src/helpers/unified-file-status"; import { applyListQueryV2 } from "src/helpers/list-query-v2"; +import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service"; import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; import { ExpertFileAssignResultDto, @@ -115,8 +121,109 @@ export class ExpertBlameService { private readonly requestManagementService: RequestManagementService, private readonly smsOrchestrationService: SmsOrchestrationService, private readonly expertFileActivityDbService: ExpertFileActivityDbService, + private readonly claimCaseDbService: ClaimCaseDbService, ) {} + private async loadClaimsByPublicIds( + publicIds: string[], + ): Promise> { + const unique = [...new Set(publicIds.filter(Boolean))]; + if (unique.length === 0) return new Map(); + const claims = (await this.claimCaseDbService.find( + { publicId: { $in: unique } }, + { lean: true, select: "publicId status" }, + )) as Array<{ publicId?: string; status?: string }>; + return new Map( + claims.map((c) => [String(c.publicId), { status: c.status }]), + ); + } + + private filterBlameDocsByUnifiedStatus( + docs: Record[], + claimByPublicId: Map, + unifiedStatus?: string, + ): Record[] { + if (!unifiedStatus) return docs; + return docs.filter((doc) => { + const publicId = String((doc as { publicId?: string }).publicId ?? ""); + const claim = claimByPublicId.get(publicId); + return ( + resolveUnifiedFileStatus({ + blameStatus: String((doc as { status?: string }).status ?? ""), + claimStatus: claim?.status, + }) === unifiedStatus + ); + }); + } + + async getUnifiedFileStatusReportV2(actor: any) { + const rows = await this.collectBlamePortfolioDocs(actor); + const claimByPublicId = await this.loadClaimsByPublicIds( + rows.map((d) => String((d as { publicId?: string }).publicId ?? "")), + ); + return { + statuses: getUnifiedFileStatusCatalog(), + counts: countUnifiedFileStatuses( + rows.map((doc) => { + const publicId = String((doc as { publicId?: string }).publicId ?? ""); + const claim = claimByPublicId.get(publicId); + return { + blameStatus: String((doc as { status?: string }).status ?? ""), + claimStatus: claim?.status, + }; + }), + ), + }; + } + + private async collectBlamePortfolioDocs( + actor: any, + ): Promise[]> { + if (actor.role === RoleEnum.FIELD_EXPERT) { + return (await this.blameRequestDbService.find( + { + expertInitiated: true, + initiatedByFieldExpertId: new Types.ObjectId(actor.sub), + }, + { lean: true }, + )) as Record[]; + } + + requireActorClientKey(actor); + const expertId = String(actor.sub); + const expertOid = new Types.ObjectId(expertId); + const activityEvents = await this.expertFileActivityDbService.findByExpert( + expertId, + ExpertFileKind.BLAME, + ); + const activityFileIds = + expertPortfolioFileIdsFromActivityEvents(activityEvents); + const orClauses: Record[] = [ + { initiatedByFieldExpertId: expertOid }, + { "expert.decision.decidedByExpertId": expertOid }, + { "expert.resend.requestedByExpertId": expertOid }, + { "workflow.lockedBy.actorId": expertOid }, + ]; + const activityOids = objectIdsFromStringSet(activityFileIds); + if (activityOids.length > 0) { + orClauses.push({ _id: { $in: activityOids } }); + } + const rows = (await this.blameRequestDbService.find( + { blameStatus: BlameStatus.DISAGREEMENT, $or: orClauses }, + { lean: true }, + )) as Record[]; + const seen = new Set(); + const out: Record[] = []; + for (const doc of rows) { + const id = String(doc._id ?? ""); + if (seen.has(id)) continue; + if (!blameDocInExpertPortfolio(doc, actor, activityFileIds)) continue; + seen.add(id); + out.push(doc); + } + return out; + } + /** Load immutable profile fields from `expert` for the acting field expert. */ private async snapshotFieldExpert(actorId: string) { const expertDoc = await this.expertDbService.findOne({ @@ -386,44 +493,8 @@ export class ExpertBlameService { } } - const paged = applyListQueryV2( - visibleCases, - { - publicId: (doc) => - String((doc as { publicId?: string }).publicId ?? ""), - createdAt: (doc) => (doc as { createdAt?: Date }).createdAt, - requestNo: (doc) => - String( - (doc as { requestNo?: string }).requestNo ?? - (doc as { publicId?: string }).publicId ?? - "", - ), - status: (doc) => String((doc as { status?: string }).status ?? ""), - searchExtras: (doc) => { - const d = doc as { - _id?: unknown; - blameStatus?: string; - type?: string; - }; - return [String(d._id ?? ""), d.blameStatus, d.type].filter( - Boolean, - ) as string[]; - }, - }, - query, - ); - - const items: AllRequestDtoV2[] = paged.list.map((doc) => - this.mapBlameRequestToListItemV2(doc), - ); - - return new AllRequestDtoRsV2({ - data: items, - total: paged.total, - page: paged.page, - limit: paged.limit, - totalPages: paged.totalPages, - }); + const pagedResult = await this.paginateBlameListV2(visibleCases, query); + return pagedResult; } catch (error) { if (error instanceof HttpException) throw error; this.logger.error( @@ -480,8 +551,27 @@ export class ExpertBlameService { } } - const paged = applyListQueryV2( + const pagedResult = await this.paginateBlameListV2(visibleCases, query); + return pagedResult; + } + + private async paginateBlameListV2( + visibleCases: Record[], + query: ListQueryV2Dto, + ): Promise { + const claimByPublicId = await this.loadClaimsByPublicIds( + visibleCases.map((d) => + String((d as { publicId?: string }).publicId ?? ""), + ), + ); + const filtered = this.filterBlameDocsByUnifiedStatus( visibleCases, + claimByPublicId, + query.unifiedStatus, + ); + + const paged = applyListQueryV2( + filtered, { publicId: (doc) => String((doc as { publicId?: string }).publicId ?? ""), @@ -492,23 +582,42 @@ export class ExpertBlameService { (doc as { publicId?: string }).publicId ?? "", ), - status: (doc) => String((doc as { status?: string }).status ?? ""), + status: (doc) => { + const publicId = String((doc as { publicId?: string }).publicId ?? ""); + const claim = claimByPublicId.get(publicId); + return resolveUnifiedFileStatus({ + blameStatus: String((doc as { status?: string }).status ?? ""), + claimStatus: claim?.status, + }); + }, searchExtras: (doc) => { const d = doc as { _id?: unknown; blameStatus?: string; type?: string; + publicId?: string; + status?: string; }; - return [String(d._id ?? ""), d.blameStatus, d.type].filter( - Boolean, - ) as string[]; + const publicId = String(d.publicId ?? ""); + const claim = claimByPublicId.get(publicId); + const unified = resolveUnifiedFileStatus({ + blameStatus: String(d.status ?? ""), + claimStatus: claim?.status, + }); + return [ + String(d._id ?? ""), + d.blameStatus, + d.type, + unified, + claim?.status, + ].filter(Boolean) as string[]; }, }, query, ); const items: AllRequestDtoV2[] = paged.list.map((doc) => - this.mapBlameRequestToListItemV2(doc), + this.mapBlameRequestToListItemV2(doc, claimByPublicId), ); return new AllRequestDtoRsV2({ @@ -522,6 +631,7 @@ export class ExpertBlameService { private mapBlameRequestToListItemV2( doc: Record, + claimByPublicId?: Map, ): AllRequestDtoV2 { const createdAt = doc.createdAt ? new Date(doc.createdAt as string) @@ -552,10 +662,17 @@ export class ExpertBlameService { const firstParty = parties.find((p) => p.role === "FIRST"); const secondParty = parties.find((p) => p.role === "SECOND"); + const publicId = String(doc.publicId ?? ""); + const linkedClaim = claimByPublicId?.get(publicId); + const unifiedFileStatus = resolveUnifiedFileStatus({ + blameStatus: String(doc.status ?? ""), + claimStatus: linkedClaim?.status, + }); return { requestId: String(doc._id), status: String(doc.status ?? ""), + unifiedFileStatus, userComment: null, requestCode: String(doc.publicId ?? ""), date, diff --git a/src/expert-blame/expert-blame.v2.controller.ts b/src/expert-blame/expert-blame.v2.controller.ts index 7680d87..a7c4b96 100644 --- a/src/expert-blame/expert-blame.v2.controller.ts +++ b/src/expert-blame/expert-blame.v2.controller.ts @@ -44,7 +44,7 @@ export class ExpertBlameV2Controller { description: "Damage experts (`expert`): tenant-scoped **DISAGREEMENT** queue (available, locked, or decided by you). " + "Field experts (`field_expert`): all expert-initiated blame files they created (LINK + IN_PERSON). Use expert-claim for claims after blame completion. " + - "Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`.", + "Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`, `unifiedStatus` (calculated blame+claim status).", }) async findAll( @CurrentUser() actor: any, @@ -60,11 +60,31 @@ export class ExpertBlameV2Controller { } } + @Get("report/unified-file-statuses") + @ApiOperation({ + summary: "Unified file status catalog + counts (blame + linked claim)", + description: + "Calculatable statuses for this expert's blame portfolio. Filter lists with `unifiedStatus` query param.", + }) + async getUnifiedFileStatusReportV2(@CurrentUser() actor: any) { + try { + return await this.expertBlameService.getUnifiedFileStatusReportV2(actor); + } catch (error) { + if (error instanceof HttpException) throw error; + throw new InternalServerErrorException( + error instanceof Error + ? error.message + : "Failed to load unified file status report", + ); + } + } + @Get("report/status-counts") @ApiOperation({ summary: "Count blame cases by grouped status bucket (this field expert)", + deprecated: true, description: - "Counts only files in the acting expert’s portfolio: expertFileActivities (checked or handled), workflow lock, expert decision, or expert-initiated blame. IN_PROGRESS groups OPEN and WAITING_FOR_SECOND_PARTY. Other keys match CaseStatus. Does not include the open WAITING_FOR_EXPERT queue unless this expert has touched the file.", + "Legacy blame-only buckets. Prefer GET report/unified-file-statuses for blame+claim calculatable statuses.", }) async getStatusReportBucketsV2(@CurrentUser() actor: any) { try { diff --git a/src/expert-claim/dto/claim-list-v2.dto.ts b/src/expert-claim/dto/claim-list-v2.dto.ts index 88e7885..8cdb453 100644 --- a/src/expert-claim/dto/claim-list-v2.dto.ts +++ b/src/expert-claim/dto/claim-list-v2.dto.ts @@ -15,6 +15,12 @@ export class ClaimListItemV2Dto { }) status: string; + @ApiPropertyOptional({ + description: "Calculated unified blame+claim lifecycle status", + example: "WAITING_FOR_DAMAGE_EXPERT", + }) + unifiedFileStatus?: string; + @ApiProperty({ description: 'Workflow step', example: 'USER_SUBMISSION_COMPLETE' }) currentStep: string; diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index e849ac4..aab018a 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -142,6 +142,11 @@ import { import { getClaimV2TotalPaymentCapToman } from "src/constants/repair-amount-limits"; import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; import { applyListQueryV2 } from "src/helpers/list-query-v2"; +import { + countUnifiedFileStatuses, + getUnifiedFileStatusCatalog, + resolveUnifiedFileStatus, +} from "src/helpers/unified-file-status"; import { buildEnrichedDamagedParts } from "./dto/claim-damaged-part.enricher"; import { canonicalizeResendDocumentKey } from "src/helpers/claim-resend-document-keys"; @@ -3411,6 +3416,144 @@ export class ExpertClaimService { return buckets; } + async getUnifiedFileStatusReportV2(actor: any) { + const { claims, blameById } = + await this.collectClaimPortfolioWithBlame(actor); + return { + statuses: getUnifiedFileStatusCatalog(), + counts: countUnifiedFileStatuses( + claims.map((c) => { + const blame = c.blameRequestId + ? blameById.get(String(c.blameRequestId)) + : undefined; + return { + blameStatus: blame?.status, + claimStatus: c.status, + }; + }), + ), + }; + } + + private async collectClaimPortfolioWithBlame(actor: any): Promise<{ + claims: any[]; + blameById: Map; + }> { + let claims: any[] = []; + if (actor.role === RoleEnum.FIELD_EXPERT) { + const expertOid = new Types.ObjectId(actor.sub); + const expertBlameIds = await this.blameRequestDbService + .find( + { expertInitiated: true, initiatedByFieldExpertId: expertOid }, + { select: "_id", lean: true }, + ) + .then((docs) => docs.map((d) => (d as { _id: unknown })._id)); + const claimOr: Record[] = [ + { initiatedByFieldExpertId: expertOid }, + ]; + if (expertBlameIds.length > 0) { + claimOr.push({ blameRequestId: { $in: expertBlameIds } }); + } + claims = (await this.claimCaseDbService.find({ $or: claimOr })) as any[]; + } else { + const clientKey = requireActorClientKey(actor); + const expertId = String(actor.sub); + const expertOid = new Types.ObjectId(expertId); + const activityEvents = await this.expertFileActivityDbService.findByExpert( + expertId, + ExpertFileKind.CLAIM, + ); + const activityFileIds = + expertPortfolioFileIdsFromActivityEvents(activityEvents); + const orClauses: Record[] = [ + { "workflow.lockedBy.actorId": expertOid }, + { "evaluation.damageExpertReply.actorDetail.actorId": expertOid }, + { "evaluation.damageExpertReplyFinal.actorDetail.actorId": expertOid }, + { "evaluation.damageExpertResend.requestedByExpertId": expertOid }, + ]; + const activityOids = objectIdsFromStringSet(activityFileIds); + if (activityOids.length > 0) { + orClauses.push({ _id: { $in: activityOids } }); + } + const rows = + orClauses.length === 0 + ? [] + : await this.claimCaseDbService.find({ $or: orClauses }, { lean: true }); + const seen = new Set(); + for (const doc of rows as Record[]) { + const id = String(doc._id ?? ""); + if (seen.has(id)) continue; + if ( + !claimDocInExpertPortfolio(doc, expertId, activityFileIds, clientKey) + ) { + continue; + } + seen.add(id); + claims.push(doc); + } + } + + const blameIds = [ + ...new Set( + claims + .map((c) => c.blameRequestId?.toString()) + .filter((id): id is string => !!id), + ), + ]; + const blames = + blameIds.length > 0 + ? ((await this.blameRequestDbService.find( + { + _id: { $in: blameIds.map((id) => new Types.ObjectId(id)) }, + }, + { lean: true, select: "status publicId" }, + )) as any[]) + : []; + const blameById = new Map( + blames.map((b) => [String(b._id), b]), + ); + return { claims, blameById }; + } + + private paginateClaimListV2( + list: ClaimListItemV2Dto[], + query: ListQueryV2Dto, + ): GetClaimListV2ResponseDto { + let filtered = list; + if (query.unifiedStatus) { + filtered = list.filter( + (item) => item.unifiedFileStatus === query.unifiedStatus, + ); + } + const paged = applyListQueryV2( + filtered, + { + publicId: (r) => r.publicId, + createdAt: (r) => r.createdAt, + requestNo: (r) => r.publicId, + status: (r) => r.unifiedFileStatus ?? r.status, + searchExtras: (r) => + [ + r.claimRequestId, + r.currentStep, + r.vehicle?.carName, + r.vehicle?.carModel, + r.blameRequestType, + r.unifiedFileStatus, + r.status, + ].filter(Boolean) as string[], + }, + query, + ); + return { + list: paged.list, + total: paged.total, + page: paged.page, + limit: paged.limit, + totalPages: paged.totalPages, + }; + } + /** * V2: Get claim list for damage expert * @@ -3581,6 +3724,10 @@ export class ExpertClaimService { claimRequestId: c._id.toString(), publicId: c.publicId, status: statusForList, + unifiedFileStatus: resolveUnifiedFileStatus({ + blameStatus: blame?.status, + claimStatus: c.status, + }), currentStep: c.workflow?.currentStep || "", locked: lockActive, lockedBy: @@ -3601,32 +3748,7 @@ export class ExpertClaimService { }; }) as ClaimListItemV2Dto[]; - const paged = applyListQueryV2( - list, - { - publicId: (r) => r.publicId, - createdAt: (r) => r.createdAt, - requestNo: (r) => r.publicId, - status: (r) => r.status, - searchExtras: (r) => - [ - r.claimRequestId, - r.currentStep, - r.vehicle?.carName, - r.vehicle?.carModel, - r.blameRequestType, - ].filter(Boolean) as string[], - }, - query, - ); - - return { - list: paged.list, - total: paged.total, - page: paged.page, - limit: paged.limit, - totalPages: paged.totalPages, - }; + return this.paginateClaimListV2(list, query); } /** @@ -3687,6 +3809,10 @@ export class ExpertClaimService { claimRequestId: c._id.toString(), publicId: c.publicId, status: c.status, + unifiedFileStatus: resolveUnifiedFileStatus({ + blameStatus: blame?.status, + claimStatus: c.status, + }), currentStep: c.workflow?.currentStep || "", locked: lockActive, lockedBy: @@ -3707,31 +3833,7 @@ export class ExpertClaimService { }; }) as ClaimListItemV2Dto[]; - const paged = applyListQueryV2( - list, - { - publicId: (r) => r.publicId, - createdAt: (r) => r.createdAt, - requestNo: (r) => r.publicId, - status: (r) => r.status, - searchExtras: (r) => - [ - r.claimRequestId, - r.currentStep, - r.vehicle?.carName, - r.vehicle?.carModel, - ].filter(Boolean) as string[], - }, - query, - ); - - return { - list: paged.list, - total: paged.total, - page: paged.page, - limit: paged.limit, - totalPages: paged.totalPages, - }; + return this.paginateClaimListV2(list, query); } /** diff --git a/src/expert-claim/expert-claim.v2.controller.ts b/src/expert-claim/expert-claim.v2.controller.ts index 9bb33dd..22d812c 100644 --- a/src/expert-claim/expert-claim.v2.controller.ts +++ b/src/expert-claim/expert-claim.v2.controller.ts @@ -65,11 +65,22 @@ export class ExpertClaimV2Controller { private readonly claimRequestManagementService: ClaimRequestManagementService, ) {} + @Get("report/unified-file-statuses") + @ApiOperation({ + summary: "Unified file status catalog + counts (claim + linked blame)", + description: + "Calculatable statuses for this expert's claim portfolio. Filter lists with `unifiedStatus` query param.", + }) + async getUnifiedFileStatusReportV2(@CurrentUser() actor: any) { + return await this.expertClaimService.getUnifiedFileStatusReportV2(actor); + } + @Get("report/status-counts") @ApiOperation({ summary: "Count claim cases by grouped status bucket (this damage expert)", + deprecated: true, description: - "Counts only files in the acting expert’s portfolio: expertFileActivities (checked or handled), current workflow lock, or a prior damageExpertReply / damageExpertReplyFinal by this expert. IN_PROGRESS groups user-phase statuses before submission is complete (CREATED through CAPTURING_PART_DAMAGES). Other keys match ClaimCaseStatus. Does not include the open tenant queue unless this expert has touched the file.", + "Legacy claim-only buckets. Prefer GET report/unified-file-statuses for blame+claim calculatable statuses.", }) async getStatusReportBucketsV2(@CurrentUser() actor: any) { return await this.expertClaimService.getStatusReportBucketsV2(actor); @@ -116,7 +127,7 @@ export class ExpertClaimV2Controller { summary: "List available claim requests for damage expert", description: "Damage experts: tenant queue (`WAITING_FOR_DAMAGE_EXPERT`), locked/in-progress, factor validation. " + - "Field experts: all claims linked to blame files they initiated (`initiatedByFieldExpertId` or matching `blameRequestId`). Optional query: `search`, `sortBy`, `sortOrder`, `page`, `limit`.", + "Field experts: all claims linked to blame files they initiated (`initiatedByFieldExpertId` or matching `blameRequestId`). Optional query: `search`, `sortBy`, `sortOrder`, `page`, `limit`, `unifiedStatus`.", }) @ApiResponse({ status: 200, type: GetClaimListV2ResponseDto }) async getClaimListV2( diff --git a/src/expert-insurer/expert-insurer.controller.ts b/src/expert-insurer/expert-insurer.controller.ts index 7282b06..6fcbbba 100644 --- a/src/expert-insurer/expert-insurer.controller.ts +++ b/src/expert-insurer/expert-insurer.controller.ts @@ -26,6 +26,7 @@ import { CurrentUser } from "src/decorators/user.decorator"; import { FileRating } from "src/request-management/entities/schema/request-management.schema"; import { RoleEnum } from "src/Types&Enums/role.enum"; import { ExpertInsurerService } from "./expert-insurer.service"; +import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; import { CreateBranchDto } from "src/client/dto/create-branch.dto"; import { CreateBlameExpertByInsurerDto, @@ -162,9 +163,46 @@ export class ExpertInsurerController { } @Get("files") - async getAllFiles(@CurrentUser() insurer) { + @ApiOperation({ + summary: "List insurer files (blame + claim merged by publicId)", + description: + "Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`, `unifiedStatus`.", + }) + async getAllFiles( + @CurrentUser() insurer, + @Query() query: ListQueryV2Dto, + ) { return await this.expertInsurerService.retrieveAllFilesOfClient( insurer.clientKey, + query, + ); + } + + @Get("report/unified-file-statuses") + @ApiOperation({ + summary: "Unified file status catalog + counts (blame + claim)", + description: + "Returns calculatable unified statuses and per-status counts for this insurer's files. Use `unifiedStatus` on GET files to filter.", + }) + @ApiQuery({ + name: "from", + required: false, + description: "Optional start datetime (ISO string)", + }) + @ApiQuery({ + name: "to", + required: false, + description: "Optional end datetime (ISO string)", + }) + async getUnifiedFileStatusReport( + @CurrentUser() actor, + @Query("from") from?: string, + @Query("to") to?: string, + ) { + return await this.expertInsurerService.getInsurerUnifiedFileStatusReport( + actor, + from, + to, ); } @@ -265,6 +303,11 @@ export class ExpertInsurerController { } @Get("report/status-counts") + @ApiOperation({ + summary: "Legacy status counts (mapped from unified statuses)", + deprecated: true, + description: "Prefer GET report/unified-file-statuses for full calculatable blame+claim statuses.", + }) @ApiQuery({ name: "from", required: false, diff --git a/src/expert-insurer/expert-insurer.service.ts b/src/expert-insurer/expert-insurer.service.ts index fbeb66d..d35ff02 100644 --- a/src/expert-insurer/expert-insurer.service.ts +++ b/src/expert-insurer/expert-insurer.service.ts @@ -39,6 +39,13 @@ import { toJalaliDateAndTime } from "src/helpers/date-jalali"; import { enrichBlamePartiesForAgreementView } from "src/helpers/blame-party-agreement-decision"; import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; import { PartyRole } from "src/request-management/entities/schema/partyRole.enum"; +import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; +import { applyListQueryV2 } from "src/helpers/list-query-v2"; +import { + countUnifiedFileStatuses, + getUnifiedFileStatusCatalog, + resolveUnifiedFileStatus, +} from "src/helpers/unified-file-status"; import { getClaimCarAngleCaptureBlob, getDamagedPartCaptureBlob, @@ -1145,13 +1152,10 @@ export class ExpertInsurerService { }; } - async retrieveAllFilesOfClient(insurerId: string) { - const id = this.getClientId(insurerId); - const idStr = String(id); - + private async buildMergedInsurerFiles(clientObjectId: Types.ObjectId) { const [blameFiles, claimFiles] = await Promise.all([ - this.getClientBlameFiles(id), - this.getClientClaimFiles(id), + this.getClientBlameFiles(clientObjectId), + this.getClientClaimFiles(clientObjectId), ]); const byPublicId = new Map< @@ -1192,7 +1196,6 @@ export class ExpertInsurerService { } >(); - // Index blame files first for (const b of blameFiles) { const key = String((b as any).publicId || (b as any)._id || ""); if (!key) continue; @@ -1216,8 +1219,6 @@ export class ExpertInsurerService { byPublicId.set(key, prev); } - // For claim files whose blame wasn't in blameFiles (different insurer's blame), - // fetch the linked blame directly so we can get vehicle + parties data const claimOnlyBlameIds = claimFiles .filter((c) => { const key = String((c as any).publicId || (c as any)._id || ""); @@ -1243,13 +1244,11 @@ export class ExpertInsurerService { linkedBlames.map((b) => [String(b.publicId), b]), ); - // Index claim files for (const c of claimFiles) { const key = String((c as any).publicId || (c as any)._id || ""); if (!key) continue; const prev = byPublicId.get(key) ?? { publicId: key }; - // If no blame entry yet, use the linked blame for parties/vehicle/fileType if (!prev.blame) { const linkedBlame = linkedBlameByPublicId.get(key); if (linkedBlame) { @@ -1293,26 +1292,85 @@ export class ExpertInsurerService { byPublicId.set(key, prev); } - const files = Array.from(byPublicId.values()) - .map((f) => ({ - publicId: f.publicId, - fileType: f.fileType, - hasClaim: !!f.claim, - parties: f.parties, - blame: f.blame, - claim: f.claim, - createdAt: f.createdAt, - updatedAt: f.updatedAt, - })) - .sort((a, b) => { - const at = a.updatedAt ? new Date(a.updatedAt).getTime() : 0; - const bt = b.updatedAt ? new Date(b.updatedAt).getTime() : 0; - return bt - at; - }); + return Array.from(byPublicId.values()).map((f) => ({ + publicId: f.publicId, + fileType: f.fileType, + hasClaim: !!f.claim, + unifiedFileStatus: resolveUnifiedFileStatus({ + blameStatus: f.blame?.status, + claimStatus: f.claim?.status, + }), + parties: f.parties, + blame: f.blame, + claim: f.claim, + createdAt: f.createdAt, + updatedAt: f.updatedAt, + })); + } + + async retrieveAllFilesOfClient( + insurerId: string, + query: ListQueryV2Dto = {}, + ) { + const id = this.getClientId(insurerId); + let files = await this.buildMergedInsurerFiles(id); + + if (query.unifiedStatus) { + files = files.filter( + (f) => f.unifiedFileStatus === query.unifiedStatus, + ); + } + + const paged = applyListQueryV2( + files, + { + publicId: (f) => f.publicId, + createdAt: (f) => f.updatedAt ?? f.createdAt, + requestNo: (f) => f.publicId, + status: (f) => f.unifiedFileStatus, + searchExtras: (f) => + [ + f.fileType, + f.blame?.requestNo, + f.claim?.requestNo, + f.blame?.status, + f.claim?.status, + f.unifiedFileStatus, + ].filter(Boolean) as string[], + }, + query, + ); return { - total: files.length, - files, + total: paged.total, + page: paged.page, + limit: paged.limit, + totalPages: paged.totalPages, + files: paged.list, + }; + } + + async getInsurerUnifiedFileStatusReport( + actor: any, + from?: string, + to?: string, + ) { + const clientObjectId = this.getClientId(actor); + const { fromDate, toDate } = this.parseDateRange(from, to); + + const files = await this.buildMergedInsurerFiles(clientObjectId); + const inRange = files.filter((f) => + this.isInDateRange(f.createdAt, fromDate, toDate), + ); + + return { + statuses: getUnifiedFileStatusCatalog(), + counts: countUnifiedFileStatuses( + inRange.map((f) => ({ + blameStatus: f.blame?.status, + claimStatus: f.claim?.status, + })), + ), }; } @@ -1888,72 +1946,17 @@ export class ExpertInsurerService { under_review: number; waiting_for_documents_resend: number; }> { - const clientObjectId = this.getClientId(actor); - const { fromDate, toDate } = this.parseDateRange(from, to); - - const [blameFilesRaw, claimFilesRaw] = await Promise.all([ - this.getClientBlameFiles(clientObjectId), - this.getClientClaimFiles(clientObjectId), - ]); - - const blameFiles = blameFilesRaw.filter((f) => - this.isInDateRange((f as any)?.createdAt, fromDate, toDate), - ); - const claimFiles = claimFilesRaw.filter((f) => - this.isInDateRange((f as any)?.createdAt, fromDate, toDate), - ); - - const fileMap = new Map(); - - for (const b of blameFiles) { - const key = String((b as any).publicId || (b as any)._id || ""); - if (!key) continue; - const prev = fileMap.get(key) ?? {}; - prev.blame = b; - fileMap.set(key, prev); - } - for (const c of claimFiles) { - const key = String((c as any).publicId || (c as any)._id || ""); - if (!key) continue; - const prev = fileMap.get(key) ?? {}; - prev.claim = c; - fileMap.set(key, prev); - } - - let completed = 0; - let underReview = 0; - let waitingForDocumentsResend = 0; - - for (const entry of fileMap.values()) { - const blameStatus = entry.blame?.status; - const claimStatus = entry.claim?.status; - - if (claimStatus === ClaimCaseStatus.COMPLETED) { - completed += 1; - } - - const blameUnderReview = blameStatus === CaseStatus.WAITING_FOR_EXPERT; - const claimUnderReview = - claimStatus === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT || - claimStatus === ClaimCaseStatus.EXPERT_REVIEWING; - if (blameUnderReview || claimUnderReview) { - underReview += 1; - } - - const blameResend = - blameStatus === CaseStatus.WAITING_FOR_DOCUMENT_RESEND; - const claimResend = - claimStatus === ClaimCaseStatus.WAITING_FOR_USER_RESEND; - if (blameResend || claimResend) { - waitingForDocumentsResend += 1; - } - } - + const report = await this.getInsurerUnifiedFileStatusReport(actor, from, to); + const counts = report.counts; return { - all: fileMap.size, - completed, - under_review: underReview, - waiting_for_documents_resend: waitingForDocumentsResend, + all: counts.all ?? 0, + completed: counts.COMPLETED ?? 0, + under_review: + (counts.WAITING_FOR_BLAME_EXPERT ?? 0) + + (counts.WAITING_FOR_DAMAGE_EXPERT ?? 0) + + (counts.EXPERT_REVIEWING ?? 0) + + (counts.EXPERT_VALIDATING_FACTORS ?? 0), + waiting_for_documents_resend: counts.WAITING_FOR_DOCUMENT_RESEND ?? 0, }; } } diff --git a/src/helpers/unified-file-status.spec.ts b/src/helpers/unified-file-status.spec.ts new file mode 100644 index 0000000..0b54551 --- /dev/null +++ b/src/helpers/unified-file-status.spec.ts @@ -0,0 +1,46 @@ +import { + countUnifiedFileStatuses, + resolveUnifiedFileStatus, +} from "./unified-file-status"; +import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum"; +import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum"; + +describe("resolveUnifiedFileStatus", () => { + it("prefers claim completed over blame in progress", () => { + expect( + resolveUnifiedFileStatus({ + blameStatus: CaseStatus.WAITING_FOR_EXPERT, + claimStatus: ClaimCaseStatus.COMPLETED, + }), + ).toBe("COMPLETED"); + }); + + it("maps blame expert queue", () => { + expect( + resolveUnifiedFileStatus({ + blameStatus: CaseStatus.WAITING_FOR_EXPERT, + }), + ).toBe("WAITING_FOR_BLAME_EXPERT"); + }); + + it("maps claim damage expert queue", () => { + expect( + resolveUnifiedFileStatus({ + blameStatus: CaseStatus.COMPLETED, + claimStatus: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT, + }), + ).toBe("WAITING_FOR_DAMAGE_EXPERT"); + }); + + it("counts merged entries", () => { + const counts = countUnifiedFileStatuses([ + { blameStatus: CaseStatus.OPEN }, + { + blameStatus: CaseStatus.COMPLETED, + claimStatus: ClaimCaseStatus.CAPTURING_PART_DAMAGES, + }, + ]); + expect(counts.all).toBe(2); + expect(counts.IN_PROGRESS).toBe(2); + }); +}); diff --git a/src/helpers/unified-file-status.ts b/src/helpers/unified-file-status.ts new file mode 100644 index 0000000..06f154b --- /dev/null +++ b/src/helpers/unified-file-status.ts @@ -0,0 +1,221 @@ +import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum"; +import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum"; + +/** Calculated lifecycle status for a publicId (blame + claim combined). */ +export const UNIFIED_FILE_STATUS_KEYS = [ + "IN_PROGRESS", + "WAITING_FOR_SECOND_PARTY", + "WAITING_FOR_BLAME_EXPERT", + "WAITING_FOR_SIGNATURES", + "WAITING_FOR_DOCUMENT_RESEND", + "WAITING_FOR_DAMAGE_EXPERT", + "EXPERT_REVIEWING", + "EXPERT_VALIDATING_FACTORS", + "INSURER_REVIEW", + "COMPLETED", + "CANCELLED", + "REJECTED", + "STOPPED", +] as const; + +export type UnifiedFileStatusKey = (typeof UNIFIED_FILE_STATUS_KEYS)[number]; + +export type UnifiedFileStatusInput = { + blameStatus?: string | null; + claimStatus?: string | null; +}; + +const CLAIM_USER_PHASE = new Set([ + ClaimCaseStatus.CREATED, + ClaimCaseStatus.SELECTING_OUTER_PARTS, + ClaimCaseStatus.SELECTING_OTHER_PARTS, + ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS, + ClaimCaseStatus.CAPTURING_PART_DAMAGES, +]); + +const INSURER_REVIEW_CLAIM_STATUSES = new Set([ + ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL, + ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN, + ClaimCaseStatus.INSURER_REVIEW_MIXED_FACTORS_PENDING, + ClaimCaseStatus.OWNER_REPAIR_FACTOR_UPLOAD_PENDING, +]); + +export type UnifiedFileStatusDefinition = { + key: UnifiedFileStatusKey; + labelEn: string; + labelFa: string; + description: string; +}; + +export const UNIFIED_FILE_STATUS_CATALOG: UnifiedFileStatusDefinition[] = [ + { + key: "IN_PROGRESS", + labelEn: "In progress (user)", + labelFa: "در حال تکمیل توسط کاربر", + description: + "Parties are still filling blame steps and/or the damaged party is completing the claim (parts, documents, capture).", + }, + { + key: "WAITING_FOR_SECOND_PARTY", + labelEn: "Waiting for second party", + labelFa: "در انتظار طرف دوم", + description: "Blame file is waiting for the second party to join or complete steps.", + }, + { + key: "WAITING_FOR_BLAME_EXPERT", + labelEn: "Waiting for blame expert", + labelFa: "در انتظار کارشناس مقصر", + description: "Blame case is in the expert disagreement / guilt-decision queue.", + }, + { + key: "WAITING_FOR_SIGNATURES", + labelEn: "Waiting for signatures", + labelFa: "در انتظار امضا", + description: "Blame file is waiting for party signatures on the expert decision.", + }, + { + key: "WAITING_FOR_DOCUMENT_RESEND", + labelEn: "Waiting for document resend", + labelFa: "در انتظار ارسال مجدد مدارک", + description: + "Blame or claim is waiting for the user to resubmit documents or photos requested by an expert.", + }, + { + key: "WAITING_FOR_DAMAGE_EXPERT", + labelEn: "Waiting for damage expert", + labelFa: "در انتظار کارشناس خسارت", + description: "Claim is in the damage-expert review queue (not yet locked).", + }, + { + key: "EXPERT_REVIEWING", + labelEn: "Damage expert reviewing", + labelFa: "در حال بررسی کارشناس خسارت", + description: "A damage expert has locked the claim and is assessing damage.", + }, + { + key: "EXPERT_VALIDATING_FACTORS", + labelEn: "Validating repair factors", + labelFa: "اعتبارسنجی فاکتور تعمیر", + description: "Damage expert is validating uploaded repair-factor documents.", + }, + { + key: "INSURER_REVIEW", + labelEn: "Insurer / owner review", + labelFa: "بررسی بیمه‌گر / مالک", + description: + "Expert pricing is done; owner signature, factor upload, or insurer approval is pending.", + }, + { + key: "COMPLETED", + labelEn: "Completed", + labelFa: "تکمیل شده", + description: "Claim (and blame when present) is finished successfully.", + }, + { + key: "CANCELLED", + labelEn: "Cancelled", + labelFa: "لغو شده", + description: "File was cancelled or auto-closed.", + }, + { + key: "REJECTED", + labelEn: "Rejected", + labelFa: "رد شده", + description: "Claim was rejected.", + }, + { + key: "STOPPED", + labelEn: "Stopped", + labelFa: "متوقف شده", + description: "Blame file was stopped.", + }, +]; + +export function initialUnifiedFileStatusCounts(): Record< + UnifiedFileStatusKey | "all", + number +> { + const out: Record = { all: 0 }; + for (const key of UNIFIED_FILE_STATUS_KEYS) { + out[key] = 0; + } + return out as Record; +} + +/** + * Single calculatable status for a shared publicId from raw blame + claim statuses. + * Claim pipeline takes precedence once a claim exists and has left the user-only phase. + */ +export function resolveUnifiedFileStatus( + input: UnifiedFileStatusInput, +): UnifiedFileStatusKey { + const blame = input.blameStatus?.trim() || undefined; + const claim = input.claimStatus?.trim() || undefined; + + if (claim === ClaimCaseStatus.COMPLETED) return "COMPLETED"; + if (claim === ClaimCaseStatus.REJECTED) return "REJECTED"; + if ( + claim === ClaimCaseStatus.CANCELLED || + blame === CaseStatus.CANCELLED || + blame === CaseStatus.AUTO_CLOSED + ) { + return "CANCELLED"; + } + if (blame === CaseStatus.STOPPED) return "STOPPED"; + + if ( + blame === CaseStatus.WAITING_FOR_DOCUMENT_RESEND || + claim === ClaimCaseStatus.WAITING_FOR_USER_RESEND + ) { + return "WAITING_FOR_DOCUMENT_RESEND"; + } + + if (claim === ClaimCaseStatus.EXPERT_REVIEWING) return "EXPERT_REVIEWING"; + if (claim === ClaimCaseStatus.EXPERT_VALIDATING_REPAIR_FACTORS) { + return "EXPERT_VALIDATING_FACTORS"; + } + if (claim && INSURER_REVIEW_CLAIM_STATUSES.has(claim)) { + return "INSURER_REVIEW"; + } + if (claim === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT) { + return "WAITING_FOR_DAMAGE_EXPERT"; + } + + if (blame === CaseStatus.WAITING_FOR_EXPERT) return "WAITING_FOR_BLAME_EXPERT"; + if (blame === CaseStatus.WAITING_FOR_SIGNATURES) { + return "WAITING_FOR_SIGNATURES"; + } + if (blame === CaseStatus.WAITING_FOR_SECOND_PARTY) { + return "WAITING_FOR_SECOND_PARTY"; + } + + if (blame === CaseStatus.COMPLETED && !claim) return "COMPLETED"; + + if ( + blame === CaseStatus.OPEN || + (claim && CLAIM_USER_PHASE.has(claim)) || + (blame === CaseStatus.COMPLETED && claim && CLAIM_USER_PHASE.has(claim)) + ) { + return "IN_PROGRESS"; + } + + if (claim) return "IN_PROGRESS"; + if (blame) return "IN_PROGRESS"; + return "IN_PROGRESS"; +} + +export function countUnifiedFileStatuses( + entries: UnifiedFileStatusInput[], +): Record { + const buckets = initialUnifiedFileStatusCounts(); + for (const entry of entries) { + const key = resolveUnifiedFileStatus(entry); + buckets.all++; + buckets[key]++; + } + return buckets; +} + +export function getUnifiedFileStatusCatalog(): UnifiedFileStatusDefinition[] { + return UNIFIED_FILE_STATUS_CATALOG; +} diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 9f2ea9e..82d1103 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -8284,7 +8284,7 @@ export class RequestManagementService { inquiries: (req as any).inquiries ?? {}, workflow: { currentStep: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, - nextStep: ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, + nextStep: ClaimWorkflowStep.SELECT_OUTER_PARTS, completedSteps: [ClaimWorkflowStep.CLAIM_CREATED], locked: false, }, @@ -8881,6 +8881,33 @@ export class RequestManagementService { } await (req as any).save(); + await this.claimCaseDbService.findByIdAndUpdate(String(claim._id), { + $set: { + status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT, + claimStatus: ClaimStatus.PENDING, + "workflow.currentStep": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, + "workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, + }, + $push: { + "workflow.completedSteps": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, + history: { + type: "STEP_COMPLETED", + actor: { + actorId: new Types.ObjectId(expert.sub), + actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), + actorType: "field_expert", + }, + timestamp: new Date(), + metadata: { + stepKey: ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, + description: + "V3 field workflow complete. Claim ready for damage expert review.", + v3InPersonFlow: true, + }, + }, + }, + }); + return { requestId: String(req._id), publicId: req.publicId,