forked from Yara724/api
2910 lines
95 KiB
TypeScript
2910 lines
95 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,
|
|
CreateFileMakerByInsurerDto,
|
|
CreateFileReviewerByInsurerDto,
|
|
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 { FileMakerDbService } from "src/users/entities/db-service/file-maker.db.service";
|
|
import { FileReviewerDbService } from "src/users/entities/db-service/file-reviewer.db.service";
|
|
import { RegistrarDbService } from "src/users/entities/db-service/registrar.db.service";
|
|
import { CallCenterAgentDbService } from "src/users/entities/db-service/call-center-agent.db.service";
|
|
import { UserDbService } from "src/users/entities/db-service/user.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 { sanitizeStoredInquiryParticipants } from "src/request-management/inquiry-participant-resolver";
|
|
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
|
import {
|
|
UnifiedFileStatusReportDto,
|
|
UnifiedFileStatusReportQueryDto,
|
|
} from "src/common/dto/unified-file-status-report.dto";
|
|
import {
|
|
applyListQueryV2,
|
|
isInListDateRange,
|
|
parseListDateRange,
|
|
} from "src/helpers/list-query-v2";
|
|
import {
|
|
countUnifiedFileStatuses,
|
|
getUnifiedFileStatusCatalog,
|
|
resolveUnifiedFileStatus,
|
|
} from "src/helpers/unified-file-status";
|
|
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 {
|
|
getEventFaDescription,
|
|
getEventFaLabel,
|
|
localizeTimelineMetadata,
|
|
} from "./helper/timeline-fa-labels";
|
|
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,
|
|
private readonly fileMakerDbService: FileMakerDbService,
|
|
private readonly fileReviewerDbService: FileReviewerDbService,
|
|
private readonly registrarDbService: RegistrarDbService,
|
|
private readonly callCenterAgentDbService: CallCenterAgentDbService,
|
|
private readonly userDbService: UserDbService,
|
|
) {}
|
|
|
|
/**
|
|
* 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 calculateReviewDurationMinutes(
|
|
createdAt: Date | string | undefined,
|
|
updatedAt: Date | string | undefined,
|
|
): number | null {
|
|
const startedAtMs = new Date(createdAt as any).getTime();
|
|
const reviewedAtMs = new Date(updatedAt as any).getTime();
|
|
if (Number.isNaN(startedAtMs) || Number.isNaN(reviewedAtMs)) return null;
|
|
|
|
const durationMinutes = Math.max(reviewedAtMs - startedAtMs, 0) / 60_000;
|
|
return Math.round(durationMinutes * 100) / 100;
|
|
}
|
|
|
|
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,
|
|
reviewDurationMinutes: this.calculateReviewDurationMinutes(
|
|
b.createdAt,
|
|
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,
|
|
reviewDurationMinutes: this.calculateReviewDurationMinutes(
|
|
c.createdAt,
|
|
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,
|
|
participants: sanitizeStoredInquiryParticipants(party.participants),
|
|
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));
|
|
}
|
|
|
|
private startOfDay(date: Date): Date {
|
|
return new Date(
|
|
date.getFullYear(),
|
|
date.getMonth(),
|
|
date.getDate(),
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
);
|
|
}
|
|
|
|
private endOfDay(date: Date): Date {
|
|
return new Date(
|
|
date.getFullYear(),
|
|
date.getMonth(),
|
|
date.getDate(),
|
|
23,
|
|
59,
|
|
59,
|
|
999,
|
|
);
|
|
}
|
|
|
|
private toUtcDateKey(date: Date): string {
|
|
return date.toISOString().slice(0, 10);
|
|
}
|
|
|
|
private parseReportPeriodDays(value?: string | number): 30 | 60 | 90 {
|
|
const parsed = Number(value ?? 30);
|
|
if (parsed === 30 || parsed === 60 || parsed === 90) {
|
|
return parsed;
|
|
}
|
|
throw new BadRequestException("periodDays must be one of 30, 60, or 90");
|
|
}
|
|
|
|
private serializeReportRange(range: {
|
|
mode: "preset" | "custom";
|
|
fromDate: Date;
|
|
toDate: Date;
|
|
periodDays?: 30 | 60 | 90;
|
|
}) {
|
|
return {
|
|
mode: range.mode,
|
|
from: range.fromDate.toISOString(),
|
|
to: range.toDate.toISOString(),
|
|
...(range.periodDays ? { periodDays: range.periodDays } : {}),
|
|
};
|
|
}
|
|
|
|
private resolveReportDateRange(opts: {
|
|
from?: string;
|
|
to?: string;
|
|
periodDays?: string | number;
|
|
} = {}) {
|
|
const hasFrom = typeof opts.from === "string" && opts.from.trim().length > 0;
|
|
const hasTo = typeof opts.to === "string" && opts.to.trim().length > 0;
|
|
const periodDays = this.parseReportPeriodDays(opts.periodDays);
|
|
const now = new Date();
|
|
|
|
if (!hasFrom && !hasTo) {
|
|
const toDate = this.endOfDay(now);
|
|
const fromBase = new Date(toDate);
|
|
fromBase.setDate(fromBase.getDate() - (periodDays - 1));
|
|
const fromDate = this.startOfDay(fromBase);
|
|
return {
|
|
mode: "preset" as const,
|
|
fromDate,
|
|
toDate,
|
|
periodDays,
|
|
};
|
|
}
|
|
|
|
let { fromDate, toDate } = this.parseDateRange(opts.from, opts.to);
|
|
|
|
if (!fromDate && toDate) {
|
|
const fromBase = new Date(toDate);
|
|
fromBase.setDate(fromBase.getDate() - (periodDays - 1));
|
|
fromDate = this.startOfDay(fromBase);
|
|
}
|
|
if (fromDate && !toDate) {
|
|
toDate = this.endOfDay(now);
|
|
}
|
|
|
|
if (!fromDate || !toDate) {
|
|
throw new BadRequestException("Could not resolve reporting date range");
|
|
}
|
|
if (fromDate > toDate) {
|
|
throw new BadRequestException("'from' must be before or equal to 'to'");
|
|
}
|
|
|
|
return {
|
|
mode: "custom" as const,
|
|
fromDate,
|
|
toDate,
|
|
periodDays,
|
|
};
|
|
}
|
|
|
|
private buildExpertFileStateMap(
|
|
events: Array<{
|
|
expertId: Types.ObjectId | string;
|
|
fileId: Types.ObjectId | string;
|
|
eventType: ExpertFileActivityType;
|
|
occurredAt: Date | string;
|
|
}>,
|
|
cutoff: Date,
|
|
): Map<string, { checked: boolean; handled: boolean; handledAt?: Date }> {
|
|
const stateByExpertFile = new Map<
|
|
string,
|
|
{ checked: boolean; handled: boolean; handledAt?: Date }
|
|
>();
|
|
|
|
const filtered = events
|
|
.map((event) => ({
|
|
expertId: String(event.expertId),
|
|
fileId: String(event.fileId),
|
|
eventType: event.eventType,
|
|
occurredAt:
|
|
event.occurredAt instanceof Date
|
|
? event.occurredAt
|
|
: new Date(event.occurredAt),
|
|
}))
|
|
.filter(
|
|
(event) =>
|
|
!Number.isNaN(event.occurredAt.getTime()) && event.occurredAt <= cutoff,
|
|
)
|
|
.sort((a, b) => a.occurredAt.getTime() - b.occurredAt.getTime());
|
|
|
|
for (const event of filtered) {
|
|
const key = `${event.expertId}:${event.fileId}`;
|
|
const prev = stateByExpertFile.get(key) ?? {
|
|
checked: false,
|
|
handled: false,
|
|
};
|
|
if (event.eventType === ExpertFileActivityType.CHECKED) {
|
|
if (!prev.handled) prev.checked = true;
|
|
} else if (event.eventType === ExpertFileActivityType.UNCHECKED) {
|
|
prev.checked = false;
|
|
} else if (event.eventType === ExpertFileActivityType.HANDLED) {
|
|
prev.handled = true;
|
|
prev.checked = false;
|
|
prev.handledAt = event.occurredAt;
|
|
}
|
|
stateByExpertFile.set(key, prev);
|
|
}
|
|
|
|
return stateByExpertFile;
|
|
}
|
|
|
|
private emptyExpertCaseBreakdown() {
|
|
return {
|
|
totalFiles: 0,
|
|
reviewedFiles: 0,
|
|
unreviewedFiles: 0,
|
|
thirdPartyFiles: 0,
|
|
carBodyFiles: 0,
|
|
thirdPartyReviewedFiles: 0,
|
|
thirdPartyUnreviewedFiles: 0,
|
|
carBodyReviewedFiles: 0,
|
|
carBodyUnreviewedFiles: 0,
|
|
};
|
|
}
|
|
|
|
private addFileToExpertCaseBreakdown(
|
|
counts: {
|
|
totalFiles: number;
|
|
reviewedFiles: number;
|
|
unreviewedFiles: number;
|
|
thirdPartyFiles: number;
|
|
carBodyFiles: number;
|
|
thirdPartyReviewedFiles: number;
|
|
thirdPartyUnreviewedFiles: number;
|
|
carBodyReviewedFiles: number;
|
|
carBodyUnreviewedFiles: number;
|
|
},
|
|
fileType: string | undefined,
|
|
reviewed: boolean,
|
|
) {
|
|
counts.totalFiles += 1;
|
|
if (reviewed) counts.reviewedFiles += 1;
|
|
else counts.unreviewedFiles += 1;
|
|
|
|
if (fileType === BlameRequestType.CAR_BODY) {
|
|
counts.carBodyFiles += 1;
|
|
if (reviewed) counts.carBodyReviewedFiles += 1;
|
|
else counts.carBodyUnreviewedFiles += 1;
|
|
return;
|
|
}
|
|
|
|
counts.thirdPartyFiles += 1;
|
|
if (reviewed) counts.thirdPartyReviewedFiles += 1;
|
|
else counts.thirdPartyUnreviewedFiles += 1;
|
|
}
|
|
|
|
private async buildExpertReportRows(
|
|
actor: any,
|
|
opts: { from?: string; to?: string; periodDays?: string | number } = {},
|
|
) {
|
|
const clientObjectId = this.getClientId(actor);
|
|
const ckFilter = this.clientKeyScopeFilter(clientObjectId);
|
|
const range = this.resolveReportDateRange(opts);
|
|
const [experts, damageExperts, fieldExperts, blameFiles, claimFiles, activityEvents] =
|
|
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),
|
|
this.expertFileActivityDbService.findByTenant(clientObjectId),
|
|
]);
|
|
|
|
const roster = [
|
|
...(experts as any[]).map((expert) => ({
|
|
_id: String(expert._id),
|
|
fullName: `${expert.firstName} ${expert.lastName}`.trim(),
|
|
expertKind: "blame" as const,
|
|
role: expert.role,
|
|
type: expert.userType,
|
|
createdAt: expert.createdAt ?? null,
|
|
})),
|
|
...(damageExperts as any[]).map((expert) => ({
|
|
_id: String(expert._id),
|
|
fullName: `${expert.firstName} ${expert.lastName}`.trim(),
|
|
expertKind: "claim" as const,
|
|
role: expert.role,
|
|
type: expert.userType,
|
|
createdAt: expert.createdAt ?? null,
|
|
})),
|
|
...(fieldExperts as any[]).map((expert) => ({
|
|
_id: String(expert._id),
|
|
fullName: `${expert.firstName} ${expert.lastName}`.trim(),
|
|
expertKind: "blame" as const,
|
|
role: expert.role,
|
|
type: expert.userType,
|
|
createdAt: expert.createdAt ?? null,
|
|
})),
|
|
];
|
|
|
|
const rosterById = new Map(
|
|
roster.map((expert) => [expert._id, { ...expert, ...this.emptyExpertCaseBreakdown() }]),
|
|
);
|
|
const fileState = this.buildExpertFileStateMap(activityEvents as any[], range.toDate);
|
|
|
|
const blameFilesInRange = blameFiles.filter((file) =>
|
|
this.isInDateRange(file?.createdAt, range.fromDate, range.toDate),
|
|
);
|
|
const claimFilesInRange = claimFiles.filter((file) =>
|
|
this.isInDateRange(file?.createdAt, range.fromDate, range.toDate),
|
|
);
|
|
|
|
for (const file of blameFilesInRange as any[]) {
|
|
const expertIds = this.blameFieldExpertIdCandidates(file).filter((expertId) =>
|
|
rosterById.has(expertId),
|
|
);
|
|
for (const expertId of new Set(expertIds)) {
|
|
const row = rosterById.get(expertId);
|
|
if (!row) continue;
|
|
const state = fileState.get(`${expertId}:${String(file._id)}`);
|
|
this.addFileToExpertCaseBreakdown(
|
|
row,
|
|
String(file?.type ?? BlameRequestType.THIRD_PARTY),
|
|
!!state?.handled,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const file of claimFilesInRange as any[]) {
|
|
const expertIds = [
|
|
file?.initiatedByFieldExpertId
|
|
? String(file.initiatedByFieldExpertId)
|
|
: undefined,
|
|
this.claimDamageExpertActorId(file) ?? undefined,
|
|
]
|
|
.filter((expertId): expertId is string => !!expertId)
|
|
.filter((expertId) => rosterById.has(expertId));
|
|
const fileType = String(
|
|
file?.snapshot?.accident?.type ??
|
|
file?.blameFile?.type ??
|
|
BlameRequestType.THIRD_PARTY,
|
|
);
|
|
for (const expertId of new Set(expertIds)) {
|
|
const row = rosterById.get(expertId);
|
|
if (!row) continue;
|
|
const state = fileState.get(`${expertId}:${String(file._id)}`);
|
|
this.addFileToExpertCaseBreakdown(row, fileType, !!state?.handled);
|
|
}
|
|
}
|
|
|
|
return {
|
|
range,
|
|
experts: Array.from(rosterById.values()),
|
|
};
|
|
}
|
|
|
|
private portfolioFileReviewed(
|
|
file: {
|
|
blame?: { requestId?: string };
|
|
claim?: { requestId?: string };
|
|
},
|
|
handledAtByFileId: Map<string, Date>,
|
|
): boolean {
|
|
const blameHandled = file.blame?.requestId
|
|
? handledAtByFileId.has(String(file.blame.requestId))
|
|
: false;
|
|
const claimHandled = file.claim?.requestId
|
|
? handledAtByFileId.has(String(file.claim.requestId))
|
|
: false;
|
|
return blameHandled || claimHandled;
|
|
}
|
|
|
|
private portfolioFileHandledAt(
|
|
file: {
|
|
blame?: { requestId?: string };
|
|
claim?: { requestId?: string };
|
|
},
|
|
handledAtByFileId: Map<string, Date>,
|
|
): Date | null {
|
|
const handledDates = [
|
|
file.blame?.requestId
|
|
? handledAtByFileId.get(String(file.blame.requestId))
|
|
: undefined,
|
|
file.claim?.requestId
|
|
? handledAtByFileId.get(String(file.claim.requestId))
|
|
: undefined,
|
|
].filter((value): value is Date => value instanceof Date);
|
|
|
|
if (!handledDates.length) return null;
|
|
return new Date(Math.max(...handledDates.map((value) => value.getTime())));
|
|
}
|
|
|
|
async retrieveAllExpertsOfClient(
|
|
actor,
|
|
currentPage: number,
|
|
countPerPage: number,
|
|
opts: { from?: string; to?: string; periodDays?: string | number } = {},
|
|
) {
|
|
const report = await this.buildExpertReportRows(actor, opts);
|
|
const page = Number(currentPage) > 0 ? Number(currentPage) : 1;
|
|
const perPage = Number(countPerPage) > 0 ? Number(countPerPage) : 20;
|
|
const start = (page - 1) * perPage;
|
|
|
|
return {
|
|
range: this.serializeReportRange(report.range),
|
|
total: report.experts.length,
|
|
page,
|
|
countPerPage: perPage,
|
|
experts: report.experts.slice(start, start + perPage),
|
|
};
|
|
}
|
|
|
|
async getTopExpertsForClient(
|
|
actor,
|
|
opts: { from?: string; to?: string; periodDays?: string | number } = {},
|
|
): Promise<{
|
|
range: {
|
|
mode: "preset" | "custom";
|
|
from: string;
|
|
to: string;
|
|
periodDays?: 30 | 60 | 90;
|
|
};
|
|
experts: any[];
|
|
}> {
|
|
const report = await this.buildExpertReportRows(actor, opts);
|
|
const experts = report.experts
|
|
.filter((expert) => expert.totalFiles > 0)
|
|
.sort((a, b) => {
|
|
if (b.totalFiles !== a.totalFiles) return b.totalFiles - a.totalFiles;
|
|
return a.fullName.localeCompare(b.fullName, "fa");
|
|
})
|
|
.slice(0, 10)
|
|
.map((expert) => ({
|
|
_id: expert._id,
|
|
fullName: expert.fullName,
|
|
expertKind: expert.expertKind,
|
|
totalFiles: expert.totalFiles,
|
|
reviewedFiles: expert.reviewedFiles,
|
|
unreviewedFiles: expert.unreviewedFiles,
|
|
thirdPartyFiles: expert.thirdPartyFiles,
|
|
carBodyFiles: expert.carBodyFiles,
|
|
thirdPartyReviewedFiles: expert.thirdPartyReviewedFiles,
|
|
thirdPartyUnreviewedFiles: expert.thirdPartyUnreviewedFiles,
|
|
carBodyReviewedFiles: expert.carBodyReviewedFiles,
|
|
carBodyUnreviewedFiles: expert.carBodyUnreviewedFiles,
|
|
}));
|
|
|
|
return {
|
|
range: this.serializeReportRange(report.range),
|
|
experts,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Legacy endpoint kept for backward compatibility.
|
|
* Returns top 10 claim files for the current insurer client based on
|
|
* combined insurer + user ratings.
|
|
*/
|
|
async getTopFilesForClient(
|
|
insurerId: string,
|
|
opts: { from?: string; to?: string; periodDays?: string | number } = {},
|
|
): Promise<Array<{
|
|
publicId: string;
|
|
createdAt: string;
|
|
combinedScore: number;
|
|
userRating: { comment: string | null; overallEvaluation: number | null };
|
|
}>> {
|
|
const range = this.resolveReportDateRange(opts);
|
|
let claimFiles = await this.getClientClaimFiles(this.getClientId(insurerId));
|
|
|
|
claimFiles = claimFiles.filter((file) =>
|
|
this.isInDateRange(file.createdAt, range.fromDate, range.toDate),
|
|
);
|
|
|
|
return claimFiles
|
|
.map((file) => {
|
|
const combinedScore = this.getCombinedFileScore(file);
|
|
if (combinedScore === null) return null;
|
|
const ur = file?.userRating;
|
|
return {
|
|
publicId: String(file.publicId ?? ""),
|
|
createdAt:
|
|
file.createdAt instanceof Date
|
|
? file.createdAt.toISOString()
|
|
: String(file.createdAt ?? ""),
|
|
combinedScore,
|
|
userRating: {
|
|
comment: ur?.comment ?? null,
|
|
overallEvaluation:
|
|
typeof ur?.overallEvaluation === "number"
|
|
? ur.overallEvaluation
|
|
: null,
|
|
},
|
|
};
|
|
})
|
|
.filter((f): f is NonNullable<typeof f> => f !== null)
|
|
.sort((a, b) => b.combinedScore - a.combinedScore)
|
|
.slice(0, 10);
|
|
}
|
|
|
|
async getReportCharts(
|
|
actor: any,
|
|
opts: { from?: string; to?: string; periodDays?: string | number } = {},
|
|
) {
|
|
const clientObjectId = this.getClientId(actor);
|
|
const [expertReport, mergedFiles, activityEvents] = await Promise.all([
|
|
this.buildExpertReportRows(actor, opts),
|
|
this.buildMergedInsurerFiles(clientObjectId),
|
|
this.expertFileActivityDbService.findByTenant(clientObjectId),
|
|
]);
|
|
|
|
const range = expertReport.range;
|
|
const handledState = this.buildExpertFileStateMap(
|
|
activityEvents as any[],
|
|
range.toDate,
|
|
);
|
|
const handledAtByFileId = new Map<string, Date>();
|
|
for (const [key, state] of handledState.entries()) {
|
|
if (!state.handled || !state.handledAt) continue;
|
|
const fileId = key.split(":").slice(1).join(":");
|
|
const prev = handledAtByFileId.get(fileId);
|
|
if (!prev || prev < state.handledAt) {
|
|
handledAtByFileId.set(fileId, state.handledAt);
|
|
}
|
|
}
|
|
|
|
const filesInRange = mergedFiles.filter((file) =>
|
|
this.isInDateRange(file.createdAt, range.fromDate, range.toDate),
|
|
);
|
|
|
|
const filesPerDayMap = new Map<
|
|
string,
|
|
{ totalFiles: number; thirdPartyFiles: number; carBodyFiles: number }
|
|
>();
|
|
const avgHandlingByDayMap = new Map<
|
|
string,
|
|
{ handledFiles: number; totalDurationMs: number }
|
|
>();
|
|
|
|
for (
|
|
let cursor = this.startOfDay(range.fromDate);
|
|
cursor <= range.toDate;
|
|
cursor = new Date(cursor.getTime() + 24 * 60 * 60 * 1000)
|
|
) {
|
|
const key = this.toUtcDateKey(cursor);
|
|
filesPerDayMap.set(key, {
|
|
totalFiles: 0,
|
|
thirdPartyFiles: 0,
|
|
carBodyFiles: 0,
|
|
});
|
|
avgHandlingByDayMap.set(key, { handledFiles: 0, totalDurationMs: 0 });
|
|
}
|
|
|
|
for (const file of filesInRange as any[]) {
|
|
const createdAt = new Date(file.createdAt);
|
|
if (!Number.isNaN(createdAt.getTime())) {
|
|
const createdKey = this.toUtcDateKey(createdAt);
|
|
const createdBucket = filesPerDayMap.get(createdKey);
|
|
if (createdBucket) {
|
|
createdBucket.totalFiles += 1;
|
|
if (file.fileType === BlameRequestType.CAR_BODY) {
|
|
createdBucket.carBodyFiles += 1;
|
|
} else {
|
|
createdBucket.thirdPartyFiles += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
const handledAt = this.portfolioFileHandledAt(file, handledAtByFileId);
|
|
if (!handledAt || handledAt < range.fromDate || handledAt > range.toDate) {
|
|
continue;
|
|
}
|
|
if (Number.isNaN(createdAt.getTime())) continue;
|
|
const handledKey = this.toUtcDateKey(handledAt);
|
|
const handledBucket = avgHandlingByDayMap.get(handledKey);
|
|
if (!handledBucket) continue;
|
|
handledBucket.handledFiles += 1;
|
|
handledBucket.totalDurationMs += Math.max(
|
|
handledAt.getTime() - createdAt.getTime(),
|
|
0,
|
|
);
|
|
}
|
|
|
|
const topExperts = expertReport.experts
|
|
.filter((expert) => expert.totalFiles > 0)
|
|
.sort((a, b) => {
|
|
if (b.totalFiles !== a.totalFiles) return b.totalFiles - a.totalFiles;
|
|
return a.fullName.localeCompare(b.fullName, "fa");
|
|
})
|
|
.slice(0, 10)
|
|
.map((expert) => ({
|
|
_id: expert._id,
|
|
fullName: expert.fullName,
|
|
expertKind: expert.expertKind,
|
|
totalFiles: expert.totalFiles,
|
|
reviewedFiles: expert.reviewedFiles,
|
|
unreviewedFiles: expert.unreviewedFiles,
|
|
thirdPartyFiles: expert.thirdPartyFiles,
|
|
carBodyFiles: expert.carBodyFiles,
|
|
thirdPartyReviewedFiles: expert.thirdPartyReviewedFiles,
|
|
thirdPartyUnreviewedFiles: expert.thirdPartyUnreviewedFiles,
|
|
carBodyReviewedFiles: expert.carBodyReviewedFiles,
|
|
carBodyUnreviewedFiles: expert.carBodyUnreviewedFiles,
|
|
}));
|
|
|
|
return {
|
|
range: this.serializeReportRange(range),
|
|
topExperts,
|
|
filesPerExpertThirdParty: expertReport.experts
|
|
.filter((expert) => expert.thirdPartyFiles > 0)
|
|
.sort((a, b) => b.thirdPartyFiles - a.thirdPartyFiles)
|
|
.map((expert) => ({
|
|
expertId: expert._id,
|
|
fullName: expert.fullName,
|
|
expertKind: expert.expertKind,
|
|
value: expert.thirdPartyFiles,
|
|
})),
|
|
filesPerExpertCarBody: expertReport.experts
|
|
.filter((expert) => expert.carBodyFiles > 0)
|
|
.sort((a, b) => b.carBodyFiles - a.carBodyFiles)
|
|
.map((expert) => ({
|
|
expertId: expert._id,
|
|
fullName: expert.fullName,
|
|
expertKind: expert.expertKind,
|
|
value: expert.carBodyFiles,
|
|
})),
|
|
filesByDay: Array.from(filesPerDayMap.entries()).map(([date, value]) => ({
|
|
date,
|
|
...value,
|
|
})),
|
|
averageHandlingTimeByDay: Array.from(avgHandlingByDayMap.entries()).map(
|
|
([date, value]) => ({
|
|
date,
|
|
handledFiles: value.handledFiles,
|
|
averageHandlingDays:
|
|
value.handledFiles > 0
|
|
? parseFloat(
|
|
(
|
|
value.totalDurationMs /
|
|
value.handledFiles /
|
|
(24 * 60 * 60 * 1000)
|
|
).toFixed(2),
|
|
)
|
|
: 0,
|
|
}),
|
|
),
|
|
};
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
private async buildMergedInsurerFiles(clientObjectId: Types.ObjectId) {
|
|
const [blameFiles, claimFiles] = await Promise.all([
|
|
this.getClientBlameFiles(clientObjectId),
|
|
this.getClientClaimFiles(clientObjectId),
|
|
]);
|
|
|
|
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;
|
|
}
|
|
>();
|
|
|
|
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);
|
|
}
|
|
|
|
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]),
|
|
);
|
|
|
|
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 (!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);
|
|
}
|
|
|
|
return Array.from(byPublicId.values()).map((f) => ({
|
|
publicId: f.publicId,
|
|
fileType: f.fileType,
|
|
hasClaim: !!f.claim,
|
|
unifiedFileStatus: resolveUnifiedFileStatus({
|
|
blameStatus: f.blame?.status,
|
|
claimStatus: f.claim?.status,
|
|
}),
|
|
parties: f.parties,
|
|
blame: f.blame,
|
|
claim: f.claim,
|
|
createdAt: f.createdAt,
|
|
updatedAt: f.updatedAt,
|
|
}));
|
|
}
|
|
|
|
async retrieveAllFilesOfClient(
|
|
insurerId: string,
|
|
query: ListQueryV2Dto = {},
|
|
) {
|
|
const id = this.getClientId(insurerId);
|
|
let files = await this.buildMergedInsurerFiles(id);
|
|
|
|
if (query.unifiedStatus) {
|
|
files = files.filter(
|
|
(f) => f.unifiedFileStatus === query.unifiedStatus,
|
|
);
|
|
}
|
|
const { fromDate, toDate } = parseListDateRange(query.startDate, query.endDate);
|
|
if (fromDate || toDate) {
|
|
files = files.filter((f) =>
|
|
isInListDateRange(f.createdAt, fromDate, toDate),
|
|
);
|
|
}
|
|
|
|
const paged = applyListQueryV2(
|
|
files,
|
|
{
|
|
publicId: (f) => f.publicId,
|
|
createdAt: (f) => f.updatedAt ?? f.createdAt,
|
|
requestNo: (f) => f.publicId,
|
|
status: (f) => f.unifiedFileStatus,
|
|
fileType: (f) => f.fileType,
|
|
searchExtras: (f) =>
|
|
[
|
|
f.fileType,
|
|
f.blame?.requestNo,
|
|
f.claim?.requestNo,
|
|
f.blame?.status,
|
|
f.claim?.status,
|
|
f.unifiedFileStatus,
|
|
].filter(Boolean) as string[],
|
|
},
|
|
query,
|
|
);
|
|
|
|
return {
|
|
total: paged.total,
|
|
page: paged.page,
|
|
limit: paged.limit,
|
|
totalPages: paged.totalPages,
|
|
files: paged.list,
|
|
};
|
|
}
|
|
|
|
async getInsurerUnifiedFileStatusReport(
|
|
actor: any,
|
|
query: UnifiedFileStatusReportQueryDto = {},
|
|
): Promise<UnifiedFileStatusReportDto> {
|
|
const clientObjectId = this.getClientId(actor);
|
|
const range = this.resolveReportDateRange({
|
|
from: query.from,
|
|
to: query.to,
|
|
periodDays: query.periodDays,
|
|
});
|
|
|
|
const files = await this.buildMergedInsurerFiles(clientObjectId);
|
|
let inRange = files.filter((f) =>
|
|
this.isInDateRange(f.createdAt, range.fromDate, range.toDate),
|
|
);
|
|
if (query.fileType) {
|
|
inRange = inRange.filter((f) => f.fileType === query.fileType);
|
|
}
|
|
|
|
return {
|
|
statuses: getUnifiedFileStatusCatalog(),
|
|
counts: countUnifiedFileStatuses(
|
|
inRange.map((f) => ({
|
|
blameStatus: f.blame?.status,
|
|
claimStatus: f.claim?.status,
|
|
})),
|
|
),
|
|
};
|
|
}
|
|
|
|
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,
|
|
creationMethod:
|
|
(blame as any)?.creationMethod ?? (blameFull as any)?.creationMethod,
|
|
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_FILE_REVIEWER,
|
|
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 normalizeExpertLocations(
|
|
locations?: Array<{ id?: string; name?: string }> | null,
|
|
): Array<{ id: string; name: string }> | undefined {
|
|
if (!Array.isArray(locations) || locations.length === 0) return undefined;
|
|
const normalized = locations
|
|
.map((entry) => ({
|
|
id: String(entry?.id ?? "").trim(),
|
|
name: String(entry?.name ?? "").trim(),
|
|
}))
|
|
.filter((entry) => entry.id && entry.name);
|
|
return normalized.length > 0 ? normalized : undefined;
|
|
}
|
|
|
|
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,
|
|
);
|
|
}
|
|
|
|
async addFileMaker(
|
|
insurerClientKey: string,
|
|
payload: CreateFileMakerByInsurerDto,
|
|
) {
|
|
const clientObjectId = this.getClientId(insurerClientKey);
|
|
const branch = await this.assertBranchBelongsToClient(
|
|
payload.branchId,
|
|
clientObjectId,
|
|
);
|
|
|
|
const email = payload.email.trim().toLowerCase();
|
|
const existing = await this.fileMakerDbService.findOne({ email });
|
|
if (existing) {
|
|
throw new ConflictException("A FileMaker with this email already exists.");
|
|
}
|
|
|
|
const nationalCode = payload.nationalCode.trim();
|
|
const existingByNational = await this.fileMakerDbService.findOne({
|
|
nationalCode,
|
|
});
|
|
if (existingByNational) {
|
|
throw new ConflictException(
|
|
"A FileMaker with this national code already exists.",
|
|
);
|
|
}
|
|
|
|
const hashedPassword = await this.hashService.hash(payload.password);
|
|
const locations = this.normalizeExpertLocations(payload.locations);
|
|
const created = await this.fileMakerDbService.create({
|
|
...payload,
|
|
email,
|
|
nationalCode,
|
|
password: hashedPassword,
|
|
role: RoleEnum.FILE_MAKER,
|
|
clientKey: clientObjectId,
|
|
branchId: new Types.ObjectId(payload.branchId),
|
|
...(locations ? { locations } : {}),
|
|
});
|
|
|
|
return {
|
|
fileMaker: 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 addFileReviewer(
|
|
insurerClientKey: string,
|
|
payload: CreateFileReviewerByInsurerDto,
|
|
) {
|
|
const clientObjectId = this.getClientId(insurerClientKey);
|
|
const branch = await this.assertBranchBelongsToClient(
|
|
payload.branchId,
|
|
clientObjectId,
|
|
);
|
|
|
|
const email = payload.email.trim().toLowerCase();
|
|
const existing = await this.fileReviewerDbService.findOne({ email });
|
|
if (existing) {
|
|
throw new ConflictException(
|
|
"A FileReviewer with this email already exists.",
|
|
);
|
|
}
|
|
|
|
const nationalCode = payload.nationalCode.trim();
|
|
const existingByNational = await this.fileReviewerDbService.findOne({
|
|
nationalCode,
|
|
});
|
|
if (existingByNational) {
|
|
throw new ConflictException(
|
|
"A FileReviewer with this national code already exists.",
|
|
);
|
|
}
|
|
|
|
const hashedPassword = await this.hashService.hash(payload.password);
|
|
const locations = this.normalizeExpertLocations(payload.locations);
|
|
const created = await this.fileReviewerDbService.create({
|
|
...payload,
|
|
email,
|
|
nationalCode,
|
|
password: hashedPassword,
|
|
role: RoleEnum.FILE_REVIEWER,
|
|
clientKey: clientObjectId,
|
|
branchId: new Types.ObjectId(payload.branchId),
|
|
...(locations ? { locations } : {}),
|
|
});
|
|
|
|
return {
|
|
fileReviewer: 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 getExpertStatisticsReport(
|
|
actor: any,
|
|
opts: { from?: string; to?: string; periodDays?: string | number } = {},
|
|
) {
|
|
const clientObjectId = this.getClientId(actor);
|
|
const [expertReport, mergedFiles, activityEvents] = await Promise.all([
|
|
this.buildExpertReportRows(actor, opts),
|
|
this.buildMergedInsurerFiles(clientObjectId),
|
|
this.expertFileActivityDbService.findByTenant(clientObjectId),
|
|
]);
|
|
|
|
const range = expertReport.range;
|
|
const fileState = this.buildExpertFileStateMap(
|
|
activityEvents as any[],
|
|
range.toDate,
|
|
);
|
|
const handledAtByFileId = new Map<string, Date>();
|
|
for (const [key, state] of fileState.entries()) {
|
|
if (!state.handled || !state.handledAt) continue;
|
|
const fileId = key.split(":").slice(1).join(":");
|
|
const prev = handledAtByFileId.get(fileId);
|
|
if (!prev || prev < state.handledAt) {
|
|
handledAtByFileId.set(fileId, state.handledAt);
|
|
}
|
|
}
|
|
|
|
const filesInRange = mergedFiles.filter((file) =>
|
|
this.isInDateRange(file.createdAt, range.fromDate, range.toDate),
|
|
);
|
|
|
|
const summary = this.emptyExpertCaseBreakdown();
|
|
for (const file of filesInRange as any[]) {
|
|
this.addFileToExpertCaseBreakdown(
|
|
summary,
|
|
String(file.fileType ?? BlameRequestType.THIRD_PARTY),
|
|
this.portfolioFileReviewed(file, handledAtByFileId),
|
|
);
|
|
}
|
|
|
|
return {
|
|
range: this.serializeReportRange(range),
|
|
thirdPartyFiles: summary.thirdPartyFiles,
|
|
carBodyFiles: summary.carBodyFiles,
|
|
totalFiles: summary.totalFiles,
|
|
activeExperts: expertReport.experts.filter((expert) => expert.totalFiles > 0)
|
|
.length,
|
|
reviewedFiles: summary.reviewedFiles,
|
|
unreviewedFiles: summary.unreviewedFiles,
|
|
thirdPartyReviewedFiles: summary.thirdPartyReviewedFiles,
|
|
thirdPartyUnreviewedFiles: summary.thirdPartyUnreviewedFiles,
|
|
carBodyReviewedFiles: summary.carBodyReviewedFiles,
|
|
carBodyUnreviewedFiles: summary.carBodyUnreviewedFiles,
|
|
};
|
|
}
|
|
|
|
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,
|
|
periodDays?: string | number,
|
|
): Promise<{
|
|
all: number;
|
|
completed: number;
|
|
under_review: number;
|
|
waiting_for_documents_resend: number;
|
|
}> {
|
|
const report = await this.getInsurerUnifiedFileStatusReport(actor, {
|
|
from,
|
|
to,
|
|
periodDays,
|
|
});
|
|
const counts = report.counts;
|
|
return {
|
|
all: counts.all ?? 0,
|
|
completed: counts.COMPLETED ?? 0,
|
|
under_review:
|
|
(counts.WAITING_FOR_BLAME_EXPERT ?? 0) +
|
|
(counts.WAITING_FOR_DAMAGE_EXPERT ?? 0) +
|
|
(counts.EXPERT_REVIEWING ?? 0) +
|
|
(counts.EXPERT_VALIDATING_FACTORS ?? 0),
|
|
waiting_for_documents_resend: counts.WAITING_FOR_DOCUMENT_RESEND ?? 0,
|
|
};
|
|
}
|
|
|
|
private static readonly TIMELINE_PERFORMER_FA_LABELS: Record<string, string> = {
|
|
user: "کاربر",
|
|
field_expert: "کارشناس میدانی",
|
|
damage_expert: "کارشناس خسارت",
|
|
file_maker: "پروندهساز",
|
|
file_reviewer: "بازبین پرونده",
|
|
registrar: "ثبتکننده",
|
|
call_center: "اپراتور کالسنتر",
|
|
system: "سیستم",
|
|
expert: "کارشناس",
|
|
};
|
|
|
|
/**
|
|
* Returns the most specific actor role recorded in history so the frontend can
|
|
* distinguish between user/system/back-office/expert actions.
|
|
*/
|
|
private static resolveTimelinePerformedBy(
|
|
actorType: string | undefined,
|
|
): string | null {
|
|
if (!actorType?.trim()) return null;
|
|
return actorType.trim().toLowerCase();
|
|
}
|
|
|
|
private static resolveTimelinePerformedByCategory(
|
|
actorType: string | undefined,
|
|
): "user" | "expert" | "staff" | "system" | null {
|
|
const performedBy = ExpertInsurerService.resolveTimelinePerformedBy(actorType);
|
|
if (!performedBy) return null;
|
|
if (performedBy === "user") return "user";
|
|
if (performedBy === "system") return "system";
|
|
if (
|
|
performedBy === "field_expert" ||
|
|
performedBy === "damage_expert" ||
|
|
performedBy === "expert"
|
|
) {
|
|
return "expert";
|
|
}
|
|
return "staff";
|
|
}
|
|
|
|
private static resolveTimelinePerformedByFaLabel(
|
|
actorType: string | undefined,
|
|
): string | null {
|
|
const performedBy = ExpertInsurerService.resolveTimelinePerformedBy(actorType);
|
|
if (!performedBy) return null;
|
|
return (
|
|
ExpertInsurerService.TIMELINE_PERFORMER_FA_LABELS[performedBy] ??
|
|
performedBy
|
|
);
|
|
}
|
|
|
|
private static readonly TIMELINE_PLACEHOLDER_ACTOR_NAMES = new Set([
|
|
"user",
|
|
"actor",
|
|
"expert",
|
|
"unknown expert",
|
|
"field_expert",
|
|
"damage_expert",
|
|
"file_maker",
|
|
"file_reviewer",
|
|
"registrar",
|
|
"call_center",
|
|
"system",
|
|
]);
|
|
|
|
private static getDisplayNameFromRecord(record: any): string | null {
|
|
if (!record) return null;
|
|
|
|
const fullName =
|
|
typeof record.fullName === "string" ? record.fullName.trim() : "";
|
|
if (fullName) return fullName;
|
|
|
|
const firstName =
|
|
typeof record.firstName === "string" ? record.firstName.trim() : "";
|
|
const lastName =
|
|
typeof record.lastName === "string" ? record.lastName.trim() : "";
|
|
const combined = [firstName, lastName].filter(Boolean).join(" ").trim();
|
|
return combined || null;
|
|
}
|
|
|
|
private static isMeaningfulTimelineActorName(name: string | undefined): boolean {
|
|
const trimmed = typeof name === "string" ? name.trim() : "";
|
|
if (!trimmed) return false;
|
|
return !ExpertInsurerService.TIMELINE_PLACEHOLDER_ACTOR_NAMES.has(
|
|
trimmed.toLowerCase(),
|
|
);
|
|
}
|
|
|
|
private static resolveBlamePartyName(sourceDoc: any, partyRole?: string): string | null {
|
|
const parties = Array.isArray(sourceDoc?.parties) ? sourceDoc.parties : [];
|
|
const normalizedPartyRole =
|
|
typeof partyRole === "string" ? partyRole.trim().toUpperCase() : "";
|
|
|
|
const matchedParty = normalizedPartyRole
|
|
? parties.find(
|
|
(party: any) =>
|
|
String(party?.role ?? "").trim().toUpperCase() === normalizedPartyRole,
|
|
)
|
|
: null;
|
|
|
|
const matchedName = matchedParty?.person?.fullName?.trim?.();
|
|
if (matchedName) return matchedName;
|
|
|
|
if (parties.length === 1) {
|
|
return parties[0]?.person?.fullName?.trim?.() || null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private async resolveTimelineActualActorType(
|
|
actor: any,
|
|
cache: Map<string, string | null> = new Map(),
|
|
): Promise<string | null> {
|
|
const rawActorType = ExpertInsurerService.resolveTimelinePerformedBy(
|
|
actor?.actorType,
|
|
);
|
|
const actorId = actor?.actorId ? String(actor.actorId).trim() : "";
|
|
|
|
if (!actorId || !Types.ObjectId.isValid(actorId)) {
|
|
return rawActorType;
|
|
}
|
|
|
|
if (cache.has(actorId)) {
|
|
return cache.get(actorId) ?? rawActorType ?? null;
|
|
}
|
|
|
|
const objectId = new Types.ObjectId(actorId);
|
|
const [fileMaker, fileReviewer, registrar, callCenter, fieldExpert, damageExpert, expert, user] = await Promise.all([
|
|
this.fileMakerDbService.findById(actorId),
|
|
this.fileReviewerDbService.findById(actorId),
|
|
this.registrarDbService.findById(actorId),
|
|
this.callCenterAgentDbService.findById(actorId),
|
|
this.fieldExpertDbService.findById(actorId),
|
|
this.damageExpertDbService.findById(actorId),
|
|
this.expertDbService.findOne({ _id: objectId }),
|
|
this.userDbService.findOne({ _id: objectId }),
|
|
]);
|
|
|
|
const resolvedActorType = fileMaker
|
|
? "file_maker"
|
|
: fileReviewer
|
|
? "file_reviewer"
|
|
: registrar
|
|
? "registrar"
|
|
: callCenter
|
|
? "call_center"
|
|
: fieldExpert
|
|
? "field_expert"
|
|
: damageExpert
|
|
? "damage_expert"
|
|
: expert
|
|
? "expert"
|
|
: user
|
|
? "user"
|
|
: rawActorType;
|
|
|
|
cache.set(actorId, resolvedActorType ?? null);
|
|
return resolvedActorType ?? null;
|
|
}
|
|
|
|
private async resolveTimelineActorNameFromId(
|
|
actorType: string,
|
|
actorId: string,
|
|
): Promise<string | null> {
|
|
if (!Types.ObjectId.isValid(actorId)) return null;
|
|
|
|
const objectId = new Types.ObjectId(actorId);
|
|
|
|
switch (actorType) {
|
|
case "user": {
|
|
const user = await this.userDbService.findOne({ _id: objectId });
|
|
return ExpertInsurerService.getDisplayNameFromRecord(user);
|
|
}
|
|
case "field_expert": {
|
|
const [fieldExpert, expert] = await Promise.all([
|
|
this.fieldExpertDbService.findById(actorId),
|
|
this.expertDbService.findOne({ _id: objectId }),
|
|
]);
|
|
return (
|
|
ExpertInsurerService.getDisplayNameFromRecord(fieldExpert) ??
|
|
ExpertInsurerService.getDisplayNameFromRecord(expert)
|
|
);
|
|
}
|
|
case "damage_expert": {
|
|
const [damageExpert, expert] = await Promise.all([
|
|
this.damageExpertDbService.findById(actorId),
|
|
this.expertDbService.findOne({ _id: objectId }),
|
|
]);
|
|
return (
|
|
ExpertInsurerService.getDisplayNameFromRecord(damageExpert) ??
|
|
ExpertInsurerService.getDisplayNameFromRecord(expert)
|
|
);
|
|
}
|
|
case "file_maker": {
|
|
const fileMaker = await this.fileMakerDbService.findById(actorId);
|
|
return ExpertInsurerService.getDisplayNameFromRecord(fileMaker);
|
|
}
|
|
case "file_reviewer": {
|
|
const [fileReviewer, expert] = await Promise.all([
|
|
this.fileReviewerDbService.findById(actorId),
|
|
this.expertDbService.findOne({ _id: objectId }),
|
|
]);
|
|
return (
|
|
ExpertInsurerService.getDisplayNameFromRecord(fileReviewer) ??
|
|
ExpertInsurerService.getDisplayNameFromRecord(expert)
|
|
);
|
|
}
|
|
case "registrar": {
|
|
const registrar = await this.registrarDbService.findById(actorId);
|
|
return ExpertInsurerService.getDisplayNameFromRecord(registrar);
|
|
}
|
|
case "call_center": {
|
|
const agent = await this.callCenterAgentDbService.findById(actorId);
|
|
return ExpertInsurerService.getDisplayNameFromRecord(agent);
|
|
}
|
|
case "expert": {
|
|
const expert = await this.expertDbService.findOne({ _id: objectId });
|
|
return ExpertInsurerService.getDisplayNameFromRecord(expert);
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private buildTimelineActor(
|
|
actor: any,
|
|
actorName: string | null,
|
|
actorType: string | null,
|
|
): { actorId?: string; actorName?: string; actorType?: string } | null {
|
|
const actorId = actor?.actorId ? String(actor.actorId).trim() : "";
|
|
|
|
if (!actorType && !actorId && !actorName) return null;
|
|
|
|
return {
|
|
...(actorId ? { actorId } : {}),
|
|
...(actorName ? { actorName } : {}),
|
|
...(actorType ? { actorType } : {}),
|
|
};
|
|
}
|
|
|
|
private async resolveTimelineActorName(
|
|
actor: any,
|
|
actorType: string | null,
|
|
sourceDoc?: any,
|
|
event?: any,
|
|
cache: Map<string, string | null> = new Map(),
|
|
): Promise<string | null> {
|
|
const explicitName =
|
|
typeof actor?.actorName === "string" ? actor.actorName.trim() : "";
|
|
if (ExpertInsurerService.isMeaningfulTimelineActorName(explicitName)) {
|
|
return explicitName;
|
|
}
|
|
|
|
if (!actorType) return null;
|
|
|
|
if (actorType === "system") {
|
|
return ExpertInsurerService.resolveTimelinePerformedByFaLabel(actorType);
|
|
}
|
|
|
|
if (actorType === "user") {
|
|
const ownerName = sourceDoc?.owner?.fullName?.trim?.();
|
|
if (ownerName) return ownerName;
|
|
|
|
const blamePartyName = ExpertInsurerService.resolveBlamePartyName(
|
|
sourceDoc,
|
|
event?.metadata?.partyRole,
|
|
);
|
|
if (blamePartyName) return blamePartyName;
|
|
}
|
|
|
|
const actorId = actor?.actorId ? String(actor.actorId).trim() : "";
|
|
if (actorId) {
|
|
const cacheKey = `${actorType}:${actorId}`;
|
|
if (cache.has(cacheKey)) {
|
|
return cache.get(cacheKey) ?? null;
|
|
}
|
|
|
|
let resolvedName = await this.resolveTimelineActorNameFromId(
|
|
actorType,
|
|
actorId,
|
|
);
|
|
|
|
if (!resolvedName && actorType === "user") {
|
|
resolvedName =
|
|
sourceDoc?.owner?.fullName?.trim?.() ??
|
|
ExpertInsurerService.resolveBlamePartyName(
|
|
sourceDoc,
|
|
event?.metadata?.partyRole,
|
|
);
|
|
}
|
|
|
|
if (!resolvedName) {
|
|
resolvedName =
|
|
ExpertInsurerService.resolveTimelinePerformedByFaLabel(actorType);
|
|
}
|
|
|
|
cache.set(cacheKey, resolvedName ?? null);
|
|
return resolvedName ?? null;
|
|
}
|
|
|
|
return ExpertInsurerService.resolveTimelinePerformedByFaLabel(actorType);
|
|
}
|
|
|
|
async getFileTimeline(
|
|
insurerId: string,
|
|
publicId: string,
|
|
): Promise<{ publicId: string; events: object[] }> {
|
|
if (!publicId?.trim()) {
|
|
throw new BadRequestException("publicId is required");
|
|
}
|
|
|
|
const id = this.getClientId(insurerId);
|
|
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 events: object[] = [];
|
|
const actorNameCache = new Map<string, string | null>();
|
|
const actorTypeCache = new Map<string, string | null>();
|
|
|
|
if (blame) {
|
|
const blameDoc = await this.blameRequestDbService.findById(
|
|
String((blame as any)._id),
|
|
);
|
|
const blameHistory: any[] = (blameDoc as any)?.history ?? [];
|
|
for (const ev of blameHistory) {
|
|
const performedBy = await this.resolveTimelineActualActorType(
|
|
ev.actor,
|
|
actorTypeCache,
|
|
);
|
|
const actorName = await this.resolveTimelineActorName(
|
|
ev.actor,
|
|
performedBy,
|
|
blameDoc,
|
|
ev,
|
|
actorNameCache,
|
|
);
|
|
events.push({
|
|
source: "blame",
|
|
type: ev.type,
|
|
faLabel: getEventFaLabel(ev),
|
|
description: getEventFaDescription(ev),
|
|
timestamp: ev.timestamp,
|
|
actor: this.buildTimelineActor(ev.actor, actorName, performedBy),
|
|
actorName,
|
|
performedBy,
|
|
performedByCategory:
|
|
ExpertInsurerService.resolveTimelinePerformedByCategory(performedBy),
|
|
performedByFaLabel:
|
|
ExpertInsurerService.resolveTimelinePerformedByFaLabel(performedBy),
|
|
metadata: localizeTimelineMetadata(ev),
|
|
});
|
|
}
|
|
}
|
|
|
|
if (claim) {
|
|
const claimId = String((claim as any)._id);
|
|
const rows = (await this.claimCaseDbService.find(
|
|
{ _id: new Types.ObjectId(claimId) },
|
|
{ lean: true, select: "history createdAt owner" },
|
|
)) as Record<string, unknown>[];
|
|
const claimDoc = rows[0] as any;
|
|
const claimHistory: any[] = claimDoc?.history ?? [];
|
|
for (const ev of claimHistory) {
|
|
const performedBy = await this.resolveTimelineActualActorType(
|
|
ev.actor,
|
|
actorTypeCache,
|
|
);
|
|
const actorName = await this.resolveTimelineActorName(
|
|
ev.actor,
|
|
performedBy,
|
|
claimDoc,
|
|
ev,
|
|
actorNameCache,
|
|
);
|
|
events.push({
|
|
source: "claim",
|
|
type: ev.type,
|
|
faLabel: getEventFaLabel(ev),
|
|
description: getEventFaDescription(ev),
|
|
timestamp: ev.timestamp,
|
|
actor: this.buildTimelineActor(ev.actor, actorName, performedBy),
|
|
actorName,
|
|
performedBy,
|
|
performedByCategory:
|
|
ExpertInsurerService.resolveTimelinePerformedByCategory(performedBy),
|
|
performedByFaLabel:
|
|
ExpertInsurerService.resolveTimelinePerformedByFaLabel(performedBy),
|
|
metadata: localizeTimelineMetadata(ev),
|
|
});
|
|
}
|
|
}
|
|
|
|
events.sort((a: any, b: any) => {
|
|
const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0;
|
|
const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0;
|
|
return ta - tb;
|
|
});
|
|
|
|
// Resolve insurer's client name for context if needed — just return raw events
|
|
return { publicId, events };
|
|
}
|
|
}
|