fix: harden claim review and inquiry workflows

Preserve damage history and current vehicle price, restore depreciation mapping, normalize inquiry/report output, and support resumable expert review with paginated case retrieval.
This commit is contained in:
2026-09-18 16:04:33 +03:30
parent a84d83a135
commit 7d1a50db7b
38 changed files with 2345 additions and 370 deletions

View File

@@ -143,6 +143,10 @@ import {
} from "src/helpers/outer-damage-parts";
import { normalizeResendCarPartsForStorage } from "src/helpers/claim-expert-resend";
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
import {
buildDamagedPartSelectionRevision,
serializeDamagedPartSelectionHistory,
} from "src/helpers/claim-damaged-part-audit";
import { snapshotFromDamageExpert } from "src/helpers/expert-profile-snapshot";
import { DamageExpertModel } from "src/users/entities/schema/damage-expert.schema";
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
@@ -172,6 +176,10 @@ import {
import { buildEnrichedDamagedParts } from "./dto/claim-damaged-part.enricher";
import { canonicalizeResendDocumentKey } from "src/helpers/claim-resend-document-keys";
import { getExpertReplyPricingValidationError } from "src/helpers/expert-reply-pricing";
import {
normalizeMoneyAmountString,
parseMoneyAmountToman,
} from "src/utils/unicode-digits";
@Injectable()
export class ExpertClaimService {
@@ -2719,9 +2727,14 @@ export class ExpertClaimService {
if (blameStatus === "WAITING_FOR_FILE_REVIEWER") {
if (assignedReviewerId && assignedReviewerId === actor.sub) {
// Reviewer already assigned (e.g. after a FileMaker rejection that reset
// blame back to WAITING_FOR_FILE_REVIEWER) — skip Phase 1 and fall
// through to the damage-expert workflow lock below.
// Phase 1 is intentionally idempotent. The linked claim can still be
// in a data-capture status here, so falling through to the damage
// assessment status gate would incorrectly reject the same reviewer.
return {
success: true,
status: "already_assigned_to_you",
message: "You have already taken this file.",
};
} else {
// Phase 1: first-time blame assignment
return this.assignFileReviewerToV4Blame(claimRequestId, claim, actor);
@@ -3263,8 +3276,6 @@ export class ExpertClaimService {
) {
if (actor.role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor);
const claim = await this.claimCaseDbService.findById(claimRequestId);
const blame = await this.blameRequestDbService.findById(claim.blameRequestId);
if (!claim) {
throw new NotFoundException(
this.expertReplySubmissionError(
@@ -3274,6 +3285,18 @@ export class ExpertClaimService {
);
}
const blame = await this.blameRequestDbService.findById(
claim.blameRequestId,
);
if (!blame) {
throw new NotFoundException(
this.expertReplySubmissionError(
"پرونده تعیین خسارت مرتبط یافت نشد.",
"BLAME_NOT_FOUND",
),
);
}
await this.assertExpertActorOnClaim(claim, actor);
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
@@ -3304,12 +3327,33 @@ export class ExpertClaimService {
);
}
if (reply.carPrice && blame.type !== BlameRequestType.CAR_BODY) {
throw new ForbiddenException("قیمت روز خودرو فقط در پرونده های بدنه باید بررسی شود")
}
const carPriceWasProvided = reply.carPrice != null;
let normalizedCurrentCarPrice: string | undefined;
if (blame.type === BlameRequestType.THIRD_PARTY && reply.carPrice) {
throw new ForbiddenException("قیمت روز خودرو فقط در پرونده های بدنه باید بررسی شود")
if (blame.type === BlameRequestType.CAR_BODY) {
const parsedCurrentCarPrice = parseMoneyAmountToman(reply.carPrice);
if (
parsedCurrentCarPrice == null ||
!Number.isSafeInteger(parsedCurrentCarPrice) ||
parsedCurrentCarPrice <= 0
) {
throw new BadRequestException(
this.expertReplySubmissionError(
"قیمت روز خودرو در پرونده بدنه الزامی است و باید مبلغی صحیح و بیشتر از صفر باشد.",
"CAR_PRICE_REQUIRED",
{ field: "carPrice" },
),
);
}
normalizedCurrentCarPrice = normalizeMoneyAmountString(reply.carPrice!);
} else if (carPriceWasProvided) {
throw new ForbiddenException(
this.expertReplySubmissionError(
"قیمت روز خودرو فقط برای پرونده بدنه قابل ثبت است.",
"CAR_PRICE_NOT_ALLOWED",
{ field: "carPrice" },
),
);
}
const pricingValidationError = getExpertReplyPricingValidationError(
@@ -3540,7 +3584,9 @@ export class ExpertClaimService {
"evaluation.ownerInsurerApproval": "",
"evaluation.ownerPricedPartsApproval": "",
},
"vehicle.price": reply.carPrice,
...(normalizedCurrentCarPrice
? { "vehicle.price": normalizedCurrentCarPrice }
: {}),
"workflow.currentStep": currentStep,
"workflow.nextStep": nextWorkflowStep,
[`evaluation.${replyField}`]: replyPayload,
@@ -5050,6 +5096,10 @@ export class ExpertClaimService {
buildFileLink,
resolveStoredFileUrl,
});
const damagedPartsHistory = serializeDamagedPartSelectionHistory({
history: (claim.damage as any)?.partSelectionHistory,
currentSelectedParts: selectedNormExpert,
});
// Vehicle payload — fall back to blame inquiry if claim vehicle is sparse
let vehiclePayload = claim.vehicle as any;
@@ -5229,6 +5279,7 @@ export class ExpertClaimService {
: undefined,
carAngles,
damagedParts,
damagedPartsHistory,
awaitingFactorValidation: isFactorValidationPending,
requiresFileMakerApproval: !!(claim as any).requiresFileMakerApproval,
fileMakerRejectionCount: (claim as any).fileMakerRejectionCount ?? 0,
@@ -5606,33 +5657,58 @@ export class ExpertClaimService {
);
const mergedExpertAdded = [...existingExpertAdded, ...expertAddedToAppend];
const changedAt = new Date();
const partSelectionRevision = buildDamagedPartSelectionRevision({
revisionId: new Types.ObjectId().toString(),
changedAt,
changedBy: {
actorId: actor.sub,
actorName: actor.fullName,
actorType: "damage_expert",
},
expertProfileSnapshot: damagedPartsEditSnapshot,
previousParts: previousNorm,
selectedParts: nextNorm,
previousMedia: prevMedia,
});
const $set: Record<string, unknown> = {
"damage.selectedParts": nextNorm,
"media.damagedParts": nextMedia,
"damage.expertAddedParts": mergedExpertAdded,
};
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set,
$push: {
history: {
type: "EXPERT_DAMAGED_PARTS_UPDATED",
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: actor.fullName,
actorType: "damage_expert",
},
timestamp: new Date(),
metadata: {
previousSelectedParts: previous,
selectedParts: nextNorm,
expertAddedParts: mergedExpertAdded,
...(damagedPartsEditSnapshot && {
expertProfileSnapshot: damagedPartsEditSnapshot,
}),
},
const push: Record<string, unknown> = {
history: {
type: "EXPERT_DAMAGED_PARTS_UPDATED",
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: actor.fullName,
actorType: "damage_expert",
},
timestamp: changedAt,
metadata: {
previousSelectedParts: previous,
selectedParts: nextNorm,
expertAddedParts: mergedExpertAdded,
...(partSelectionRevision && {
partSelectionRevisionId: partSelectionRevision.revisionId,
removedParts: partSelectionRevision.removedParts,
addedParts: partSelectionRevision.addedParts,
}),
...(damagedPartsEditSnapshot && {
expertProfileSnapshot: damagedPartsEditSnapshot,
}),
},
},
};
if (partSelectionRevision) {
push["damage.partSelectionHistory"] = partSelectionRevision;
}
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set,
$push: push,
});
return {
@@ -5640,6 +5716,7 @@ export class ExpertClaimService {
selectedParts: nextNorm,
previousSelectedParts: previous,
expertAddedParts: mergedExpertAdded,
partSelectionRevision,
message: "Damaged parts updated successfully.",
};
}