forked from Yara724/api
2992 lines
97 KiB
TypeScript
2992 lines
97 KiB
TypeScript
"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 expert’s 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.",
|
||
};
|
||
}
|
||
}
|