forked from Yara724/api
Compare commits
2 Commits
80ef885d3b
...
ebf8a9a624
| Author | SHA1 | Date | |
|---|---|---|---|
| ebf8a9a624 | |||
| 993d809de2 |
@@ -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
|
* DTO for required document item
|
||||||
@@ -93,6 +94,12 @@ export class DamagedPartItem {
|
|||||||
example: false,
|
example: false,
|
||||||
})
|
})
|
||||||
captured: boolean;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -110,7 +110,8 @@ export class ClaimSubmitResendV2Dto {
|
|||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
type: [String],
|
type: [String],
|
||||||
enum: ClaimRequiredDocumentType,
|
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()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
initialClaimExpertReportBuckets,
|
initialClaimExpertReportBuckets,
|
||||||
} from "src/helpers/expert-panel-status-report";
|
} from "src/helpers/expert-panel-status-report";
|
||||||
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
|
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 { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||||||
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
|
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
|
||||||
import { GetClaimListV2ResponseDto, ClaimListItemV2Dto } from "./dto/claim-list-v2.dto";
|
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`. */
|
/** Owner mobile: linked blame party phone when available, else `users.mobile`. */
|
||||||
private async resolveClaimOwnerPhone(claim: any): Promise<string | undefined> {
|
private async resolveClaimOwnerPhone(claim: any): Promise<string | undefined> {
|
||||||
if (!claim?.owner?.userId) return undefined;
|
if (!claim?.owner?.userId) return undefined;
|
||||||
@@ -1976,7 +1992,7 @@ export class ExpertClaimService {
|
|||||||
|
|
||||||
await this.recordClaimExpertActivity({
|
await this.recordClaimExpertActivity({
|
||||||
expertId: String(actor.sub),
|
expertId: String(actor.sub),
|
||||||
tenantId: String(actor.clientKey),
|
tenantId: this.claimActivityTenantId(claim, actor),
|
||||||
claimId: String(claimRequestId),
|
claimId: String(claimRequestId),
|
||||||
eventType: ExpertFileActivityType.CHECKED,
|
eventType: ExpertFileActivityType.CHECKED,
|
||||||
idempotencyKey: `claim:${claimRequestId}:checked:${actor.sub}`,
|
idempotencyKey: `claim:${claimRequestId}:checked:${actor.sub}`,
|
||||||
@@ -2076,7 +2092,7 @@ export class ExpertClaimService {
|
|||||||
|
|
||||||
await this.recordClaimExpertActivity({
|
await this.recordClaimExpertActivity({
|
||||||
expertId: String(actor.sub),
|
expertId: String(actor.sub),
|
||||||
tenantId: String(actor.clientKey),
|
tenantId: this.claimActivityTenantId(claim, actor),
|
||||||
claimId: String(claimRequestId),
|
claimId: String(claimRequestId),
|
||||||
eventType: ExpertFileActivityType.UNCHECKED,
|
eventType: ExpertFileActivityType.UNCHECKED,
|
||||||
idempotencyKey: `claim:${claimRequestId}:unchecked:resend:${actor.sub}`,
|
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);
|
const ownerPhone = await this.resolveClaimOwnerPhone(claim);
|
||||||
if (ownerPhone) {
|
if (ownerPhone) {
|
||||||
const expertLastName =
|
const expertLastName =
|
||||||
@@ -2293,10 +2317,38 @@ export class ExpertClaimService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const docs = reply.resendDocuments ?? [];
|
const allowedDocTypes = new Set<string>(
|
||||||
const parts = reply.resendCarParts ?? [];
|
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<string, unknown> = {
|
||||||
|
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();
|
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(
|
throw new BadRequestException(
|
||||||
"Provide resendDescription and/or resendDocuments and/or resendCarParts.",
|
"Provide resendDescription and/or resendDocuments and/or resendCarParts.",
|
||||||
);
|
);
|
||||||
@@ -2313,8 +2365,8 @@ export class ExpertClaimService {
|
|||||||
"workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
|
"workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
|
||||||
"evaluation.damageExpertResend": {
|
"evaluation.damageExpertResend": {
|
||||||
resendDescription: desc || undefined,
|
resendDescription: desc || undefined,
|
||||||
resendDocuments: docs,
|
resendDocuments: uniqueDocs,
|
||||||
resendCarParts: parts,
|
resendCarParts: normalizedParts,
|
||||||
...(resendSnapshot && { expertProfileSnapshot: resendSnapshot }),
|
...(resendSnapshot && { expertProfileSnapshot: resendSnapshot }),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -2334,8 +2386,8 @@ export class ExpertClaimService {
|
|||||||
},
|
},
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
metadata: {
|
metadata: {
|
||||||
documentCount: docs.length,
|
documentCount: uniqueDocs.length,
|
||||||
carPartCount: parts.length,
|
carPartCount: normalizedParts.length,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -2511,7 +2563,7 @@ export class ExpertClaimService {
|
|||||||
|
|
||||||
await this.recordClaimExpertActivity({
|
await this.recordClaimExpertActivity({
|
||||||
expertId: String(actor.sub),
|
expertId: String(actor.sub),
|
||||||
tenantId: String(actor.clientKey),
|
tenantId: this.claimActivityTenantId(claim, actor),
|
||||||
claimId: String(claimRequestId),
|
claimId: String(claimRequestId),
|
||||||
eventType: ExpertFileActivityType.HANDLED,
|
eventType: ExpertFileActivityType.HANDLED,
|
||||||
idempotencyKey: `claim:${claimRequestId}:handled:${actor.sub}`,
|
idempotencyKey: `claim:${claimRequestId}:handled:${actor.sub}`,
|
||||||
@@ -2616,7 +2668,7 @@ export class ExpertClaimService {
|
|||||||
|
|
||||||
await this.recordClaimExpertActivity({
|
await this.recordClaimExpertActivity({
|
||||||
expertId: String(actor.sub),
|
expertId: String(actor.sub),
|
||||||
tenantId: String(actor.clientKey),
|
tenantId: this.claimActivityTenantId(claim, actor),
|
||||||
claimId: String(claimRequestId),
|
claimId: String(claimRequestId),
|
||||||
eventType: ExpertFileActivityType.HANDLED,
|
eventType: ExpertFileActivityType.HANDLED,
|
||||||
idempotencyKey: `claim:${claimRequestId}:handled:inperson:${actor.sub}`,
|
idempotencyKey: `claim:${claimRequestId}:handled:inperson:${actor.sub}`,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
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 { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
|
||||||
|
import { canonicalizeResendDocumentKey } from "./claim-resend-document-keys";
|
||||||
|
|
||||||
export function resendRequestHasPayload(r: {
|
export function resendRequestHasPayload(r: {
|
||||||
resendDescription?: string;
|
resendDescription?: string;
|
||||||
@@ -13,21 +14,24 @@ export function resendRequestHasPayload(r: {
|
|||||||
return false;
|
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[] {
|
export function normalizeResendDocumentKeys(docs: unknown[] | undefined): string[] {
|
||||||
if (!Array.isArray(docs)) return [];
|
if (!Array.isArray(docs)) return [];
|
||||||
const keys: string[] = [];
|
const keys: string[] = [];
|
||||||
for (const d of docs) {
|
for (const d of docs) {
|
||||||
if (typeof d === "string" && d.trim()) keys.push(d.trim());
|
if (typeof d === "string" && d.trim()) {
|
||||||
else if (d && typeof d === "object" && "key" in (d as object)) {
|
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();
|
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[] {
|
export function normalizeResendPartKeys(parts: unknown[] | undefined): string[] {
|
||||||
if (!Array.isArray(parts)) return [];
|
if (!Array.isArray(parts)) return [];
|
||||||
const keys: string[] = [];
|
const keys: string[] = [];
|
||||||
@@ -94,7 +98,9 @@ export function documentKeyAllowedForExpertResend(
|
|||||||
const r = claim?.evaluation?.damageExpertResend;
|
const r = claim?.evaluation?.damageExpertResend;
|
||||||
if (!r || r.fulfilledAt) return false;
|
if (!r || r.fulfilledAt) return false;
|
||||||
const allowed = new Set(normalizeResendDocumentKeys(r.resendDocuments));
|
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 {
|
export function partKeyAllowedForExpertResend(claim: any, partKey: string): boolean {
|
||||||
|
|||||||
35
src/helpers/claim-resend-document-keys.ts
Normal file
35
src/helpers/claim-resend-document-keys.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
|
||||||
|
|
||||||
|
const REQUIRED_VALUES = new Set<string>(
|
||||||
|
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<string, ClaimRequiredDocumentType>
|
||||||
|
> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,29 +1,99 @@
|
|||||||
/**
|
/**
|
||||||
* Native workflow status keys from blameCases (`CaseStatus`) or claimCases (`ClaimCaseStatus`),
|
* V2-style status buckets (`IN_PROGRESS` + native statuses) for insurer tenant scope.
|
||||||
* plus `all` = total matching documents for the tenant.
|
* `unifiedByPublicId.total` matches merging blame + claim by `publicId` (see insurer `files` API).
|
||||||
*/
|
*/
|
||||||
export class BlameCaseStatusCountReportDtoRs {
|
export class InsurerRequestsSummaryDtoRs {
|
||||||
[key: string]: number;
|
|
||||||
|
|
||||||
constructor(report: Record<string, number>) {
|
|
||||||
Object.assign(this, report);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ClaimCaseStatusCountReportDtoRs {
|
|
||||||
[key: string]: number;
|
|
||||||
|
|
||||||
constructor(report: Record<string, number>) {
|
|
||||||
Object.assign(this, report);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CompanyAllRequestsCountReportDtoRs {
|
|
||||||
blame: Record<string, number>;
|
|
||||||
claim: Record<string, number>;
|
claim: Record<string, number>;
|
||||||
|
blame: Record<string, number>;
|
||||||
|
unifiedByPublicId: { total: number };
|
||||||
|
|
||||||
constructor(blame: Record<string, number>, claim: Record<string, number>) {
|
constructor(
|
||||||
this.blame = blame;
|
claim: Record<string, number>,
|
||||||
|
blame: Record<string, number>,
|
||||||
|
unifiedTotal: number,
|
||||||
|
) {
|
||||||
this.claim = claim;
|
this.claim = claim;
|
||||||
|
this.blame = blame;
|
||||||
|
this.unifiedByPublicId = { total: unifiedTotal };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InsurerPerMonthReportRowDtoRs {
|
||||||
|
stDate: Date;
|
||||||
|
enDate: Date;
|
||||||
|
faLabel: string;
|
||||||
|
data: InsurerRequestsSummaryDtoRs;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
stDate: Date,
|
||||||
|
enDate: Date,
|
||||||
|
faLabel: string,
|
||||||
|
data: InsurerRequestsSummaryDtoRs,
|
||||||
|
) {
|
||||||
|
this.stDate = stDate;
|
||||||
|
this.enDate = enDate;
|
||||||
|
this.faLabel = faLabel;
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { Controller, Get, UseGuards } from "@nestjs/common";
|
import { Controller, Get, Query, UseGuards } from "@nestjs/common";
|
||||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiOperation,
|
||||||
|
ApiQuery,
|
||||||
|
ApiTags,
|
||||||
|
} from "@nestjs/swagger";
|
||||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||||
import { ClientKey } from "src/decorators/clientKey.decorator";
|
|
||||||
import { Roles } from "src/decorators/roles.decorator";
|
import { Roles } from "src/decorators/roles.decorator";
|
||||||
import { CurrentUser } from "src/decorators/user.decorator";
|
import { CurrentUser } from "src/decorators/user.decorator";
|
||||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
@@ -11,29 +15,76 @@ import { ReportsService } from "./reports.service";
|
|||||||
@ApiTags("reports")
|
@ApiTags("reports")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||||
@Roles(RoleEnum.DAMAGE_EXPERT, RoleEnum.EXPERT, RoleEnum.COMPANY)
|
@Roles(RoleEnum.COMPANY)
|
||||||
@Controller("reports")
|
@Controller("reports")
|
||||||
export class ReportsController {
|
export class ReportsController {
|
||||||
constructor(private readonly reportsService: ReportsService) {}
|
constructor(private readonly reportsService: ReportsService) {}
|
||||||
|
|
||||||
@Get("/report/requests")
|
@Get("report/insurer/requests")
|
||||||
async getAllRequestsReport(@CurrentUser() actor, @ClientKey() client) {
|
@ApiOperation({
|
||||||
return await this.reportsService.getAllRequestsReportCount(actor, client);
|
summary: "Insurer: claim/blame status buckets + unified file count",
|
||||||
|
description:
|
||||||
|
"Claim and blame counts use the same V2 bucket rules as expert panels (IN_PROGRESS grouping), scoped to the insurer JWT tenant. unifiedByPublicId.total counts distinct publicId values like GET expert-insurer/files.",
|
||||||
|
})
|
||||||
|
async getInsurerRequestsSummary(@CurrentUser() actor) {
|
||||||
|
return await this.reportsService.getInsurerRequestsSummary(actor);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("/report/perMonthRequests")
|
@Get("report/insurer/per-month-requests")
|
||||||
async getAllRequestsReportPerMonth(
|
@ApiOperation({
|
||||||
|
summary: "Insurer: same summary as report/insurer/requests, per calendar month",
|
||||||
|
description:
|
||||||
|
"Last five calendar months; each slice filters blame/claim rows by createdAt in that month, then claim buckets, blame buckets, and unified publicId count.",
|
||||||
|
})
|
||||||
|
async getInsurerRequestsSummaryPerMonth(@CurrentUser() actor) {
|
||||||
|
return await this.reportsService.getInsurerRequestsSummaryPerMonth(actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("report/insurer/checked-requests")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Insurer: summary filtered by optional createdAt range",
|
||||||
|
description:
|
||||||
|
"Same payload as report/insurer/requests. When from/to are omitted, all tenant files are included. When provided, only blame/claim rows whose createdAt falls in the range are counted (inclusive).",
|
||||||
|
})
|
||||||
|
@ApiQuery({
|
||||||
|
name: "from",
|
||||||
|
required: false,
|
||||||
|
description: "Optional start datetime (ISO string)",
|
||||||
|
})
|
||||||
|
@ApiQuery({
|
||||||
|
name: "to",
|
||||||
|
required: false,
|
||||||
|
description: "Optional end datetime (ISO string)",
|
||||||
|
})
|
||||||
|
async getInsurerRequestsSummaryByDateRange(
|
||||||
@CurrentUser() actor,
|
@CurrentUser() actor,
|
||||||
@ClientKey() client,
|
@Query("from") from?: string,
|
||||||
|
@Query("to") to?: string,
|
||||||
) {
|
) {
|
||||||
return await this.reportsService.getAllRequestsReportPerMonth(
|
return await this.reportsService.getInsurerRequestsSummaryByDateRange(
|
||||||
actor,
|
actor,
|
||||||
client,
|
from,
|
||||||
|
to,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("/report/checkedRequests")
|
@Get("report/insurer/expert-work-log")
|
||||||
async getAllCheckedRequestsCount(@CurrentUser() actor, @ClientKey() client) {
|
@ApiOperation({
|
||||||
return await this.reportsService.getAllCheckedRequestsCount(actor, client);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { ClaimRequestManagementModule } from "src/claim-request-management/claim-request-management.module";
|
import { ClaimRequestManagementModule } from "src/claim-request-management/claim-request-management.module";
|
||||||
import { RequestManagementModule } from "src/request-management/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 { ReportsController } from "./reports.controller";
|
||||||
import { ReportsService } from "./reports.service";
|
import { ReportsService } from "./reports.service";
|
||||||
|
|
||||||
@@ -8,6 +9,7 @@ import { ReportsService } from "./reports.service";
|
|||||||
imports: [
|
imports: [
|
||||||
RequestManagementModule,
|
RequestManagementModule,
|
||||||
ClaimRequestManagementModule,
|
ClaimRequestManagementModule,
|
||||||
|
UsersModule,
|
||||||
],
|
],
|
||||||
controllers: [ReportsController],
|
controllers: [ReportsController],
|
||||||
providers: [ReportsService],
|
providers: [ReportsService],
|
||||||
|
|||||||
@@ -1,197 +1,199 @@
|
|||||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||||
import { Types } from "mongoose";
|
import { Types } from "mongoose";
|
||||||
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
|
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
|
||||||
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
|
|
||||||
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
|
|
||||||
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
|
||||||
import { requireActorClientKey } from "src/helpers/tenant-scope";
|
|
||||||
import {
|
import {
|
||||||
BlameCaseStatusCountReportDtoRs,
|
blameCaseStatusToReportBucket,
|
||||||
ClaimCaseStatusCountReportDtoRs,
|
claimCaseStatusToReportBucket,
|
||||||
CompanyAllRequestsCountReportDtoRs,
|
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";
|
} from "./dto/reports.dto";
|
||||||
|
|
||||||
|
type NormalizedActivity = {
|
||||||
|
expertId: string;
|
||||||
|
fileId: string;
|
||||||
|
eventType: ExpertFileActivityType;
|
||||||
|
occurredAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ReportsService {
|
export class ReportsService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly blameRequestDbService: BlameRequestDbService,
|
private readonly blameRequestDbService: BlameRequestDbService,
|
||||||
private readonly claimCaseDbService: ClaimCaseDbService,
|
private readonly claimCaseDbService: ClaimCaseDbService,
|
||||||
|
private readonly expertDbService: ExpertDbService,
|
||||||
|
private readonly damageExpertDbService: DamageExpertDbService,
|
||||||
|
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private clientObjectId(client: string | undefined): Types.ObjectId {
|
private parseDateRange(from?: string, to?: string) {
|
||||||
const ck = requireActorClientKey({ clientKey: client });
|
const fromDate = from ? new Date(from) : undefined;
|
||||||
return new Types.ObjectId(ck);
|
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 isBlameForClient(r: any, client: Types.ObjectId): boolean {
|
private isInDateRange(
|
||||||
const idStr = String(client);
|
value: unknown,
|
||||||
return (r?.parties || []).some(
|
fromDate?: Date,
|
||||||
(p) => String(p?.person?.clientId || "") === idStr,
|
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 isClaimForClient(r: any, client: Types.ObjectId): boolean {
|
private publicIdKey(doc: Record<string, unknown>): string {
|
||||||
return String(r?.owner?.userClientKey || "") === String(client);
|
return String(doc.publicId ?? doc._id ?? "");
|
||||||
}
|
}
|
||||||
|
|
||||||
private countBlameByCaseStatus(
|
private buildClaimBucketsForInsurer(
|
||||||
blames: any[],
|
rows: Record<string, unknown>[],
|
||||||
clientId: Types.ObjectId,
|
clientKey: string,
|
||||||
|
dateFrom?: Date,
|
||||||
|
dateTo?: Date,
|
||||||
): Record<string, number> {
|
): Record<string, number> {
|
||||||
const out: Record<string, number> = { all: 0 };
|
const buckets = initialClaimExpertReportBuckets();
|
||||||
for (const s of Object.values(CaseStatus)) {
|
for (const doc of rows) {
|
||||||
out[s] = 0;
|
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;
|
||||||
}
|
}
|
||||||
for (const r of blames) {
|
return buckets;
|
||||||
if (!this.isBlameForClient(r, clientId)) continue;
|
|
||||||
const st = r?.status as string;
|
|
||||||
if (st in out) out[st]++;
|
|
||||||
else out[st] = (out[st] ?? 0) + 1;
|
|
||||||
out.all++;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private countClaimByCaseStatus(
|
private buildBlameBucketsForInsurer(
|
||||||
claims: any[],
|
rows: Record<string, unknown>[],
|
||||||
clientId: Types.ObjectId,
|
clientKey: string,
|
||||||
|
dateFrom?: Date,
|
||||||
|
dateTo?: Date,
|
||||||
): Record<string, number> {
|
): Record<string, number> {
|
||||||
const out: Record<string, number> = { all: 0 };
|
const buckets = initialBlameExpertReportBuckets();
|
||||||
for (const s of Object.values(ClaimCaseStatus)) {
|
for (const doc of rows) {
|
||||||
out[s] = 0;
|
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;
|
||||||
}
|
}
|
||||||
for (const r of claims) {
|
return buckets;
|
||||||
if (!this.isClaimForClient(r, clientId)) continue;
|
|
||||||
const st = r?.status as string;
|
|
||||||
if (st in out) out[st]++;
|
|
||||||
else out[st] = (out[st] ?? 0) + 1;
|
|
||||||
out.all++;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAllRequestsCountByRole(role: string, client: string | undefined) {
|
|
||||||
const clientId = this.clientObjectId(client);
|
|
||||||
const [blames, claims] = await Promise.all([
|
|
||||||
this.blameRequestDbService.find({}, { lean: true }),
|
|
||||||
this.claimCaseDbService.find({}, { lean: true }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (role === "expert") {
|
|
||||||
return this.countBlameByCaseStatus(blames as any[], clientId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (role === "damage_expert") {
|
|
||||||
return this.countClaimByCaseStatus(claims as any[], clientId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (role === "company") {
|
|
||||||
return {
|
|
||||||
blame: this.countBlameByCaseStatus(blames as any[], clientId),
|
|
||||||
claim: this.countClaimByCaseStatus(claims as any[], clientId),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per calendar month, counts by native `CaseStatus` / `ClaimCaseStatus`.
|
* One insurer “file” per `publicId`, aligned with `ExpertInsurerService.retrieveAllFilesOfClient`.
|
||||||
* Company gets both blame and claim maps; expert / damage_expert get one map.
|
|
||||||
*/
|
*/
|
||||||
async getDateFilteredRequestByStatus(
|
private countUnifiedByPublicId(
|
||||||
role: string,
|
blameRows: Record<string, unknown>[],
|
||||||
client: string | undefined,
|
claimRows: Record<string, unknown>[],
|
||||||
start: Date,
|
clientKey: string,
|
||||||
end: Date,
|
dateFrom?: Date,
|
||||||
) {
|
dateTo?: Date,
|
||||||
const clientId = this.clientObjectId(client);
|
): number {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
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<string, unknown>[],
|
||||||
|
claims: Record<string, unknown>[],
|
||||||
|
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<InsurerRequestsSummaryDtoRs> {
|
||||||
|
const clientKey = requireActorClientKey(actor);
|
||||||
const [blames, claims] = await Promise.all([
|
const [blames, claims] = await Promise.all([
|
||||||
this.blameRequestDbService.find({}, { lean: true }),
|
this.blameRequestDbService.find({}, { lean: true }),
|
||||||
this.claimCaseDbService.find({}, { lean: true }),
|
this.claimCaseDbService.find({}, { lean: true }),
|
||||||
]);
|
]);
|
||||||
|
return this.buildInsurerSummary(
|
||||||
const inRange = (r: any) => {
|
blames as Record<string, unknown>[],
|
||||||
const createdAt = new Date(r.createdAt);
|
claims as Record<string, unknown>[],
|
||||||
return createdAt >= start && createdAt <= end;
|
clientKey,
|
||||||
};
|
|
||||||
|
|
||||||
if (role === "company") {
|
|
||||||
const blameOut: Record<string, number> = { all: 0 };
|
|
||||||
const claimOut: Record<string, number> = { all: 0 };
|
|
||||||
for (const s of Object.values(CaseStatus)) blameOut[s] = 0;
|
|
||||||
for (const s of Object.values(ClaimCaseStatus)) claimOut[s] = 0;
|
|
||||||
|
|
||||||
for (const r of blames as any[]) {
|
|
||||||
if (!this.isBlameForClient(r, clientId) || !inRange(r)) continue;
|
|
||||||
const st = r?.status as string;
|
|
||||||
if (st in blameOut) blameOut[st]++;
|
|
||||||
else blameOut[st] = (blameOut[st] ?? 0) + 1;
|
|
||||||
blameOut.all++;
|
|
||||||
}
|
|
||||||
for (const r of claims as any[]) {
|
|
||||||
if (!this.isClaimForClient(r, clientId) || !inRange(r)) continue;
|
|
||||||
const st = r?.status as string;
|
|
||||||
if (st in claimOut) claimOut[st]++;
|
|
||||||
else claimOut[st] = (claimOut[st] ?? 0) + 1;
|
|
||||||
claimOut.all++;
|
|
||||||
}
|
|
||||||
return { blame: blameOut, claim: claimOut };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (role === "expert") {
|
|
||||||
const out: Record<string, number> = { all: 0 };
|
|
||||||
for (const s of Object.values(CaseStatus)) out[s] = 0;
|
|
||||||
for (const r of blames as any[]) {
|
|
||||||
if (!this.isBlameForClient(r, clientId) || !inRange(r)) continue;
|
|
||||||
const st = r?.status as string;
|
|
||||||
if (st in out) out[st]++;
|
|
||||||
else out[st] = (out[st] ?? 0) + 1;
|
|
||||||
out.all++;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (role === "damage_expert") {
|
|
||||||
const out: Record<string, number> = { all: 0 };
|
|
||||||
for (const s of Object.values(ClaimCaseStatus)) out[s] = 0;
|
|
||||||
for (const r of claims as any[]) {
|
|
||||||
if (!this.isClaimForClient(r, clientId) || !inRange(r)) continue;
|
|
||||||
const st = r?.status as string;
|
|
||||||
if (st in out) out[st]++;
|
|
||||||
else out[st] = (out[st] ?? 0) + 1;
|
|
||||||
out.all++;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new BadRequestException("Unsupported role for reports");
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAllRequestsReportCount(actor: any, client: string | undefined) {
|
|
||||||
requireActorClientKey(actor);
|
|
||||||
|
|
||||||
if (actor.role === "damage_expert") {
|
|
||||||
const data = await this.getAllRequestsCountByRole(actor.role, client);
|
|
||||||
return new ClaimCaseStatusCountReportDtoRs(data as Record<string, number>);
|
|
||||||
}
|
|
||||||
if (actor.role === "expert") {
|
|
||||||
const data = await this.getAllRequestsCountByRole(actor.role, client);
|
|
||||||
return new BlameCaseStatusCountReportDtoRs(data as Record<string, number>);
|
|
||||||
}
|
|
||||||
const companyPayload = await this.getAllRequestsCountByRole(
|
|
||||||
"company",
|
|
||||||
client,
|
|
||||||
);
|
|
||||||
return new CompanyAllRequestsCountReportDtoRs(
|
|
||||||
(companyPayload as { blame: Record<string, number> }).blame,
|
|
||||||
(companyPayload as { claim: Record<string, number> }).claim,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllRequestsReportPerMonth(actor: any, client: string | undefined) {
|
async getInsurerRequestsSummaryPerMonth(actor: {
|
||||||
requireActorClientKey(actor);
|
clientKey?: string;
|
||||||
const result: any[] = [];
|
}): Promise<InsurerPerMonthReportRowDtoRs[]> {
|
||||||
|
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<string, unknown>[];
|
||||||
|
const claimRows = claims as Record<string, unknown>[];
|
||||||
|
|
||||||
|
const result: InsurerPerMonthReportRowDtoRs[] = [];
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
@@ -209,35 +211,286 @@ export class ReportsService {
|
|||||||
year: "numeric",
|
year: "numeric",
|
||||||
});
|
});
|
||||||
|
|
||||||
let data: any;
|
const data = this.buildInsurerSummary(
|
||||||
if (actor.role === "company") {
|
blameRows,
|
||||||
data = await this.getDateFilteredRequestByStatus(
|
claimRows,
|
||||||
"company",
|
clientKey,
|
||||||
client,
|
|
||||||
monthStart,
|
monthStart,
|
||||||
monthEnd,
|
monthEnd,
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
data = await this.getDateFilteredRequestByStatus(
|
|
||||||
actor.role,
|
|
||||||
client,
|
|
||||||
monthStart,
|
|
||||||
monthEnd,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
result.unshift({
|
result.unshift(
|
||||||
stDate: monthStart,
|
new InsurerPerMonthReportRowDtoRs(monthStart, monthEnd, label, data),
|
||||||
enDate: monthEnd,
|
);
|
||||||
faLabel: label,
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Same aggregates as `GET /reports/report/requests` (native status keys). */
|
async getInsurerRequestsSummaryByDateRange(
|
||||||
async getAllCheckedRequestsCount(actor: any, client: string | undefined) {
|
actor: { clientKey?: string },
|
||||||
return this.getAllRequestsReportCount(actor, client);
|
from?: string,
|
||||||
|
to?: string,
|
||||||
|
): Promise<InsurerRequestsSummaryDtoRs> {
|
||||||
|
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<string, unknown>[],
|
||||||
|
claims as Record<string, unknown>[],
|
||||||
|
clientKey,
|
||||||
|
fromDate,
|
||||||
|
toDate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same replay rules as `ExpertInsurerService.buildExpertActivityStatsMap`. */
|
||||||
|
private replayExpertFileStates(
|
||||||
|
events: NormalizedActivity[],
|
||||||
|
cutoff: Date,
|
||||||
|
): Map<string, { checked: boolean; handled: boolean }> {
|
||||||
|
const stateByKey = new Map<string, { checked: boolean; handled: boolean }>();
|
||||||
|
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<string, { checked: boolean; handled: boolean }>,
|
||||||
|
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<string>();
|
||||||
|
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<string>();
|
||||||
|
|
||||||
|
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<InsurerExpertWorkLogResponseDtoRs> {
|
||||||
|
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<InsurerExpertWorkLogPerMonthRowDtoRs[]> {
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,4 +70,22 @@ export class ExpertFileActivityDbService {
|
|||||||
.sort({ occurredAt: 1, _id: 1 })
|
.sort({ occurredAt: 1, _id: 1 })
|
||||||
.lean();
|
.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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user