diff --git a/src/claim-request-management/dto/capture-requirements-v2.dto.ts b/src/claim-request-management/dto/capture-requirements-v2.dto.ts index 6c9cb97..e118d0f 100644 --- a/src/claim-request-management/dto/capture-requirements-v2.dto.ts +++ b/src/claim-request-management/dto/capture-requirements-v2.dto.ts @@ -1,4 +1,5 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsInt, IsOptional } from 'class-validator'; /** * DTO for required document item @@ -93,6 +94,12 @@ export class DamagedPartItem { example: false, }) captured: boolean; + + /** Static catalog id (same as `damagedParts[].id` in capture requirements) when the part comes from the outer-parts catalog. */ + @ApiPropertyOptional({ example: 12 }) + @IsOptional() + @IsInt() + id?: number; } /** diff --git a/src/expert-claim/dto/expert-claim-v2.dto.ts b/src/expert-claim/dto/expert-claim-v2.dto.ts index eb91c58..a6b2dba 100644 --- a/src/expert-claim/dto/expert-claim-v2.dto.ts +++ b/src/expert-claim/dto/expert-claim-v2.dto.ts @@ -110,7 +110,8 @@ export class ClaimSubmitResendV2Dto { @ApiPropertyOptional({ type: [String], enum: ClaimRequiredDocumentType, - description: "Extra document keys to upload (may include types not in the initial claim form).", + description: + "Document keys to re-upload; each value must be a ClaimRequiredDocumentType string (e.g. national_card).", }) @IsOptional() @IsArray() diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 07efb79..b65a951 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -45,6 +45,7 @@ import { initialClaimExpertReportBuckets, } from "src/helpers/expert-panel-status-report"; import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum"; +import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum"; import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; import { PartyRole } from "src/request-management/entities/schema/partyRole.enum"; import { GetClaimListV2ResponseDto, ClaimListItemV2Dto } from "./dto/claim-list-v2.dto"; @@ -229,6 +230,21 @@ export class ExpertClaimService { }); } + /** + * Insurer tenant id stored on claim file-activity rows. Prefer `owner.userClientKey` + * so V2 events line up with `expireClaimWorkflowLockV2IfStale` and insurer `findByTenant`. + */ + private claimActivityTenantId( + claim: { owner?: { userClientKey?: unknown } } | null | undefined, + actor: { clientKey?: string }, + ): string { + const fromOwner = claim?.owner?.userClientKey; + if (fromOwner != null && String(fromOwner).length > 0) { + return String(fromOwner); + } + return String(actor.clientKey ?? ""); + } + /** Owner mobile: linked blame party phone when available, else `users.mobile`. */ private async resolveClaimOwnerPhone(claim: any): Promise { if (!claim?.owner?.userId) return undefined; @@ -1976,7 +1992,7 @@ export class ExpertClaimService { await this.recordClaimExpertActivity({ expertId: String(actor.sub), - tenantId: String(actor.clientKey), + tenantId: this.claimActivityTenantId(claim, actor), claimId: String(claimRequestId), eventType: ExpertFileActivityType.CHECKED, idempotencyKey: `claim:${claimRequestId}:checked:${actor.sub}`, @@ -2076,7 +2092,7 @@ export class ExpertClaimService { await this.recordClaimExpertActivity({ expertId: String(actor.sub), - tenantId: String(actor.clientKey), + tenantId: this.claimActivityTenantId(claim, actor), claimId: String(claimRequestId), eventType: ExpertFileActivityType.UNCHECKED, idempotencyKey: `claim:${claimRequestId}:unchecked:resend:${actor.sub}`, @@ -2228,6 +2244,14 @@ export class ExpertClaimService { }, }); + await this.recordClaimExpertActivity({ + expertId: String(actor.sub), + tenantId: this.claimActivityTenantId(claim, actor), + claimId: String(claimRequestId), + eventType: ExpertFileActivityType.CHECKED, + idempotencyKey: `claim:${claimRequestId}:checked:v2:${actor.sub}`, + }); + const ownerPhone = await this.resolveClaimOwnerPhone(claim); if (ownerPhone) { const expertLastName = @@ -2293,10 +2317,38 @@ export class ExpertClaimService { ); } - const docs = reply.resendDocuments ?? []; - const parts = reply.resendCarParts ?? []; + const allowedDocTypes = new Set( + Object.values(ClaimRequiredDocumentType), + ); + const rawDocs = reply.resendDocuments ?? []; + const docs: ClaimRequiredDocumentType[] = []; + for (const x of rawDocs) { + const s = String(x).trim(); + if (!allowedDocTypes.has(s)) { + throw new BadRequestException( + `Invalid resendDocuments value: "${String(x)}". Must be a ClaimRequiredDocumentType string (e.g. national_card, damaged_driving_license_front).`, + ); + } + docs.push(s as ClaimRequiredDocumentType); + } + const uniqueDocs = [...new Set(docs)]; + + const normalizedParts = (reply.resendCarParts ?? []).map((p) => { + const row: Record = { + key: p.key, + label_fa: p.label_fa, + label_en: p.label_en, + captured: false, + }; + const id = (p as { id?: number }).id; + if (typeof id === "number" && Number.isFinite(id)) { + row.id = Math.trunc(id); + } + return row; + }); + const desc = String(reply.resendDescription ?? "").trim(); - if (!desc && docs.length === 0 && parts.length === 0) { + if (!desc && uniqueDocs.length === 0 && normalizedParts.length === 0) { throw new BadRequestException( "Provide resendDescription and/or resendDocuments and/or resendCarParts.", ); @@ -2313,8 +2365,8 @@ export class ExpertClaimService { "workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, "evaluation.damageExpertResend": { resendDescription: desc || undefined, - resendDocuments: docs, - resendCarParts: parts, + resendDocuments: uniqueDocs, + resendCarParts: normalizedParts, ...(resendSnapshot && { expertProfileSnapshot: resendSnapshot }), }, }, @@ -2334,8 +2386,8 @@ export class ExpertClaimService { }, timestamp: new Date(), metadata: { - documentCount: docs.length, - carPartCount: parts.length, + documentCount: uniqueDocs.length, + carPartCount: normalizedParts.length, }, }, }, @@ -2511,7 +2563,7 @@ export class ExpertClaimService { await this.recordClaimExpertActivity({ expertId: String(actor.sub), - tenantId: String(actor.clientKey), + tenantId: this.claimActivityTenantId(claim, actor), claimId: String(claimRequestId), eventType: ExpertFileActivityType.HANDLED, idempotencyKey: `claim:${claimRequestId}:handled:${actor.sub}`, @@ -2616,7 +2668,7 @@ export class ExpertClaimService { await this.recordClaimExpertActivity({ expertId: String(actor.sub), - tenantId: String(actor.clientKey), + tenantId: this.claimActivityTenantId(claim, actor), claimId: String(claimRequestId), eventType: ExpertFileActivityType.HANDLED, idempotencyKey: `claim:${claimRequestId}:handled:inperson:${actor.sub}`, diff --git a/src/helpers/claim-expert-resend.ts b/src/helpers/claim-expert-resend.ts index cce0b35..5d55773 100644 --- a/src/helpers/claim-expert-resend.ts +++ b/src/helpers/claim-expert-resend.ts @@ -1,5 +1,6 @@ import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum"; import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum"; +import { canonicalizeResendDocumentKey } from "./claim-resend-document-keys"; export function resendRequestHasPayload(r: { resendDescription?: string; @@ -13,21 +14,24 @@ export function resendRequestHasPayload(r: { return false; } -/** Normalize expert-requested document keys (strings or { key }). */ +/** Normalize expert-requested document keys (strings or `{ key }`) to canonical enum strings. */ export function normalizeResendDocumentKeys(docs: unknown[] | undefined): string[] { if (!Array.isArray(docs)) return []; const keys: string[] = []; for (const d of docs) { - if (typeof d === "string" && d.trim()) keys.push(d.trim()); - else if (d && typeof d === "object" && "key" in (d as object)) { + if (typeof d === "string" && d.trim()) { + const c = canonicalizeResendDocumentKey(d.trim()); + if (c) keys.push(c); + } else if (d && typeof d === "object" && "key" in (d as object)) { const k = String((d as { key?: string }).key || "").trim(); - if (k) keys.push(k); + const c = canonicalizeResendDocumentKey(k); + if (c) keys.push(c); } } - return keys; + return [...new Set(keys)]; } -/** Normalize expert-requested damaged part keys (DamagedPartItem uses `key`). */ +/** Normalize expert-requested damaged part keys (`DamagedPartItem.key`). */ export function normalizeResendPartKeys(parts: unknown[] | undefined): string[] { if (!Array.isArray(parts)) return []; const keys: string[] = []; @@ -94,7 +98,9 @@ export function documentKeyAllowedForExpertResend( const r = claim?.evaluation?.damageExpertResend; if (!r || r.fulfilledAt) return false; const allowed = new Set(normalizeResendDocumentKeys(r.resendDocuments)); - return allowed.has(documentKey); + const normalized = + canonicalizeResendDocumentKey(documentKey) ?? documentKey.trim(); + return allowed.has(normalized); } export function partKeyAllowedForExpertResend(claim: any, partKey: string): boolean { diff --git a/src/helpers/claim-resend-document-keys.ts b/src/helpers/claim-resend-document-keys.ts new file mode 100644 index 0000000..67739b5 --- /dev/null +++ b/src/helpers/claim-resend-document-keys.ts @@ -0,0 +1,35 @@ +import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum"; + +const REQUIRED_VALUES = new Set( + Object.values(ClaimRequiredDocumentType), +); + +/** + * Non-canonical document keys that may still appear on stored + * `evaluation.damageExpertResend.resendDocuments` → {@link ClaimRequiredDocumentType}. + */ +const RESEND_DOCUMENT_KEY_ALIASES: Readonly< + Record +> = { + nationalCertificate: ClaimRequiredDocumentType.NATIONAL_CARD, + NationalCertificate: ClaimRequiredDocumentType.NATIONAL_CARD, + drivingLicense: ClaimRequiredDocumentType.DAMAGED_DRIVING_LICENSE_FRONT, + DrivingLicense: ClaimRequiredDocumentType.DAMAGED_DRIVING_LICENSE_FRONT, + carGreenCard: ClaimRequiredDocumentType.CAR_GREEN_CARD, + CarGreenCard: ClaimRequiredDocumentType.CAR_GREEN_CARD, + carCertificate: ClaimRequiredDocumentType.DAMAGED_CAR_CARD_FRONT, + CarCertificate: ClaimRequiredDocumentType.DAMAGED_CAR_CARD_FRONT, + plate: ClaimRequiredDocumentType.DAMAGED_METAL_PLATE, + carPlate: ClaimRequiredDocumentType.DAMAGED_METAL_PLATE, + CarPlate: ClaimRequiredDocumentType.DAMAGED_METAL_PLATE, +}; + +/** Normalize a single key to a {@link ClaimRequiredDocumentType} value, or null if unknown. */ +export function canonicalizeResendDocumentKey( + raw: string, +): ClaimRequiredDocumentType | null { + const t = String(raw || "").trim(); + if (!t) return null; + if (REQUIRED_VALUES.has(t)) return t as ClaimRequiredDocumentType; + return RESEND_DOCUMENT_KEY_ALIASES[t] ?? null; +} diff --git a/src/reports/dto/reports.dto.ts b/src/reports/dto/reports.dto.ts index efb11f7..1b69a51 100644 --- a/src/reports/dto/reports.dto.ts +++ b/src/reports/dto/reports.dto.ts @@ -36,3 +36,64 @@ export class InsurerPerMonthReportRowDtoRs { this.data = data; } } + +/** Per-expert file-activity stats (ExpertFileActivity: checked / unchecked / handled). */ +export class InsurerExpertWorkLogEntryDtoRs { + expertId: string; + fullName: string; + /** + * `expert` = blame-panel expert (`expert` collection — same as insurer expert list, not `field-expert`). + * `damage_expert` = claim damage expert (`damage-expert` collection). + * Field experts (`RoleEnum.FIELD_EXPERT`, `field-expert` collection) are out of scope here. + */ + expertKind: "expert" | "damage_expert"; + /** Distinct files in handled state after replaying activity up to the cutoff (or “now”). */ + totalHandled: number; + /** Distinct files currently marked checked and not yet handled (same rules as expert-insurer stats). */ + currentlyChecking: number; + /** Distinct files that had at least one CHECKED event in the reporting window (all-time = ever; monthly = that month only). */ + distinctFilesCheckedInPeriod: number; + + constructor( + expertId: string, + fullName: string, + expertKind: "expert" | "damage_expert", + totalHandled: number, + currentlyChecking: number, + distinctFilesCheckedInPeriod: number, + ) { + this.expertId = expertId; + this.fullName = fullName; + this.expertKind = expertKind; + this.totalHandled = totalHandled; + this.currentlyChecking = currentlyChecking; + this.distinctFilesCheckedInPeriod = distinctFilesCheckedInPeriod; + } +} + +export class InsurerExpertWorkLogResponseDtoRs { + experts: InsurerExpertWorkLogEntryDtoRs[]; + + constructor(experts: InsurerExpertWorkLogEntryDtoRs[]) { + this.experts = experts; + } +} + +export class InsurerExpertWorkLogPerMonthRowDtoRs { + stDate: Date; + enDate: Date; + faLabel: string; + experts: InsurerExpertWorkLogEntryDtoRs[]; + + constructor( + stDate: Date, + enDate: Date, + faLabel: string, + experts: InsurerExpertWorkLogEntryDtoRs[], + ) { + this.stDate = stDate; + this.enDate = enDate; + this.faLabel = faLabel; + this.experts = experts; + } +} diff --git a/src/reports/reports.controller.ts b/src/reports/reports.controller.ts index 8b45fa9..58b43d1 100644 --- a/src/reports/reports.controller.ts +++ b/src/reports/reports.controller.ts @@ -67,4 +67,24 @@ export class ReportsController { to, ); } + + @Get("report/insurer/expert-work-log") + @ApiOperation({ + summary: "Insurer: per-expert work log (blame experts + damage experts)", + description: + "Rows are every tenant expert from the `expert` collection (blame / Role expert) and `damage-expert` collection (claim damage experts)—same scope as GET expert-insurer/experts/list. Field experts (`field-expert` / FIELD_EXPERT) are a different product surface and are not included. Uses expertFileActivities: totalHandled and currentlyChecking from replaying CHECKED/UNCHECKED/HANDLED; distinctFilesCheckedInPeriod = distinct files with CHECKED over all time.", + }) + async getInsurerExpertWorkLog(@CurrentUser() actor) { + return await this.reportsService.getInsurerExpertWorkLog(actor); + } + + @Get("report/insurer/expert-work-log/per-month") + @ApiOperation({ + summary: "Insurer: blame + damage expert work log, last five calendar months", + description: + "Same expert roster as expert-work-log (expert + damage-expert collections for the tenant, not field-expert). Per month: totalHandled and currentlyChecking are snapshots after replay through that month’s end; distinctFilesCheckedInPeriod counts CHECKED events in that month only (distinct files).", + }) + async getInsurerExpertWorkLogPerMonth(@CurrentUser() actor) { + return await this.reportsService.getInsurerExpertWorkLogPerMonth(actor); + } } diff --git a/src/reports/reports.module.ts b/src/reports/reports.module.ts index c735eb3..ddcb007 100644 --- a/src/reports/reports.module.ts +++ b/src/reports/reports.module.ts @@ -1,6 +1,7 @@ import { Module } from "@nestjs/common"; import { ClaimRequestManagementModule } from "src/claim-request-management/claim-request-management.module"; import { RequestManagementModule } from "src/request-management/request-management.module"; +import { UsersModule } from "src/users/users.module"; import { ReportsController } from "./reports.controller"; import { ReportsService } from "./reports.service"; @@ -8,6 +9,7 @@ import { ReportsService } from "./reports.service"; imports: [ RequestManagementModule, ClaimRequestManagementModule, + UsersModule, ], controllers: [ReportsController], providers: [ReportsService], diff --git a/src/reports/reports.service.ts b/src/reports/reports.service.ts index 2012e3e..55a9e90 100644 --- a/src/reports/reports.service.ts +++ b/src/reports/reports.service.ts @@ -1,4 +1,5 @@ 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, @@ -12,16 +13,33 @@ import { 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) { @@ -228,4 +246,251 @@ export class ReportsService { 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; + } } diff --git a/src/users/entities/db-service/expert-file-activity.db.service.ts b/src/users/entities/db-service/expert-file-activity.db.service.ts index f62c031..97decd3 100644 --- a/src/users/entities/db-service/expert-file-activity.db.service.ts +++ b/src/users/entities/db-service/expert-file-activity.db.service.ts @@ -70,4 +70,22 @@ export class ExpertFileActivityDbService { .sort({ occurredAt: 1, _id: 1 }) .lean(); } + + /** All file-activity rows for an insurer tenant (blame + claim experts). */ + async findByTenant(tenantId: string | Types.ObjectId): Promise< + Array<{ + expertId: Types.ObjectId; + fileId: Types.ObjectId; + eventType: ExpertFileActivityType; + occurredAt: Date; + }> + > { + return this.activityModel + .find( + { tenantId: new Types.ObjectId(String(tenantId)) }, + { expertId: 1, fileId: 1, eventType: 1, occurredAt: 1 }, + ) + .sort({ occurredAt: 1, _id: 1 }) + .lean(); + } }