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

@@ -131,6 +131,33 @@ export class ClaimDetailV2ResponseDto {
url?: string;
}>;
@ApiPropertyOptional({
description:
"Append-only expert damaged-part revisions. Removed rows retain their original capture URL even after the authoritative selection changes.",
type: "array",
items: {
type: "object",
properties: {
revisionId: { type: "string" },
changedAt: { type: "string", format: "date-time" },
changedBy: { type: "object" },
removedParts: { type: "array", items: { type: "object" } },
addedParts: { type: "array", items: { type: "object" } },
},
},
})
damagedPartsHistory?: Array<{
revisionId: string;
changedAt: Date | string;
changedBy: {
actorId: string;
actorName?: string;
actorType: string;
};
removedParts: Array<Record<string, unknown>>;
addedParts: Array<Record<string, unknown>>;
}>;
@ApiPropertyOptional({
description:
"True when user uploaded all required factors and the case awaits expert approve/reject.",

View File

@@ -90,4 +90,21 @@ describe("SubmitExpertReplyV2Dto", () => {
expect(await validate(dto)).toHaveLength(0);
});
it("accepts a grouped Persian current car price as a string", async () => {
const dto = plainToInstance(SubmitExpertReplyV2Dto, {
carPrice: "۱٬۲۵۰٬۰۰۰٬۰۰۰",
parts: [
{
partId: 201,
typeOfDamage: TypeOfDamage.Repair,
salary: "1000000",
totalPayment: "1000000",
factorNeeded: false,
},
],
});
expect(await validate(dto)).toHaveLength(0);
});
});

View File

@@ -122,8 +122,9 @@ export class SubmitExpertReplyV2Dto {
description?: string;
@ApiPropertyOptional({
example: '1_000_000_000',
description: "Today's car price",
example: '1000000000',
description:
"Current vehicle price in Rial. Required for car-body claims and forbidden for third-party claims. This is independent from evaluation.priceDrop.carPrice.",
})
@IsOptional()
@IsMoneyAmountString()

View File

@@ -1,10 +1,14 @@
import { BadRequestException } from "@nestjs/common";
import { Types } from "mongoose";
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { DaghiOption } from "src/Types&Enums/claim-request-management/daghi-option.enum";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { TypeOfDamage } from "src/Types&Enums/claim-request-management/type-of-damage.enum";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { ExpertClaimService } from "./expert-claim.service";
const V2_EXPERT_ID = "66ec0e480e321873c0900001";
const blankPricingReply = {
description: "Damage assessment",
parts: [
@@ -26,7 +30,144 @@ function createService() {
) as ExpertClaimService;
}
function setupSuccessfulV2Submit(blameType: BlameRequestType) {
const service = createService() as any;
const findByIdAndUpdate = jest.fn().mockResolvedValue(undefined);
const claim = {
_id: "66ec0e480e321873c0900002",
publicId: "CLM-1",
blameRequestId: "66ec0e480e321873c0900003",
status: ClaimCaseStatus.EXPERT_REVIEWING,
workflow: { locked: true, lockedBy: { actorId: V2_EXPERT_ID } },
vehicle: { carType: "sedan" },
damage: {
selectedParts: [
{
id: 1,
name: "front",
side: "",
label_fa: "جلو کامل",
catalogKey: "1",
},
],
},
};
service.claimCaseDbService = {
findById: jest.fn().mockResolvedValue(claim),
findByIdAndUpdate,
};
service.blameRequestDbService = {
findById: jest.fn().mockResolvedValue({
type: blameType,
expertInitiated: true,
creationMethod: "IN_PERSON",
}),
};
service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined);
service.snapshotDamageExpert = jest.fn().mockResolvedValue(undefined);
service.recordClaimExpertActivity = jest.fn().mockResolvedValue(undefined);
return { service, findByIdAndUpdate };
}
const validV2Reply = {
parts: [
{
partId: 1,
typeOfDamage: TypeOfDamage.Repair,
salary: "1000000",
totalPayment: "1000000",
factorNeeded: false,
},
],
};
describe("ExpertClaimService expert-reply pricing", () => {
it("requires the current car price for a car-body expert reply", async () => {
const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
BlameRequestType.CAR_BODY,
);
await expect(
service.submitExpertReplyV2("v2-claim", validV2Reply, {
sub: V2_EXPERT_ID,
fullName: "Expert One",
role: RoleEnum.FIELD_EXPERT,
}),
).rejects.toMatchObject({
response: {
code: "CAR_PRICE_REQUIRED",
field: "carPrice",
},
});
expect(findByIdAndUpdate).not.toHaveBeenCalled();
});
it("normalizes and persists the current car price on car-body replies", async () => {
const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
BlameRequestType.CAR_BODY,
);
await service.submitExpertReplyV2(
"v2-claim",
{ ...validV2Reply, carPrice: "۱٬۲۵۰٬۰۰۰٬۰۰۰" },
{
sub: V2_EXPERT_ID,
fullName: "Expert One",
role: RoleEnum.FIELD_EXPERT,
},
);
expect(findByIdAndUpdate).toHaveBeenCalledTimes(1);
expect(findByIdAndUpdate.mock.calls[0][1]).toMatchObject({
"vehicle.price": "1250000000",
});
});
it("rejects the current car price on third-party replies", async () => {
const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
BlameRequestType.THIRD_PARTY,
);
await expect(
service.submitExpertReplyV2(
"v2-claim",
{ ...validV2Reply, carPrice: "1250000000" },
{
sub: V2_EXPERT_ID,
fullName: "Expert One",
role: RoleEnum.FIELD_EXPERT,
},
),
).rejects.toMatchObject({
response: {
code: "CAR_PRICE_NOT_ALLOWED",
field: "carPrice",
},
});
expect(findByIdAndUpdate).not.toHaveBeenCalled();
});
it("does not overwrite vehicle.price on third-party replies", async () => {
const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
BlameRequestType.THIRD_PARTY,
);
await service.submitExpertReplyV2("v2-claim", validV2Reply, {
sub: V2_EXPERT_ID,
fullName: "Expert One",
role: RoleEnum.FIELD_EXPERT,
});
expect(findByIdAndUpdate).toHaveBeenCalledTimes(1);
expect(findByIdAndUpdate.mock.calls[0][1]).not.toHaveProperty(
"vehicle.price",
);
});
it("allows a repair line without daghi and removes a stray daghi payload", () => {
const service = createService() as any;
@@ -105,18 +246,27 @@ describe("ExpertClaimService expert-reply pricing", () => {
const findByIdAndUpdate = jest.fn();
service.claimCaseDbService = {
findById: jest.fn().mockResolvedValue({
blameRequestId: "blame-1",
status: ClaimCaseStatus.EXPERT_REVIEWING,
workflow: { locked: true, lockedBy: { actorId: "expert-1" } },
}),
findByIdAndUpdate,
};
service.blameRequestDbService = {
findById: jest.fn().mockResolvedValue({ type: "CAR_BODY" }),
};
service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined);
service.snapshotDamageExpert = jest.fn().mockResolvedValue(undefined);
await expect(
service.submitExpertReplyV2("v2-claim", blankPricingReply, {
sub: "expert-1",
role: RoleEnum.FIELD_EXPERT,
}),
service.submitExpertReplyV2(
"v2-claim",
{ ...blankPricingReply, carPrice: "1000000" },
{
sub: "expert-1",
role: RoleEnum.FIELD_EXPERT,
},
),
).rejects.toBeInstanceOf(BadRequestException);
expect(findByIdAndUpdate).not.toHaveBeenCalled();
@@ -127,18 +277,24 @@ describe("ExpertClaimService expert-reply pricing", () => {
const findByIdAndUpdate = jest.fn();
service.claimCaseDbService = {
findById: jest.fn().mockResolvedValue({
blameRequestId: "blame-1",
status: ClaimCaseStatus.EXPERT_REVIEWING,
workflow: { locked: true, lockedBy: { actorId: "expert-1" } },
}),
findByIdAndUpdate,
};
service.blameRequestDbService = {
findById: jest.fn().mockResolvedValue({ type: "CAR_BODY" }),
};
service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined);
service.snapshotDamageExpert = jest.fn().mockResolvedValue(undefined);
await expect(
service.submitExpertReplyV2(
"v2-claim",
{
description: "Damage assessment",
carPrice: "1000000",
parts: [
{
partId: 201,
@@ -163,3 +319,132 @@ describe("ExpertClaimService expert-reply pricing", () => {
expect(findByIdAndUpdate).not.toHaveBeenCalled();
});
});
describe("ExpertClaimService damaged-part audit", () => {
it("archives removed capture evidence before replacing the live arrays", async () => {
const service = createService() as any;
const expertId = "66ec0e480e321873c0900001";
const findByIdAndUpdate = jest.fn().mockResolvedValue(undefined);
service.claimCaseDbService = {
findById: jest.fn().mockResolvedValue({
_id: "66ec0e480e321873c0900002",
status: ClaimCaseStatus.EXPERT_REVIEWING,
workflow: { locked: true, lockedBy: { actorId: expertId } },
vehicle: { carType: "SEDAN" },
damage: {
selectedParts: [
{
id: 101,
name: "hood",
side: "front",
label_fa: "کاپوت",
catalogKey: "front_hood",
},
{
id: 202,
name: "door",
side: "left",
label_fa: "درب چپ",
catalogKey: "left_door",
},
],
},
media: {
damagedParts: [
{ path: "claims/hood.jpg", fileName: "hood.jpg" },
{ path: "claims/door.jpg", fileName: "door.jpg" },
],
},
}),
findByIdAndUpdate,
};
service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined);
service.snapshotDamageExpert = jest.fn().mockResolvedValue({
firstName: "Expert",
});
await service.updateClaimDamagedPartsV2(
"66ec0e480e321873c0900002",
{
selectedParts: [
{
id: 202,
name: "door",
side: "left",
label_fa: "درب چپ",
catalogKey: "left_door",
},
],
},
{
sub: expertId,
fullName: "Expert One",
role: RoleEnum.FIELD_EXPERT,
},
);
expect(findByIdAndUpdate).toHaveBeenCalledTimes(1);
const update = findByIdAndUpdate.mock.calls[0][1];
expect(update.$set["media.damagedParts"]).toEqual([
expect.objectContaining({ path: "claims/door.jpg" }),
]);
expect(update.$push["damage.partSelectionHistory"]).toEqual(
expect.objectContaining({
changedBy: expect.objectContaining({ actorId: expertId }),
removedParts: [
expect.objectContaining({
id: 101,
capture: expect.objectContaining({ path: "claims/hood.jpg" }),
}),
],
}),
);
});
});
describe("ExpertClaimService FileReviewer assignment", () => {
it("lets the assigned reviewer reopen a file while it is still awaiting FileReviewer work", async () => {
const service = createService() as any;
const reviewerId = new Types.ObjectId();
const clientId = new Types.ObjectId();
const blameId = new Types.ObjectId();
const claimId = new Types.ObjectId();
service.expireClaimWorkflowLockV2IfStale = jest.fn().mockResolvedValue(undefined);
service.claimCaseDbService = {
findById: jest.fn().mockResolvedValue({
_id: claimId,
blameRequestId: blameId,
status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
}),
};
service.blameRequestDbService = {
findById: jest.fn().mockResolvedValue({
_id: blameId,
type: BlameRequestType.CAR_BODY,
status: "WAITING_FOR_FILE_REVIEWER",
assignedFileReviewerId: reviewerId,
parties: [
{
role: "FIRST",
person: { clientId },
},
],
}),
};
service.assertExpertActorOnClaim = jest.fn();
await expect(
service.assignClaimForReviewV2(String(claimId), {
sub: String(reviewerId),
role: RoleEnum.FILE_REVIEWER,
clientKey: String(clientId),
}),
).resolves.toMatchObject({
success: true,
status: "already_assigned_to_you",
});
expect(service.assertExpertActorOnClaim).not.toHaveBeenCalled();
});
});

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.",
};
}