forked from Yara724/api
resolved conflicts and maintained new features
This commit is contained in:
@@ -4,6 +4,7 @@ import { join } from "node:path";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
@@ -49,7 +50,9 @@ import { ClaimRequiredDocumentDbService } from "src/claim-request-management/ent
|
||||
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";
|
||||
|
||||
@Injectable()
|
||||
export class ExpertClaimService {
|
||||
@@ -162,14 +165,14 @@ export class ExpertClaimService {
|
||||
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,
|
||||
private readonly blameRequestDbService: BlameRequestDbService,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
/** Map blame party `Vehicle` (name/model/type) to expert panel shape. */
|
||||
private expertVehicleFromPartyVehicle(v: any): {
|
||||
@@ -604,7 +607,7 @@ export class ExpertClaimService {
|
||||
|
||||
async calculatePriceDrop(request: any, carPartsAi: any, query?: any) {
|
||||
const requestId = request?._id?.toString() || 'unknown';
|
||||
|
||||
|
||||
try {
|
||||
// Step 1: Check SandHub data
|
||||
if (!request?.blameFile?.sanHubId) {
|
||||
@@ -662,7 +665,7 @@ export class ExpertClaimService {
|
||||
} 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.`,
|
||||
@@ -672,9 +675,9 @@ export class ExpertClaimService {
|
||||
`[PriceDrop] Request ${requestId}: Fetched ${carPrices.length} car prices. Searching for: "${request.carDetail.carName}"`,
|
||||
);
|
||||
|
||||
const closestCar = this.findClosestCar(
|
||||
request.carDetail.carName,
|
||||
carPrices,
|
||||
const closestCar = this.findClosestCar(
|
||||
request.carDetail.carName,
|
||||
carPrices,
|
||||
);
|
||||
|
||||
if (!closestCar) {
|
||||
@@ -688,11 +691,11 @@ export class ExpertClaimService {
|
||||
} else {
|
||||
this.logger.debug(
|
||||
`[PriceDrop] Request ${requestId}: Found matching car "${closestCar.carName}" with marketPrice: ${closestCar.marketPrice}`,
|
||||
);
|
||||
autoCalculatedData = {
|
||||
);
|
||||
autoCalculatedData = {
|
||||
carPrice: closestCar.marketPrice,
|
||||
carValue: severityValues,
|
||||
};
|
||||
carValue: severityValues,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -730,10 +733,10 @@ export class ExpertClaimService {
|
||||
}
|
||||
|
||||
// Step 6: Save to database
|
||||
await this.claimRequestManagementDbService.findOneAndUpdate(
|
||||
{ _id: new Types.ObjectId(request._id) },
|
||||
{ $set: { priceDrop: finalPriceDropData } },
|
||||
);
|
||||
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.`,
|
||||
@@ -801,7 +804,7 @@ export class ExpertClaimService {
|
||||
|
||||
const severityValue =
|
||||
this.priceDropPart[originalPriceDropKey]?.[
|
||||
damagedPartInfo.severity
|
||||
damagedPartInfo.severity
|
||||
];
|
||||
|
||||
if (severityValue !== undefined) {
|
||||
@@ -1098,13 +1101,13 @@ export class ExpertClaimService {
|
||||
|
||||
private isCurrentUserAllowed(request: any, currentUser: any): boolean {
|
||||
// Check if locked by current user and lock is still active
|
||||
const isLockedByCurrentUser =
|
||||
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) {
|
||||
@@ -1116,7 +1119,7 @@ export class ExpertClaimService {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1124,7 +1127,7 @@ export class ExpertClaimService {
|
||||
if (!request.lockFile || request.claimStatus !== ReqClaimStatus.ReviewRequest) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Check if lock has expired
|
||||
if (request.unlockTime) {
|
||||
const unlockTime = new Date(request.unlockTime).getTime();
|
||||
@@ -1134,15 +1137,15 @@ export class ExpertClaimService {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// If currentUser is provided, allow access if they are the one who locked it
|
||||
if (currentUser) {
|
||||
const isLockedByCurrentUser =
|
||||
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;
|
||||
}
|
||||
@@ -1165,18 +1168,18 @@ export class ExpertClaimService {
|
||||
) {
|
||||
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");
|
||||
}
|
||||
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",
|
||||
@@ -1385,6 +1388,66 @@ export class ExpertClaimService {
|
||||
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);
|
||||
@@ -1658,6 +1721,210 @@ export class ExpertClaimService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
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.");
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -1768,6 +2035,80 @@ export class ExpertClaimService {
|
||||
};
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
const existingResend = claim.evaluation?.damageExpertResend;
|
||||
const resendHasContent =
|
||||
existingResend &&
|
||||
(String(existingResend.resendDescription || '').trim() !== '' ||
|
||||
(existingResend.resendDocuments?.length ?? 0) > 0 ||
|
||||
(existingResend.resendCarParts?.length ?? 0) > 0);
|
||||
if (resendHasContent) {
|
||||
throw new ConflictException('A resend request already exists for this claim');
|
||||
}
|
||||
|
||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||
$set: {
|
||||
claimStatus: ClaimStatus.NEEDS_REVISION,
|
||||
'workflow.locked': false,
|
||||
'evaluation.damageExpertResend': {
|
||||
resendDescription: reply.resendDescription,
|
||||
resendDocuments: reply.resendDocuments ?? [],
|
||||
resendCarParts: reply.resendCarParts ?? [],
|
||||
},
|
||||
},
|
||||
$push: {
|
||||
history: {
|
||||
type: 'EXPERT_RESEND_REQUESTED',
|
||||
actor: {
|
||||
actorId: new Types.ObjectId(actor.sub),
|
||||
actorName: actor.fullName,
|
||||
actorType: 'damage_expert',
|
||||
},
|
||||
timestamp: new Date(),
|
||||
metadata: {
|
||||
documentCount: reply.resendDocuments?.length ?? 0,
|
||||
carPartCount: reply.resendCarParts?.length ?? 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
claimRequestId,
|
||||
claimStatus: ClaimStatus.NEEDS_REVISION,
|
||||
message:
|
||||
'Resend request recorded. The damaged party can review requirements and submit an objection or updated materials.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* V2: Submit damage expert reply for a claim.
|
||||
*
|
||||
@@ -1831,6 +2172,23 @@ export class ExpertClaimService {
|
||||
|
||||
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 replyPayload = {
|
||||
description: reply.description,
|
||||
parts: reply.parts,
|
||||
@@ -1850,7 +2208,7 @@ export class ExpertClaimService {
|
||||
? ClaimStatus.NEEDS_REVISION
|
||||
: ClaimStatus.APPROVED;
|
||||
|
||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||
const updatePayload: Record<string, unknown> = {
|
||||
status: nextCaseStatus,
|
||||
claimStatus: nextClaimStatus,
|
||||
'workflow.locked': false,
|
||||
@@ -1858,18 +2216,30 @@ export class ExpertClaimService {
|
||||
'workflow.nextStep': needsFactorUpload
|
||||
? ClaimWorkflowStep.INSURER_REVIEW
|
||||
: ClaimWorkflowStep.CLAIM_COMPLETED,
|
||||
'evaluation.damageExpertReply': replyPayload,
|
||||
[`evaluation.${replyField}`]: replyPayload,
|
||||
$push: {
|
||||
'workflow.completedSteps': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
|
||||
'workflow.completedSteps': completedStep,
|
||||
history: {
|
||||
event: 'EXPERT_REPLY_SUBMITTED',
|
||||
performedBy: actor.sub,
|
||||
performedByName: actor.fullName,
|
||||
performedAt: new Date(),
|
||||
note: `Expert reply submitted. factorNeeded=${needsFactorUpload}`,
|
||||
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: reply.parts?.length ?? 0,
|
||||
isFinalReplyAfterObjection,
|
||||
replyField,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, updatePayload);
|
||||
|
||||
return {
|
||||
claimRequestId,
|
||||
@@ -1877,6 +2247,7 @@ export class ExpertClaimService {
|
||||
claimStatus: nextClaimStatus,
|
||||
currentStep: nextStep,
|
||||
factorNeeded: needsFactorUpload,
|
||||
isFinalReplyAfterObjection,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1949,10 +2320,10 @@ export class ExpertClaimService {
|
||||
/**
|
||||
* V2: Get claim list for damage expert
|
||||
*
|
||||
* Returns claims that are:
|
||||
* 1. WAITING_FOR_DAMAGE_EXPERT status AND not locked (available for anyone to pick)
|
||||
* 2. WAITING_FOR_DAMAGE_EXPERT status AND not locked (claimStatus PENDING)
|
||||
* 3. Any status locked by THIS expert (their own in-progress work)
|
||||
* 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);
|
||||
@@ -1970,6 +2341,12 @@ export class ExpertClaimService {
|
||||
'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,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1996,6 +2373,10 @@ export class ExpertClaimService {
|
||||
);
|
||||
|
||||
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())
|
||||
@@ -2022,6 +2403,7 @@ export class ExpertClaimService {
|
||||
: undefined,
|
||||
...fileCtx,
|
||||
createdAt: c.createdAt,
|
||||
awaitingFactorValidation,
|
||||
};
|
||||
}) as ClaimListItemV2Dto[];
|
||||
|
||||
@@ -2032,15 +2414,14 @@ export class ExpertClaimService {
|
||||
* V2: Get claim detail for damage expert
|
||||
*
|
||||
* Validations:
|
||||
* - Claim must exist
|
||||
* - Claim status must be WAITING_FOR_DAMAGE_EXPERT
|
||||
* - If claim is locked, only the locking expert can access it
|
||||
* - Claim must exist and belong to the actor's insurer (`assertClaimCaseForTenant`)
|
||||
* - Allowed when picking up/reviewing: WAITING_FOR_DAMAGE_EXPERT (if locked, only the locking expert)
|
||||
* - Or when validating factors: WAITING_FOR_INSURER_APPROVAL + UNDER_REVIEW at EXPERT_COST_EVALUATION
|
||||
*/
|
||||
async getClaimDetailV2(
|
||||
claimRequestId: string,
|
||||
actor: any,
|
||||
): Promise<ClaimDetailV2ResponseDto> {
|
||||
requireActorClientKey(actor);
|
||||
const actorId = actor.sub;
|
||||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||
|
||||
@@ -2050,14 +2431,22 @@ export class ExpertClaimService {
|
||||
|
||||
assertClaimCaseForTenant(claim, actor);
|
||||
|
||||
if (claim.status !== ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT) {
|
||||
const isPickupReview =
|
||||
claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT;
|
||||
const isFactorValidationPending =
|
||||
claim.status === ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL &&
|
||||
claim.claimStatus === ClaimStatus.UNDER_REVIEW &&
|
||||
claim.workflow?.currentStep === ClaimWorkflowStep.EXPERT_COST_EVALUATION;
|
||||
|
||||
if (!isPickupReview && !isFactorValidationPending) {
|
||||
throw new ForbiddenException(
|
||||
`This claim is not available for expert review. Current status: ${claim.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
// If locked by someone else, deny access
|
||||
// If locked by someone else, deny access (initial pickup / assessment only)
|
||||
if (
|
||||
isPickupReview &&
|
||||
claim.workflow?.locked &&
|
||||
claim.workflow.lockedBy?.actorId?.toString() !== actorId
|
||||
) {
|
||||
@@ -2146,16 +2535,16 @@ export class ExpertClaimService {
|
||||
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(),
|
||||
}
|
||||
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,
|
||||
}
|
||||
userId: claim.owner.userId?.toString(),
|
||||
fullName: claim.owner.fullName,
|
||||
}
|
||||
: undefined,
|
||||
vehicle: vehiclePayload,
|
||||
...blameFileContext,
|
||||
@@ -2169,6 +2558,13 @@ export class ExpertClaimService {
|
||||
: undefined,
|
||||
carAngles,
|
||||
damagedParts,
|
||||
awaitingFactorValidation: isFactorValidationPending,
|
||||
evaluation: isFactorValidationPending
|
||||
? {
|
||||
damageExpertReply: (claim as any).evaluation?.damageExpertReply,
|
||||
damageExpertReplyFinal: (claim as any).evaluation?.damageExpertReplyFinal,
|
||||
}
|
||||
: undefined,
|
||||
createdAt: (claim as any).createdAt,
|
||||
updatedAt: (claim as any).updatedAt,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user