diff --git a/src/claim-request-management/claim-request-management.module.ts b/src/claim-request-management/claim-request-management.module.ts index 75c8166..f11fcb2 100644 --- a/src/claim-request-management/claim-request-management.module.ts +++ b/src/claim-request-management/claim-request-management.module.ts @@ -98,6 +98,7 @@ import { JwtModule } from "@nestjs/jwt"; DamageImageDbService, VideoCaptureDbService, ClaimRequiredDocumentDbService, + ClaimSignDbService, ], }) export class ClaimRequestManagementModule {} diff --git a/src/expert-insurer/expert-insurer.service.ts b/src/expert-insurer/expert-insurer.service.ts index 62755e9..cb33bdd 100644 --- a/src/expert-insurer/expert-insurer.service.ts +++ b/src/expert-insurer/expert-insurer.service.ts @@ -29,6 +29,28 @@ import { ExpertFileActivityType } from "src/users/entities/schema/expert-file-ac 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 } from "src/helpers/urlCreator"; +import { toJalaliDateAndTime } from "src/helpers/date-jalali"; +import { enrichBlamePartiesForAgreementView } from "src/helpers/blame-party-agreement-decision"; +import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; +import { PartyRole } from "src/request-management/entities/schema/partyRole.enum"; +import { + getClaimCarAngleCaptureBlob, + getDamagedPartCaptureBlob, + hasDamagedPartCapture, + type ClaimCarAngleKey, +} from "src/helpers/claim-car-angle-media"; +import { + catalogLikeKeyFromPart, + normalizeDamageSelectedParts, + type DamageSelectedPartV2, +} from "src/helpers/outer-damage-parts"; +import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog"; +import { ClaimSignDbService } from "src/claim-request-management/entites/db-service/claim-sign.db.service"; @Injectable() export class ExpertInsurerService { @@ -40,6 +62,11 @@ export class ExpertInsurerService { 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 getClientId(actorOrId: any): Types.ObjectId { @@ -225,6 +252,445 @@ export class ExpertInsurerService { }; } + private serializeBlameExpertDecisionForInsurer( + decision: unknown, + ): Record | undefined { + if (!decision || typeof decision !== "object") { + return undefined; + } + const d = decision as Record; + 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 | 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, + ): Promise> { + const unique = [ + ...new Set( + ids + .filter((x): x is string => !!x && Types.ObjectId.isValid(String(x))) + .map(String), + ), + ]; + const out: Record = {}; + 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, + ) { + 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, + ) { + if (!party || typeof party !== "object") return party; + return { + ...party, + person: this.mapPersonForInsurer(party.person, clientNames), + vehicle: this.sanitizeVehicleInquiry(party.vehicle), + }; + } + + /** Public URL for a stored relative path or pass-through if already absolute. */ + private linkFromPathOrUrl(stored: string | undefined): string | undefined { + if (stored == null || stored === "") return undefined; + const s = String(stored); + if (/^https?:\/\//i.test(s)) return s; + return buildFileLink(s); + } + + private enrichSignatureRecord( + sig: Record | undefined, + ): Record | 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), + }; + } + } + } + + 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 = cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined); + 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) }; + 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; + const nextV = Object.keys(rest).length ? rest : undefined; + return { ...p, vehicle: nextV }; + }); + } + + private async claimSignLinkFromId(signDetailId: unknown): Promise { + 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 | undefined, + ): Promise | undefined> { + if (!evaluation || typeof evaluation !== "object") return evaluation; + const ev = JSON.parse(JSON.stringify(evaluation)) as Record; + + 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 | 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 { + if (!Array.isArray(parties)) return; + for (const party of parties) { + if (!party?.evidence) continue; + const evidence = party.evidence as Record; + + 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, + ): Promise> { + const doc: Record = { ...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) }; + 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, + blameForContext: Record | null, + ): Promise> { + const requiredDocs = claim.requiredDocuments as any; + const requiredDocumentsStatus: Record = {}; + 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 = {}; + 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: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined), + }; + } + + const carTypeExpert = (claim.vehicle as any)?.carType as ClaimVehicleTypeV2 | undefined; + const selectedNormExpert = normalizeDamageSelectedParts( + (claim.damage as any)?.selectedParts, + carTypeExpert, + (claim.damage as any)?.selectedOuterParts, + ); + const damagedPartsDataExpert = (claim.media as any)?.damagedParts; + const damagedParts = selectedNormExpert.map((sp, index) => + this.insurerDamagedPartRowFromCapture( + sp, + index, + damagedPartsDataExpert, + selectedNormExpert, + ), + ); + const selectedPartsNormalized = selectedNormExpert.map((sp, index) => + this.insurerDamagedPartRowFromCapture( + sp, + index, + damagedPartsDataExpert, + selectedNormExpert, + ), + ); + + 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 | 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; + 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, + selectedPartsNormalized, + 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; @@ -655,6 +1121,7 @@ export class ExpertInsurerService { } const id = this.getClientId(insurerId); + const idStr = String(id); const [blameFiles, claimFiles] = await Promise.all([ this.getClientBlameFiles(id), this.getClientClaimFiles(id), @@ -667,12 +1134,31 @@ export class ExpertInsurerService { throw new NotFoundException("File not found for this publicId"); } - const parties = Array.isArray((blame as any)?.parties) ? (blame as any).parties : []; - const firstParty = parties.find((p: any) => p?.role === "FIRST"); - const secondParty = parties.find((p: any) => p?.role === "SECOND"); - const claimSnapshotParties = Array.isArray((claim as any)?.snapshot?.parties) - ? (claim as any).snapshot.parties - : undefined; + let blameFull: Record | null = null; + if (blame) { + blameFull = (await this.blameRequestDbService.findByIdWithoutHistory( + String((blame as any)._id), + )) as Record | null; + } + + let claimFull: Record | null = null; + if (claim) { + const rows = (await this.claimCaseDbService.find( + { _id: new Types.ObjectId(String((claim as any)._id)) }, + { lean: true }, + )) as Record[]; + claimFull = rows[0] ?? null; + } + + let blameForClaimContext: Record | null = blameFull; + if (!blameForClaimContext && claimFull?.blameRequestId) { + const linked = (await this.blameRequestDbService.findByIdWithoutHistory( + String(claimFull.blameRequestId), + )) as Record | null; + if (linked && blameCaseTouchesClient(linked, idStr)) { + blameForClaimContext = linked; + } + } const overview = { publicId, @@ -681,97 +1167,21 @@ export class ExpertInsurerService { (claim as any)?.requestNo || (blame as any)?.requestNumber || (claim as any)?.requestNumber, - type: (blame as any)?.type, + type: (blame as any)?.type ?? (blameFull as any)?.type, hasClaim: !!claim, - blameStatus: (blame as any)?.status, + 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 = blame - ? { - requestId: - (blame as any)?._id?.toString?.() || String((blame as any)?._id || ""), - requestNo: - (blame as any).requestNo || String((blame as any).requestNumber || ""), - status: (blame as any).status, - blameStatus: (blame as any).blameStatus, - workflow: (blame as any).workflow, - parties: { - first: firstParty - ? { - fullName: firstParty?.person?.fullName, - phoneNumber: firstParty?.person?.phoneNumber, - role: firstParty?.role, - vehicle: this.sanitizeVehicleInquiry(firstParty?.vehicle), - } - : undefined, - second: secondParty - ? { - fullName: secondParty?.person?.fullName, - phoneNumber: secondParty?.person?.phoneNumber, - role: secondParty?.role, - vehicle: this.sanitizeVehicleInquiry(secondParty?.vehicle), - } - : undefined, - }, - expert: (blame as any).expert - ? { - assignedExpertId: (blame as any).expert?.assignedExpertId, - decision: (blame as any).expert?.decision, - resend: (blame as any).expert?.resend, - rating: (blame as any).expert?.rating, - } - : undefined, - createdAt: (blame as any).createdAt, - updatedAt: (blame as any).updatedAt, - } + const blameDetails = blameFull + ? await this.buildInsurerBlameDetail(blameFull) : undefined; - // Keep claim section claim-specific to avoid duplicating shared data already in blame/overview. - const claimDetails = claim - ? { - requestId: - (claim as any)?._id?.toString?.() || String((claim as any)?._id || ""), - requestNo: - (claim as any).requestNo || String((claim as any).requestNumber || ""), - status: (claim as any).status, - claimStatus: (claim as any).claimStatus, - owner: (claim as any).owner, - vehicle: (claim as any).vehicle, - money: (claim as any).money, - damage: (claim as any).damage, - media: (claim as any).media, - requiredDocuments: (claim as any).requiredDocuments, - snapshot: (claim as any).snapshot - ? { - ...(claim as any).snapshot, - parties: claimSnapshotParties?.map((p: any) => - this.sanitizePartyForInsurerView(p), - ), - } - : undefined, - workflow: { - currentStep: (claim as any).currentStep, - nextStep: (claim as any).nextStep, - }, - evaluation: { - damageExpertReply: (claim as any).damageExpertReply, - damageExpertReplyFinal: (claim as any).damageExpertReplyFinal, - damageExpertResend: (claim as any).damageExpertResend, - objection: (claim as any).objection, - priceDrop: (claim as any).priceDrop, - visitLocation: (claim as any).visitLocation, - factors: (claim as any).evaluation?.factors, - }, - userResendDocuments: (claim as any).userResendDocuments, - userRating: (claim as any).userRating, - insurerRating: (claim as any).rating, - createdAt: (claim as any).createdAt, - updatedAt: (claim as any).updatedAt, - } + const claimDetails = claimFull + ? await this.buildInsurerClaimDetail(claimFull, blameForClaimContext) : undefined; return {