forked from Yara724/api
2072 lines
64 KiB
TypeScript
2072 lines
64 KiB
TypeScript
"use strict";
|
||
import { createReadStream, existsSync, statSync } from "node:fs";
|
||
import { join } from "node:path";
|
||
import { HttpService } from "@nestjs/axios";
|
||
import {
|
||
BadRequestException,
|
||
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 { 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 { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.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";
|
||
|
||
@Injectable()
|
||
export class ExpertClaimService {
|
||
private readonly logger = new Logger(ExpertClaimService.name);
|
||
|
||
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 sandHubService: SandHubService,
|
||
private readonly damageExpertDbService: DamageExpertDbService,
|
||
private readonly clientDbService: ClientDbService,
|
||
private readonly claimFactorsImageDbService: ClaimFactorsImageDbService,
|
||
private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService,
|
||
private readonly branchDbService: BranchDbService,
|
||
) {}
|
||
|
||
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");
|
||
|
||
if (request.lockFile) {
|
||
throw new BadRequestException("Claim request is locked");
|
||
}
|
||
|
||
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);
|
||
|
||
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,
|
||
},
|
||
},
|
||
$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, ""),
|
||
);
|
||
}
|
||
|
||
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 replyPayload = {
|
||
description: reply.description,
|
||
parts: processedParts,
|
||
submitTime: new Date(),
|
||
actorDetail: {
|
||
actorName: userId.fullName,
|
||
actorId: userId.sub,
|
||
},
|
||
};
|
||
|
||
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");
|
||
|
||
await this.claimRequestManagementDbService.findAndUpdate(requestId, {
|
||
lockFile: false,
|
||
claimStatus: ReqClaimStatus.WaitingForUserToResend,
|
||
damageExpertResend: {
|
||
resendDescription: body.resendDescription,
|
||
resendDocuments: body.resendDocuments,
|
||
resendCarParts: body.resendCarParts,
|
||
},
|
||
$push: {
|
||
actorsChecker: {
|
||
[ReqClaimStatus.WaitingForUserToResend]: request.actorLocked,
|
||
},
|
||
Date: new Date(),
|
||
},
|
||
});
|
||
|
||
return request;
|
||
}
|
||
|
||
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 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,
|
||
};
|
||
}
|
||
|
||
async inPersonVisit(requestId: string, actorDetail: any) {
|
||
const request =
|
||
await this.claimRequestManagementDbService.findOne(requestId);
|
||
if (!request) {
|
||
throw new NotFoundException("Blame not found");
|
||
}
|
||
|
||
const updated = await this.claimRequestManagementDbService.findAndUpdate(
|
||
requestId,
|
||
{
|
||
claimStatus: ReqClaimStatus.InPersonVisit,
|
||
},
|
||
);
|
||
|
||
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' };
|
||
}
|
||
|
||
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',
|
||
},
|
||
'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,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
*
|
||
* 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 needsFactorUpload = reply.parts?.some((p) => p.factorNeeded === true) ?? false;
|
||
|
||
const replyPayload = {
|
||
description: reply.description,
|
||
parts: reply.parts,
|
||
submittedAt: new Date(),
|
||
actorDetail: {
|
||
actorId: actor.sub,
|
||
actorName: actor.fullName,
|
||
},
|
||
};
|
||
|
||
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;
|
||
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||
status: nextCaseStatus,
|
||
claimStatus: nextClaimStatus,
|
||
'workflow.locked': false,
|
||
'workflow.currentStep': nextStep,
|
||
'workflow.nextStep': needsFactorUpload
|
||
? ClaimWorkflowStep.INSURER_REVIEW
|
||
: ClaimWorkflowStep.CLAIM_COMPLETED,
|
||
'evaluation.damageExpertReply': replyPayload,
|
||
$push: {
|
||
'workflow.completedSteps': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
|
||
history: {
|
||
event: 'EXPERT_REPLY_SUBMITTED',
|
||
performedBy: actor.sub,
|
||
performedByName: actor.fullName,
|
||
performedAt: new Date(),
|
||
note: `Expert reply submitted. factorNeeded=${needsFactorUpload}`,
|
||
},
|
||
},
|
||
});
|
||
|
||
return {
|
||
claimRequestId,
|
||
status: nextCaseStatus,
|
||
claimStatus: nextClaimStatus,
|
||
currentStep: nextStep,
|
||
factorNeeded: needsFactorUpload,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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');
|
||
}
|
||
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||
claimStatus: ClaimStatus.NEEDS_REVISION,
|
||
'workflow.locked': false,
|
||
...(note ? { 'evaluation.visitLocation': note } : {}),
|
||
$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: Get claim list for damage expert
|
||
*
|
||
* Returns claims that are:
|
||
* 1. WAITING_FOR_DAMAGE_EXPERT status AND not locked (available for anyone to pick)
|
||
* 2. WAITING_FOR_DAMAGE_EXPERT status AND not locked (claimStatus PENDING)
|
||
* 3. Any status locked by THIS expert (their own in-progress work)
|
||
*/
|
||
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),
|
||
},
|
||
],
|
||
});
|
||
|
||
const list = (claims as any[])
|
||
.filter((c) => claimCaseTouchesClient(c, clientKey))
|
||
.map((c) => ({
|
||
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: c.vehicle
|
||
? {
|
||
carName: c.vehicle.carName,
|
||
carModel: c.vehicle.carModel,
|
||
carType: c.vehicle.carType,
|
||
}
|
||
: undefined,
|
||
createdAt: c.createdAt,
|
||
})) as ClaimListItemV2Dto[];
|
||
|
||
return { list, total: list.length };
|
||
}
|
||
|
||
/**
|
||
* V2: Get claim detail for damage expert
|
||
*
|
||
* Validations:
|
||
* - Claim must exist
|
||
* - Claim status must be WAITING_FOR_DAMAGE_EXPERT
|
||
* - If claim is locked, only the locking expert can access it
|
||
*/
|
||
async getClaimDetailV2(
|
||
claimRequestId: string,
|
||
actor: any,
|
||
): Promise<ClaimDetailV2ResponseDto> {
|
||
requireActorClientKey(actor);
|
||
const actorId = actor.sub;
|
||
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 ForbiddenException(
|
||
`This claim is not available for expert review. Current status: ${claim.status}`,
|
||
);
|
||
}
|
||
|
||
// If locked by someone else, deny access
|
||
if (
|
||
claim.workflow?.locked &&
|
||
claim.workflow.lockedBy?.actorId?.toString() !== 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),
|
||
};
|
||
}
|
||
|
||
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: claim.vehicle,
|
||
blameRequestId: claim.blameRequestId?.toString(),
|
||
blameRequestNo: claim.blameRequestNo,
|
||
selectedParts: claim.damage?.selectedParts,
|
||
otherParts: claim.damage?.otherParts,
|
||
requiredDocuments:
|
||
Object.keys(requiredDocumentsStatus).length > 0
|
||
? requiredDocumentsStatus
|
||
: undefined,
|
||
carAngles,
|
||
damagedParts,
|
||
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 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,
|
||
},
|
||
});
|
||
|
||
await (claim as any).save();
|
||
|
||
return {
|
||
claimRequestId: claim._id.toString(),
|
||
selectedParts,
|
||
previousSelectedParts: previous,
|
||
message: "Damaged parts updated successfully.",
|
||
};
|
||
}
|
||
}
|