forked from Yara724/api
1960 lines
65 KiB
TypeScript
1960 lines
65 KiB
TypeScript
import {
|
||
BadRequestException,
|
||
ConflictException,
|
||
ForbiddenException,
|
||
Injectable,
|
||
NotFoundException,
|
||
} from "@nestjs/common";
|
||
import { Types } from "mongoose";
|
||
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
|
||
import { CreateBranchDto } from "src/client/dto/create-branch.dto";
|
||
import { BranchDbService } from "src/client/entities/db-service/branch.db.service";
|
||
import {
|
||
CreateBlameExpertByInsurerDto,
|
||
CreateClaimExpertByInsurerDto,
|
||
CreateInsurerExpertDto,
|
||
} from "./dto/create-insurer-expert.dto";
|
||
import {
|
||
blameCaseTouchesClient,
|
||
claimCaseTouchesClient,
|
||
} from "src/helpers/tenant-scope";
|
||
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
|
||
import { FileRating } from "src/request-management/entities/schema/request-management.schema";
|
||
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 { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
|
||
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
|
||
import { FieldExpertDbService } from "src/users/entities/db-service/field-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 { HashService } from "src/utils/hash/hash.service";
|
||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||
import { UserType } from "src/Types&Enums/userType.enum";
|
||
import { BlameVideoDbService } from "src/request-management/entities/db-service/blame-video.db.service";
|
||
import { BlameVoiceDbService } from "src/request-management/entities/db-service/blame.voice.db.service";
|
||
import { VideoCaptureDbService } from "src/claim-request-management/entites/db-service/video-capture.db.service";
|
||
import { ClientDbService } from "src/client/entities/db-service/client.db.service";
|
||
import { buildFileLink, resolveStoredFileUrl } from "src/helpers/urlCreator";
|
||
import { toJalaliDateAndTime } from "src/helpers/date-jalali";
|
||
import { enrichBlamePartiesForAgreementView } from "src/helpers/blame-party-agreement-decision";
|
||
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
|
||
import {
|
||
getClaimCarAngleCaptureBlob,
|
||
getDamagedPartCaptureBlob,
|
||
hasDamagedPartCapture,
|
||
type ClaimCarAngleKey,
|
||
} from "src/helpers/claim-car-angle-media";
|
||
import {
|
||
catalogLikeKeyFromPart,
|
||
normalizeDamageSelectedParts,
|
||
type DamageSelectedPartV2,
|
||
} from "src/helpers/outer-damage-parts";
|
||
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||
import { ClaimSignDbService } from "src/claim-request-management/entites/db-service/claim-sign.db.service";
|
||
import {
|
||
extractExpertNamesFromBlame,
|
||
extractExpertNamesFromClaim,
|
||
} from "./helper/insurer.helper";
|
||
import { buildEnrichedDamagedParts } from "src/expert-claim/dto/claim-damaged-part.enricher";
|
||
|
||
@Injectable()
|
||
export class ExpertInsurerService {
|
||
constructor(
|
||
private readonly expertDbService: ExpertDbService,
|
||
private readonly damageExpertDbService: DamageExpertDbService,
|
||
private readonly fieldExpertDbService: FieldExpertDbService,
|
||
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
|
||
private readonly blameRequestDbService: BlameRequestDbService,
|
||
private readonly claimCaseDbService: ClaimCaseDbService,
|
||
private readonly branchDbService: BranchDbService,
|
||
private readonly hashService: HashService,
|
||
private readonly blameVideoDbService: BlameVideoDbService,
|
||
private readonly blameVoiceDbService: BlameVoiceDbService,
|
||
private readonly videoCaptureDbService: VideoCaptureDbService,
|
||
private readonly clientDbService: ClientDbService,
|
||
private readonly claimSignDbService: ClaimSignDbService,
|
||
) {}
|
||
|
||
/**
|
||
* Returns all expert IDs (blame-expert, damage-expert, field-expert) that
|
||
* belong to this insurer's clientKey in a single parallel fetch.
|
||
*/
|
||
private async getAllRosterExpertIds(
|
||
clientObjectId: Types.ObjectId,
|
||
): Promise<Set<string>> {
|
||
const ckFilter = this.clientKeyScopeFilter(clientObjectId);
|
||
const [experts, damageExperts, fieldExperts] = await Promise.all([
|
||
this.expertDbService.findAll(ckFilter as never),
|
||
this.damageExpertDbService.findAll(ckFilter as never),
|
||
this.fieldExpertDbService.findAll(ckFilter as never),
|
||
]);
|
||
return new Set<string>(
|
||
[...experts, ...damageExperts, ...fieldExperts].map((e: any) =>
|
||
String(e._id),
|
||
),
|
||
);
|
||
}
|
||
|
||
private getClientId(actorOrId: any): Types.ObjectId {
|
||
const raw =
|
||
typeof actorOrId === "string" ? actorOrId : actorOrId?.clientKey;
|
||
if (!raw || !Types.ObjectId.isValid(raw)) {
|
||
throw new BadRequestException("Client key is required");
|
||
}
|
||
return new Types.ObjectId(raw);
|
||
}
|
||
|
||
/**
|
||
* Tenant scope on expert / damage-expert: `clientKey` may be stored as ObjectId or
|
||
* string (legacy and collection-specific writes). Match both, same as reports service.
|
||
*/
|
||
private clientKeyScopeFilter(clientObjectId: Types.ObjectId) {
|
||
const oid = clientObjectId;
|
||
const str = String(oid);
|
||
return { $or: [{ clientKey: oid }, { clientKey: str }] };
|
||
}
|
||
|
||
private async buildExpertActivityStatsMap(
|
||
tenantId: Types.ObjectId,
|
||
expertIds: string[],
|
||
): Promise<Record<string, { totalHandled: number; totalChecked: number }>> {
|
||
const events =
|
||
await this.expertFileActivityDbService.findByTenantAndExperts(
|
||
tenantId,
|
||
expertIds,
|
||
);
|
||
const stateByExpertFile = new Map<
|
||
string,
|
||
{ checked: boolean; handled: boolean }
|
||
>();
|
||
|
||
for (const e of events) {
|
||
const key = `${String(e.expertId)}:${String(e.fileId)}`;
|
||
const prev = stateByExpertFile.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;
|
||
}
|
||
stateByExpertFile.set(key, prev);
|
||
}
|
||
|
||
const result: Record<
|
||
string,
|
||
{ totalHandled: number; totalChecked: number }
|
||
> = {};
|
||
for (const expertId of expertIds) {
|
||
result[expertId] = { totalHandled: 0, totalChecked: 0 };
|
||
}
|
||
for (const [key, st] of stateByExpertFile.entries()) {
|
||
const [expertId] = key.split(":");
|
||
if (!result[expertId])
|
||
result[expertId] = { totalHandled: 0, totalChecked: 0 };
|
||
if (st.handled) result[expertId].totalHandled += 1;
|
||
else if (st.checked) result[expertId].totalChecked += 1;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
private parseObjectId(value: string, label: string): Types.ObjectId {
|
||
if (!Types.ObjectId.isValid(value)) {
|
||
throw new BadRequestException(`Invalid ${label}`);
|
||
}
|
||
return new Types.ObjectId(value);
|
||
}
|
||
|
||
private normalizeClaimCase(claim: any): any {
|
||
return {
|
||
...claim,
|
||
requestNumber: claim.requestNo,
|
||
userClientKey: claim.owner?.userClientKey,
|
||
fullName: claim.owner?.fullName,
|
||
carDetail: claim.vehicle,
|
||
carPlate: claim.vehicle?.plate,
|
||
claimStatus: claim.status,
|
||
rating: claim.evaluation?.rating,
|
||
userRating: claim.evaluation?.userRating,
|
||
damageExpertReply: claim.evaluation?.damageExpertReply,
|
||
damageExpertReplyFinal: claim.evaluation?.damageExpertReplyFinal,
|
||
damageExpertResend: claim.evaluation?.damageExpertResend,
|
||
objection: claim.evaluation?.objection,
|
||
userResendDocuments: claim.evaluation?.userResendDocuments,
|
||
priceDrop: claim.evaluation?.priceDrop,
|
||
visitLocation: claim.evaluation?.visitLocation,
|
||
currentStep: claim.workflow?.currentStep,
|
||
nextStep: claim.workflow?.nextStep,
|
||
};
|
||
}
|
||
|
||
private normalizeBlameCase(blame: any): any {
|
||
const firstParty = blame?.parties?.find((p) => p?.role === "FIRST");
|
||
const secondParty = blame?.parties?.find((p) => p?.role === "SECOND");
|
||
return {
|
||
...blame,
|
||
requestNumber: blame.requestNo,
|
||
rating: blame?.expert?.rating,
|
||
actorLocked: { actorId: blame?.expert?.assignedExpertId },
|
||
firstPartyDetails: {
|
||
firstPartyClient: { clientId: firstParty?.person?.clientId },
|
||
},
|
||
secondPartyDetails: {
|
||
secondPartyClient: { clientId: secondParty?.person?.clientId },
|
||
},
|
||
};
|
||
}
|
||
|
||
/** BlameCases may tie the field expert via assignment, expert-initiated id, or decision author. */
|
||
private blameFieldExpertIdCandidates(b: any): string[] {
|
||
const raw: unknown[] = [
|
||
b?.expert?.assignedExpertId,
|
||
b?.initiatedByFieldExpertId,
|
||
b?.expert?.decision?.decidedByExpertId,
|
||
];
|
||
const out = new Set<string>();
|
||
for (const x of raw) {
|
||
if (x != null && x !== "") out.add(String(x));
|
||
}
|
||
return [...out];
|
||
}
|
||
|
||
private claimDamageExpertActorId(c: any): string | null {
|
||
const reply =
|
||
c?.evaluation?.damageExpertReplyFinal || c?.evaluation?.damageExpertReply;
|
||
const aid = reply?.actorDetail?.actorId;
|
||
if (aid == null || aid === "") return null;
|
||
return String(aid);
|
||
}
|
||
|
||
private mapBlameFileSummaryForInsurerExpert(b: any) {
|
||
return {
|
||
kind: "blame" as const,
|
||
requestId: String(b._id),
|
||
publicId: b.publicId,
|
||
requestNo: b.requestNo,
|
||
type: b.type,
|
||
status: b.status,
|
||
blameStatus: b.blameStatus,
|
||
createdAt: b.createdAt,
|
||
updatedAt: b.updatedAt,
|
||
};
|
||
}
|
||
|
||
private mapClaimFileSummaryForInsurerExpert(c: any) {
|
||
return {
|
||
kind: "claim" as const,
|
||
requestId: String(c._id),
|
||
publicId: c.publicId,
|
||
requestNo: c.requestNo ?? c.requestNumber,
|
||
status: c.status,
|
||
claimStatus: c.claimStatus,
|
||
createdAt: c.createdAt,
|
||
updatedAt: c.updatedAt,
|
||
};
|
||
}
|
||
|
||
private buildPartiesPreview(parties: any[] | undefined) {
|
||
if (!Array.isArray(parties) || !parties.length) return undefined;
|
||
return parties.map((p: any) => ({
|
||
role: p?.role,
|
||
fullName: p?.person?.fullName,
|
||
vehicle: {
|
||
carName: p?.vehicle?.carName ?? p?.vehicle?.name,
|
||
carModel: p?.vehicle?.carModel ?? p?.vehicle?.model,
|
||
plate: p?.vehicle?.plate ?? p?.vehicle?.plateId,
|
||
},
|
||
}));
|
||
}
|
||
|
||
private sanitizeVehicleInquiry(vehicle: any) {
|
||
if (!vehicle || typeof vehicle !== "object") return vehicle;
|
||
const inquiry = vehicle.inquiry;
|
||
if (!inquiry || typeof inquiry !== "object") return vehicle;
|
||
const { raw, mapped, ...safeInquiry } = inquiry;
|
||
return {
|
||
...vehicle,
|
||
inquiry: safeInquiry,
|
||
};
|
||
}
|
||
|
||
private sanitizePartyForInsurerView(party: any) {
|
||
if (!party || typeof party !== "object") return party;
|
||
return {
|
||
...party,
|
||
vehicle: this.sanitizeVehicleInquiry(party.vehicle),
|
||
};
|
||
}
|
||
|
||
private serializeBlameExpertDecisionForInsurer(
|
||
decision: unknown,
|
||
): Record<string, unknown> | undefined {
|
||
if (!decision || typeof decision !== "object") {
|
||
return undefined;
|
||
}
|
||
const d = decision as Record<string, unknown>;
|
||
const guilty = d.guiltyPartyId;
|
||
const decidedBy = d.decidedByExpertId;
|
||
const decidedAt = d.decidedAt;
|
||
return {
|
||
...d,
|
||
guiltyPartyId:
|
||
guilty != null &&
|
||
typeof (guilty as { toString?: () => string }).toString === "function"
|
||
? String(guilty)
|
||
: guilty,
|
||
decidedByExpertId:
|
||
decidedBy != null &&
|
||
typeof (decidedBy as { toString?: () => string }).toString ===
|
||
"function"
|
||
? String(decidedBy)
|
||
: decidedBy,
|
||
decidedAt:
|
||
decidedAt instanceof Date ? decidedAt.toISOString() : decidedAt,
|
||
};
|
||
}
|
||
|
||
/** Same semantics as damage-expert claim detail `blameFileContext`. */
|
||
private blameFileContextForInsurer(blame: Record<string, unknown> | null): {
|
||
blameRequestType?: BlameRequestType;
|
||
carBodyFirstForm?: { car?: boolean; object?: boolean };
|
||
blameStatus?: string;
|
||
} {
|
||
if (!blame?.type) return {};
|
||
const blameRequestType = blame.type as BlameRequestType;
|
||
const out: {
|
||
blameRequestType?: BlameRequestType;
|
||
carBodyFirstForm?: { car?: boolean; object?: boolean };
|
||
blameStatus?: string;
|
||
} = { blameRequestType };
|
||
out.blameStatus = blame.blameStatus as string | undefined;
|
||
if (blameRequestType !== BlameRequestType.CAR_BODY) return out;
|
||
|
||
const parties = blame.parties;
|
||
if (!Array.isArray(parties) || parties.length === 0) return out;
|
||
|
||
const first =
|
||
(parties as any[]).find((p: any) => p?.role === PartyRole.FIRST) ??
|
||
(parties as any[])[0];
|
||
const cbf = first?.carBodyFirstForm;
|
||
delete out.blameStatus;
|
||
if (cbf && typeof cbf === "object") {
|
||
out.carBodyFirstForm = {
|
||
...(typeof cbf.car === "boolean" ? { car: cbf.car } : {}),
|
||
...(typeof cbf.object === "boolean" ? { object: cbf.object } : {}),
|
||
};
|
||
}
|
||
return out;
|
||
}
|
||
|
||
private async clientNamesByClientIds(
|
||
ids: Array<string | undefined | null>,
|
||
): Promise<Record<string, { persian: string; english: string }>> {
|
||
const unique = [
|
||
...new Set(
|
||
ids
|
||
.filter((x): x is string => !!x && Types.ObjectId.isValid(String(x)))
|
||
.map(String),
|
||
),
|
||
];
|
||
const out: Record<string, { persian: string; english: string }> = {};
|
||
await Promise.all(
|
||
unique.map(async (id) => {
|
||
const doc = await this.clientDbService.find({
|
||
_id: new Types.ObjectId(id),
|
||
});
|
||
if (doc?.clientName) out[id] = doc.clientName;
|
||
}),
|
||
);
|
||
return out;
|
||
}
|
||
|
||
private mapPersonForInsurer(
|
||
person: any,
|
||
clientNames: Record<string, { persian: string; english: string }>,
|
||
) {
|
||
if (!person || typeof person !== "object") return person;
|
||
const cid =
|
||
person.clientId?.toString?.() ??
|
||
(person.clientId != null ? String(person.clientId) : undefined);
|
||
return {
|
||
...person,
|
||
userId: person.userId?.toString?.() ?? undefined,
|
||
clientId: cid,
|
||
clientName: cid ? clientNames[cid] : undefined,
|
||
};
|
||
}
|
||
|
||
private mapPartyForInsurerFull(
|
||
party: any,
|
||
clientNames: Record<string, { persian: string; english: string }>,
|
||
) {
|
||
if (!party || typeof party !== "object") return party;
|
||
return {
|
||
...party,
|
||
person: this.mapPersonForInsurer(party.person, clientNames),
|
||
vehicle: this.sanitizeVehicleInquiry(party.vehicle),
|
||
};
|
||
}
|
||
|
||
/** Public URL for a stored relative path or pass-through if already absolute. */
|
||
private linkFromPathOrUrl(stored: string | undefined): string | undefined {
|
||
if (stored == null || stored === "") return undefined;
|
||
const s = String(stored);
|
||
if (/^https?:\/\//i.test(s)) return s;
|
||
return buildFileLink(s);
|
||
}
|
||
|
||
private enrichSignatureRecord(
|
||
sig: Record<string, unknown> | undefined,
|
||
): Record<string, unknown> | undefined {
|
||
if (!sig || typeof sig !== "object") return sig;
|
||
const fileUrl = sig.fileUrl != null ? String(sig.fileUrl) : undefined;
|
||
const link = this.linkFromPathOrUrl(fileUrl);
|
||
return { ...sig, link };
|
||
}
|
||
|
||
private enrichPartiesConfirmationSignatures(parties: any[]): void {
|
||
if (!Array.isArray(parties)) return;
|
||
for (const p of parties) {
|
||
const sig = p?.confirmation?.signature;
|
||
if (sig && typeof sig === "object") {
|
||
p.confirmation = {
|
||
...p.confirmation,
|
||
signature: this.enrichSignatureRecord(sig as Record<string, unknown>),
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
private insurerDamagedPartRowFromCapture(
|
||
sp: DamageSelectedPartV2,
|
||
index: number,
|
||
damagedPartsDataExpert: unknown,
|
||
selectedNormExpert: DamageSelectedPartV2[],
|
||
) {
|
||
const ck = sp.catalogKey ?? catalogLikeKeyFromPart(sp);
|
||
const cap = getDamagedPartCaptureBlob(
|
||
damagedPartsDataExpert,
|
||
ck,
|
||
selectedNormExpert,
|
||
) as { url?: string; path?: string; fileName?: string } | undefined;
|
||
const url = resolveStoredFileUrl(cap);
|
||
const link = url;
|
||
return {
|
||
index,
|
||
id: sp.id,
|
||
name: sp.name,
|
||
side: sp.side,
|
||
label_fa: sp.label_fa,
|
||
catalogKey: sp.catalogKey,
|
||
captured: hasDamagedPartCapture(
|
||
damagedPartsDataExpert,
|
||
ck,
|
||
selectedNormExpert,
|
||
),
|
||
path: cap?.path,
|
||
fileName: cap?.fileName,
|
||
url,
|
||
link,
|
||
};
|
||
}
|
||
|
||
private sanitizeDamageForInsurerDetail(damage: unknown): unknown {
|
||
if (!damage || typeof damage !== "object") return damage;
|
||
const d = { ...(damage as Record<string, unknown>) };
|
||
delete d.selectedOuterParts;
|
||
delete d.selectedPartIds;
|
||
return d;
|
||
}
|
||
|
||
private stripCarTypeFromPartyVehicles(parties: any[]): any[] {
|
||
if (!Array.isArray(parties)) return parties;
|
||
return parties.map((p) => {
|
||
const v = p?.vehicle;
|
||
if (!v || typeof v !== "object") return p;
|
||
const { carType: _omit, ...rest } = v as Record<string, unknown>;
|
||
const nextV = Object.keys(rest).length ? rest : undefined;
|
||
return { ...p, vehicle: nextV };
|
||
});
|
||
}
|
||
|
||
private async claimSignLinkFromId(
|
||
signDetailId: unknown,
|
||
): Promise<string | undefined> {
|
||
if (signDetailId == null || signDetailId === "") return undefined;
|
||
const id = String(signDetailId);
|
||
if (!Types.ObjectId.isValid(id)) return undefined;
|
||
const doc = await this.claimSignDbService.findOne({
|
||
_id: new Types.ObjectId(id),
|
||
});
|
||
const path = (doc as any)?.path;
|
||
return path ? buildFileLink(String(path)) : undefined;
|
||
}
|
||
|
||
private async enrichClaimEvaluationForInsurer(
|
||
evaluation: Record<string, unknown> | undefined,
|
||
): Promise<Record<string, unknown> | undefined> {
|
||
if (!evaluation || typeof evaluation !== "object") return evaluation;
|
||
const ev = JSON.parse(JSON.stringify(evaluation)) as Record<
|
||
string,
|
||
unknown
|
||
>;
|
||
|
||
const enrichReply = async (reply: any) => {
|
||
if (!reply || typeof reply !== "object") return;
|
||
const uc = reply.userComment;
|
||
if (uc?.signDetailId != null) {
|
||
const signLink = await this.claimSignLinkFromId(uc.signDetailId);
|
||
reply.userComment = { ...uc, signLink };
|
||
}
|
||
};
|
||
await enrichReply(ev.damageExpertReply);
|
||
await enrichReply(ev.damageExpertReplyFinal);
|
||
|
||
const enrichApproval = async (key: string) => {
|
||
const o = ev[key] as Record<string, unknown> | undefined;
|
||
if (o?.signDetailId != null) {
|
||
const signLink = await this.claimSignLinkFromId(o.signDetailId);
|
||
ev[key] = { ...o, signLink };
|
||
}
|
||
};
|
||
await enrichApproval("ownerInsurerApproval");
|
||
await enrichApproval("ownerPricedPartsApproval");
|
||
|
||
return ev;
|
||
}
|
||
|
||
private async enrichBlamePartyEvidenceUrls(parties: any[]): Promise<void> {
|
||
if (!Array.isArray(parties)) return;
|
||
for (const party of parties) {
|
||
if (!party?.evidence) continue;
|
||
const evidence = party.evidence as Record<string, unknown>;
|
||
|
||
if (evidence.videoId) {
|
||
const videoDoc = await this.blameVideoDbService.findById(
|
||
String(evidence.videoId),
|
||
);
|
||
if (videoDoc?.path) evidence.videoUrl = buildFileLink(videoDoc.path);
|
||
}
|
||
|
||
if (evidence.voices && Array.isArray(evidence.voices)) {
|
||
const voiceUrls: string[] = [];
|
||
for (const voiceId of evidence.voices) {
|
||
const voiceDoc = await this.blameVoiceDbService.findById(
|
||
String(voiceId),
|
||
);
|
||
if (voiceDoc?.path) voiceUrls.push(buildFileLink(voiceDoc.path));
|
||
}
|
||
evidence.voiceUrls = voiceUrls;
|
||
}
|
||
}
|
||
}
|
||
|
||
private async buildInsurerBlameDetail(
|
||
blameLean: Record<string, unknown>,
|
||
): Promise<Record<string, unknown>> {
|
||
const doc: Record<string, unknown> = { ...blameLean };
|
||
const parties = (Array.isArray(doc.parties) ? doc.parties : []) as any[];
|
||
await this.enrichBlamePartyEvidenceUrls(parties);
|
||
doc.parties = enrichBlamePartiesForAgreementView(doc);
|
||
|
||
const mergedParties = doc.parties as any[];
|
||
const clientIds = mergedParties
|
||
.map((p) => p?.person?.clientId?.toString?.())
|
||
.filter(Boolean);
|
||
const clientNames = await this.clientNamesByClientIds(clientIds);
|
||
doc.parties = mergedParties.map((p) =>
|
||
this.mapPartyForInsurerFull(p, clientNames),
|
||
);
|
||
this.enrichPartiesConfirmationSignatures(doc.parties as any[]);
|
||
|
||
if (doc.expert && typeof doc.expert === "object") {
|
||
const ex = { ...(doc.expert as Record<string, unknown>) };
|
||
const serialized = this.serializeBlameExpertDecisionForInsurer(
|
||
ex.decision,
|
||
);
|
||
if (serialized !== undefined) ex.decision = serialized;
|
||
doc.expert = ex;
|
||
}
|
||
|
||
const createdAt = doc.createdAt
|
||
? new Date(doc.createdAt as string | number | Date)
|
||
: new Date();
|
||
const updatedAt = doc.updatedAt
|
||
? new Date(doc.updatedAt as string | number | Date)
|
||
: new Date();
|
||
const [cd, ct] = toJalaliDateAndTime(createdAt);
|
||
const [ud, ut] = toJalaliDateAndTime(updatedAt);
|
||
doc.createdAtFormatted = `${cd} ${ct}`;
|
||
doc.updatedAtFormatted = `${ud} ${ut}`;
|
||
|
||
delete doc.history;
|
||
|
||
return doc;
|
||
}
|
||
|
||
private async buildInsurerClaimDetail(
|
||
claim: Record<string, unknown>,
|
||
blameForContext: Record<string, unknown> | null,
|
||
): Promise<Record<string, unknown>> {
|
||
// Add at the top of buildInsurerClaimDetail, before the return:
|
||
const carType = (claim as any).vehicle?.carType as
|
||
| ClaimVehicleTypeV2
|
||
| undefined;
|
||
const selectedNorm = normalizeDamageSelectedParts(
|
||
(claim as any).damage?.selectedParts,
|
||
carType,
|
||
(claim as any).damage?.selectedOuterParts,
|
||
);
|
||
|
||
const damagedParts = buildEnrichedDamagedParts({
|
||
selectedParts: selectedNorm,
|
||
damagedPartsData: (claim as any).media?.damagedParts,
|
||
evaluationBlock: (claim as any).evaluation,
|
||
expertAddedParts: (claim as any).damage?.expertAddedParts ?? [],
|
||
getDamagedPartCaptureBlob,
|
||
hasDamagedPartCapture,
|
||
catalogLikeKeyFromPart,
|
||
buildFileLink,
|
||
});
|
||
|
||
// Then add damagedParts to the return object
|
||
const requiredDocs = claim.requiredDocuments as any;
|
||
const requiredDocumentsStatus: Record<string, any> = {};
|
||
if (requiredDocs) {
|
||
const keys =
|
||
requiredDocs instanceof Map
|
||
? Array.from(requiredDocs.keys())
|
||
: Object.keys(requiredDocs);
|
||
for (const k of keys as string[]) {
|
||
const row =
|
||
requiredDocs instanceof Map ? requiredDocs.get(k) : requiredDocs[k];
|
||
requiredDocumentsStatus[k] = {
|
||
uploaded: !!row?.uploaded,
|
||
fileId: row?.fileId?.toString?.(),
|
||
fileName: row?.fileName,
|
||
fileUrl: row?.filePath ? buildFileLink(row.filePath) : undefined,
|
||
};
|
||
}
|
||
}
|
||
|
||
const carAnglesData = (claim.media as any)?.carAngles;
|
||
const damagedPartsDataForAngles = (claim.media as any)?.damagedParts;
|
||
const carAngles: Record<string, { captured: boolean; url?: string }> = {};
|
||
for (const k of ["front", "back", "left", "right"] as ClaimCarAngleKey[]) {
|
||
const cap = getClaimCarAngleCaptureBlob(
|
||
carAnglesData,
|
||
damagedPartsDataForAngles,
|
||
k,
|
||
) as { url?: string; path?: string } | undefined;
|
||
carAngles[k] = {
|
||
captured: !!cap,
|
||
url: resolveStoredFileUrl(cap),
|
||
};
|
||
}
|
||
|
||
const videoCaptureIdRaw = (claim.media as any)?.videoCaptureId;
|
||
let videoCapture:
|
||
| { id: string; fileName?: string; path?: string; url?: string }
|
||
| undefined;
|
||
if (videoCaptureIdRaw) {
|
||
const vc = (await this.videoCaptureDbService.findById(
|
||
String(videoCaptureIdRaw),
|
||
)) as any;
|
||
if (vc) {
|
||
videoCapture = {
|
||
id: String(vc._id ?? videoCaptureIdRaw),
|
||
fileName: vc.fileName,
|
||
path: vc.path,
|
||
url: vc.path ? buildFileLink(vc.path) : undefined,
|
||
};
|
||
}
|
||
}
|
||
|
||
const evaluation = claim.evaluation as Record<string, unknown> | undefined;
|
||
const owner = claim.owner as any;
|
||
const ownerOut = owner
|
||
? {
|
||
...owner,
|
||
userId: owner.userId?.toString?.(),
|
||
userClientKey: owner.userClientKey?.toString?.(),
|
||
}
|
||
: undefined;
|
||
|
||
let snapshotOut = claim.snapshot as any;
|
||
if (snapshotOut && Array.isArray(snapshotOut.parties)) {
|
||
const snapIds = snapshotOut.parties
|
||
.map((p: any) => p?.person?.clientId?.toString?.())
|
||
.filter(Boolean);
|
||
const snapNames = await this.clientNamesByClientIds(snapIds);
|
||
const snapParties = this.stripCarTypeFromPartyVehicles(
|
||
snapshotOut.parties.map((p: any) =>
|
||
this.mapPartyForInsurerFull(p, snapNames),
|
||
),
|
||
);
|
||
this.enrichPartiesConfirmationSignatures(snapParties);
|
||
snapshotOut = {
|
||
...snapshotOut,
|
||
parties: snapParties,
|
||
};
|
||
}
|
||
|
||
const evaluationEnriched =
|
||
await this.enrichClaimEvaluationForInsurer(evaluation);
|
||
const vehicleSanitized = claim.vehicle
|
||
? this.sanitizeVehicleInquiry(claim.vehicle as any)
|
||
: undefined;
|
||
const vehicleOut = vehicleSanitized
|
||
? (() => {
|
||
const { carType: _omit, ...rest } = vehicleSanitized as Record<
|
||
string,
|
||
unknown
|
||
>;
|
||
return rest;
|
||
})()
|
||
: undefined;
|
||
|
||
return {
|
||
claimRequestId: (claim._id as any)?.toString?.(),
|
||
publicId: claim.publicId,
|
||
status: claim.status,
|
||
claimStatus: claim.claimStatus,
|
||
blameDocumentResendPending: claim.blameDocumentResendPending,
|
||
workflow: claim.workflow,
|
||
owner: ownerOut,
|
||
vehicle: vehicleOut,
|
||
blameRequestId: (claim as any).blameRequestId?.toString?.(),
|
||
blameRequestNo: claim.blameRequestNo,
|
||
blameFileContext: blameForContext
|
||
? this.blameFileContextForInsurer(blameForContext)
|
||
: undefined,
|
||
money: claim.money,
|
||
damage: this.sanitizeDamageForInsurerDetail(claim.damage),
|
||
media: claim.media,
|
||
requiredDocuments:
|
||
Object.keys(requiredDocumentsStatus).length > 0
|
||
? requiredDocumentsStatus
|
||
: undefined,
|
||
carAngles,
|
||
damagedParts,
|
||
videoCapture,
|
||
evaluation: evaluationEnriched,
|
||
userRating: claim.userRating,
|
||
insurerRating: (evaluationEnriched as any)?.rating,
|
||
snapshot: snapshotOut,
|
||
createdAt: claim.createdAt,
|
||
updatedAt: claim.updatedAt,
|
||
};
|
||
}
|
||
|
||
private getCombinedFileScore(file: any): number | null {
|
||
const insurerRating = file?.rating;
|
||
const userRating = file?.userRating;
|
||
const insurerValues = insurerRating
|
||
? (
|
||
[
|
||
insurerRating.collisionMethodAccuracy,
|
||
insurerRating.evaluationTimeliness,
|
||
insurerRating.accidentCauseAccuracy,
|
||
insurerRating.guiltyVehicleIdentification,
|
||
] as unknown[]
|
||
).filter((v): v is number => typeof v === "number" && !isNaN(v))
|
||
: [];
|
||
const userValues = userRating
|
||
? [
|
||
userRating.progressSpeed,
|
||
userRating.registrationEase,
|
||
userRating.overallEvaluation,
|
||
].filter((v) => typeof v === "number" && !isNaN(v))
|
||
: [];
|
||
const insurerAvg = insurerValues.length
|
||
? insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length
|
||
: null;
|
||
const userAvg = userValues.length
|
||
? userValues.reduce((a, b) => a + b, 0) / userValues.length
|
||
: null;
|
||
const scores = [insurerAvg, userAvg].filter(
|
||
(v): v is number => typeof v === "number",
|
||
);
|
||
if (!scores.length) return null;
|
||
return parseFloat(
|
||
(scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(2),
|
||
);
|
||
}
|
||
|
||
private async getClientBlameFiles(
|
||
clientObjectId: Types.ObjectId,
|
||
): Promise<any[]> {
|
||
const all = (await this.blameRequestDbService.find(
|
||
{},
|
||
{ lean: true },
|
||
)) as any[];
|
||
const idStr = String(clientObjectId);
|
||
return all
|
||
.filter((f) =>
|
||
(f?.parties || []).some(
|
||
(p) => String(p?.person?.clientId || "") === idStr,
|
||
),
|
||
)
|
||
.map((f) => this.normalizeBlameCase(f));
|
||
}
|
||
|
||
private async getClientClaimFiles(
|
||
clientObjectId: Types.ObjectId,
|
||
): Promise<any[]> {
|
||
const [all, rosterExpertIds] = await Promise.all([
|
||
this.claimCaseDbService.find({}, { lean: true }) as Promise<any[]>,
|
||
this.getAllRosterExpertIds(clientObjectId),
|
||
]);
|
||
const idStr = String(clientObjectId);
|
||
|
||
return all
|
||
.filter((f) => {
|
||
// Primary: owner.clientId matches this insurer (guilty party's insurance)
|
||
if (claimCaseTouchesClient(f, idStr)) return true;
|
||
// Secondary: initiated or evaluated by one of this insurer's experts
|
||
// (covers field-expert IN_PERSON flow where owner.clientId ≠ this insurer)
|
||
const fieldExpertId = f?.initiatedByFieldExpertId
|
||
? String(f.initiatedByFieldExpertId)
|
||
: null;
|
||
if (fieldExpertId && rosterExpertIds.has(fieldExpertId)) return true;
|
||
const damageExpertId = this.claimDamageExpertActorId(f);
|
||
if (damageExpertId && rosterExpertIds.has(damageExpertId)) return true;
|
||
return false;
|
||
})
|
||
.map((f) => this.normalizeClaimCase(f));
|
||
}
|
||
|
||
async retrieveAllExpertsOfClient(
|
||
actor,
|
||
currentPage: number,
|
||
countPerPage: number,
|
||
) {
|
||
const clientObjectId = this.getClientId(actor);
|
||
const ckFilter = this.clientKeyScopeFilter(clientObjectId);
|
||
const [experts, damageExperts, fieldExperts, blameFiles, claimFiles] =
|
||
await Promise.all([
|
||
this.expertDbService.findAll(ckFilter as never),
|
||
this.damageExpertDbService.findAll(ckFilter as never),
|
||
this.fieldExpertDbService.findAll(ckFilter as never),
|
||
this.getClientBlameFiles(clientObjectId),
|
||
this.getClientClaimFiles(clientObjectId),
|
||
]);
|
||
|
||
const allExpertsRaw = [...experts, ...damageExperts, ...fieldExperts];
|
||
const expertIds = allExpertsRaw.map((e) => String(e._id));
|
||
const expertActivityStatsMap = await this.buildExpertActivityStatsMap(
|
||
clientObjectId,
|
||
expertIds,
|
||
);
|
||
const expertTotalRatingsMap: Record<string, number[]> = {};
|
||
const expertRatingsByCategoryMap: Record<
|
||
string,
|
||
Record<string, number[]>
|
||
> = {};
|
||
|
||
const processRatings = (expertId: string | undefined, file: any) => {
|
||
if (!expertId) return;
|
||
const rating = file?.rating;
|
||
const combinedScore = this.getCombinedFileScore(file);
|
||
if (combinedScore !== null) {
|
||
if (!expertTotalRatingsMap[expertId])
|
||
expertTotalRatingsMap[expertId] = [];
|
||
expertTotalRatingsMap[expertId].push(combinedScore);
|
||
}
|
||
if (!rating || typeof rating !== "object") return;
|
||
if (!expertRatingsByCategoryMap[expertId])
|
||
expertRatingsByCategoryMap[expertId] = {};
|
||
for (const [category, value] of Object.entries(rating)) {
|
||
if (category === "botRating") continue;
|
||
if (typeof value === "number" && !isNaN(value)) {
|
||
if (!expertRatingsByCategoryMap[expertId][category]) {
|
||
expertRatingsByCategoryMap[expertId][category] = [];
|
||
}
|
||
expertRatingsByCategoryMap[expertId][category].push(value);
|
||
}
|
||
}
|
||
};
|
||
|
||
for (const file of blameFiles) {
|
||
processRatings(file?.actorLocked?.actorId?.toString?.(), file);
|
||
}
|
||
for (const file of claimFiles) {
|
||
processRatings(file?.damageExpertReply?.actorDetail?.actorId, file);
|
||
}
|
||
|
||
const mapExpertRow = (expert: any, expertKind: "blame" | "claim") => {
|
||
const expertIdStr = expert._id.toString();
|
||
const totalRatings = expertTotalRatingsMap[expertIdStr] || [];
|
||
const overallAverageRating = totalRatings.length
|
||
? parseFloat(
|
||
(
|
||
totalRatings.reduce((a, b) => a + b, 0) / totalRatings.length
|
||
).toFixed(2),
|
||
)
|
||
: null;
|
||
const averageRatingsByCategory: Record<string, number> = {};
|
||
const ratingsByCat = expertRatingsByCategoryMap[expertIdStr];
|
||
if (ratingsByCat) {
|
||
for (const [category, values] of Object.entries(ratingsByCat)) {
|
||
averageRatingsByCategory[category] = parseFloat(
|
||
(values.reduce((a, b) => a + b, 0) / values.length).toFixed(2),
|
||
);
|
||
}
|
||
}
|
||
return {
|
||
_id: expert._id,
|
||
fullName: `${expert.firstName} ${expert.lastName}`,
|
||
role: expert.role,
|
||
expertKind,
|
||
type: expert.userType,
|
||
requestStats: expertActivityStatsMap[expertIdStr] ?? {
|
||
totalHandled: 0,
|
||
totalChecked: 0,
|
||
},
|
||
createdAt: expert.createdAt,
|
||
overallAverageRating,
|
||
averageRatingsByCategory,
|
||
};
|
||
};
|
||
|
||
const allExperts = [
|
||
...experts.map((e) => mapExpertRow(e, "blame")),
|
||
...damageExperts.map((e) => mapExpertRow(e, "claim")),
|
||
...fieldExperts.map((e) => mapExpertRow(e, "blame")),
|
||
];
|
||
|
||
const page = Number(currentPage) > 0 ? Number(currentPage) : 1;
|
||
const perPage = Number(countPerPage) > 0 ? Number(countPerPage) : 20;
|
||
const start = (page - 1) * perPage;
|
||
return {
|
||
total: allExperts.length,
|
||
page,
|
||
countPerPage: perPage,
|
||
experts: allExperts.slice(start, start + perPage),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Top experts per roster: blame rows come from the `expert` collection (files they
|
||
* handled on blame cases); claim rows from `damage-expert` (claim evaluations).
|
||
* Scores are aggregated from **file documents** that insurer can see: for each such
|
||
* file we take `evaluation.rating` / `expert.rating` (insurer dimensions only,
|
||
* excluding `botRating`), optionally blend with the file’s user rating, average those
|
||
* combined scores per file, then average across that expert’s files (`overallAverageRating`).
|
||
*/
|
||
async getTopExpertsForClient(actor): Promise<{
|
||
blameExperts: any[];
|
||
claimExperts: any[];
|
||
}> {
|
||
const result = await this.retrieveAllExpertsOfClient(actor, 1, 1000);
|
||
const rows = result?.experts || [];
|
||
|
||
const byRatingDesc = (a: any, b: any) =>
|
||
(b.overallAverageRating ?? 0) - (a.overallAverageRating ?? 0);
|
||
|
||
const blameExperts = rows
|
||
.filter((e) => e.expertKind === "blame")
|
||
.sort(byRatingDesc)
|
||
.slice(0, 10);
|
||
const claimExperts = rows
|
||
.filter((e) => e.expertKind === "claim")
|
||
.sort(byRatingDesc)
|
||
.slice(0, 10);
|
||
|
||
return { blameExperts, claimExperts };
|
||
}
|
||
|
||
/**
|
||
* Returns top 10 claim files for the current insurer client based on
|
||
* combined insurer + user ratings.
|
||
*/
|
||
async getTopFilesForClient(insurerId: string): Promise<any[]> {
|
||
const claimFiles = await this.getClientClaimFiles(
|
||
this.getClientId(insurerId),
|
||
);
|
||
const scored = claimFiles
|
||
.map((file) => {
|
||
const combinedScore = this.getCombinedFileScore(file);
|
||
if (combinedScore === null) return null;
|
||
return { ...file, combinedScore };
|
||
})
|
||
.filter((f) => f !== null);
|
||
return scored
|
||
.sort((a, b) => b.combinedScore - a.combinedScore)
|
||
.slice(0, 10);
|
||
}
|
||
|
||
async getAllFilesForInsurerExpert(
|
||
expertId: string,
|
||
insurerClientKey: string,
|
||
) {
|
||
const expertObjectId = this.parseObjectId(expertId, "expert id");
|
||
const clientOid = this.getClientId(insurerClientKey);
|
||
const ckFilter = this.clientKeyScopeFilter(clientOid);
|
||
const [experts, damageExperts, fieldExperts] = await Promise.all([
|
||
this.expertDbService.findAll(ckFilter as never),
|
||
this.damageExpertDbService.findAll(ckFilter as never),
|
||
this.fieldExpertDbService.findAll(ckFilter as never),
|
||
]);
|
||
const id = String(expertObjectId);
|
||
const onBlameRoster =
|
||
experts.some((e) => String(e._id) === id) ||
|
||
fieldExperts.some((e) => String(e._id) === id);
|
||
const onClaimRoster = damageExperts.some((e) => String(e._id) === id);
|
||
if (!onBlameRoster && !onClaimRoster) {
|
||
throw new ForbiddenException(
|
||
"This expert is not registered under your insurance company.",
|
||
);
|
||
}
|
||
|
||
// Gather all files this expert touched — blame and claim — regardless of
|
||
// which insurance company's party appears on the file. The roster check
|
||
// above already ensures the expert belongs to THIS insurer, so showing all
|
||
// files they handled is the correct scope.
|
||
const [allBlames, allClaims] = await Promise.all([
|
||
onBlameRoster
|
||
? (this.blameRequestDbService.find({}, { lean: true }) as Promise<any[]>)
|
||
: Promise.resolve([] as any[]),
|
||
(this.claimCaseDbService.find({}, { lean: true }) as Promise<any[]>),
|
||
]);
|
||
|
||
const result: any[] = [];
|
||
|
||
// Blame files handled by a field/blame expert
|
||
if (onBlameRoster) {
|
||
for (const b of allBlames) {
|
||
if (this.blameFieldExpertIdCandidates(b).includes(id)) {
|
||
result.push(this.mapBlameFileSummaryForInsurerExpert(b));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Claim files: initiated by this field expert OR evaluated by this damage expert
|
||
for (const c of allClaims) {
|
||
const isFieldExpertClaim =
|
||
c?.initiatedByFieldExpertId &&
|
||
String(c.initiatedByFieldExpertId) === id;
|
||
const isDamageExpertClaim =
|
||
onClaimRoster && this.claimDamageExpertActorId(c) === id;
|
||
if (isFieldExpertClaim || isDamageExpertClaim) {
|
||
result.push(this.mapClaimFileSummaryForInsurerExpert(c));
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
private validateInsurerFileRating(rating: FileRating) {
|
||
const required: (keyof FileRating)[] = [
|
||
"collisionMethodAccuracy",
|
||
"evaluationTimeliness",
|
||
"accidentCauseAccuracy",
|
||
"guiltyVehicleIdentification",
|
||
"botRating",
|
||
];
|
||
for (const key of required) {
|
||
const value = rating?.[key];
|
||
if (typeof value !== "number" || value < 0 || value > 5) {
|
||
throw new BadRequestException(
|
||
`${key} must be a number between 0 and 5`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Applies one insurer rating to every underlying case (claim and/or blame) for
|
||
* the shared `publicId`, when that case exists and belongs to this insurer.
|
||
*/
|
||
async rateExpertByPublicId(
|
||
publicId: string,
|
||
rating: FileRating,
|
||
insurerClientKey: string,
|
||
) {
|
||
if (!publicId?.trim()) {
|
||
throw new BadRequestException("publicId is required");
|
||
}
|
||
this.validateInsurerFileRating(rating);
|
||
|
||
const id = this.getClientId(insurerClientKey);
|
||
const [blameFiles, claimFiles] = await Promise.all([
|
||
this.getClientBlameFiles(id),
|
||
this.getClientClaimFiles(id),
|
||
]);
|
||
|
||
const blame = blameFiles.find(
|
||
(b) => String((b as any).publicId) === publicId,
|
||
);
|
||
const claim = claimFiles.find(
|
||
(c) => String((c as any).publicId) === publicId,
|
||
);
|
||
|
||
if (!blame && !claim) {
|
||
throw new NotFoundException("File not found for this publicId");
|
||
}
|
||
|
||
const out: {
|
||
publicId: string;
|
||
claim?: { requestId: string; updatedRating: FileRating };
|
||
blame?: { requestId: string; updatedRating: FileRating };
|
||
} = { publicId };
|
||
|
||
if (claim) {
|
||
const claimId = this.parseObjectId(
|
||
String((claim as any)._id),
|
||
"claim id",
|
||
);
|
||
const updated = await this.claimCaseDbService.findByIdAndUpdate(claimId, {
|
||
$set: { "evaluation.rating": rating },
|
||
});
|
||
if (!updated) throw new NotFoundException("Claim file not found");
|
||
out.claim = {
|
||
requestId: String((updated as any)._id),
|
||
updatedRating: (updated as any)?.evaluation?.rating ?? rating,
|
||
};
|
||
}
|
||
|
||
if (blame) {
|
||
const blameId = this.parseObjectId(
|
||
String((blame as any)._id),
|
||
"blame id",
|
||
);
|
||
const updated = await this.blameRequestDbService.findByIdAndUpdate(
|
||
blameId,
|
||
{
|
||
$set: { "expert.rating": rating },
|
||
},
|
||
);
|
||
if (!updated) throw new NotFoundException("Blame file not found");
|
||
out.blame = {
|
||
requestId: String((updated as any)._id),
|
||
updatedRating: (updated as any)?.expert?.rating ?? rating,
|
||
};
|
||
}
|
||
|
||
return {
|
||
message: "Rating saved for this file",
|
||
...out,
|
||
};
|
||
}
|
||
|
||
async retrieveAllFilesOfClient(insurerId: string) {
|
||
const id = this.getClientId(insurerId);
|
||
const idStr = String(id);
|
||
|
||
const [blameFiles, claimFiles] = await Promise.all([
|
||
this.getClientBlameFiles(id),
|
||
this.getClientClaimFiles(id),
|
||
]);
|
||
|
||
const byPublicId = new Map<
|
||
string,
|
||
{
|
||
publicId: string;
|
||
fileType?: string;
|
||
parties?: Array<{
|
||
role?: string;
|
||
fullName?: string;
|
||
vehicle?: { carName?: string; carModel?: string; plate?: string };
|
||
}>;
|
||
blame?: {
|
||
requestId?: string;
|
||
requestNo?: string;
|
||
status?: string;
|
||
parties?: Array<{
|
||
role?: string;
|
||
fullName?: string;
|
||
vehicle?: { carName?: string; carModel?: string; plate?: string };
|
||
}>;
|
||
expertNames?: string[];
|
||
createdAt?: Date | string;
|
||
updatedAt?: Date | string;
|
||
};
|
||
claim?: {
|
||
requestId?: string;
|
||
requestNo?: string;
|
||
status?: string;
|
||
claimStatus?: string;
|
||
currentStep?: string;
|
||
expertNames?: string[];
|
||
createdAt?: Date | string;
|
||
updatedAt?: Date | string;
|
||
};
|
||
createdAt?: Date | string;
|
||
updatedAt?: Date | string;
|
||
}
|
||
>();
|
||
|
||
// Index blame files first
|
||
for (const b of blameFiles) {
|
||
const key = String((b as any).publicId || (b as any)._id || "");
|
||
if (!key) continue;
|
||
const prev = byPublicId.get(key) ?? { publicId: key };
|
||
const partiesPreview = this.buildPartiesPreview((b as any).parties);
|
||
prev.fileType = prev.fileType ?? (b as any).type;
|
||
prev.blame = {
|
||
requestId:
|
||
(b as any)?._id?.toString?.() || String((b as any)?._id || ""),
|
||
requestNo:
|
||
(b as any).requestNo || String((b as any).requestNumber || ""),
|
||
status: (b as any).status,
|
||
parties: partiesPreview,
|
||
expertNames: extractExpertNamesFromBlame(b),
|
||
createdAt: (b as any).createdAt,
|
||
updatedAt: (b as any).updatedAt,
|
||
};
|
||
prev.parties = prev.parties ?? partiesPreview;
|
||
prev.createdAt = prev.createdAt ?? (b as any).createdAt;
|
||
prev.updatedAt = (b as any).updatedAt ?? prev.updatedAt;
|
||
byPublicId.set(key, prev);
|
||
}
|
||
|
||
// For claim files whose blame wasn't in blameFiles (different insurer's blame),
|
||
// fetch the linked blame directly so we can get vehicle + parties data
|
||
const claimOnlyBlameIds = claimFiles
|
||
.filter((c) => {
|
||
const key = String((c as any).publicId || (c as any)._id || "");
|
||
return key && !byPublicId.has(key) && (c as any).blameRequestId;
|
||
})
|
||
.map((c) => String((c as any).blameRequestId));
|
||
|
||
const uniqueBlameIds = [...new Set(claimOnlyBlameIds)].filter(Boolean);
|
||
|
||
const linkedBlames =
|
||
uniqueBlameIds.length > 0
|
||
? ((await this.blameRequestDbService.find(
|
||
{
|
||
_id: {
|
||
$in: uniqueBlameIds.map((id) => new Types.ObjectId(id)),
|
||
},
|
||
},
|
||
{ lean: true },
|
||
)) as any[])
|
||
: [];
|
||
|
||
const linkedBlameByPublicId = new Map<string, any>(
|
||
linkedBlames.map((b) => [String(b.publicId), b]),
|
||
);
|
||
|
||
// Index claim files
|
||
for (const c of claimFiles) {
|
||
const key = String((c as any).publicId || (c as any)._id || "");
|
||
if (!key) continue;
|
||
const prev = byPublicId.get(key) ?? { publicId: key };
|
||
|
||
// If no blame entry yet, use the linked blame for parties/vehicle/fileType
|
||
if (!prev.blame) {
|
||
const linkedBlame = linkedBlameByPublicId.get(key);
|
||
if (linkedBlame) {
|
||
const partiesFromBlame = this.buildPartiesPreview(
|
||
linkedBlame.parties,
|
||
);
|
||
prev.fileType = prev.fileType ?? linkedBlame.type;
|
||
prev.blame = {
|
||
requestId: String(linkedBlame._id),
|
||
requestNo: linkedBlame.requestNo || "",
|
||
status: linkedBlame.status,
|
||
parties: partiesFromBlame,
|
||
expertNames: extractExpertNamesFromBlame(linkedBlame),
|
||
createdAt: linkedBlame.createdAt,
|
||
updatedAt: linkedBlame.updatedAt,
|
||
};
|
||
prev.parties = prev.parties ?? partiesFromBlame;
|
||
prev.createdAt = prev.createdAt ?? linkedBlame.createdAt;
|
||
}
|
||
}
|
||
|
||
const claimPartiesPreview = this.buildPartiesPreview(
|
||
(c as any)?.snapshot?.parties,
|
||
);
|
||
prev.fileType = prev.fileType ?? (c as any)?.snapshot?.accident?.type;
|
||
prev.claim = {
|
||
requestId:
|
||
(c as any)?._id?.toString?.() || String((c as any)?._id || ""),
|
||
requestNo:
|
||
(c as any).requestNo || String((c as any).requestNumber || ""),
|
||
status: (c as any).status,
|
||
claimStatus: (c as any).claimStatus,
|
||
currentStep: (c as any).currentStep,
|
||
expertNames: extractExpertNamesFromClaim(c),
|
||
createdAt: (c as any).createdAt,
|
||
updatedAt: (c as any).updatedAt,
|
||
};
|
||
prev.parties = prev.parties ?? claimPartiesPreview;
|
||
prev.createdAt = prev.createdAt ?? (c as any).createdAt;
|
||
prev.updatedAt = (c as any).updatedAt ?? prev.updatedAt;
|
||
byPublicId.set(key, prev);
|
||
}
|
||
|
||
const files = Array.from(byPublicId.values())
|
||
.map((f) => ({
|
||
publicId: f.publicId,
|
||
fileType: f.fileType,
|
||
hasClaim: !!f.claim,
|
||
parties: f.parties,
|
||
blame: f.blame,
|
||
claim: f.claim,
|
||
createdAt: f.createdAt,
|
||
updatedAt: f.updatedAt,
|
||
}))
|
||
.sort((a, b) => {
|
||
const at = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
|
||
const bt = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
|
||
return bt - at;
|
||
});
|
||
|
||
return {
|
||
total: files.length,
|
||
files,
|
||
};
|
||
}
|
||
|
||
async retrieveFileDetailsByPublicId(insurerId: string, publicId: string) {
|
||
if (!publicId?.trim()) {
|
||
throw new BadRequestException("publicId is required");
|
||
}
|
||
|
||
const id = this.getClientId(insurerId);
|
||
const idStr = String(id);
|
||
const [blameFiles, claimFiles] = await Promise.all([
|
||
this.getClientBlameFiles(id),
|
||
this.getClientClaimFiles(id),
|
||
]);
|
||
|
||
const blame = blameFiles.find(
|
||
(b) => String((b as any).publicId) === publicId,
|
||
);
|
||
const claim = claimFiles.find(
|
||
(c) => String((c as any).publicId) === publicId,
|
||
);
|
||
|
||
if (!blame && !claim) {
|
||
throw new NotFoundException("File not found for this publicId");
|
||
}
|
||
|
||
let blameFull: Record<string, unknown> | null = null;
|
||
if (blame) {
|
||
blameFull = (await this.blameRequestDbService.findByIdWithoutHistory(
|
||
String((blame as any)._id),
|
||
)) as Record<string, unknown> | null;
|
||
}
|
||
|
||
let claimFull: Record<string, unknown> | null = null;
|
||
if (claim) {
|
||
const rows = (await this.claimCaseDbService.find(
|
||
{ _id: new Types.ObjectId(String((claim as any)._id)) },
|
||
{ lean: true },
|
||
)) as Record<string, unknown>[];
|
||
claimFull = rows[0] ?? null;
|
||
}
|
||
|
||
let blameForClaimContext: Record<string, unknown> | null = blameFull;
|
||
if (!blameForClaimContext && claimFull?.blameRequestId) {
|
||
const linked = (await this.blameRequestDbService.findByIdWithoutHistory(
|
||
String(claimFull.blameRequestId),
|
||
)) as Record<string, unknown> | null;
|
||
if (linked && blameCaseTouchesClient(linked, idStr)) {
|
||
blameForClaimContext = linked;
|
||
}
|
||
}
|
||
|
||
const overview = {
|
||
publicId,
|
||
requestNo:
|
||
(blame as any)?.requestNo ||
|
||
(claim as any)?.requestNo ||
|
||
(blame as any)?.requestNumber ||
|
||
(claim as any)?.requestNumber,
|
||
type: (blame as any)?.type ?? (blameFull as any)?.type,
|
||
hasClaim: !!claim,
|
||
blameStatus: (blame as any)?.status ?? (blameFull as any)?.status,
|
||
claimStatus: (claim as any)?.status,
|
||
claimReviewStatus: (claim as any)?.claimStatus,
|
||
createdAt: (blame as any)?.createdAt ?? (claim as any)?.createdAt,
|
||
updatedAt: (claim as any)?.updatedAt ?? (blame as any)?.updatedAt,
|
||
};
|
||
|
||
const blameDetails = blameFull
|
||
? await this.buildInsurerBlameDetail(blameFull)
|
||
: undefined;
|
||
|
||
const claimDetails = claimFull
|
||
? await this.buildInsurerClaimDetail(claimFull, blameForClaimContext)
|
||
: undefined;
|
||
|
||
return {
|
||
overview,
|
||
blame: blameDetails,
|
||
claim: claimDetails,
|
||
};
|
||
}
|
||
|
||
async addBranch(clientKey: string, branchDto: CreateBranchDto) {
|
||
const clientId = this.getClientId(clientKey);
|
||
|
||
const existingBranch = await this.branchDbService.findOne({
|
||
clientKey: clientId,
|
||
code: branchDto.code,
|
||
});
|
||
|
||
if (existingBranch) {
|
||
throw new ConflictException(
|
||
`A branch with code '${branchDto.code}' already exists for this client.`,
|
||
);
|
||
}
|
||
|
||
const newBranch = await this.branchDbService.create({
|
||
...branchDto,
|
||
isActive: branchDto.isActive ?? true,
|
||
activityStartDate: branchDto.activityStartDate
|
||
? new Date(branchDto.activityStartDate)
|
||
: undefined,
|
||
clientKey: clientId,
|
||
});
|
||
|
||
return newBranch;
|
||
}
|
||
|
||
private parseOptionalBoolean(value?: string): boolean | undefined {
|
||
if (value == null || value === "") return undefined;
|
||
const normalized = String(value).trim().toLowerCase();
|
||
if (["true", "1", "yes"].includes(normalized)) return true;
|
||
if (["false", "0", "no"].includes(normalized)) return false;
|
||
throw new BadRequestException("Invalid boolean value for 'isActive'");
|
||
}
|
||
|
||
private buildHandlingBranchStatusSets() {
|
||
const blameInHandling = new Set<string>([
|
||
CaseStatus.OPEN,
|
||
CaseStatus.WAITING_FOR_SECOND_PARTY,
|
||
CaseStatus.WAITING_FOR_EXPERT,
|
||
CaseStatus.WAITING_FOR_DOCUMENT_RESEND,
|
||
CaseStatus.WAITING_FOR_SIGNATURES,
|
||
]);
|
||
const claimInHandling = new Set<string>([
|
||
ClaimCaseStatus.CREATED,
|
||
ClaimCaseStatus.SELECTING_OUTER_PARTS,
|
||
ClaimCaseStatus.SELECTING_OTHER_PARTS,
|
||
ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
|
||
ClaimCaseStatus.CAPTURING_PART_DAMAGES,
|
||
ClaimCaseStatus.WAITING_FOR_USER_RESEND,
|
||
ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
|
||
ClaimCaseStatus.EXPERT_REVIEWING,
|
||
ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL,
|
||
ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN,
|
||
ClaimCaseStatus.INSURER_REVIEW_MIXED_FACTORS_PENDING,
|
||
ClaimCaseStatus.OWNER_REPAIR_FACTOR_UPLOAD_PENDING,
|
||
ClaimCaseStatus.EXPERT_VALIDATING_REPAIR_FACTORS,
|
||
]);
|
||
return { blameInHandling, claimInHandling };
|
||
}
|
||
|
||
async setBranchActive(
|
||
clientKey: string,
|
||
branchId: string,
|
||
isActive: boolean,
|
||
) {
|
||
const clientId = this.getClientId(clientKey);
|
||
await this.assertBranchBelongsToClient(branchId, clientId);
|
||
const updated = await this.branchDbService.findByIdAndUpdate(branchId, {
|
||
$set: { isActive },
|
||
});
|
||
if (!updated) {
|
||
throw new NotFoundException("Branch not found");
|
||
}
|
||
return updated;
|
||
}
|
||
|
||
private async assertBranchBelongsToClient(
|
||
branchId: string,
|
||
clientId: Types.ObjectId,
|
||
) {
|
||
const branch = await this.branchDbService.findById(branchId);
|
||
if (!branch) {
|
||
throw new NotFoundException("Branch not found");
|
||
}
|
||
if (String(branch.clientKey) !== String(clientId)) {
|
||
throw new ForbiddenException(
|
||
"Selected branch does not belong to your insurance company.",
|
||
);
|
||
}
|
||
return branch;
|
||
}
|
||
|
||
private sanitizeExpertResponse(doc: any) {
|
||
const obj = typeof doc?.toObject === "function" ? doc.toObject() : doc;
|
||
if (!obj) return obj;
|
||
const { password, otp, ...rest } = obj;
|
||
return rest;
|
||
}
|
||
|
||
private async createExpertForInsurer(
|
||
insurerClientKey: string,
|
||
payload: CreateInsurerExpertDto,
|
||
role: RoleEnum.EXPERT | RoleEnum.DAMAGE_EXPERT,
|
||
) {
|
||
const clientObjectId = this.getClientId(insurerClientKey);
|
||
const branch = await this.assertBranchBelongsToClient(
|
||
payload.branchId,
|
||
clientObjectId,
|
||
);
|
||
|
||
const email = payload.email.trim().toLowerCase();
|
||
const existingByEmail = await this.expertDbService.findOne({ email });
|
||
const existingDamageByEmail = await this.damageExpertDbService.findOne({
|
||
email,
|
||
});
|
||
if (existingByEmail || existingDamageByEmail) {
|
||
throw new ConflictException("An expert with this email already exists.");
|
||
}
|
||
|
||
const nationalCode = payload.nationalCode.trim();
|
||
const existingByNational = await this.expertDbService.findOne({
|
||
nationalCode,
|
||
});
|
||
const existingDamageByNational = await this.damageExpertDbService.findOne({
|
||
nationalCode,
|
||
});
|
||
if (existingByNational || existingDamageByNational) {
|
||
throw new ConflictException(
|
||
"An expert with this national code already exists.",
|
||
);
|
||
}
|
||
|
||
const hashedPassword = await this.hashService.hash(payload.password);
|
||
const basePayload: any = {
|
||
...payload,
|
||
email,
|
||
nationalCode,
|
||
password: hashedPassword,
|
||
role,
|
||
userType: payload.userType ?? UserType.LEGAL,
|
||
clientKey:
|
||
role === RoleEnum.EXPERT ? String(clientObjectId) : clientObjectId,
|
||
branchId: new Types.ObjectId(payload.branchId),
|
||
insuActivityCo: String(clientObjectId),
|
||
};
|
||
|
||
const created =
|
||
role === RoleEnum.EXPERT
|
||
? await this.expertDbService.create(basePayload)
|
||
: await this.damageExpertDbService.create(basePayload);
|
||
|
||
return {
|
||
expert: this.sanitizeExpertResponse(created),
|
||
branch: {
|
||
_id: (branch as any)._id,
|
||
name: branch.name,
|
||
code: branch.code,
|
||
city: branch.city,
|
||
state: branch.state,
|
||
address: branch.address,
|
||
},
|
||
};
|
||
}
|
||
|
||
async addBlameExpert(
|
||
insurerClientKey: string,
|
||
payload: CreateBlameExpertByInsurerDto,
|
||
) {
|
||
return this.createExpertForInsurer(
|
||
insurerClientKey,
|
||
payload,
|
||
RoleEnum.EXPERT,
|
||
);
|
||
}
|
||
|
||
async addClaimExpert(
|
||
insurerClientKey: string,
|
||
payload: CreateClaimExpertByInsurerDto,
|
||
) {
|
||
return this.createExpertForInsurer(
|
||
insurerClientKey,
|
||
payload,
|
||
RoleEnum.DAMAGE_EXPERT,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Get comprehensive statistics for all experts of a client
|
||
* Returns:
|
||
* - Percentage of insurer's rating to BOT
|
||
* - Percentage of expert's rating given by users
|
||
* - Percentage of files that have objection
|
||
* - Number of files created in the current month
|
||
*/
|
||
async getExpertStatisticsReport(actor: any) {
|
||
const clientObjectId = this.getClientId(actor);
|
||
const claimFiles = await this.getClientClaimFiles(clientObjectId);
|
||
|
||
// Calculate current month date range
|
||
const now = new Date();
|
||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||
const monthEnd = new Date(
|
||
now.getFullYear(),
|
||
now.getMonth() + 1,
|
||
0,
|
||
23,
|
||
59,
|
||
59,
|
||
);
|
||
|
||
// Filter files created this month
|
||
const filesThisMonth = claimFiles.filter((file) => {
|
||
const createdAt = new Date(file.createdAt);
|
||
return createdAt >= monthStart && createdAt <= monthEnd;
|
||
});
|
||
|
||
// Calculate statistics
|
||
let totalInsurerRatings = 0;
|
||
let totalBotRatings = 0;
|
||
let filesWithInsurerRating = 0;
|
||
let filesWithBotRating = 0;
|
||
let filesWithUserRating = 0;
|
||
let filesWithObjection = 0;
|
||
|
||
for (const file of claimFiles) {
|
||
const insurerRating = file?.rating;
|
||
const userRating = file?.userRating;
|
||
const objection = file?.objection;
|
||
|
||
// Check for insurer rating (excluding botRating)
|
||
if (insurerRating) {
|
||
const insurerValues = [
|
||
insurerRating.collisionMethodAccuracy,
|
||
insurerRating.evaluationTimeliness,
|
||
insurerRating.accidentCauseAccuracy,
|
||
insurerRating.guiltyVehicleIdentification,
|
||
].filter(
|
||
(val): val is number => typeof val === "number" && !isNaN(val),
|
||
);
|
||
|
||
if (insurerValues.length > 0) {
|
||
filesWithInsurerRating++;
|
||
const insurerAvg =
|
||
insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length;
|
||
totalInsurerRatings += insurerAvg;
|
||
}
|
||
}
|
||
|
||
// Check for bot rating (if botRating field exists in rating object)
|
||
const botRating = (insurerRating as any)?.botRating;
|
||
if (
|
||
botRating !== undefined &&
|
||
!isNaN(botRating) &&
|
||
typeof botRating === "number"
|
||
) {
|
||
filesWithBotRating++;
|
||
totalBotRatings += botRating;
|
||
}
|
||
|
||
// Check for user rating
|
||
if (userRating) {
|
||
filesWithUserRating++;
|
||
}
|
||
|
||
// Check for objection
|
||
if (objection) {
|
||
filesWithObjection++;
|
||
}
|
||
}
|
||
|
||
// Calculate percentages
|
||
const totalFiles = claimFiles.length;
|
||
|
||
// Calculate average insurer rating (excluding botRating) and average bot rating
|
||
const averageInsurerRating =
|
||
filesWithInsurerRating > 0
|
||
? totalInsurerRatings / filesWithInsurerRating
|
||
: 0;
|
||
const averageBotRating =
|
||
filesWithBotRating > 0 ? totalBotRatings / filesWithBotRating : 0;
|
||
|
||
// Calculate percentage: (averageInsurerRating / averageBotRating) * 100
|
||
// This shows what percentage the insurer rating is compared to bot rating
|
||
const insurerToBotPercentage =
|
||
averageBotRating > 0 && averageInsurerRating > 0
|
||
? parseFloat(
|
||
((averageInsurerRating / averageBotRating) * 100).toFixed(2),
|
||
)
|
||
: 0;
|
||
|
||
const userRatingPercentage =
|
||
totalFiles > 0
|
||
? parseFloat(((filesWithUserRating / totalFiles) * 100).toFixed(2))
|
||
: 0;
|
||
|
||
const objectionPercentage =
|
||
totalFiles > 0
|
||
? parseFloat(((filesWithObjection / totalFiles) * 100).toFixed(2))
|
||
: 0;
|
||
|
||
return {
|
||
insurerToBotRatingPercentage: insurerToBotPercentage,
|
||
userRatingPercentage: userRatingPercentage,
|
||
objectionPercentage: objectionPercentage,
|
||
filesCreatedThisMonth: filesThisMonth.length,
|
||
totalFiles: totalFiles,
|
||
breakdown: {
|
||
filesWithInsurerRating,
|
||
filesWithBotRating,
|
||
filesWithUserRating,
|
||
filesWithObjection,
|
||
averageInsurerRating:
|
||
filesWithInsurerRating > 0
|
||
? parseFloat(
|
||
(totalInsurerRatings / filesWithInsurerRating).toFixed(2),
|
||
)
|
||
: 0,
|
||
averageBotRating:
|
||
filesWithBotRating > 0
|
||
? parseFloat((totalBotRatings / filesWithBotRating).toFixed(2))
|
||
: 0,
|
||
},
|
||
};
|
||
}
|
||
|
||
async retrieveInsuranceBranches(
|
||
insuranceId: string,
|
||
opts?: {
|
||
search?: string;
|
||
from?: string;
|
||
to?: string;
|
||
isActive?: string;
|
||
},
|
||
) {
|
||
const clientObjectId = this.getClientId(insuranceId);
|
||
const { fromDate, toDate } = this.parseDateRange(opts?.from, opts?.to);
|
||
const isActiveFilter = this.parseOptionalBoolean(opts?.isActive);
|
||
const branches = await this.branchDbService.findAllWithFilters(
|
||
String(clientObjectId),
|
||
{
|
||
search: opts?.search,
|
||
from: fromDate,
|
||
to: toDate,
|
||
isActive: isActiveFilter,
|
||
},
|
||
);
|
||
|
||
const branchIdSet = new Set(branches.map((b: any) => String(b._id)));
|
||
const ckFilter = this.clientKeyScopeFilter(clientObjectId);
|
||
const [experts, damageExperts, fieldExperts, blameFiles, claimFiles] =
|
||
await Promise.all([
|
||
this.expertDbService.findAll(ckFilter as never),
|
||
this.damageExpertDbService.findAll(ckFilter as never),
|
||
this.fieldExpertDbService.findAll(ckFilter as never),
|
||
this.getClientBlameFiles(clientObjectId),
|
||
this.getClientClaimFiles(clientObjectId),
|
||
]);
|
||
|
||
const activeExpertsByBranch = new Map<string, Set<string>>();
|
||
const expertBranchById = new Map<string, string>();
|
||
const claimExpertBranchById = new Map<string, string>();
|
||
// blame-expert + field-expert both handle blame/IN_PERSON files
|
||
for (const e of [...(experts as any[]), ...(fieldExperts as any[])]) {
|
||
const bid = e?.branchId ? String(e.branchId) : "";
|
||
if (!bid || !branchIdSet.has(bid)) continue;
|
||
const eid = String(e?._id || "");
|
||
if (!eid) continue;
|
||
expertBranchById.set(eid, bid);
|
||
if (!activeExpertsByBranch.has(bid))
|
||
activeExpertsByBranch.set(bid, new Set());
|
||
activeExpertsByBranch.get(bid)!.add(eid);
|
||
}
|
||
for (const e of damageExperts as any[]) {
|
||
const bid = e?.branchId ? String(e.branchId) : "";
|
||
if (!bid || !branchIdSet.has(bid)) continue;
|
||
const eid = String(e?._id || "");
|
||
if (!eid) continue;
|
||
claimExpertBranchById.set(eid, bid);
|
||
if (!activeExpertsByBranch.has(bid))
|
||
activeExpertsByBranch.set(bid, new Set());
|
||
activeExpertsByBranch.get(bid)!.add(eid);
|
||
}
|
||
|
||
const completedByBranch = new Map<string, Set<string>>();
|
||
const handlingByBranch = new Map<string, Set<string>>();
|
||
const { blameInHandling, claimInHandling } =
|
||
this.buildHandlingBranchStatusSets();
|
||
const addPublicId = (
|
||
map: Map<string, Set<string>>,
|
||
branchId: string,
|
||
fileKey: string,
|
||
) => {
|
||
if (!map.has(branchId)) map.set(branchId, new Set());
|
||
map.get(branchId)!.add(fileKey);
|
||
};
|
||
|
||
for (const b of blameFiles as any[]) {
|
||
// Resolve expert via assignment OR field-expert initiation
|
||
const candidateIds = this.blameFieldExpertIdCandidates(b);
|
||
const bid = candidateIds
|
||
.map((eid) => expertBranchById.get(eid))
|
||
.find(Boolean);
|
||
if (!bid) continue;
|
||
const fileKey = String(b?.publicId || b?._id || "");
|
||
if (!fileKey) continue;
|
||
const status = String(b?.status || "");
|
||
if (status === CaseStatus.COMPLETED)
|
||
addPublicId(completedByBranch, bid, fileKey);
|
||
if (blameInHandling.has(status))
|
||
addPublicId(handlingByBranch, bid, fileKey);
|
||
}
|
||
|
||
for (const c of claimFiles as any[]) {
|
||
// Field-expert-initiated claims
|
||
const fieldExpertId = c?.initiatedByFieldExpertId
|
||
? String(c.initiatedByFieldExpertId)
|
||
: null;
|
||
const damageExpertId = this.claimDamageExpertActorId(c);
|
||
const expertId = fieldExpertId ?? damageExpertId ?? "";
|
||
if (!expertId) continue;
|
||
const bid =
|
||
expertBranchById.get(expertId) ??
|
||
claimExpertBranchById.get(expertId) ??
|
||
undefined;
|
||
if (!bid) continue;
|
||
const fileKey = String(c?.publicId || c?._id || "");
|
||
if (!fileKey) continue;
|
||
const status = String(c?.status || "");
|
||
if (status === ClaimCaseStatus.COMPLETED)
|
||
addPublicId(completedByBranch, bid, fileKey);
|
||
if (claimInHandling.has(status))
|
||
addPublicId(handlingByBranch, bid, fileKey);
|
||
}
|
||
|
||
const list = branches.map((branch: any) => {
|
||
const bid = String(branch._id);
|
||
return {
|
||
...branch,
|
||
totalActiveExperts: activeExpertsByBranch.get(bid)?.size ?? 0,
|
||
totalFinishedFiles: completedByBranch.get(bid)?.size ?? 0,
|
||
totalFilesInHandling: handlingByBranch.get(bid)?.size ?? 0,
|
||
};
|
||
});
|
||
|
||
return {
|
||
total: list.length,
|
||
filters: {
|
||
search: opts?.search ?? null,
|
||
from: fromDate ?? null,
|
||
to: toDate ?? null,
|
||
isActive: isActiveFilter ?? null,
|
||
},
|
||
branches: list,
|
||
};
|
||
}
|
||
|
||
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: any, fromDate?: Date, toDate?: Date): boolean {
|
||
if (!fromDate && !toDate) return true;
|
||
if (!value) return false;
|
||
const d = new Date(value);
|
||
if (Number.isNaN(d.getTime())) return false;
|
||
if (fromDate && d < fromDate) return false;
|
||
if (toDate && d > toDate) return false;
|
||
return true;
|
||
}
|
||
|
||
async getInsurerFileStatusCounts(
|
||
actor: any,
|
||
from?: string,
|
||
to?: string,
|
||
): Promise<{
|
||
all: number;
|
||
completed: number;
|
||
under_review: number;
|
||
waiting_for_documents_resend: number;
|
||
}> {
|
||
const clientObjectId = this.getClientId(actor);
|
||
const { fromDate, toDate } = this.parseDateRange(from, to);
|
||
|
||
const [blameFilesRaw, claimFilesRaw] = await Promise.all([
|
||
this.getClientBlameFiles(clientObjectId),
|
||
this.getClientClaimFiles(clientObjectId),
|
||
]);
|
||
|
||
const blameFiles = blameFilesRaw.filter((f) =>
|
||
this.isInDateRange((f as any)?.createdAt, fromDate, toDate),
|
||
);
|
||
const claimFiles = claimFilesRaw.filter((f) =>
|
||
this.isInDateRange((f as any)?.createdAt, fromDate, toDate),
|
||
);
|
||
|
||
const fileMap = new Map<string, { blame?: any; claim?: any }>();
|
||
|
||
for (const b of blameFiles) {
|
||
const key = String((b as any).publicId || (b as any)._id || "");
|
||
if (!key) continue;
|
||
const prev = fileMap.get(key) ?? {};
|
||
prev.blame = b;
|
||
fileMap.set(key, prev);
|
||
}
|
||
for (const c of claimFiles) {
|
||
const key = String((c as any).publicId || (c as any)._id || "");
|
||
if (!key) continue;
|
||
const prev = fileMap.get(key) ?? {};
|
||
prev.claim = c;
|
||
fileMap.set(key, prev);
|
||
}
|
||
|
||
let completed = 0;
|
||
let underReview = 0;
|
||
let waitingForDocumentsResend = 0;
|
||
|
||
for (const entry of fileMap.values()) {
|
||
const blameStatus = entry.blame?.status;
|
||
const claimStatus = entry.claim?.status;
|
||
|
||
if (claimStatus === ClaimCaseStatus.COMPLETED) {
|
||
completed += 1;
|
||
}
|
||
|
||
const blameUnderReview = blameStatus === CaseStatus.WAITING_FOR_EXPERT;
|
||
const claimUnderReview =
|
||
claimStatus === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT ||
|
||
claimStatus === ClaimCaseStatus.EXPERT_REVIEWING;
|
||
if (blameUnderReview || claimUnderReview) {
|
||
underReview += 1;
|
||
}
|
||
|
||
const blameResend =
|
||
blameStatus === CaseStatus.WAITING_FOR_DOCUMENT_RESEND;
|
||
const claimResend =
|
||
claimStatus === ClaimCaseStatus.WAITING_FOR_USER_RESEND;
|
||
if (blameResend || claimResend) {
|
||
waitingForDocumentsResend += 1;
|
||
}
|
||
}
|
||
|
||
return {
|
||
all: fileMap.size,
|
||
completed,
|
||
under_review: underReview,
|
||
waiting_for_documents_resend: waitingForDocumentsResend,
|
||
};
|
||
}
|
||
}
|