Files
yara724api/src/expert-claim/expert-claim.service.ts

2992 lines
97 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
import { createReadStream, existsSync, statSync } from "node:fs";
import { join } from "node:path";
import { HttpService } from "@nestjs/axios";
import {
BadRequestException,
ConflictException,
ForbiddenException,
HttpException,
HttpStatus,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import { distance as stringDistance } from "fastest-levenshtein"; // حتما نصب بشه
import { Types } from "mongoose";
import { lastValueFrom } from "rxjs";
import { ClaimRequestManagementDbService } from "src/claim-request-management/entites/db-service/claim-request-management.db.service";
import { DamageImageDbService } from "src/claim-request-management/entites/db-service/damage-image.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 { 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";
import { SandHubService } from "src/sand-hub/sand-hub.service";
import { ReqClaimStatus } from "src/Types&Enums/claim-request-management/status.enum";
import { ClaimStepsEnum } from "src/Types&Enums/claim-request-management/steps.enum";
import { UserType } from "src/Types&Enums/userType.enum";
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
import { ClaimPerIdRs } from "./dto/claim-list-perId-rs.dto";
import { ClaimListDtoRs } from "./dto/claim-list-rs.dto";
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
import {
assertClaimCaseForTenant,
claimCaseTouchesClient,
requireActorClientKey,
} from "src/helpers/tenant-scope";
import {
claimCaseStatusToReportBucket,
initialClaimExpertReportBuckets,
} from "src/helpers/expert-panel-status-report";
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
import { GetClaimListV2ResponseDto, ClaimListItemV2Dto } from "./dto/claim-list-v2.dto";
import { ClaimDetailV2ResponseDto } from "./dto/claim-detail-v2.dto";
import { ClaimSubmitReplyDto, ClaimSubmitResendDto } from "./dto/reply.dto";
import { UpdateClaimDamagedPartsV2Dto } from "./dto/update-claim-damaged-parts-v2.dto";
import { ClaimFactorsImageDbService } from "src/claim-request-management/entites/db-service/factor-image.db.service";
import { ClaimRequiredDocumentDbService } from "src/claim-request-management/entites/db-service/claim-required-document.db.service";
import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum";
import { DaghiOption } from "src/Types&Enums/claim-request-management/daghi-option.enum";
import { BranchDbService } from "src/client/entities/db-service/branch.db.service";
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";
import { resendRequestHasPayload } from "src/helpers/claim-expert-resend";
import { snapshotFromDamageExpert } from "src/helpers/expert-profile-snapshot";
import { DamageExpertModel } from "src/users/entities/schema/damage-expert.schema";
@Injectable()
export class ExpertClaimService {
private readonly logger = new Logger(ExpertClaimService.name);
/** Matches blame v2 `workflow` lock TTL: locker may re-enter until expired, then others may open. */
private readonly claimV2WorkflowLockTtlMs = 15 * 60 * 1000;
private readonly priceDropPart = {
backFender: {
Minor: 2,
Moderate: 3,
Severe: 5,
},
backDoor: {
Minor: 1,
Moderate: 2,
Severe: 3,
},
frontDoor: {
Minor: 1,
Moderate: 2,
Severe: 3,
},
frontFender: {
Minor: 1,
Moderate: 2,
Severe: 3,
},
frontBumper: {
Minor: 1,
Moderate: 2,
Severe: 3,
},
Hood: {
Minor: 2,
moderate: 3,
Severe: 4,
},
Trunk: {
minor: 1,
moderate: 3,
Severe: 5,
},
Roof: {
Minor: 3,
Moderate: 5,
Severe: 7,
},
coil: {
Minor: 2,
Moderate: 3,
Severe: 4,
},
column: {
Minor: 2,
Moderate: 3,
Severe: 4,
},
frontTray: {
Minor: 1,
Moderate: 2,
Severe: 3,
},
backTray: {
Minor: 2,
moderate: 4,
Severe: 5,
},
frontChassis: {
Minor: 3,
Moderate: 5,
Severe: 7,
},
backChassis: {
Minor: 2,
Moderate: 4,
Severe: 6,
},
carFootrest: {
Minor: 1,
Moderate: 2,
Severe: 3,
},
carFloor: {
Minor: 4,
moderate: 6,
Severe: 8,
},
};
private readonly yearCoefficient = {
1393: 2.05,
1394: 2.1,
1395: 2.2,
1396: 2.3,
1397: 2.4,
1398: 2.5,
1399: 2.6,
1400: 2.7,
1401: 2.8,
1402: 2.9,
1403: 3,
1404: 3,
};
constructor(
private readonly blameVoiceDbService: BlameVoiceDbService,
private readonly blameDocumentDbService: BlameDocumentDbService,
private readonly claimRequestManagementDbService: ClaimRequestManagementDbService,
private readonly damageImageDbService: DamageImageDbService,
private readonly blameVideoDbService: BlameVideoDbService,
private readonly claimVideoCaptureDbService: VideoCaptureDbService,
private readonly httpService: HttpService,
private readonly claimCaseDbService: ClaimCaseDbService,
private readonly blameRequestDbService: BlameRequestDbService,
private readonly sandHubService: SandHubService,
private readonly damageExpertDbService: DamageExpertDbService,
private readonly clientDbService: ClientDbService,
private readonly claimFactorsImageDbService: ClaimFactorsImageDbService,
private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService,
private readonly branchDbService: BranchDbService,
) { }
/** Load immutable profile fields from `damage-expert` for the acting expert. */
private async snapshotDamageExpert(actorId: string) {
const doc = await this.damageExpertDbService.findById(actorId);
return snapshotFromDamageExpert(doc as DamageExpertModel);
}
/** Map blame party `Vehicle` (name/model/type) to expert panel shape. */
private expertVehicleFromPartyVehicle(v: any): {
carName?: string;
carModel?: string;
carType?: string;
plate?: { plateId?: string };
} | undefined {
if (!v || typeof v !== "object") return undefined;
const carName = v.name ?? v.carName;
const carModel = v.model ?? v.carModel;
const carType = v.type ?? v.carType;
const plateId = v.plateId;
const plate =
plateId != null && String(plateId).length > 0
? { plateId: String(plateId) }
: undefined;
if (!carName && !carModel && !carType && !plate) return undefined;
return { carName, carModel, carType, ...(plate ? { plate } : {}) };
}
/** Damaged party vehicle on blame (matches claim owner, or non-guilty party for THIRD_PARTY). */
private damagedPartyVehicleFromBlame(
blame: any,
claim: any,
):
| {
carName?: string;
carModel?: string;
carType?: string;
plate?: { plateId?: string };
}
| undefined {
const parties = blame?.parties;
if (!Array.isArray(parties) || parties.length === 0) return undefined;
const ownerId = claim?.owner?.userId ? String(claim.owner.userId) : "";
let party = ownerId
? parties.find(
(p: any) =>
p?.person?.userId && String(p.person.userId) === ownerId,
)
: undefined;
if (!party && blame?.expert?.decision?.guiltyPartyId) {
const guilty = String(blame.expert.decision.guiltyPartyId);
party = parties.find(
(p: any) =>
p?.person?.userId && String(p.person.userId) !== guilty,
);
}
if (!party && parties.length === 1) party = parties[0];
return this.expertVehicleFromPartyVehicle(party?.vehicle);
}
private vehicleForExpertFromClaimAndBlameMap(
claim: any,
blameById: Map<string, any>,
):
| { carName?: string; carModel?: string; carType?: string; plate?: { plateId?: string } }
| undefined {
const cv = claim?.vehicle;
if (
cv &&
(cv.carName || cv.carModel || cv.carType || (cv as any).plate)
) {
return {
carName: cv.carName,
carModel: cv.carModel,
carType: cv.carType,
...((cv as any).plate ? { plate: (cv as any).plate } : {}),
};
}
const bid = claim?.blameRequestId?.toString();
if (!bid) return undefined;
const blame = blameById.get(bid);
if (!blame) return undefined;
return this.damagedPartyVehicleFromBlame(blame, claim);
}
/**
* Blame file kind for damage-expert UI: THIRD_PARTY vs CAR_BODY, and CAR_BODY first-step flags.
*/
private blameFileContextForExpert(blame: any): {
blameRequestType?: BlameRequestType;
carBodyFirstForm?: { car?: boolean; object?: boolean };
} {
if (!blame?.type) return {};
const blameRequestType = blame.type as BlameRequestType;
const out: {
blameRequestType?: BlameRequestType;
carBodyFirstForm?: { car?: boolean; object?: boolean };
} = { blameRequestType };
if (blameRequestType !== BlameRequestType.CAR_BODY) return out;
const parties = blame.parties;
if (!Array.isArray(parties) || parties.length === 0) return out;
const first =
parties.find((p: any) => p?.role === PartyRole.FIRST) ?? parties[0];
const cbf = first?.carBodyFirstForm;
if (cbf && typeof cbf === "object") {
out.carBodyFirstForm = {
...(typeof cbf.car === "boolean" ? { car: cbf.car } : {}),
...(typeof cbf.object === "boolean" ? { object: cbf.object } : {}),
};
}
return out;
}
private isVisibleToClientType(client: any, actor: any): boolean {
if (actor.userType === UserType.GENUINE) {
return true;
}
if (
actor.userType === UserType.LEGAL &&
String(client._id) === actor.clientKey
) {
return true;
}
return false;
}
private wasHandledByActor(request: any, actorSub: string): boolean {
type ActorCheckerEntry = { CheckedRequest?: { actorId: string } };
const actorChecker = request.actorsChecker as ActorCheckerEntry[];
if (!Array.isArray(actorChecker)) {
return false;
}
const matchingEntry = actorChecker.find(
(entry) => String(entry?.CheckedRequest?.actorId) === actorSub,
);
return !!matchingEntry;
}
async getClaimRequestsListForExpert(actor): Promise<ClaimListDtoRs> {
console.log(actor);
// const requests = await this.claimRequestManagementDbService.findAllByStatus(
// {
// claimStatus: {
// $in: [
// ReqClaimStatus.UnChecked,
// ReqClaimStatus.ReviewRequest,
// ReqClaimStatus.CheckAgain,
// ReqClaimStatus.CloseRequest,
// ReqClaimStatus.InPersonVisit,
// ReqClaimStatus.CheckedRequest,
// ReqClaimStatus.PendingFactorValidation,
// ],
// },
// "damageExpertReply.actorDetail.actorId":actor.sub
// },
// );
const requests = await this.claimRequestManagementDbService.findAllByStatus(
{
$or: [
{
claimStatus: ReqClaimStatus.UnChecked,
},
{
claimStatus: {
$in: [
ReqClaimStatus.ReviewRequest,
ReqClaimStatus.CheckAgain,
ReqClaimStatus.CloseRequest,
ReqClaimStatus.InPersonVisit,
ReqClaimStatus.CheckedRequest,
ReqClaimStatus.PendingFactorValidation,
],
},
"damageExpertReply.actorDetail.actorId": actor.sub,
},
],
}
);
const filteredRequests = [];
for (const r of requests) {
// For expert-initiated blame files, only show to the initiating expert
if (r.blameFile?.expertInitiated && r.blameFile?.initiatedBy) {
if (String(r.blameFile.initiatedBy) !== actor.sub) {
continue; // Skip if not the initiating expert
}
// Expert-initiated claim files are always visible to the initiating expert
filteredRequests.push(r);
continue;
}
const client = await this.clientDbService.findOne({
_id: r.userClientKey,
});
if (!client) {
this.logger.warn(
`Client not found for claim request with ID: ${r._id}. Skipping.`,
);
continue;
}
const specialHandlingStatuses = [
ReqClaimStatus.CheckAgain,
ReqClaimStatus.ReviewRequest,
ReqClaimStatus.PendingFactorValidation,
];
const requiresSpecificActorCheck = specialHandlingStatuses.includes(
r.claimStatus,
);
if (requiresSpecificActorCheck) {
if (this.wasHandledByActor(r, actor.sub)) {
filteredRequests.push(r);
}
} else {
if (this.isVisibleToClientType(client, actor)) {
filteredRequests.push(r);
}
}
}
if (filteredRequests.length === 0) {
throw new NotFoundException(
"No claim requests found for you at this time.",
);
}
return new ClaimListDtoRs(filteredRequests);
}
public unlockApi(request, timer) {
// ADD to project
return setTimeout(() => {
this.claimRequestManagementDbService.findOne(request._id).then((r) => {
const updateExp = {
lockFile: false,
unlockTime: null,
actorLocked: {},
};
if (r?.claimStatus == ReqClaimStatus.ReviewRequest) {
Object.assign(updateExp, {
claimStatus: ReqClaimStatus.UnChecked,
});
}
this.claimRequestManagementDbService.findOneAndUpdate(request._id, {
...updateExp,
});
});
this.logger.log(`unlock this request : ${request._id}`);
// TODO enum request claimStatus create
}, timer);
}
async lockClaimRequest(requestId: string, actorDetail) {
const request =
await this.claimRequestManagementDbService.findOne(requestId);
if (!request) throw new BadRequestException("Claim request not found");
const isLocked = !!request.lockFile;
const lockedByCurrent =
String(request?.actorLocked?.actorId || "") === String(actorDetail.sub);
const isLockExpired =
!!request.unlockTime && Date.now() >= new Date(request.unlockTime).getTime();
// Idempotent behavior: same expert can re-enter their locked file.
if (isLocked && lockedByCurrent && !isLockExpired) {
return { _id: requestId, lock: true, message: "Already locked by you" };
}
if (isLocked && !lockedByCurrent && !isLockExpired) {
throw new BadRequestException("Claim request is locked by another expert");
}
if (request.claimStatus === ReqClaimStatus.UserPending) {
throw new BadRequestException(
"Cannot lock request because claimStatus is UserPending",
);
}
if (
request.damageExpertReplyFinal &&
request.claimStatus === ReqClaimStatus.CheckedRequest
) {
throw new BadRequestException("Request has damage expert reply");
}
const fifteenMinutes = new Date(Date.now() + 15 * 60 * 1000);
const lockSnapshot = await this.snapshotDamageExpert(actorDetail.sub);
await this.claimRequestManagementDbService.findOneAndUpdate(
new Types.ObjectId(requestId),
{
lockFile: true,
claimStatus: ReqClaimStatus.ReviewRequest,
unlockTime: fifteenMinutes,
lockTime: Date.now(),
$set: {
actorLocked: {
actorId: new Types.ObjectId(actorDetail.sub),
fullName: actorDetail.fullName,
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
},
},
$push: {
actorsChecker: {
[ReqClaimStatus.ReviewRequest]: {
fullName: actorDetail.fullName,
actorId: new Types.ObjectId(actorDetail.sub),
},
Date: new Date(),
},
},
},
);
await this.damageExpertDbService.updateStats(
actorDetail.sub,
"checked",
requestId,
);
this.unlockApi(request, fifteenMinutes.getTime() - Date.now());
return { _id: requestId, lock: true };
}
private normalizeText(str: string) {
if (!str || typeof str !== 'string') {
return '';
}
return str
.replace(/[٠-٩۰-۹]/g, (d) => String.fromCharCode(d.charCodeAt(0) - 1728))
.replace(/[\u064B-\u065F]/g, "")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
}
normalizeKey(str: string): string {
return str.toLowerCase().replace(/[^a-z0-9]/g, "");
}
parsePersianNumber(input: string): number {
return Number(
input
.replace(/[۰-۹]/g, (d) => String.fromCharCode(d.charCodeAt(0) - 1728))
.replace(/,/g, ""),
);
}
/** Same rules as V1 `submitReplyRequest`: daghi option + conditional price / branchId; `branchId` stored as ObjectId. */
private validateAndNormalizeDaghiForExpertReplyV2(
parts: import("./dto/expert-claim-v2.dto").PartPricingV2Dto[],
) {
for (const part of parts) {
if (!part.daghi || !part.daghi.option) {
throw new BadRequestException(
`Daghi option is required for part ${part.partId}`,
);
}
if (part.daghi.option === DaghiOption.RECYCLED_PARTS_VALUE) {
if (!part.daghi.price) {
throw new BadRequestException(
`Price is required for part ${part.partId} when option is '${DaghiOption.RECYCLED_PARTS_VALUE}'`,
);
}
} else if (part.daghi.option === DaghiOption.DELIVER_DAMAGED_PART) {
if (!part.daghi.branchId) {
throw new BadRequestException(
`Branch ID is required for part ${part.partId} when option is '${DaghiOption.DELIVER_DAMAGED_PART}'`,
);
}
if (!Types.ObjectId.isValid(part.daghi.branchId)) {
throw new BadRequestException(
`Invalid branch ID format for part ${part.partId}`,
);
}
}
}
return parts.map((part) => ({
...part,
daghi: {
option: part.daghi.option,
...(part.daghi.price && { price: part.daghi.price }),
...(part.daghi.branchId && {
branchId: new Types.ObjectId(part.daghi.branchId),
}),
},
}));
}
private findClosestCar(input: string, cars: { carName: string }[]) {
if (!input || !cars || cars.length === 0) {
this.logger.debug(
`[PriceDrop] findClosestCar: Invalid input. input: ${input}, cars length: ${cars?.length || 0}`,
);
return null;
}
const normalizedInput = this.normalizeText(input);
if (!normalizedInput) {
this.logger.debug(
`[PriceDrop] findClosestCar: Could not normalize input "${input}"`,
);
return null;
}
let bestMatch = null;
let minDistance = Infinity;
let validCarsChecked = 0;
for (const car of cars) {
if (!car || !car.carName) {
continue;
}
const normalizedCarName = this.normalizeText(car.carName);
if (!normalizedCarName) {
continue;
}
validCarsChecked++;
const dist = stringDistance(normalizedInput, normalizedCarName);
if (dist < minDistance) {
minDistance = dist;
bestMatch = car;
}
}
if (!bestMatch) {
this.logger.debug(
`[PriceDrop] findClosestCar: No match found for "${input}" after checking ${validCarsChecked} valid cars out of ${cars.length} total`,
);
} else {
this.logger.debug(
`[PriceDrop] findClosestCar: Best match for "${input}" is "${bestMatch.carName}" with distance ${minDistance}`,
);
}
return bestMatch;
}
private priceDropFormula(
carPrice: number | string,
carModel: number,
carValue: number[],
) {
let normalizePrice: number;
if (typeof carPrice === "string") {
normalizePrice = this.parsePersianNumber(carPrice);
} else if (typeof carPrice === "number") {
normalizePrice = carPrice;
} else {
this.logger.warn(
`Invalid carPrice type received in formula: ${typeof carPrice}`,
);
normalizePrice = 0;
}
const coefficient = this.yearCoefficient[carModel] || 1;
const sumOfSeverity = carValue.reduce((a, c) => a + c, 0);
const totalDrop = (normalizePrice * coefficient * sumOfSeverity) / 400;
return {
carPrice: normalizePrice,
carModel: carModel || null,
carValue: carValue || [],
sumOfSeverity: sumOfSeverity || 0,
coefficientYear: coefficient || 1,
total: totalDrop || 0,
};
}
async calculatePriceDrop(request: any, carPartsAi: any, query?: any) {
const requestId = request?._id?.toString() || 'unknown';
try {
// Step 1: Check SandHub data
if (!request?.blameFile?.sanHubId) {
this.logger.warn(
`[PriceDrop] Request ${requestId}: Missing sanHubId in blameFile. Cannot calculate price drop.`,
);
return;
}
const sandHubData = await this.sandHubService.getSandHubDataFromId(
request.blameFile.sanHubId,
);
if (!sandHubData) {
this.logger.warn(
`[PriceDrop] Request ${requestId}: Could not retrieve SandHub data for sanHubId: ${request.blameFile.sanHubId}. Cannot calculate price drop.`,
);
return;
}
if (sandHubData.MapTypNam === undefined || sandHubData.MapTypNam === null) {
this.logger.warn(
`[PriceDrop] Request ${requestId}: SandHub data missing ModelCii. Cannot calculate price drop.`,
);
return;
}
const existingPriceDrop = request.priceDrop || {};
const manualOverride = query || {};
// Step 2: Check car parts and get severity values
if (!carPartsAi || (Array.isArray(carPartsAi) && carPartsAi.length === 0)) {
this.logger.warn(
`[PriceDrop] Request ${requestId}: No car parts data provided. Cannot calculate severity values.`,
);
}
const severityValues = this.getSeverityValues(carPartsAi);
let autoCalculatedData: any = {};
if (severityValues.length === 0) {
this.logger.warn(
`[PriceDrop] Request ${requestId}: No severity values found from car parts. Price drop calculation will use existing data or manual override only.`,
);
}
// Step 3: Check car detail and find closest car
if (!request?.carDetail) {
this.logger.warn(
`[PriceDrop] Request ${requestId}: Missing carDetail in request. Cannot find car price.`,
);
} else if (!request.carDetail.carName) {
this.logger.warn(
`[PriceDrop] Request ${requestId}: Missing carName in carDetail. Cannot find car price.`,
);
} else if (severityValues.length > 0) {
// Only fetch car prices if we have severity values to calculate
const carPrices = await this.fetchCarPrices();
if (!carPrices || carPrices.length === 0) {
this.logger.error(
`[PriceDrop] Request ${requestId}: Failed to fetch car prices or car prices list is empty. Cannot find matching car.`,
);
} else {
this.logger.debug(
`[PriceDrop] Request ${requestId}: Fetched ${carPrices.length} car prices. Searching for: "${request.carDetail.carName}"`,
);
const closestCar = this.findClosestCar(
request.carDetail.carName,
carPrices,
);
if (!closestCar) {
this.logger.warn(
`[PriceDrop] Request ${requestId}: Could not find closest matching car for "${request.carDetail.carName}". Available cars: ${carPrices.length}. Cannot determine car price.`,
);
} else if (!closestCar.marketPrice) {
this.logger.warn(
`[PriceDrop] Request ${requestId}: Found matching car "${closestCar.carName}" but marketPrice is missing. Cannot calculate price drop.`,
);
} else {
this.logger.debug(
`[PriceDrop] Request ${requestId}: Found matching car "${closestCar.carName}" with marketPrice: ${closestCar.marketPrice}`,
);
autoCalculatedData = {
carPrice: closestCar.marketPrice,
carValue: severityValues,
};
}
}
}
// Step 4: Merge and recalculate
const finalPriceDropData = this.mergeAndRecalculatePriceDrop(
existingPriceDrop,
autoCalculatedData,
manualOverride,
sandHubData.ModelCii,
);
if (!finalPriceDropData) {
this.logger.error(
`[PriceDrop] Request ${requestId}: Failed to generate final price drop data.`,
);
return;
}
// Step 5: Log calculation result
if (!finalPriceDropData.carPrice || !finalPriceDropData.carValue || finalPriceDropData.carValue.length === 0) {
this.logger.warn(
`[PriceDrop] Request ${requestId}: Price drop calculated but missing critical data. ` +
`carPrice: ${finalPriceDropData.carPrice}, ` +
`carValue length: ${finalPriceDropData.carValue?.length || 0}, ` +
`total: ${finalPriceDropData.total}`,
);
} else {
this.logger.debug(
`[PriceDrop] Request ${requestId}: Successfully calculated price drop. ` +
`carPrice: ${finalPriceDropData.carPrice}, ` +
`sumOfSeverity: ${finalPriceDropData.sumOfSeverity}, ` +
`total: ${finalPriceDropData.total}`,
);
}
// Step 6: Save to database
await this.claimRequestManagementDbService.findOneAndUpdate(
{ _id: new Types.ObjectId(request._id) },
{ $set: { priceDrop: finalPriceDropData } },
);
this.logger.log(
`[PriceDrop] Request ${requestId}: Price drop calculation completed and saved.`,
);
} catch (err) {
this.logger.error(
`[PriceDrop] Request ${requestId}: An error occurred during the price drop calculation: ${err.message}`,
err.stack,
);
}
}
private mergeAndRecalculatePriceDrop(
existingData: any,
autoCalculatedData: any,
manualOverride: any,
carModel: number,
) {
const mergedInputs = {
...existingData,
...autoCalculatedData,
...manualOverride,
};
if (!mergedInputs.carPrice || !mergedInputs.carValue) {
return {
carPrice: mergedInputs.carPrice || null,
carModel: carModel,
carValue: mergedInputs.carValue || [],
sumOfSeverity: 0,
coefficientYear: this.yearCoefficient[carModel] || 1,
total: 0,
};
}
return this.priceDropFormula(
mergedInputs.carPrice,
carModel,
mergedInputs.carValue,
);
}
private getSeverityValues(carPartsAi: any[]): number[] {
const severities: number[] = [];
const knownPartKeyMap = new Map<string, string>();
for (const originalKey of Object.keys(this.priceDropPart)) {
if (typeof originalKey === "string") {
knownPartKeyMap.set(this.normalizeKey(originalKey), originalKey);
}
}
for (const part of carPartsAi) {
const damagedPartTable = part?.aiReport?.damaged_part_table;
if (Array.isArray(damagedPartTable) && damagedPartTable.length > 0) {
for (const damagedPartInfo of damagedPartTable) {
if (damagedPartInfo && typeof damagedPartInfo.name === "string") {
const normalizedAiPartName = this.normalizeKey(
damagedPartInfo.name,
);
if (knownPartKeyMap.has(normalizedAiPartName)) {
const originalPriceDropKey =
knownPartKeyMap.get(normalizedAiPartName);
const severityValue =
this.priceDropPart[originalPriceDropKey]?.[
damagedPartInfo.severity
];
if (severityValue !== undefined) {
severities.push(severityValue);
}
}
}
}
}
}
return severities;
}
private async fetchCarPrices(): Promise<
{ carName: string; marketPrice: number }[]
> {
const carPrices: { carName: string; marketPrice: number }[] = [];
const endpoints = ["akharin", "hamrah"];
for (const item of endpoints) {
try {
const url = `${process.env.CW_URL}price?${item}`;
const response = await lastValueFrom(
this.httpService.get(url),
);
if (Array.isArray(response.data)) {
let validEntries = 0;
for (const r of response.data) {
if (r?.carName && r?.marketPrice) {
carPrices.push({
carName: r.carName,
marketPrice: r.marketPrice,
});
validEntries++;
}
}
this.logger.debug(
`[PriceDrop] Fetched ${validEntries} valid car prices from endpoint '${item}' (total entries: ${response.data.length})`,
);
} else {
this.logger.warn(
`[PriceDrop] Endpoint '${item}' returned non-array data: ${typeof response.data}`,
);
}
} catch (err) {
this.logger.error(
`[PriceDrop] Error fetching car prices for endpoint '${item}': ${err.message}`,
err.stack,
);
}
}
if (carPrices.length === 0) {
this.logger.error(
`[PriceDrop] No car prices fetched from any endpoint. Endpoints tried: ${endpoints.join(', ')}`,
);
} else {
this.logger.debug(
`[PriceDrop] Total car prices fetched: ${carPrices.length}`,
);
}
return carPrices;
}
private calculateFinalDropPrice(
closestCar: { marketPrice: number } | null,
carModel: any,
severities: number[],
query?: any,
) {
if (query?.carPrice && query?.carValue) {
return this.priceDropFormula(query.carPrice, carModel, query.carValue);
}
if (closestCar?.marketPrice && severities.length > 0) {
return this.priceDropFormula(
closestCar.marketPrice,
carModel,
severities,
);
}
return null;
}
async requestPerId(requestId: string, currentUser: any, query?: any) {
try {
// 1. Fetch the initial request document and perform calculations
const initialRequest =
await this.claimRequestManagementDbService.findOneDocument(requestId);
if (!initialRequest) {
throw new HttpException(
{ responseCode: 1006, message: "Request not found" },
HttpStatus.NOT_FOUND,
);
}
await this.calculatePriceDrop(
initialRequest,
initialRequest.imageRequired.selectPartOfCar,
query,
);
const requestUpdated =
await this.claimRequestManagementDbService.findOneDocument(requestId);
await this.populateFactorLinks(requestUpdated.damageExpertReply);
await this.populateFactorLinks(requestUpdated.damageExpertReplyFinal);
// 2. Populate required documents with file links
if (requestUpdated.requiredDocuments && Object.keys(requestUpdated.requiredDocuments).length > 0) {
for (const documentType in requestUpdated.requiredDocuments) {
const documentId = requestUpdated.requiredDocuments[documentType];
if (documentId) {
try {
const doc = await this.claimRequiredDocumentDbService.findById(
documentId.toString(),
);
if (doc && doc.path) {
// Replace ID with file URL
requestUpdated.requiredDocuments[documentType] = buildFileLink(doc.path) as any;
}
} catch (error) {
this.logger.warn(
`Failed to populate required document ${documentType}: ${error.message}`,
);
// Keep the ID if population fails
}
}
}
}
// 3. Populate the resent document and voice links from the nested blameFile.
if (requestUpdated.blameFile?.expertResendReply) {
// Helper function to avoid repeating code
const populatePartyLinks = async (
partyKey: "firstParty" | "secondParty",
) => {
const partyReply =
requestUpdated.blameFile.expertResendReply[partyKey];
if (!partyReply) return;
// Populate the voice link
if (partyReply.voice) {
const voiceDoc = await this.blameVoiceDbService.findOne(
partyReply.voice.toString(),
);
if (voiceDoc) {
partyReply.voice = buildFileLink(voiceDoc.path);
}
}
// Populate the document links
if (partyReply.documents) {
for (const docType in partyReply.documents) {
const docId = partyReply.documents[docType];
if (docId) {
// This line will now work correctly after you inject the service.
const doc = await this.blameDocumentDbService.findById(
docId.toString(),
);
if (doc) {
partyReply.documents[docType] = buildFileLink(doc.path); // Replace ID with URL
}
}
}
}
};
// Run the population for both parties
await populatePartyLinks("firstParty");
await populatePartyLinks("secondParty");
}
// 4. Perform authorization checks (as before)
if (this.isCurrentUserAllowed(requestUpdated, currentUser)) {
return new ClaimPerIdRs(requestUpdated);
}
// Check if locked by someone else (allow if locked by current user)
if (this.isRequestLocked(requestUpdated, currentUser)) {
throw new HttpException(
{ responseCode: 1007, message: "Request is locked" },
HttpStatus.FORBIDDEN,
);
}
// 5. Return the fully populated response
return new ClaimPerIdRs(requestUpdated);
} catch (error) {
this.globalErrHandler(error);
}
}
async imageRequired(requestId, currentUser) {
const request =
await this.claimRequestManagementDbService.findOneDocument(requestId);
if (!request) {
throw new HttpException(
{ responseCode: 1006, message: "Request not found" },
HttpStatus.NOT_FOUND,
);
}
if (this.isCurrentUserAllowed(request, currentUser)) {
return (await this.claimRequestManagementDbService.findOne(requestId))
.imageRequired;
} else if (this.isRequestLocked(request, currentUser)) {
throw new HttpException(
{ responseCode: 1007, message: "Request is locked" },
HttpStatus.FORBIDDEN,
);
} else {
throw new HttpException(
{ responseCode: 1008, message: "notAllowed is locked" },
HttpStatus.FORBIDDEN,
);
}
}
private async populateFactorLinks(reply: any): Promise<void> {
if (!reply || !Array.isArray(reply.parts)) {
return;
}
for (const part of reply.parts) {
if (part.factorLink && Types.ObjectId.isValid(part.factorLink)) {
const factorDoc = await this.claimFactorsImageDbService.findById(
part.factorLink.toString(),
);
if (factorDoc) {
part.factorLink = buildFileLink(factorDoc.path);
}
}
}
}
async getRequiredDocuments(requestId: string, currentUser: any) {
try {
const request =
await this.claimRequestManagementDbService.findOneDocument(requestId);
if (!request) {
throw new HttpException(
{ responseCode: 1006, message: "Request not found" },
HttpStatus.NOT_FOUND,
);
}
// Perform authorization checks - allow if locked by current user
if (this.isRequestLocked(request, currentUser)) {
throw new HttpException(
{ responseCode: 1007, message: "Request is locked" },
HttpStatus.FORBIDDEN,
);
}
// Fetch all required documents for this claim
const documents = await this.claimRequiredDocumentDbService.findByClaimId(
requestId,
);
// Populate with file URLs
const populatedDocuments = documents.map((doc) => ({
_id: doc._id,
documentType: doc.documentType,
fileName: doc.fileName,
fileUrl: buildFileLink(doc.path),
uploadedAt: doc.uploadedAt,
}));
return {
claimId: requestId,
totalDocuments: populatedDocuments.length,
documents: populatedDocuments,
};
} catch (error) {
this.globalErrHandler(error);
}
}
private async processAiImages(
imageRequired: any,
): Promise<{ aroundTheCar: any[]; damagePartOfCar: any[] }> {
const aiImages = { aroundTheCar: [], damagePartOfCar: [] };
return aiImages;
}
private async processImage(imageId: string) {
return this.damageImageDbService.findOne(imageId);
}
private isCurrentUserAllowed(request: any, currentUser: any): boolean {
// Check if locked by current user and lock is still active
const isLockedByCurrentUser =
String(request?.actorLocked?.actorId) === currentUser.sub &&
request.lockFile;
if (!isLockedByCurrentUser) {
return false;
}
// Also check if lock has expired (unlockTime has passed)
if (request.unlockTime) {
const unlockTime = new Date(request.unlockTime).getTime();
const now = Date.now();
if (now >= unlockTime) {
// Lock has expired, but still allow access if they were the one who locked it
// The unlockApi will handle the cleanup
return true;
}
}
return true;
}
private isRequestLocked(request: any, currentUser?: any): boolean {
if (!request.lockFile || request.claimStatus !== ReqClaimStatus.ReviewRequest) {
return false;
}
// Check if lock has expired
if (request.unlockTime) {
const unlockTime = new Date(request.unlockTime).getTime();
const now = Date.now();
if (now >= unlockTime) {
// Lock has expired, treat as not locked
return false;
}
}
// If currentUser is provided, allow access if they are the one who locked it
if (currentUser) {
const isLockedByCurrentUser =
String(request?.actorLocked?.actorId) === currentUser.sub;
// Return false (not locked) if locked by current user, true if locked by someone else
return !isLockedByCurrentUser;
}
// If no currentUser provided, treat as locked
return true;
}
async submitReplyRequest(
requestId: string,
reply: ClaimSubmitReplyDto,
userId: any,
) {
try {
const request =
await this.claimRequestManagementDbService.findOne(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
if (
String(request?.actorLocked?.actorId) !== userId.sub &&
!request?.objection
) {
throw new ForbiddenException("Access denied to this request");
}
// Check if lock has expired (unlockTime has passed)
if (request?.unlockTime && !request?.objection) {
const unlockTime = new Date(request.unlockTime).getTime();
const now = Date.now();
if (now >= unlockTime) {
throw new ForbiddenException("Your time has expired");
}
} else if (request?.unlockTime == null && !request?.objection) {
throw new ForbiddenException("Your time has expired");
}
if (!request.lockFile && !request?.objection) {
throw new ForbiddenException(
"For submit reply you must lock the request",
);
}
// Validate total price cap (30,000,000)
if (reply.parts && reply.parts.length > 0) {
const PRICE_CAP = 30000000;
let totalPrice = 0;
for (const part of reply.parts) {
if (part.totalPayment) {
let partPrice: number;
if (typeof part.totalPayment === "string") {
// Handle Persian numbers and remove commas
partPrice = this.parsePersianNumber(part.totalPayment);
} else if (typeof part.totalPayment === "number") {
partPrice = part.totalPayment;
} else {
this.logger.warn(
`Invalid totalPayment type for part ${part.partId}: ${typeof part.totalPayment}`,
);
partPrice = 0;
}
if (isNaN(partPrice)) {
this.logger.warn(
`Could not parse totalPayment for part ${part.partId}: ${part.totalPayment}`,
);
partPrice = 0;
}
totalPrice += partPrice;
}
}
if (totalPrice > PRICE_CAP) {
throw new BadRequestException({
message: `[PRICE_CAP_ERROR] Total price of damaged parts (${totalPrice.toLocaleString()}) exceeds the maximum allowed amount (${PRICE_CAP.toLocaleString()}). Please adjust the prices.`,
error: "PRICE_CAP_ERROR",
code: "PRICE_CAP_ERROR",
totalPrice: totalPrice,
priceCap: PRICE_CAP,
});
}
}
// Validate daghi fields
if (reply.parts && reply.parts.length > 0) {
for (const part of reply.parts) {
if (!part.daghi || !part.daghi.option) {
throw new BadRequestException(
`Daghi option is required for part ${part.partId}`,
);
}
// Validate based on option
if (part.daghi.option === DaghiOption.RECYCLED_PARTS_VALUE) {
if (!part.daghi.price) {
throw new BadRequestException(
`Price is required for part ${part.partId} when option is '${DaghiOption.RECYCLED_PARTS_VALUE}'`,
);
}
} else if (part.daghi.option === DaghiOption.DELIVER_DAMAGED_PART) {
if (!part.daghi.branchId) {
throw new BadRequestException(
`Branch ID is required for part ${part.partId} when option is '${DaghiOption.DELIVER_DAMAGED_PART}'`,
);
}
// Validate branchId is a valid ObjectId
if (!Types.ObjectId.isValid(part.daghi.branchId)) {
throw new BadRequestException(
`Invalid branch ID format for part ${part.partId}`,
);
}
}
// For NO_VALUE and WITH_DAMAGED_PART_CALCULATION, no additional fields needed
}
}
const isObjection = !!request.objection;
const replyFieldKey = isObjection
? "damageExpertReplyFinal"
: "damageExpertReply";
const needsFactorUpload =
reply.parts?.some((part) => part.factorNeeded === true) ?? false;
const nextStatus = needsFactorUpload
? ReqClaimStatus.PendingFactorUpload
: ReqClaimStatus.CheckedRequest;
const nextStep = needsFactorUpload
? ClaimStepsEnum.WaitingForFactorUpload
: ClaimStepsEnum.WaitingForUserToReact;
// Process parts: convert branchId string to ObjectId if present
const processedParts = reply.parts.map((part) => ({
...part,
daghi: {
option: part.daghi.option,
...(part.daghi.price && { price: part.daghi.price }),
...(part.daghi.branchId && {
branchId: new Types.ObjectId(part.daghi.branchId),
}),
},
}));
const expertProfileSnapshot = await this.snapshotDamageExpert(userId.sub);
const replyPayload = {
description: reply.description,
parts: processedParts,
submitTime: new Date(),
actorDetail: {
actorName: userId.fullName,
actorId: userId.sub,
},
...(expertProfileSnapshot && { expertProfileSnapshot }),
};
const updatePayload = {
$set: {
lockFile: false,
claimStatus: nextStatus,
currentStep: nextStep,
[replyFieldKey]: replyPayload,
},
$push: {
actorsChecker: {
[ReqClaimStatus.CheckedRequest]: request.actorLocked,
Date: new Date(),
},
steps: nextStep,
},
};
await this.claimRequestManagementDbService.findAndUpdate(
requestId,
updatePayload,
);
if (!isObjection) {
await this.damageExpertDbService.updateStats(
userId.sub,
"handled",
requestId,
);
}
return {
requestId: request._id,
claimStatus: nextStatus,
};
} catch (error) {
this.logger.error(
`Error in submitReplyRequest for claim ${requestId}:`,
error.stack,
);
if (error instanceof HttpException) {
throw error;
}
throw new HttpException(
"An internal server error occurred.",
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
async submitResend(
requestId: string,
body: ClaimSubmitResendDto,
userId: any,
) {
const request =
await this.claimRequestManagementDbService.findOne(requestId);
if (!request) throw new NotFoundException("request_not_found");
if (String(request?.actorLocked?.actorId) !== userId)
throw new ForbiddenException("access_denied_please_lock");
if (request.unlockTime == null)
throw new ForbiddenException("time_expired");
if (!request.lockFile)
throw new ForbiddenException(
"for submit reply you must lock the request",
);
if (request.damageExpertResend)
throw new ForbiddenException("request already has resend reply");
if (request.damageExpertReplyFinal)
throw new ForbiddenException("request already has final reply");
const resendSnapshot = await this.snapshotDamageExpert(userId);
await this.claimRequestManagementDbService.findAndUpdate(requestId, {
lockFile: false,
claimStatus: ReqClaimStatus.WaitingForUserToResend,
damageExpertResend: {
resendDescription: body.resendDescription,
resendDocuments: body.resendDocuments,
resendCarParts: body.resendCarParts,
...(resendSnapshot && { expertProfileSnapshot: resendSnapshot }),
},
$push: {
actorsChecker: {
[ReqClaimStatus.WaitingForUserToResend]: request.actorLocked,
},
Date: new Date(),
},
});
return request;
}
async streamServiceV2(requestId, query, res, header) {
const request = await this.claimCaseDbService.findById(requestId);
console.log(request)
// const blameCase = await this.blameCaseDbService
return request;
// let videoPath: string = "";
// switch (query) {
// case "accident":
// videoPath = await this.getAccidentVideoPath(
// String(
// request?.blameFile?.firstPartyDetails?.firstPartyFile
// ?.firstPartyVideoId,
// ),
// );
// break;
// case "car-capture":
// videoPath = await this.getCarCapture(requestId);
// break;
// default:
// throw new NotFoundException("Invalid query type");
// }
// if (!videoPath) throw new NotFoundException("video_not_found");
// const absolutePath = join(process.cwd(), videoPath.replace(/^\/+/, ""));
// if (!existsSync(absolutePath)) {
// throw new NotFoundException(
// `Video file not found at path: ${absolutePath}`,
// );
// }
// const { size } = statSync(absolutePath);
// const range = header.range;
// if (range) {
// const [startStr, endStr] = range.replace(/bytes=/, "").split("-");
// const start = parseInt(startStr, 10);
// const end = endStr ? parseInt(endStr, 10) : size - 1;
// const chunkSize = end - start + 1;
// const file = createReadStream(absolutePath, { start, end });
// res.writeHead(206, {
// "Content-Range": `bytes ${start}-${end}/${size}`,
// "Accept-Ranges": "bytes",
// "Content-Length": chunkSize,
// "Content-Type": "video/mp4",
// });
// file.pipe(res);
// } else {
// res.writeHead(200, {
// "Content-Length": size,
// "Content-Type": "video/mp4",
// });
// createReadStream(absolutePath).pipe(res);
// }
}
async streamService(requestId, query, res, header) {
const request =
await this.claimRequestManagementDbService.findOne(requestId);
let videoPath: string = "";
switch (query) {
case "accident":
videoPath = await this.getAccidentVideoPath(
String(
request?.blameFile?.firstPartyDetails?.firstPartyFile
?.firstPartyVideoId,
),
);
break;
case "car-capture":
videoPath = await this.getCarCapture(requestId);
break;
default:
throw new NotFoundException("Invalid query type");
}
if (!videoPath) throw new NotFoundException("video_not_found");
const absolutePath = join(process.cwd(), videoPath.replace(/^\/+/, ""));
if (!existsSync(absolutePath)) {
throw new NotFoundException(
`Video file not found at path: ${absolutePath}`,
);
}
const { size } = statSync(absolutePath);
const range = header.range;
if (range) {
const [startStr, endStr] = range.replace(/bytes=/, "").split("-");
const start = parseInt(startStr, 10);
const end = endStr ? parseInt(endStr, 10) : size - 1;
const chunkSize = end - start + 1;
const file = createReadStream(absolutePath, { start, end });
res.writeHead(206, {
"Content-Range": `bytes ${start}-${end}/${size}`,
"Accept-Ranges": "bytes",
"Content-Length": chunkSize,
"Content-Type": "video/mp4",
});
file.pipe(res);
} else {
res.writeHead(200, {
"Content-Length": size,
"Content-Type": "video/mp4",
});
createReadStream(absolutePath).pipe(res);
}
}
async getVideoLink(
requestId: string,
query: "car-capture" | "accident",
): Promise<{ url: string }> {
const request =
await this.claimRequestManagementDbService.findOne(requestId);
if (!request) {
throw new NotFoundException("Claim request not found");
}
let videoPath: string = "";
switch (query) {
case "accident":
videoPath = await this.getAccidentVideoPath(
String(
request?.blameFile?.firstPartyDetails?.firstPartyFile
?.firstPartyVideoId,
),
);
break;
case "car-capture":
videoPath = await this.getCarCapture(requestId);
break;
default:
throw new BadRequestException("Invalid query type specified");
}
if (!videoPath) {
throw new NotFoundException(
"Video file not found for the specified type.",
);
}
// Use your existing helper to build the full, public URL
const fileUrl = buildFileLink(videoPath);
// Return the URL in a clean JSON object
return { url: fileUrl };
}
async voiceStream(id, res, headers) {
// audio/mpeg
const voice = await this.blameVoiceDbService.findOne(id);
if (!voice) throw new NotFoundException("video_not_found");
const { size } = statSync(voice.path);
const voicePath = voice.path;
const VoiceRange = headers.range;
if (VoiceRange) {
const parts = VoiceRange?.replace(/bytes=/, "").split("-");
const end = parts[1] ? parseInt(parts[1], 16) : size - 1;
const start = parseInt(parts[0], 10);
const chunksize = end - start + 1;
const file = createReadStream(voicePath, { start, end });
const header = {
"Content-Range": `bytes ${start}-${end}/${size}`,
"Accept-Ranges": "bytes",
"Content-Length": chunksize,
"Content-Type": "audio/mpeg",
};
res.writeHead(206, header);
file.pipe(res);
} else {
const head = {
"Content-Length": size,
"Content-Type": "audio/mpeg",
};
res.writeHead(200, head);
createReadStream(voicePath).pipe(res);
}
}
async getVoiceLink(id: string): Promise<{ url: string }> {
const voice = await this.blameVoiceDbService.findOne(id);
if (!voice) {
throw new NotFoundException("Voice file not found");
}
const fileUrl = buildFileLink(voice.path);
return { url: fileUrl };
}
async getCarCapture(requestId): Promise<string> {
const video = await this.claimVideoCaptureDbService.findOne({
claimId: new Types.ObjectId(requestId),
});
if (!video?.path) {
throw new NotFoundException("Car capture video not found");
}
return String(video.path);
}
async getAccidentVideoPath(requestId: string): Promise<string> {
const video = await this.blameVideoDbService.findOneByRequestId(requestId);
if (!video?.path) {
throw new NotFoundException("Accident video not found");
}
return video.path;
}
async validateClaimFactors(
claimId: string,
decisions: {
partId: string;
status: FactorStatus;
rejectionReason?: string;
}[],
expertId: string,
) {
const claim = await this.claimRequestManagementDbService.findOne(claimId);
if (!claim) {
throw new NotFoundException("Claim not found");
}
const finalReply = claim.damageExpertReplyFinal || claim.damageExpertReply;
const replyFieldKey = claim.damageExpertReplyFinal
? "damageExpertReplyFinal"
: "damageExpertReply";
if (!finalReply) {
throw new BadRequestException("No expert reply found in this claim.");
}
for (const decision of decisions) {
const partIndex = finalReply.parts.findIndex(
(p) => p.partId === decision.partId,
);
if (partIndex === -1) {
this.logger.warn(
`Part with ID ${decision.partId} not found in claim ${claimId}. Skipping.`,
);
continue;
}
await this.claimRequestManagementDbService.findOneAndUpdate(
{ _id: new Types.ObjectId(claimId) },
{
$set: {
[`${replyFieldKey}.parts.${partIndex}.factorStatus`]:
decision.status,
[`${replyFieldKey}.parts.${partIndex}.rejectionReason`]:
decision.rejectionReason || null,
},
},
);
}
const factorSnap = await this.snapshotDamageExpert(expertId);
if (factorSnap && decisions.length > 0) {
await this.claimRequestManagementDbService.findOneAndUpdate(
{ _id: new Types.ObjectId(claimId) },
{
$set: { factorValidationExpertProfileSnapshot: factorSnap },
},
);
}
const updatedClaim =
await this.claimRequestManagementDbService.findOne(claimId);
const updatedReply =
updatedClaim.damageExpertReplyFinal || updatedClaim.damageExpertReply;
const requiredFactors = updatedReply.parts.filter((p) => p.factorNeeded);
const anyRejected = requiredFactors.some(
(p) => p.factorStatus === FactorStatus.REJECTED,
);
const anyStillPending = requiredFactors.some(
(p) => p.factorStatus === FactorStatus.PENDING || !p.factorStatus,
);
let newStatus: ReqClaimStatus | null = null;
let responseMessage = "Factor validation complete.";
if (anyRejected) {
// At least one factor is rejected → stay in factor flow and ask user to re-upload
newStatus = ReqClaimStatus.FactorRejected;
responseMessage =
"Factors reviewed. Some factors were rejected, awaiting user resubmission.";
// TODO: Send an SMS or notification to the user here.
} else if (anyStillPending) {
// Some factors still pending (e.g. not validated in this batch)
responseMessage =
"Some factors were approved, but others are still pending validation.";
} else {
// All required factors are approved:
// Move claim into a state where the user can review the final decision and object if needed.
newStatus = ReqClaimStatus.CheckedRequest;
responseMessage =
"All required factors have been approved. The claim is now ready for user review and possible objection.";
}
if (newStatus) {
const update: any = {
$set: { claimStatus: newStatus },
};
// When factors are fully approved, also move the step to WaitingForUserToReact,
// just like the non-factor flow in submitReplyRequest.
if (newStatus === ReqClaimStatus.CheckedRequest) {
update.$set.currentStep = ClaimStepsEnum.WaitingForUserToReact;
update.$set.nextStep = ClaimStepsEnum.WaitingForUserToReact;
update.$push = {
steps: ClaimStepsEnum.WaitingForUserToReact,
};
}
await this.claimRequestManagementDbService.findByIdAndUpdate(
claimId,
update,
);
}
return {
message: responseMessage,
newStatus: newStatus || updatedClaim.claimStatus,
};
}
/**
* V2 (ClaimCase): validate user-uploaded repair factors on the active expert reply.
* Preconditions: all `factorNeeded` parts have `factorLink`; case is UNDER_REVIEW at EXPERT_COST_EVALUATION.
* — All approved → same as non-factor path: claimStatus APPROVED, step INSURER_REVIEW.
* — Any rejected (expert repriced) → case COMPLETED with expert amounts (damage-expert finalization).
*/
async validateClaimFactorsV2(
claimRequestId: string,
body: FactorValidationV2Dto,
actor: { sub: string; fullName?: string; clientKey?: string },
) {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException("Claim request not found");
}
assertClaimCaseForTenant(claim, actor);
const factorValidationSnapshot = await this.snapshotDamageExpert(actor.sub);
if (
claim.status !== ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL ||
claim.claimStatus !== ClaimStatus.UNDER_REVIEW ||
claim.workflow?.currentStep !== ClaimWorkflowStep.EXPERT_COST_EVALUATION
) {
throw new BadRequestException(
"Claim is not awaiting expert factor validation (UNDER_REVIEW at EXPERT_COST_EVALUATION).",
);
}
const replyField = claim.evaluation?.damageExpertReplyFinal
? "damageExpertReplyFinal"
: "damageExpertReply";
const finalReply =
replyField === "damageExpertReplyFinal"
? claim.evaluation?.damageExpertReplyFinal
: claim.evaluation?.damageExpertReply;
if (!finalReply?.parts?.length) {
throw new BadRequestException("No expert reply found in this claim.");
}
const requiredFactors = finalReply.parts.filter((p) => p.factorNeeded);
if (requiredFactors.length === 0) {
throw new BadRequestException("No parts require factor validation.");
}
if (!requiredFactors.every((p) => !!p.factorLink)) {
throw new BadRequestException(
"Not all required factors have been uploaded yet.",
);
}
const $set: Record<string, unknown> = {};
for (const decision of body.decisions) {
const partIndex = finalReply.parts.findIndex(
(p) => String(p.partId) === String(decision.partId),
);
if (partIndex === -1) {
this.logger.warn(
`Part ${decision.partId} not found in claim ${claimRequestId}, skipping.`,
);
continue;
}
const part = finalReply.parts[partIndex];
if (!part.factorNeeded) {
this.logger.warn(
`Part ${decision.partId} is not factor-needed, skipping.`,
);
continue;
}
const base = `evaluation.${replyField}.parts.${partIndex}`;
$set[`${base}.factorStatus`] = decision.status;
$set[`${base}.rejectionReason`] = decision.rejectionReason ?? null;
if (decision.price !== undefined) {
$set[`${base}.price`] = decision.price;
}
if (decision.salary !== undefined) {
$set[`${base}.salary`] = decision.salary;
}
if (decision.totalPayment !== undefined) {
$set[`${base}.totalPayment`] = decision.totalPayment;
}
if (decision.status === FactorStatus.REJECTED) {
const hasOverride =
decision.totalPayment != null ||
(decision.price != null && decision.salary != null);
if (!hasOverride) {
throw new BadRequestException(
`Part ${decision.partId}: rejected factors require expert totalPayment (or both price and salary).`,
);
}
}
}
if (Object.keys($set).length === 0) {
throw new BadRequestException("No valid factor decisions to apply.");
}
if (factorValidationSnapshot) {
$set["evaluation.factorValidationExpertProfileSnapshot"] =
factorValidationSnapshot;
}
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set,
});
const updatedClaim = await this.claimCaseDbService.findById(claimRequestId);
const updatedReply =
replyField === "damageExpertReplyFinal"
? updatedClaim!.evaluation!.damageExpertReplyFinal!
: updatedClaim!.evaluation!.damageExpertReply!;
const reqParts = updatedReply.parts.filter((p) => p.factorNeeded);
const anyRejected = reqParts.some(
(p) => p.factorStatus === FactorStatus.REJECTED,
);
const anyStillPending = reqParts.some(
(p) => !p.factorStatus || p.factorStatus === FactorStatus.PENDING,
);
const baseMessage = "Factor validation updated.";
if (anyStillPending) {
return {
message: `${baseMessage} Some factors are still pending review.`,
claimRequestId,
claimStatus: updatedClaim!.claimStatus,
caseStatus: updatedClaim!.status,
pendingFactorValidation: true,
};
}
const PRICE_CAP = 30_000_000;
let totalPrice = 0;
for (const part of updatedReply.parts || []) {
const parsed = this.parsePersianNumber(String(part.totalPayment ?? "0"));
if (!isNaN(parsed)) {
totalPrice += parsed;
}
}
if (totalPrice > PRICE_CAP) {
throw new BadRequestException({
message: `Total price (${totalPrice.toLocaleString()}) exceeds the cap of ${PRICE_CAP.toLocaleString()}`,
error: "PRICE_CAP_ERROR",
totalPrice,
priceCap: PRICE_CAP,
});
}
const historyActor = {
actorId: new Types.ObjectId(actor.sub),
actorName: actor.fullName,
actorType: "damage_expert",
};
if (anyRejected) {
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: {
status: ClaimCaseStatus.COMPLETED,
claimStatus: ClaimStatus.APPROVED,
"workflow.currentStep": ClaimWorkflowStep.CLAIM_COMPLETED,
"workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED,
},
$push: {
history: {
type: "FACTORS_VALIDATED_REJECTED_EXPERT_FINALIZED",
actor: historyActor,
timestamp: new Date(),
metadata: { replyField },
},
},
});
return {
message:
"Factors reviewed. Rejected parts were repriced by the expert; the claim is completed.",
claimRequestId,
claimStatus: ClaimStatus.APPROVED,
caseStatus: ClaimCaseStatus.COMPLETED,
outcome: "REJECTED_REPRICED_COMPLETED",
};
}
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: {
claimStatus: ClaimStatus.APPROVED,
"workflow.currentStep": ClaimWorkflowStep.INSURER_REVIEW,
"workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED,
},
$push: {
history: {
type: "FACTORS_VALIDATED_ALL_APPROVED",
actor: historyActor,
timestamp: new Date(),
metadata: { replyField },
},
},
});
return {
message:
"All factors approved. The claim proceeds to insurer review (same as non-factor expert reply).",
claimRequestId,
claimStatus: ClaimStatus.APPROVED,
caseStatus: ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL,
outcome: "ALL_APPROVED_INSURER_REVIEW",
};
}
async inPersonVisit(requestId: string, actorDetail: any) {
const request =
await this.claimRequestManagementDbService.findOne(requestId);
if (!request) {
throw new NotFoundException("Blame not found");
}
const visitSnapshot = await this.snapshotDamageExpert(actorDetail.sub);
const updated = await this.claimRequestManagementDbService.findAndUpdate(
requestId,
{
claimStatus: ReqClaimStatus.InPersonVisit,
...(visitSnapshot && {
damageExpertInPersonVisitProfileSnapshot: visitSnapshot,
}),
},
);
await this.damageExpertDbService.updateStats(
actorDetail.sub,
"handled",
requestId,
);
return updated;
}
async retrieveInsuranceBranches(insuranceId: string) {
return await this.branchDbService.findAll(insuranceId);
}
private globalErrHandler(error) {
this.logger.error(error.response?.responseCode, error.response);
throw new HttpException(error.response, error.claimStatus);
}
/**
* V2: Lock a claim request for expert review.
*
* Validations:
* - Claim must exist
* - Status must be WAITING_FOR_DAMAGE_EXPERT
* - Must not already be locked by another expert
*
* On success:
* - Sets workflow.locked = true, workflow.lockedBy = actor
* - Sets status = EXPERT_REVIEWING
* - Sets claimStatus = UNDER_REVIEW
* - Sets currentStep = EXPERT_DAMAGE_ASSESSMENT
*/
async lockClaimRequestV2(claimRequestId: string, actor: any) {
requireActorClientKey(actor);
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException('Claim request not found');
}
assertClaimCaseForTenant(claim, actor);
if (claim.status !== ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT) {
throw new BadRequestException(
`Claim is not available for locking. Current status: ${claim.status}`,
);
}
if (
claim.workflow?.locked &&
claim.workflow.lockedBy?.actorId?.toString() !== actor.sub
) {
throw new BadRequestException(
`Claim is already locked by another expert: ${claim.workflow.lockedBy?.actorName}`,
);
}
// Already locked by this same expert — idempotent
if (
claim.workflow?.locked &&
claim.workflow.lockedBy?.actorId?.toString() === actor.sub
) {
return { claimRequestId, locked: true, message: 'Already locked by you' };
}
const lockSnapshot = await this.snapshotDamageExpert(actor.sub);
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
status: ClaimCaseStatus.EXPERT_REVIEWING,
claimStatus: ClaimStatus.UNDER_REVIEW,
'workflow.locked': true,
'workflow.lockedAt': new Date(),
'workflow.lockedBy': {
actorId: new Types.ObjectId(actor.sub),
actorName: actor.fullName,
actorRole: 'damage_expert',
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
},
'workflow.currentStep': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
$push: {
history: {
event: 'CLAIM_LOCKED',
performedBy: actor.sub,
performedByName: actor.fullName,
performedAt: new Date(),
note: `Claim locked by damage expert ${actor.fullName}`,
},
},
});
return {
claimRequestId,
locked: true,
status: ClaimCaseStatus.EXPERT_REVIEWING,
claimStatus: ClaimStatus.UNDER_REVIEW,
currentStep: ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
};
}
async submitResendDocsV2(
claimRequestId: string,
reply: ClaimSubmitResendV2Dto,
actor: any,
) {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException('Claim request not found');
}
assertClaimCaseForTenant(claim, actor);
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
throw new BadRequestException(
`Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`,
);
}
if (!claim.workflow?.locked) {
throw new ForbiddenException(
'You must lock the claim before submitting a resend request',
);
}
if (claim.workflow.lockedBy?.actorId?.toString() !== actor.sub) {
throw new ForbiddenException('This claim is locked by another expert');
}
// Block duplicate resend while a prior request is still unfinished (stale EXPERT_REVIEWING + pending resend).
// After a successful submit, status becomes WAITING_FOR_USER_RESEND so this endpoint is not callable until the owner finishes.
const existingResend = claim.evaluation?.damageExpertResend;
if (
existingResend &&
!existingResend.fulfilledAt &&
resendRequestHasPayload(existingResend)
) {
throw new ConflictException(
"A resend request is still open for this claim. Wait until the owner completes uploads or contact support if the case is stuck.",
);
}
const docs = reply.resendDocuments ?? [];
const parts = reply.resendCarParts ?? [];
const desc = String(reply.resendDescription ?? "").trim();
if (!desc && docs.length === 0 && parts.length === 0) {
throw new BadRequestException(
"Provide resendDescription and/or resendDocuments and/or resendCarParts.",
);
}
const resendSnapshot = await this.snapshotDamageExpert(actor.sub);
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: {
status: ClaimCaseStatus.WAITING_FOR_USER_RESEND,
claimStatus: ClaimStatus.NEEDS_REVISION,
"workflow.locked": false,
"workflow.currentStep": ClaimWorkflowStep.USER_EXPERT_RESEND,
"workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
"evaluation.damageExpertResend": {
resendDescription: desc || undefined,
resendDocuments: docs,
resendCarParts: parts,
...(resendSnapshot && { expertProfileSnapshot: resendSnapshot }),
},
},
$unset: {
"workflow.lockedAt": "",
"workflow.lockedBy": "",
},
$push: {
history: {
type: "EXPERT_RESEND_REQUESTED",
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: actor.fullName,
actorType: "damage_expert",
},
timestamp: new Date(),
metadata: {
documentCount: docs.length,
carPartCount: parts.length,
},
},
},
});
return {
claimRequestId,
status: ClaimCaseStatus.WAITING_FOR_USER_RESEND,
claimStatus: ClaimStatus.NEEDS_REVISION,
currentStep: ClaimWorkflowStep.USER_EXPERT_RESEND,
nextStep: ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
message:
"Resend request recorded. The owner must upload the requested documents and/or part photos (or confirm if only instructions were given). The claim will return to the damage expert queue when complete.",
};
}
/**
* V2: Submit damage expert reply for a claim.
*
* Validations:
* - Claim must exist
* - Must be locked by this expert (workflow.lockedBy.actorId === actor.sub)
* - Must be in EXPERT_REVIEWING status
* - Total payment across all parts must not exceed 30,000,000
* - Each part must include `daghi` (option + conditional price/branchId) like V1
*
* On success:
* - Stores reply in evaluation.damageExpertReply
* - Unlocks the workflow
* - If any part has factorNeeded=true → status = WAITING_FOR_INSURER_APPROVAL, step = EXPERT_COST_EVALUATION
* - Otherwise → status = WAITING_FOR_INSURER_APPROVAL, step = INSURER_REVIEW, claimStatus = APPROVED
*/
async submitExpertReplyV2(
claimRequestId: string,
reply: import('./dto/expert-claim-v2.dto').SubmitExpertReplyV2Dto,
actor: any,
) {
requireActorClientKey(actor);
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException('Claim request not found');
}
assertClaimCaseForTenant(claim, actor);
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
throw new BadRequestException(
`Claim is not in a reviewable state. Current status: ${claim.status}`,
);
}
if (!claim.workflow?.locked) {
throw new ForbiddenException(
'You must lock the claim before submitting a reply',
);
}
if (claim.workflow.lockedBy?.actorId?.toString() !== actor.sub) {
throw new ForbiddenException('This claim is locked by another expert');
}
// Price cap validation
const PRICE_CAP = 30_000_000;
let totalPrice = 0;
for (const part of reply.parts || []) {
const parsed = this.parsePersianNumber(String(part.totalPayment ?? '0'));
if (!isNaN(parsed)) totalPrice += parsed;
}
if (totalPrice > PRICE_CAP) {
throw new BadRequestException({
message: `Total price (${totalPrice.toLocaleString()}) exceeds the cap of ${PRICE_CAP.toLocaleString()}`,
error: 'PRICE_CAP_ERROR',
totalPrice,
priceCap: PRICE_CAP,
});
}
const processedParts =
reply.parts?.length > 0
? this.validateAndNormalizeDaghiForExpertReplyV2(reply.parts)
: [];
const needsFactorUpload = reply.parts?.some((p) => p.factorNeeded === true) ?? false;
const objectionSubmitted = !!claim.evaluation?.objection?.submittedAt;
const hasFinalReply = !!claim.evaluation?.damageExpertReplyFinal;
if (objectionSubmitted && hasFinalReply) {
throw new ConflictException(
'A final expert reply after objection already exists for this claim.',
);
}
const isFinalReplyAfterObjection = objectionSubmitted && !hasFinalReply;
const replyField = isFinalReplyAfterObjection
? 'damageExpertReplyFinal'
: 'damageExpertReply';
const completedStep = isFinalReplyAfterObjection
? ClaimWorkflowStep.EXPERT_FINAL_REPLY
: ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT;
const expertProfileSnapshot = await this.snapshotDamageExpert(actor.sub);
const replyPayload = {
description: reply.description,
parts: processedParts,
submittedAt: new Date(),
actorDetail: {
actorId: actor.sub,
actorName: actor.fullName,
},
...(expertProfileSnapshot && { expertProfileSnapshot }),
};
const nextStep = needsFactorUpload
? ClaimWorkflowStep.EXPERT_COST_EVALUATION
: ClaimWorkflowStep.INSURER_REVIEW;
const nextCaseStatus = ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL;
const nextClaimStatus = needsFactorUpload
? ClaimStatus.NEEDS_REVISION
: ClaimStatus.APPROVED;
const updatePayload: Record<string, unknown> = {
status: nextCaseStatus,
claimStatus: nextClaimStatus,
'workflow.locked': false,
'workflow.currentStep': nextStep,
'workflow.nextStep': needsFactorUpload
? ClaimWorkflowStep.INSURER_REVIEW
: ClaimWorkflowStep.CLAIM_COMPLETED,
[`evaluation.${replyField}`]: replyPayload,
$push: {
'workflow.completedSteps': completedStep,
history: {
type: isFinalReplyAfterObjection
? 'EXPERT_FINAL_REPLY_SUBMITTED'
: 'EXPERT_REPLY_SUBMITTED',
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: actor.fullName,
actorType: 'damage_expert',
},
timestamp: new Date(),
metadata: {
factorNeeded: needsFactorUpload,
partsCount: processedParts.length,
isFinalReplyAfterObjection,
replyField,
},
},
},
};
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, updatePayload);
return {
claimRequestId,
status: nextCaseStatus,
claimStatus: nextClaimStatus,
currentStep: nextStep,
factorNeeded: needsFactorUpload,
isFinalReplyAfterObjection,
};
}
/**
* V2: Expert marks claim for in-person visit.
*
* This is used when the expert decides the user must attend in person
* before a final assessment can be made.
*
* Validations:
* - Claim must exist
* - Must be locked by this expert
* - Status must be EXPERT_REVIEWING
*
* On success:
* - Sets status = EXPERT_REVIEWING (remains), claimStatus = NEEDS_REVISION
* - Sets evaluation.visitLocation (optional note)
* - Records history event IN_PERSON_VISIT_REQUESTED
* - Unlocks the workflow so user can act
*/
async requestInPersonVisitV2(claimRequestId: string, actor: any, note?: string) {
requireActorClientKey(actor);
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException('Claim request not found');
}
assertClaimCaseForTenant(claim, actor);
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
throw new BadRequestException(
`Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`,
);
}
if (!claim.workflow?.locked) {
throw new ForbiddenException(
'You must lock the claim before requesting an in-person visit',
);
}
if (claim.workflow.lockedBy?.actorId?.toString() !== actor.sub) {
throw new ForbiddenException('This claim is locked by another expert');
}
const visitSnapshot = await this.snapshotDamageExpert(actor.sub);
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
claimStatus: ClaimStatus.NEEDS_REVISION,
'workflow.locked': false,
...(note ? { 'evaluation.visitLocation': note } : {}),
...(visitSnapshot && {
'evaluation.inPersonVisitExpertProfileSnapshot': visitSnapshot,
}),
$push: {
history: {
event: 'IN_PERSON_VISIT_REQUESTED',
performedBy: actor.sub,
performedByName: actor.fullName,
performedAt: new Date(),
note: note || 'Expert requested in-person visit',
},
},
});
return {
claimRequestId,
status: claim.status,
claimStatus: ClaimStatus.NEEDS_REVISION,
message: 'In-person visit requested. User will be notified.',
};
}
/**
* V2: Count claim cases for this damage experts tenant, grouped for dashboard:
* `IN_PROGRESS` = user flow before submission complete (CREATED … CAPTURING_PART_DAMAGES);
* other {@link ClaimCaseStatus} keys unchanged.
*/
async getStatusReportBucketsV2(actor: any): Promise<Record<string, number>> {
const clientKey = requireActorClientKey(actor);
const rows = await this.claimCaseDbService.find({}, { lean: true });
const buckets = initialClaimExpertReportBuckets();
for (const doc of rows as Record<string, unknown>[]) {
if (!claimCaseTouchesClient(doc, clientKey)) continue;
const st = String(doc.status ?? "");
const key = claimCaseStatusToReportBucket(st);
buckets.all++;
buckets[key] = (buckets[key] ?? 0) + 1;
}
return buckets;
}
/**
* V2: Get claim list for damage expert
*
* Returns claims that are (then filtered to the actor's insurer via `claimCaseTouchesClient`):
* 1. WAITING_FOR_DAMAGE_EXPERT and not locked (open queue)
* 2. Locked by this expert (in-progress)
* 3. WAITING_FOR_INSURER_APPROVAL + UNDER_REVIEW at EXPERT_COST_EVALUATION (factor validation queue)
*/
async getClaimListV2(actor: any): Promise<GetClaimListV2ResponseDto> {
requireActorClientKey(actor);
const actorId = actor.sub;
const clientKey = actor.clientKey as string;
const claims = await this.claimCaseDbService.find({
$or: [
// Available claims: waiting for expert, not locked
{
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
'workflow.locked': { $ne: true },
},
// This expert's own locked/in-progress claims
{
'workflow.locked': true,
'workflow.lockedBy.actorId': new Types.ObjectId(actorId),
},
// User uploaded all factors; expert must approve/reject (unlocked queue)
{
status: ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL,
claimStatus: ClaimStatus.UNDER_REVIEW,
'workflow.currentStep': ClaimWorkflowStep.EXPERT_COST_EVALUATION,
},
],
});
const filtered = (claims as any[]).filter((c) =>
claimCaseTouchesClient(c, clientKey),
);
const blameIds = [
...new Set(
filtered
.map((c) => c.blameRequestId?.toString())
.filter((id): id is string => !!id),
),
];
const blames =
blameIds.length > 0
? ((await this.blameRequestDbService.find(
{ _id: { $in: blameIds.map((id) => new Types.ObjectId(id)) } },
{ lean: true, select: "type parties expert.decision" },
)) as any[])
: [];
const blameById = new Map<string, any>(
blames.map((b) => [String(b._id), b]),
);
const list = filtered.map((c) => {
const awaitingFactorValidation =
c.status === ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL &&
c.claimStatus === ClaimStatus.UNDER_REVIEW &&
c.workflow?.currentStep === ClaimWorkflowStep.EXPERT_COST_EVALUATION;
const v = this.vehicleForExpertFromClaimAndBlameMap(c, blameById);
const blame = c.blameRequestId
? blameById.get(c.blameRequestId.toString())
: undefined;
const fileCtx = blame ? this.blameFileContextForExpert(blame) : {};
return {
claimRequestId: c._id.toString(),
publicId: c.publicId,
status: c.status,
currentStep: c.workflow?.currentStep || "",
locked: c.workflow?.locked || false,
lockedBy: c.workflow?.lockedBy
? {
actorId: c.workflow.lockedBy.actorId?.toString(),
actorName: c.workflow.lockedBy.actorName,
}
: undefined,
vehicle: v
? {
carName: v.carName,
carModel: v.carModel,
carType: v.carType,
}
: undefined,
...fileCtx,
createdAt: c.createdAt,
awaitingFactorValidation,
};
}) as ClaimListItemV2Dto[];
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<Record<string, unknown> | null> {
const doc = await this.blameRequestDbService.findByIdWithoutHistory(
blameRequestId,
);
if (!doc) {
return null;
}
const docRec = doc as Record<string, unknown>;
const built = buildMutualAgreementExpertDecision(docRec);
const existingDecision = (docRec.expert as Record<string, unknown> | undefined)
?.decision as Record<string, unknown> | undefined;
if (built && !existingDecision?.guiltyPartyId) {
const prevExpert =
typeof docRec.expert === "object" && docRec.expert !== null
? { ...(docRec.expert as Record<string, unknown>) }
: {};
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<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;
}
}
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<string, unknown>).createdAtFormatted =
`${createdDate} ${createdTime}`;
(doc as Record<string, unknown>).updatedAtFormatted =
`${updatedDate} ${updatedTime}`;
docRec.parties = enrichBlamePartiesForAgreementView(docRec);
return docRec;
}
/** JSON-safe `expert.decision` for API responses (ObjectIds → string). */
private serializeBlameExpertDecisionForClaimDetail(
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,
};
}
/**
* True when another expert still holds an active (non-expired) workflow lock.
* Missing `lockedAt` on old documents: treat lock as still enforced until cleared.
*/
private isClaimV2WorkflowLockCurrentlyEnforced(claim: any): boolean {
if (!claim?.workflow?.locked) return false;
const la = claim.workflow?.lockedAt as Date | string | undefined;
if (!la) return true;
return (
Date.now() <
new Date(la).getTime() + this.claimV2WorkflowLockTtlMs
);
}
/**
* V2: Get claim detail for damage expert
*
* Validations:
* - Claim must exist and belong to the actor's insurer (`assertClaimCaseForTenant`)
* - Allowed when: WAITING_FOR_DAMAGE_EXPERT (queue), or EXPERT_REVIEWING (after lock — same expert must be able to reopen detail),
* or factor-validation queue (WAITING_FOR_INSURER_APPROVAL + UNDER_REVIEW + EXPERT_COST_EVALUATION)
* - If an active workflow lock exists (15 min from lockedAt): only the locking expert; after expiry, any expert (tenant) may view
*/
async getClaimDetailV2(
claimRequestId: string,
actor: any,
): Promise<ClaimDetailV2ResponseDto> {
const actorId = actor.sub;
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException('Claim request not found');
}
assertClaimCaseForTenant(claim, actor);
const isDamageExpertPhase =
claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT ||
claim.status === ClaimCaseStatus.EXPERT_REVIEWING;
const isFactorValidationPending =
claim.status === ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL &&
claim.claimStatus === ClaimStatus.UNDER_REVIEW &&
claim.workflow?.currentStep === ClaimWorkflowStep.EXPERT_COST_EVALUATION;
if (!isDamageExpertPhase && !isFactorValidationPending) {
throw new ForbiddenException(
`This claim is not available for expert review. Current status: ${claim.status}`,
);
}
const lockBlocksOthers = this.isClaimV2WorkflowLockCurrentlyEnforced(claim);
if (lockBlocksOthers) {
const lockerId = claim.workflow?.lockedBy?.actorId?.toString();
if (lockerId && lockerId !== actorId) {
throw new ForbiddenException(
`This claim is locked by another expert: ${claim.workflow.lockedBy?.actorName}`,
);
}
}
const hasCapture = (data: any, key: string) =>
data && (data instanceof Map ? data.get(key) : data[key]);
// Build requiredDocuments map
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 doc = requiredDocs instanceof Map ? requiredDocs.get(k) : requiredDocs[k];
requiredDocumentsStatus[k] = {
uploaded: !!doc?.uploaded,
fileId: doc?.fileId?.toString(),
fileName: doc?.fileName,
filePath: doc?.filePath ? buildFileLink(doc.filePath) : undefined,
};
}
}
// Build car angles map
const carAnglesData = claim.media?.carAngles as any;
const carAngles: Record<string, { captured: boolean; url?: string }> = {};
for (const k of ['front', 'back', 'left', 'right']) {
const cap = hasCapture(carAnglesData, k);
carAngles[k] = {
captured: !!cap,
url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined),
};
}
// Build damaged parts map
const damagedPartsData = claim.media?.damagedParts as any;
const damagedParts: Record<string, { captured: boolean; url?: string }> = {};
for (const p of claim.damage?.selectedParts || []) {
const cap = hasCapture(damagedPartsData, p);
damagedParts[p] = {
captured: !!cap,
url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined),
};
}
// --- 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 &&
(vehiclePayload.carName ||
vehiclePayload.carModel ||
vehiclePayload.carType ||
vehiclePayload.plate);
let blameLean: any = 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<string, unknown> | 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,
status: claim.status,
claimStatus: claim.claimStatus || 'PENDING',
currentStep: claim.workflow?.currentStep || '',
nextStep: claim.workflow?.nextStep,
locked: claim.workflow?.locked || false,
lockedBy: claim.workflow?.lockedBy
? {
actorId: claim.workflow.lockedBy.actorId?.toString(),
actorName: claim.workflow.lockedBy.actorName,
lockedAt: (claim.workflow as any).lockedAt?.toISOString(),
}
: undefined,
owner: claim.owner
? {
userId: claim.owner.userId?.toString(),
fullName: claim.owner.fullName,
}
: undefined,
vehicle: vehiclePayload,
...blameFileContext,
blameRequestId: claim.blameRequestId?.toString(),
blameRequestNo: claim.blameRequestNo,
money: moneyPayload,
selectedParts: claim.damage?.selectedParts,
otherParts: claim.damage?.otherParts,
requiredDocuments:
Object.keys(requiredDocumentsStatus).length > 0
? requiredDocumentsStatus
: undefined,
carAngles,
damagedParts,
awaitingFactorValidation: isFactorValidationPending,
evaluation: isFactorValidationPending
? {
damageExpertReply: (claim as any).evaluation?.damageExpertReply,
damageExpertReplyFinal: (claim as any).evaluation?.damageExpertReplyFinal,
}
: undefined,
videoCapture,
blameCase: blameCase ?? undefined,
blameExpertDecision,
createdAt: (claim as any).createdAt,
updatedAt: (claim as any).updatedAt,
};
}
/**
* V2: Damage expert can modify selected damaged parts before final submit.
* Allowed only when claim is EXPERT_REVIEWING and locked by this expert.
*/
async updateClaimDamagedPartsV2(
claimRequestId: string,
body: UpdateClaimDamagedPartsV2Dto,
actor: any,
) {
requireActorClientKey(actor);
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException("Claim request not found");
}
assertClaimCaseForTenant(claim, actor);
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
throw new BadRequestException(
`Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`,
);
}
if (!claim.workflow?.locked) {
throw new ForbiddenException(
"You must lock the claim before modifying damaged parts",
);
}
if (claim.workflow.lockedBy?.actorId?.toString() !== actor.sub) {
throw new ForbiddenException("This claim is locked by another expert");
}
const damagedPartsEditSnapshot = await this.snapshotDamageExpert(actor.sub);
const previous = Array.isArray((claim as any).damage?.selectedParts)
? [...(claim as any).damage.selectedParts]
: [];
const selectedParts = body.selectedParts as string[];
if (!(claim as any).damage) (claim as any).damage = {};
(claim as any).damage.selectedParts = selectedParts;
// Keep damaged-parts captures consistent with updated selection.
const damagedPartsMedia = (claim as any).media?.damagedParts;
if (damagedPartsMedia) {
const selectedSet = new Set(selectedParts);
if (damagedPartsMedia instanceof Map) {
for (const key of Array.from(damagedPartsMedia.keys())) {
if (!selectedSet.has(key)) damagedPartsMedia.delete(key);
}
} else {
for (const key of Object.keys(damagedPartsMedia)) {
if (!selectedSet.has(key)) delete damagedPartsMedia[key];
}
}
}
if (!Array.isArray((claim as any).history)) (claim as any).history = [];
(claim as any).history.push({
event: "EXPERT_DAMAGED_PARTS_UPDATED",
performedBy: actor.sub,
performedByName: actor.fullName,
performedAt: new Date(),
note: "Damage expert edited selected damaged parts",
metadata: {
previousSelectedParts: previous,
selectedParts,
...(damagedPartsEditSnapshot && {
expertProfileSnapshot: damagedPartsEditSnapshot,
}),
},
});
await (claim as any).save();
return {
claimRequestId: claim._id.toString(),
selectedParts,
previousSelectedParts: previous,
message: "Damaged parts updated successfully.",
};
}
}