import { BadRequestException, Injectable } from "@nestjs/common"; import { Types } from "mongoose"; import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service"; import { blameCaseStatusToReportBucket, claimCaseStatusToReportBucket, initialBlameExpertReportBuckets, initialClaimExpertReportBuckets, } from "src/helpers/expert-panel-status-report"; import { blameCaseTouchesClient, claimCaseTouchesClient, requireActorClientKey, } from "src/helpers/tenant-scope"; import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service"; import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service"; import { ExpertDbService } from "src/users/entities/db-service/expert.db.service"; import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service"; import { ExpertFileActivityType } from "src/users/entities/schema/expert-file-activity.schema"; import { InsurerExpertWorkLogPerMonthRowDtoRs, InsurerExpertWorkLogResponseDtoRs, InsurerExpertWorkLogEntryDtoRs, InsurerPerMonthReportRowDtoRs, InsurerRequestsSummaryDtoRs, } from "./dto/reports.dto"; type NormalizedActivity = { expertId: string; fileId: string; eventType: ExpertFileActivityType; occurredAt: Date; }; @Injectable() export class ReportsService { constructor( private readonly blameRequestDbService: BlameRequestDbService, private readonly claimCaseDbService: ClaimCaseDbService, private readonly expertDbService: ExpertDbService, private readonly damageExpertDbService: DamageExpertDbService, private readonly expertFileActivityDbService: ExpertFileActivityDbService, ) {} private parseDateRange(from?: string, to?: string) { const fromDate = from ? new Date(from) : undefined; const toDate = to ? new Date(to) : undefined; if (fromDate && Number.isNaN(fromDate.getTime())) { throw new BadRequestException("Invalid 'from' date"); } if (toDate && Number.isNaN(toDate.getTime())) { throw new BadRequestException("Invalid 'to' date"); } if (fromDate && toDate && fromDate > toDate) { throw new BadRequestException("'from' must be before or equal to 'to'"); } return { fromDate, toDate }; } private isInDateRange( value: unknown, fromDate?: Date, toDate?: Date, ): boolean { if (!fromDate && !toDate) return true; if (!value) return false; const d = new Date(value as string | Date); if (Number.isNaN(d.getTime())) return false; if (fromDate && d < fromDate) return false; if (toDate && d > toDate) return false; return true; } private publicIdKey(doc: Record): string { return String(doc.publicId ?? doc._id ?? ""); } private buildClaimBucketsForInsurer( rows: Record[], clientKey: string, dateFrom?: Date, dateTo?: Date, ): Record { const buckets = initialClaimExpertReportBuckets(); for (const doc of rows) { if (!claimCaseTouchesClient(doc, clientKey)) continue; if (!this.isInDateRange(doc.createdAt, dateFrom, dateTo)) continue; const st = String(doc.status ?? ""); const key = claimCaseStatusToReportBucket(st); buckets.all++; buckets[key] = (buckets[key] ?? 0) + 1; } return buckets; } private buildBlameBucketsForInsurer( rows: Record[], clientKey: string, dateFrom?: Date, dateTo?: Date, ): Record { const buckets = initialBlameExpertReportBuckets(); for (const doc of rows) { if (!blameCaseTouchesClient(doc, clientKey)) continue; if (!this.isInDateRange(doc.createdAt, dateFrom, dateTo)) continue; const st = String(doc.status ?? ""); const key = blameCaseStatusToReportBucket(st); buckets.all++; buckets[key] = (buckets[key] ?? 0) + 1; } return buckets; } /** * One insurer “file” per `publicId`, aligned with `ExpertInsurerService.retrieveAllFilesOfClient`. */ private countUnifiedByPublicId( blameRows: Record[], claimRows: Record[], clientKey: string, dateFrom?: Date, dateTo?: Date, ): number { const keys = new Set(); for (const doc of blameRows) { if (!blameCaseTouchesClient(doc, clientKey)) continue; if (!this.isInDateRange(doc.createdAt, dateFrom, dateTo)) continue; const k = this.publicIdKey(doc); if (k) keys.add(k); } for (const doc of claimRows) { if (!claimCaseTouchesClient(doc, clientKey)) continue; if (!this.isInDateRange(doc.createdAt, dateFrom, dateTo)) continue; const k = this.publicIdKey(doc); if (k) keys.add(k); } return keys.size; } private buildInsurerSummary( blames: Record[], claims: Record[], clientKey: string, dateFrom?: Date, dateTo?: Date, ): InsurerRequestsSummaryDtoRs { const claim = this.buildClaimBucketsForInsurer( claims, clientKey, dateFrom, dateTo, ); const blame = this.buildBlameBucketsForInsurer( blames, clientKey, dateFrom, dateTo, ); const unifiedTotal = this.countUnifiedByPublicId( blames, claims, clientKey, dateFrom, dateTo, ); return new InsurerRequestsSummaryDtoRs(claim, blame, unifiedTotal); } async getInsurerRequestsSummary(actor: { clientKey?: string; }): Promise { const clientKey = requireActorClientKey(actor); const [blames, claims] = await Promise.all([ this.blameRequestDbService.find({}, { lean: true }), this.claimCaseDbService.find({}, { lean: true }), ]); return this.buildInsurerSummary( blames as Record[], claims as Record[], clientKey, ); } async getInsurerRequestsSummaryPerMonth(actor: { clientKey?: string; }): Promise { const clientKey = requireActorClientKey(actor); const [blames, claims] = await Promise.all([ this.blameRequestDbService.find({}, { lean: true }), this.claimCaseDbService.find({}, { lean: true }), ]); const blameRows = blames as Record[]; const claimRows = claims as Record[]; const result: InsurerPerMonthReportRowDtoRs[] = []; const now = new Date(); for (let i = 0; i < 5; i++) { const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1); const monthEnd = new Date( now.getFullYear(), now.getMonth() - i + 1, 0, 23, 59, 59, ); const label = monthStart.toLocaleDateString("fa-IR", { month: "long", year: "numeric", }); const data = this.buildInsurerSummary( blameRows, claimRows, clientKey, monthStart, monthEnd, ); result.unshift( new InsurerPerMonthReportRowDtoRs(monthStart, monthEnd, label, data), ); } return result; } async getInsurerRequestsSummaryByDateRange( actor: { clientKey?: string }, from?: string, to?: string, ): Promise { const clientKey = requireActorClientKey(actor); const { fromDate, toDate } = this.parseDateRange(from, to); const [blames, claims] = await Promise.all([ this.blameRequestDbService.find({}, { lean: true }), this.claimCaseDbService.find({}, { lean: true }), ]); return this.buildInsurerSummary( blames as Record[], claims as Record[], clientKey, fromDate, toDate, ); } /** Same replay rules as `ExpertInsurerService.buildExpertActivityStatsMap`. */ private replayExpertFileStates( events: NormalizedActivity[], cutoff: Date, ): Map { const stateByKey = new Map(); const filtered = events.filter((e) => e.occurredAt <= cutoff); filtered.sort((a, b) => { const t = a.occurredAt.getTime() - b.occurredAt.getTime(); if (t !== 0) return t; return `${a.expertId}:${a.fileId}`.localeCompare(`${b.expertId}:${b.fileId}`); }); for (const e of filtered) { const key = `${e.expertId}:${e.fileId}`; const prev = stateByKey.get(key) ?? { checked: false, handled: false }; if (e.eventType === ExpertFileActivityType.CHECKED) { if (!prev.handled) prev.checked = true; } else if (e.eventType === ExpertFileActivityType.UNCHECKED) { prev.checked = false; } else if (e.eventType === ExpertFileActivityType.HANDLED) { prev.handled = true; prev.checked = false; } stateByKey.set(key, prev); } return stateByKey; } private snapshotCountsForExpert( stateByKey: Map, expertId: string, ): { totalHandled: number; currentlyChecking: number } { let totalHandled = 0; let currentlyChecking = 0; const prefix = `${expertId}:`; for (const [key, st] of stateByKey.entries()) { if (!key.startsWith(prefix)) continue; if (st.handled) totalHandled++; else if (st.checked) currentlyChecking++; } return { totalHandled, currentlyChecking }; } private distinctFilesWithCheckInWindow( events: NormalizedActivity[], expertId: string, from: Date, to: Date, ): number { const files = new Set(); for (const e of events) { if (e.expertId !== expertId) continue; if (e.eventType !== ExpertFileActivityType.CHECKED) continue; if (e.occurredAt < from || e.occurredAt > to) continue; files.add(e.fileId); } return files.size; } private async loadTenantExpertsAndActivities(clientKey: string): Promise<{ events: NormalizedActivity[]; expertRows: Array<{ id: string; fullName: string; kind: "expert" | "damage_expert"; }>; }> { const tenantOid = new Types.ObjectId(clientKey); const tenantStr = String(tenantOid); /** * `clientKey` is sometimes stored as ObjectId, sometimes as string — match both. * Activity is loaded for the whole tenant so claim (V2) damage experts still appear * even if a roster row was missed by shape mismatches; we then merge `_id` lookups. */ const [blamePanelExperts, damageExperts, rawEvents] = await Promise.all([ this.expertDbService.findAll({ $or: [{ clientKey: tenantStr }, { clientKey: tenantOid }], } as never), this.damageExpertDbService.findAll({ $or: [{ clientKey: tenantOid }, { clientKey: tenantStr }], } as never), this.expertFileActivityDbService.findByTenant(tenantOid), ]); const expertRows: Array<{ id: string; fullName: string; kind: "expert" | "damage_expert"; }> = []; const seenIds = new Set(); const pushRow = (id: string, fullName: string, kind: "expert" | "damage_expert") => { if (seenIds.has(id)) return; seenIds.add(id); expertRows.push({ id, fullName, kind }); }; for (const e of blamePanelExperts) { const fullName = `${(e as any).firstName ?? ""} ${(e as any).lastName ?? ""}`.trim() || (e as any).email || String((e as any)._id); pushRow(String((e as any)._id), fullName, "expert"); } for (const e of damageExperts) { const fullName = `${(e as any).firstName ?? ""} ${(e as any).lastName ?? ""}`.trim() || (e as any).email || String((e as any)._id); pushRow(String((e as any)._id), fullName, "damage_expert"); } const activityExpertIds = [ ...new Set( rawEvents .map((row) => String(row.expertId)) .filter((id) => Types.ObjectId.isValid(id)), ), ].filter((id) => !seenIds.has(id)); if (activityExpertIds.length > 0) { const oids = activityExpertIds.map((id) => new Types.ObjectId(id)); const [extraDamages, extraExperts] = await Promise.all([ this.damageExpertDbService.findAll({ _id: { $in: oids } } as never), this.expertDbService.findAll({ _id: { $in: oids } } as never), ]); for (const d of extraDamages) { const id = String((d as any)._id); if (seenIds.has(id)) continue; const fullName = `${(d as any).firstName ?? ""} ${(d as any).lastName ?? ""}`.trim() || (d as any).email || id; pushRow(id, fullName, "damage_expert"); } for (const x of extraExperts) { const id = String((x as any)._id); if (seenIds.has(id)) continue; const fullName = `${(x as any).firstName ?? ""} ${(x as any).lastName ?? ""}`.trim() || (x as any).email || id; pushRow(id, fullName, "expert"); } } const events: NormalizedActivity[] = rawEvents.map((row) => ({ expertId: String(row.expertId), fileId: String(row.fileId), eventType: row.eventType, occurredAt: row.occurredAt instanceof Date ? row.occurredAt : new Date(row.occurredAt as string | Date), })); return { events, expertRows }; } private buildWorkLogEntries( expertRows: Array<{ id: string; fullName: string; kind: "expert" | "damage_expert"; }>, events: NormalizedActivity[], cutoff: Date, checkedWindow: { from: Date; to: Date }, ): InsurerExpertWorkLogEntryDtoRs[] { const state = this.replayExpertFileStates(events, cutoff); return expertRows.map((row) => { const snap = this.snapshotCountsForExpert(state, row.id); return new InsurerExpertWorkLogEntryDtoRs( row.id, row.fullName, row.kind, snap.totalHandled, snap.currentlyChecking, this.distinctFilesWithCheckInWindow( events, row.id, checkedWindow.from, checkedWindow.to, ), ); }); } async getInsurerExpertWorkLog(actor: { clientKey?: string; }): Promise { const clientKey = requireActorClientKey(actor); const { events, expertRows } = await this.loadTenantExpertsAndActivities(clientKey); const now = new Date(); const epoch = new Date(0); const entries = this.buildWorkLogEntries(expertRows, events, now, { from: epoch, to: now, }); return new InsurerExpertWorkLogResponseDtoRs(entries); } async getInsurerExpertWorkLogPerMonth(actor: { clientKey?: string; }): Promise { const clientKey = requireActorClientKey(actor); const { events, expertRows } = await this.loadTenantExpertsAndActivities(clientKey); const result: InsurerExpertWorkLogPerMonthRowDtoRs[] = []; const now = new Date(); for (let i = 0; i < 5; i++) { const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1); const monthEnd = new Date( now.getFullYear(), now.getMonth() - i + 1, 0, 23, 59, 59, ); const label = monthStart.toLocaleDateString("fa-IR", { month: "long", year: "numeric", }); const experts = this.buildWorkLogEntries( expertRows, events, monthEnd, { from: monthStart, to: monthEnd }, ); result.unshift( new InsurerExpertWorkLogPerMonthRowDtoRs( monthStart, monthEnd, label, experts, ), ); } return result; } }