forked from Yara724/api
update some important issues
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,
|
||||
@@ -44,6 +45,7 @@ import { DaghiOption } from "src/Types&Enums/claim-request-management/daghi-opti
|
||||
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 {
|
||||
@@ -1591,6 +1593,208 @@ 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 },
|
||||
) {
|
||||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||
if (!claim) {
|
||||
throw new NotFoundException("Claim request not found");
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -1703,11 +1907,71 @@ export class ExpertClaimService {
|
||||
reply: ClaimSubmitResendV2Dto,
|
||||
actor: any,
|
||||
) {
|
||||
try {
|
||||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
if (!claim) {
|
||||
throw new NotFoundException('Claim request not found');
|
||||
}
|
||||
|
||||
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.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1770,6 +2034,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,
|
||||
@@ -1789,7 +2070,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,
|
||||
@@ -1797,18 +2078,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,
|
||||
@@ -1816,6 +2109,7 @@ export class ExpertClaimService {
|
||||
claimStatus: nextClaimStatus,
|
||||
currentStep: nextStep,
|
||||
factorNeeded: needsFactorUpload,
|
||||
isFinalReplyAfterObjection,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1903,30 +2197,43 @@ 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,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const list = (claims as any[]).map((c) => ({
|
||||
claimRequestId: c._id.toString(),
|
||||
publicId: c.publicId,
|
||||
status: c.status,
|
||||
currentStep: c.workflow?.currentStep || '',
|
||||
locked: c.workflow?.locked || false,
|
||||
lockedBy: c.workflow?.lockedBy
|
||||
? {
|
||||
actorId: c.workflow.lockedBy.actorId?.toString(),
|
||||
actorName: c.workflow.lockedBy.actorName,
|
||||
}
|
||||
: undefined,
|
||||
vehicle: c.vehicle
|
||||
? {
|
||||
carName: c.vehicle.carName,
|
||||
carModel: c.vehicle.carModel,
|
||||
carType: c.vehicle.carType,
|
||||
}
|
||||
: undefined,
|
||||
createdAt: c.createdAt,
|
||||
})) as ClaimListItemV2Dto[];
|
||||
const list = (claims as any[]).map((c) => {
|
||||
const awaitingFactorValidation =
|
||||
c.status === ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL &&
|
||||
c.claimStatus === ClaimStatus.UNDER_REVIEW &&
|
||||
c.workflow?.currentStep === ClaimWorkflowStep.EXPERT_COST_EVALUATION;
|
||||
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: c.vehicle
|
||||
? {
|
||||
carName: c.vehicle.carName,
|
||||
carModel: c.vehicle.carModel,
|
||||
carType: c.vehicle.carType,
|
||||
}
|
||||
: undefined,
|
||||
createdAt: c.createdAt,
|
||||
awaitingFactorValidation,
|
||||
};
|
||||
}) as ClaimListItemV2Dto[];
|
||||
|
||||
return { list, total: list.length };
|
||||
}
|
||||
@@ -1949,14 +2256,22 @@ export class ExpertClaimService {
|
||||
throw new NotFoundException('Claim request not found');
|
||||
}
|
||||
|
||||
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
|
||||
) {
|
||||
@@ -2041,6 +2356,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