From 08a4d754c1d7ea5f25d5082c3e739c424d1315d2 Mon Sep 17 00:00:00 2001 From: "s.hajizadeh" Date: Sun, 19 Apr 2026 11:57:18 +0330 Subject: [PATCH] add blame in claim details plus auto expert decision bug fixed --- src/expert-claim/dto/claim-detail-v2.dto.ts | 31 +++ src/expert-claim/expert-claim.service.ts | 187 +++++++++++++++++- .../expert-claim.v2.controller.ts | 2 +- src/helpers/blame-party-agreement-decision.ts | 149 ++++++++++++++ .../request-management.service.ts | 32 ++- 5 files changed, 393 insertions(+), 8 deletions(-) create mode 100644 src/helpers/blame-party-agreement-decision.ts diff --git a/src/expert-claim/dto/claim-detail-v2.dto.ts b/src/expert-claim/dto/claim-detail-v2.dto.ts index e42e34c..26951b3 100644 --- a/src/expert-claim/dto/claim-detail-v2.dto.ts +++ b/src/expert-claim/dto/claim-detail-v2.dto.ts @@ -98,6 +98,37 @@ export class ClaimDetailV2ResponseDto { damageExpertReplyFinal?: unknown; }; + @ApiPropertyOptional({ + description: + 'Car walk-around video from `claim-video-capture` (resolved from `media.videoCaptureId`).', + }) + videoCapture?: { + id: string; + url?: string; + path?: string; + fileName?: string; + }; + + @ApiPropertyOptional({ + description: + 'Linked blame case (`blameCases`), same shape as expert-blame detail: parties with video/voice URLs, workflow, expert, formatted dates.', + }) + blameCase?: Record; + + @ApiPropertyOptional({ + description: + 'Same as `blameCase.expert.decision` when present: guilty party id, description, accident fields, decidedAt.', + }) + blameExpertDecision?: Record; + + @ApiPropertyOptional({ + description: 'Claim payout / banking metadata from the claim file (e.g. Sheba).', + }) + money?: { + sheba?: string; + nationalCodeOfInsurer?: string; + }; + @ApiProperty() createdAt: string; diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index e58abbf..47d3ad9 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -20,6 +20,7 @@ import { DamageImageDbService } from "src/claim-request-management/entites/db-se 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 { BlameDocumentDbService } from "src/request-management/entities/db-service/blame-document.db.service"; 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"; @@ -53,6 +54,10 @@ import { BranchDbService } from "src/client/entities/db-service/branch.db.servic import { ClaimSubmitResendV2Dto } from "./dto/expert-claim-v2.dto"; import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service"; import { FactorValidationV2Dto } from "./dto/factor-validation.dto"; +import { + buildMutualAgreementExpertDecision, + enrichBlamePartiesForAgreementView, +} from "src/helpers/blame-party-agreement-decision"; @Injectable() export class ExpertClaimService { @@ -2410,6 +2415,119 @@ export class ExpertClaimService { return { list, total: list.length }; } + /** + * Load linked blame case for damage-expert claim detail (same enrichment as expert-blame `findOneV2`: + * party evidence `videoUrl` / `voiceUrls`, Jalali date strings). + */ + private async buildBlameCaseSnapshotForClaimDetail( + blameRequestId: string, + ): Promise | null> { + const doc = await this.blameRequestDbService.findByIdWithoutHistory( + blameRequestId, + ); + if (!doc) { + return null; + } + + const docRec = doc as Record; + const built = buildMutualAgreementExpertDecision(docRec); + const existingDecision = (docRec.expert as Record | undefined) + ?.decision as Record | undefined; + if (built && !existingDecision?.guiltyPartyId) { + const prevExpert = + typeof docRec.expert === "object" && docRec.expert !== null + ? { ...(docRec.expert as Record) } + : {}; + const expert = { ...prevExpert, decision: built }; + await this.blameRequestDbService.findByIdAndUpdate(blameRequestId, { + $set: { expert }, + }); + docRec.expert = expert; + } + + const parties = (doc.parties ?? []) as Array<{ + evidence?: { + videoId?: string | number; + voices?: (string | number)[]; + videoUrl?: string; + voiceUrls?: string[]; + }; + }>; + + 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; + } + } + + const createdAt = doc.createdAt + ? new Date(doc.createdAt as string | number) + : new Date(); + const updatedAt = doc.updatedAt + ? new Date(doc.updatedAt as string | number) + : new Date(); + const [createdDate, createdTime] = toJalaliDateAndTime(createdAt); + const [updatedDate, updatedTime] = toJalaliDateAndTime(updatedAt); + (doc as Record).createdAtFormatted = + `${createdDate} ${createdTime}`; + (doc as Record).updatedAtFormatted = + `${updatedDate} ${updatedTime}`; + + docRec.parties = enrichBlamePartiesForAgreementView(docRec); + + return docRec; + } + + /** JSON-safe `expert.decision` for API responses (ObjectIds → string). */ + private serializeBlameExpertDecisionForClaimDetail( + 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, + }; + } + /** * V2: Get claim detail for damage expert * @@ -2499,6 +2617,9 @@ export class ExpertClaimService { }; } + // --- Combine both branches of the conflict, preserving necessary logic from both --- + + // 1. Vehicle payload logic from "upstream" let vehiclePayload = claim.vehicle as any; const hasClaimVehicle = vehiclePayload && @@ -2508,23 +2629,73 @@ export class ExpertClaimService { vehiclePayload.plate); let blameLean: any = null; - if (claim.blameRequestId) { - const rows = (await this.blameRequestDbService.find( - { _id: new Types.ObjectId(claim.blameRequestId.toString()) }, - { lean: true, select: "type parties expert.decision" }, - )) as any[]; - blameLean = rows[0] ?? null; + + // 2. Video capture and blame case db queries from "stashed" + const videoCaptureIdRaw = (claim.media as any)?.videoCaptureId; + const blameRequestIdStr = claim.blameRequestId?.toString(); + + const [ + videoCaptureRow, + blameCase, + blameLeanRows + ] = await Promise.all([ + videoCaptureIdRaw + ? this.claimVideoCaptureDbService.findById(String(videoCaptureIdRaw)) + : Promise.resolve(null), + blameRequestIdStr + ? this.buildBlameCaseSnapshotForClaimDetail(blameRequestIdStr) + : Promise.resolve(null), + claim.blameRequestId + ? this.blameRequestDbService.find( + { _id: new Types.ObjectId(claim.blameRequestId.toString()) }, + { lean: true, select: "type parties expert.decision" }, + ) + : Promise.resolve([]), + ]); + + if (Array.isArray(blameLeanRows) && blameLeanRows.length) { + blameLean = blameLeanRows[0] ?? null; } + // If claim vehicle not found and blameLean exists, replace vehiclePayload if (!hasClaimVehicle && blameLean) { const fromBlame = this.damagedPartyVehicleFromBlame(blameLean, claim); if (fromBlame) vehiclePayload = fromBlame; } + // 3. Blame file context logic from "upstream" const blameFileContext = blameLean ? this.blameFileContextForExpert(blameLean) : {}; + // 4. videoCapture logic from "stashed" + let videoCapture: ClaimDetailV2ResponseDto['videoCapture'] = undefined; + if (videoCaptureRow) { + const vc = videoCaptureRow as any; + videoCapture = { + id: String(vc._id ?? videoCaptureIdRaw), + fileName: vc.fileName, + path: vc.path, + url: vc.path ? buildFileLink(vc.path) : undefined, + }; + } + + const blameExpertDecision = blameCase + ? this.serializeBlameExpertDecisionForClaimDetail( + (blameCase.expert as Record | undefined)?.decision, + ) + : undefined; + + const money = (claim as { money?: { sheba?: string; nationalCodeOfInsurer?: string } }) + .money; + const moneyPayload = + money && (money.sheba != null || money.nationalCodeOfInsurer != null) + ? { + sheba: money.sheba, + nationalCodeOfInsurer: money.nationalCodeOfInsurer, + } + : undefined; + return { claimRequestId: claim._id.toString(), publicId: claim.publicId, @@ -2550,6 +2721,7 @@ export class ExpertClaimService { ...blameFileContext, blameRequestId: claim.blameRequestId?.toString(), blameRequestNo: claim.blameRequestNo, + money: moneyPayload, selectedParts: claim.damage?.selectedParts, otherParts: claim.damage?.otherParts, requiredDocuments: @@ -2565,6 +2737,9 @@ export class ExpertClaimService { damageExpertReplyFinal: (claim as any).evaluation?.damageExpertReplyFinal, } : undefined, + videoCapture, + blameCase: blameCase ?? undefined, + blameExpertDecision, createdAt: (claim as any).createdAt, updatedAt: (claim as any).updatedAt, }; diff --git a/src/expert-claim/expert-claim.v2.controller.ts b/src/expert-claim/expert-claim.v2.controller.ts index 1ef92d2..6ea55da 100644 --- a/src/expert-claim/expert-claim.v2.controller.ts +++ b/src/expert-claim/expert-claim.v2.controller.ts @@ -48,7 +48,7 @@ export class ExpertClaimV2Controller { @ApiOperation({ summary: "Get claim request detail for damage expert", description: - "Returns full claim details including captured images, required documents status, and damage selections. Allowed when status is WAITING_FOR_DAMAGE_EXPERT (if locked, only the locking expert) or when awaiting factor validation (WAITING_FOR_INSURER_APPROVAL + UNDER_REVIEW + EXPERT_COST_EVALUATION); in the latter case `evaluation` includes the active expert reply for factors.", + "Returns full claim details including captured images, required documents, damage selections, `videoCapture` (from claim-video-capture via media.videoCaptureId), and `blameCase` (linked blameCases document with party video/voice URLs like expert-blame detail). Allowed when status is WAITING_FOR_DAMAGE_EXPERT (if locked, only the locking expert) or when awaiting factor validation.", }) @ApiParam({ name: "claimRequestId" }) async getClaimDetailV2( diff --git a/src/helpers/blame-party-agreement-decision.ts b/src/helpers/blame-party-agreement-decision.ts new file mode 100644 index 0000000..daaa007 --- /dev/null +++ b/src/helpers/blame-party-agreement-decision.ts @@ -0,0 +1,149 @@ +import { Types } from "mongoose"; +import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; +import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatus.enum"; +import { PartyRole } from "src/request-management/entities/schema/partyRole.enum"; + +/** Default accident taxonomy when guilt is established by mutual party agreement (no field-expert decision). */ +export const MUTUAL_AGREEMENT_EXPERT_DECISION_FIELDS = { + accidentWay: { id: 8, label: "جلو به عقب" }, + accidentReason: { + id: 1, + fanavaran: 1, + label: + "عدم رعايت فاصله مناسب با وسيله نقليه و يا عدم توجه به جلو", + }, + accidentType: { + id: 1, + label: "برخورد یک وسیله نقلیه با یک وسیله نقلیه", + }, +} as const; + +export type MutualAgreementDecisionPayload = { + guiltyPartyId: Types.ObjectId; + description: string; + decidedAt: Date; + decidedByExpertId?: Types.ObjectId; + fields: typeof MUTUAL_AGREEMENT_EXPERT_DECISION_FIELDS; +}; + +function partyPhone(p: { person?: { phoneNumber?: string } } | undefined): string { + return (p?.person?.phoneNumber || "").trim(); +} + +function partyUserId( + p: { person?: { userId?: Types.ObjectId } } | undefined, +): Types.ObjectId | null { + const id = p?.person?.userId; + if (!id) return null; + return id instanceof Types.ObjectId ? id : new Types.ObjectId(String(id)); +} + +/** + * When the first party chose AGREED (guilty or damaged) and the second party exists, + * derive the guilty party and build the synthetic expert.decision payload. + * Does not set decidedByExpertId — this is not a field-expert adjudication. + */ +export function buildMutualAgreementExpertDecision( + doc: Record, +): MutualAgreementDecisionPayload | null { + if (doc.type !== BlameRequestType.THIRD_PARTY) return null; + if (doc.blameStatus !== BlameStatus.AGREED) return null; + + const parties = (doc.parties ?? []) as Array<{ + role?: string; + person?: { userId?: Types.ObjectId; phoneNumber?: string }; + statement?: { + admitsGuilt?: boolean; + claimsDamage?: boolean; + acceptsExpertOpinion?: boolean; + }; + }>; + + const first = parties.find((p) => p.role === PartyRole.FIRST); + const second = parties.find((p) => p.role === PartyRole.SECOND); + if (!first || !second) return null; + + const st = first.statement; + if (!st) return null; + if (st.acceptsExpertOpinion) return null; + + const admits = !!st.admitsGuilt; + const claims = !!st.claimsDamage; + if (admits === claims) return null; + + let guiltyParty: typeof first; + let damagedParty: typeof first; + if (admits) { + guiltyParty = first; + damagedParty = second; + } else { + guiltyParty = second; + damagedParty = first; + } + + const guiltyId = partyUserId(guiltyParty); + if (!guiltyId) return null; + + const gPhone = partyPhone(guiltyParty); + const dPhone = partyPhone(damagedParty); + if (!gPhone || !dPhone) return null; + + const description = `با توافق طرفین مقصر و زیان دیده مشخص شد. کاربر ${gPhone} مقصر و کاربر ${dPhone} زیان دیده می باشد.`; + + return { + guiltyPartyId: guiltyId, + description, + decidedAt: new Date(), + fields: MUTUAL_AGREEMENT_EXPERT_DECISION_FIELDS, + }; +} + +/** + * Merge complementary statement flags onto the second party for expert/claim UIs when + * only the first party submitted a formal confession (V2 remote flow). + */ +export function enrichBlamePartiesForAgreementView( + doc: Record, +): Array> { + const parties = (doc.parties ?? []) as Array>; + if (!Array.isArray(parties) || parties.length === 0) return parties; + + const first = parties.find((p) => p.role === PartyRole.FIRST) as + | { + statement?: Record; + } + | undefined; + const secondIdx = parties.findIndex((p) => p.role === PartyRole.SECOND); + if (!first?.statement || secondIdx === -1) return parties; + + if (doc.blameStatus !== BlameStatus.AGREED) return parties; + + const st = first.statement as { + admitsGuilt?: boolean; + claimsDamage?: boolean; + acceptsExpertOpinion?: boolean; + }; + if (st.acceptsExpertOpinion) return parties; + + const admits = !!st.admitsGuilt; + const claims = !!st.claimsDamage; + if (admits === claims) return parties; + + const second = parties[secondIdx] as { + statement?: Record; + }; + const prev = (second.statement ?? {}) as Record; + const mergedStatement = { + ...prev, + admitsGuilt: admits ? false : true, + claimsDamage: admits ? true : false, + acceptsExpertOpinion: false, + }; + + const next = [...parties]; + next[secondIdx] = { + ...second, + statement: mergedStatement, + }; + return next; +} diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 4afe83f..cde2442 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -75,6 +75,10 @@ import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus. import { UploadContext } from "./entities/schema/blame-voice.schema"; import { HashService } from "src/utils/hash/hash.service"; import { UserAuthService } from "src/auth/auth-services/user.auth.service"; +import { + buildMutualAgreementExpertDecision, + MUTUAL_AGREEMENT_EXPERT_DECISION_FIELDS, +} from "src/helpers/blame-party-agreement-decision"; @Injectable() export class RequestManagementService { @@ -1157,6 +1161,22 @@ export class RequestManagementService { await this.advanceWorkflowToNext(req, stepKey); + if ( + stepKey === WorkflowStep.SECOND_DESCRIPTION && + req.type === BlameRequestType.THIRD_PARTY && + !(req as any).expert?.decision?.guiltyPartyId + ) { + const built = buildMutualAgreementExpertDecision( + (req as any).toObject + ? (req as any).toObject() + : { ...req }, + ); + if (built) { + if (!(req as any).expert) (req as any).expert = {}; + (req as any).expert.decision = built as any; + } + } + if (!Array.isArray(req.history)) req.history = []; req.history.push({ type: `${stepKey}_SUBMITTED`, @@ -4435,8 +4455,18 @@ export class RequestManagementService { formData.guiltyPartyPhoneNumber === formData.firstPartyPhoneNumber ? String(firstPartyUserId) : String(secondPartyUserId); + const damagedPhone = + formData.guiltyPartyPhoneNumber === formData.firstPartyPhoneNumber + ? formData.secondParty.phoneNumber + : formData.firstPartyPhoneNumber; if (!req.expert) req.expert = {} as any; - req.expert.decision = { guiltyPartyId: new Types.ObjectId(guiltyPartyId) } as any; + req.expert.decision = { + guiltyPartyId: new Types.ObjectId(guiltyPartyId), + description: `با توافق طرفین مقصر و زیان دیده مشخص شد. کاربر ${formData.guiltyPartyPhoneNumber} مقصر و کاربر ${damagedPhone} زیان دیده می باشد.`, + decidedAt: new Date(), + decidedByExpertId: new Types.ObjectId(expert.sub), + fields: { ...MUTUAL_AGREEMENT_EXPERT_DECISION_FIELDS }, + } as any; req.workflow = { currentStep: WorkflowStep.FIRST_LOCATION as any, nextStep: WorkflowStep.SECOND_LOCATION as any,