forked from Yara724/api
497 lines
16 KiB
TypeScript
497 lines
16 KiB
TypeScript
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, unknown>): string {
|
|
return String(doc.publicId ?? doc._id ?? "");
|
|
}
|
|
|
|
private buildClaimBucketsForInsurer(
|
|
rows: Record<string, unknown>[],
|
|
clientKey: string,
|
|
dateFrom?: Date,
|
|
dateTo?: Date,
|
|
): Record<string, number> {
|
|
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<string, unknown>[],
|
|
clientKey: string,
|
|
dateFrom?: Date,
|
|
dateTo?: Date,
|
|
): Record<string, number> {
|
|
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<string, unknown>[],
|
|
claimRows: Record<string, unknown>[],
|
|
clientKey: string,
|
|
dateFrom?: Date,
|
|
dateTo?: Date,
|
|
): 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([
|
|
this.blameRequestDbService.find({}, { lean: true }),
|
|
this.claimCaseDbService.find({}, { lean: true }),
|
|
]);
|
|
return this.buildInsurerSummary(
|
|
blames as Record<string, unknown>[],
|
|
claims as Record<string, unknown>[],
|
|
clientKey,
|
|
);
|
|
}
|
|
|
|
async getInsurerRequestsSummaryPerMonth(actor: {
|
|
clientKey?: string;
|
|
}): 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();
|
|
|
|
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<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;
|
|
}
|
|
}
|