Better error handling and validation and min/max prices added for expert submit

This commit is contained in:
SepehrYahyaee
2026-09-08 13:11:24 +03:30
parent eb6cef1127
commit 0e861ff6d1
9 changed files with 485 additions and 45 deletions

View File

@@ -51,9 +51,9 @@ describe("SubmitExpertReplyV2Dto", () => {
{
partId: 201,
typeOfDamage: TypeOfDamage.Repair,
price: "10000",
salary: "0",
totalPayment: "10000",
price: "100000",
salary: "100000",
totalPayment: "200000",
factorNeeded: false,
},
],
@@ -69,14 +69,14 @@ describe("SubmitExpertReplyV2Dto", () => {
{
partId: 11,
typeOfDamage: TypeOfDamage.Repair,
salary: "۵,۰۰۰",
totalPayment: "5000",
salary: "۱۰۰,۰۰۰",
totalPayment: "100000",
factorNeeded: false,
},
{
partId: 23,
typeOfDamage: TypeOfDamage.Change,
price: "۵۰,۰۰۰",
price: "۱۰۰,۰۰۰",
salary: "۱۰۰,۰۰۰",
totalPayment: "150000",
factorNeeded: false,

View File

@@ -27,7 +27,7 @@ export class DaghiDetailsV2Dto {
option: DaghiOption;
@ApiPropertyOptional({
description: `Required when option is '${DaghiOption.RECYCLED_PARTS_VALUE}' (Toman)`,
description: `Required when option is '${DaghiOption.RECYCLED_PARTS_VALUE}' (Toman; 100,000 to 10,000,000,000).`,
})
@ValidateIf(
(daghi: DaghiDetailsV2Dto) =>
@@ -63,7 +63,8 @@ export class PartPricingV2Dto {
@ApiPropertyOptional({
example: "5000000",
description: "Required for change lines; omitted for repair lines. Use 0 if unused.",
description:
"Required for change lines; omitted for repair lines. Every supplied amount must be between 100,000 and 10,000,000,000 Toman.",
})
@ValidateIf(
(part: PartPricingV2Dto) =>
@@ -78,7 +79,8 @@ export class PartPricingV2Dto {
@ApiProperty({
example: "2000000",
description: "Labor in Toman (integer string). Use 0 if the full amount is in price.",
description:
"Labor in Toman (integer string; 100,000 to 10,000,000,000).",
})
@IsString()
@IsNotEmpty()
@@ -87,7 +89,8 @@ export class PartPricingV2Dto {
@ApiProperty({
example: "7000000",
description: "Line total in Toman (integer string).",
description:
"Line total in Toman (integer string; 100,000 to 10,000,000,000).",
})
@IsString()
@IsNotEmpty()
@@ -116,10 +119,13 @@ export class PartPricingV2Dto {
}
export class SubmitExpertReplyV2Dto {
@ApiProperty({ example: 'Front door and hood have severe paint damage' })
@ApiPropertyOptional({
example: 'Front door and hood have severe paint damage',
description: "Optional expert note about the damage assessment.",
})
@IsOptional()
@IsString()
@IsNotEmpty()
description: string;
description?: string;
@ApiProperty({
type: [PartPricingV2Dto],

View File

@@ -95,4 +95,45 @@ describe("ExpertClaimService expert-reply pricing", () => {
expect(findByIdAndUpdate).not.toHaveBeenCalled();
});
it("returns a Persian, field-specific error when a V2 price is below the minimum", async () => {
const service = createService() as any;
const findByIdAndUpdate = jest.fn();
service.claimCaseDbService = {
findById: jest.fn().mockResolvedValue({
status: ClaimCaseStatus.EXPERT_REVIEWING,
workflow: { locked: true, lockedBy: { actorId: "expert-1" } },
}),
findByIdAndUpdate,
};
service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined);
await expect(
service.submitExpertReplyV2(
"v2-claim",
{
description: "Damage assessment",
parts: [
{
partId: 201,
typeOfDamage: TypeOfDamage.Repair,
price: "99,999",
salary: "100000",
totalPayment: "100000",
factorNeeded: false,
},
],
},
{ sub: "expert-1", role: RoleEnum.FIELD_EXPERT },
),
).rejects.toMatchObject({
response: {
code: "EXPERT_REPLY_VALIDATION_ERROR",
field: "parts[0].price",
message: expect.stringContaining("حداقل"),
},
});
expect(findByIdAndUpdate).not.toHaveBeenCalled();
});
});

View File

@@ -913,6 +913,19 @@ export class ExpertClaimService {
);
}
private expertReplySubmissionError(
message: string,
code: string,
details: Record<string, unknown> = {},
) {
return {
message,
error: "EXPERT_REPLY_SUBMISSION_ERROR",
code,
...details,
};
}
/** Factor validation: expert must send a line amount (OCR is not used for factor photos). */
private expertFactorValidationDecisionHasLinePricing(decision: {
totalPayment?: string;
@@ -948,28 +961,53 @@ export class ExpertClaimService {
private validateAndNormalizeDaghiForExpertReplyV2(
parts: import("./dto/expert-claim-v2.dto").PartPricingV2Dto[],
) {
for (const part of parts) {
for (const [partIndex, part] of parts.entries()) {
if (part.typeOfDamage === TypeOfDamage.Repair) continue;
if (!part.daghi || !part.daghi.option) {
throw new BadRequestException(
`Daghi option is required for part ${part.partId}`,
this.expertReplySubmissionError(
`گزینه داغی برای قطعه ${part.partId} الزامی است.`,
"DAGHI_OPTION_REQUIRED",
{ field: `parts[${partIndex}].daghi.option`, partId: 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}'`,
this.expertReplySubmissionError(
`قیمت داغی برای قطعه ${part.partId} الزامی است.`,
"DAGHI_PRICE_REQUIRED",
{
field: `parts[${partIndex}].daghi.price`,
partId: part.partId,
},
),
);
}
} 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}'`,
this.expertReplySubmissionError(
`شعبه تحویل داغی برای قطعه ${part.partId} الزامی است.`,
"DAGHI_BRANCH_REQUIRED",
{
field: `parts[${partIndex}].daghi.branchId`,
partId: part.partId,
},
),
);
}
if (!Types.ObjectId.isValid(part.daghi.branchId)) {
throw new BadRequestException(
`Invalid branch ID format for part ${part.partId}`,
this.expertReplySubmissionError(
`شناسه شعبه برای قطعه ${part.partId} معتبر نیست.`,
"DAGHI_BRANCH_INVALID",
{
field: `parts[${partIndex}].daghi.branchId`,
partId: part.partId,
},
),
);
}
}
@@ -2300,7 +2338,12 @@ export class ExpertClaimService {
) {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException("Claim request not found");
throw new NotFoundException(
this.expertReplySubmissionError(
"پرونده خسارت یافت نشد.",
"CLAIM_NOT_FOUND",
),
);
}
await this.assertExpertActorOnClaim(claim, actor);
@@ -3265,29 +3308,47 @@ export class ExpertClaimService {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException("Claim request not found");
throw new NotFoundException(
this.expertReplySubmissionError(
"پرونده خسارت یافت نشد.",
"CLAIM_NOT_FOUND",
),
);
}
await this.assertExpertActorOnClaim(claim, actor);
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
throw new BadRequestException(
`Claim is not in a reviewable state. Current status: ${claim.status}`,
this.expertReplySubmissionError(
"پرونده در وضعیت قابل بررسی برای ثبت پاسخ کارشناسی نیست.",
"CLAIM_NOT_REVIEWABLE",
{ currentStatus: claim.status },
),
);
}
if (!claim.workflow?.locked) {
throw new ForbiddenException(
"You must lock the claim before submitting a reply",
this.expertReplySubmissionError(
"پیش از ثبت پاسخ کارشناسی باید پرونده را قفل کنید.",
"CLAIM_NOT_LOCKED",
),
);
}
if (claim.workflow.lockedBy?.actorId?.toString() !== actor.sub) {
throw new ForbiddenException("This claim is locked by another expert");
throw new ForbiddenException(
this.expertReplySubmissionError(
"این پرونده توسط کارشناس دیگری قفل شده است.",
"CLAIM_LOCKED_BY_ANOTHER_EXPERT",
),
);
}
const pricingValidationError = getExpertReplyPricingValidationError(
reply.parts,
{ locale: "fa", enforceAmountBounds: true },
);
if (pricingValidationError) {
throw new BadRequestException(pricingValidationError);
@@ -3305,8 +3366,9 @@ export class ExpertClaimService {
}
if (totalPrice > priceCap) {
throw new BadRequestException({
message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`,
message: `مجموع مبلغ قطعات (${totalPrice.toLocaleString("fa-IR")}) از سقف مجاز (${priceCap.toLocaleString("fa-IR")}) تومان بیشتر است.`,
error: "PRICE_CAP_ERROR",
code: "PRICE_CAP_ERROR",
totalPrice,
priceCap,
});
@@ -3337,7 +3399,11 @@ export class ExpertClaimService {
);
if (!selected) {
throw new BadRequestException(
`Unknown partId "${p.partId}". Use numeric catalog id from claim detail damagedParts.`,
this.expertReplySubmissionError(
`قطعه با شناسه ${p.partId} در فهرست قطعات آسیب‌دیده پرونده وجود ندارد.`,
"PART_NOT_ON_CLAIM",
{ field: "partId", partId: p.partId },
),
);
}
@@ -3355,22 +3421,32 @@ export class ExpertClaimService {
);
} catch (err) {
throw new BadRequestException(
`Invalid part ${p.partId}: ${
err instanceof Error ? err.message : String(err)
}`,
this.expertReplySubmissionError(
`اطلاعات قطعه ${p.partId} معتبر نیست.`,
"PART_INVALID",
{ field: "partId", partId: p.partId },
),
);
}
const catalogId = catalogPartIdFromCarPartDamage(carPartDamage);
if (catalogId == null) {
throw new BadRequestException(
`Invalid part ${p.partId}: a numeric catalog id is required.`,
this.expertReplySubmissionError(
`شناسه قطعه ${p.partId} معتبر نیست.`,
"PART_ID_INVALID",
{ field: "partId", partId: p.partId },
),
);
}
if (seenCatalogIds.has(catalogId)) {
throw new BadRequestException(
`Duplicate part submitted in expert reply: ${catalogId}.`,
this.expertReplySubmissionError(
`قطعه ${catalogId} بیش از یک‌بار ارسال شده است.`,
"DUPLICATE_PART",
{ field: "partId", partId: catalogId },
),
);
}
seenCatalogIds.add(catalogId);
@@ -3399,7 +3475,10 @@ export class ExpertClaimService {
if (objectionSubmitted && hasFinalReply) {
throw new ConflictException(
"A final expert reply after objection already exists for this claim.",
this.expertReplySubmissionError(
"پاسخ نهایی کارشناسی پس از اعتراض، پیش‌تر برای این پرونده ثبت شده است.",
"FINAL_REPLY_ALREADY_SUBMITTED",
),
);
}

View File

@@ -10,6 +10,7 @@ import {
Post,
Put,
UseGuards,
UsePipes,
Query,
Res,
} from "@nestjs/common";
@@ -50,6 +51,7 @@ import {
import { FactorValidationV2Dto } from "./dto/factor-validation.dto";
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
import { OuterPartCatalogItemDto } from "src/claim-request-management/dto/select-outer-parts-v2.dto";
import { submitExpertReplyValidationPipe } from "./pipes/submit-expert-reply-validation.pipe";
class InPersonVisitV2Dto {
@ApiPropertyOptional({ example: "Paint damage requires physical inspection" })
@@ -266,7 +268,7 @@ export class ExpertClaimV2Controller {
@ApiOperation({
summary: "Submit expert damage assessment reply",
description:
"**Preconditions:** claim locked by this expert (`EXPERT_REVIEWING`). **Unlocks** the claim. Each `parts[]` line needs `partId` (from GET claim detail `damagedParts[].partId`), plus pricing, `daghi`, and optional `factorNeeded`. **Cap:** sum of line `totalPayment` values ≤ 53,000,000 **Toman** (same limit as factor-validation totals across priced + factor lines). Clears any prior `evaluation.ownerInsurerApproval` / `ownerPricedPartsApproval`.\n\n" +
"**Preconditions:** claim locked by this expert (`EXPERT_REVIEWING`). **Unlocks** the claim. Each `parts[]` line needs `partId` (from GET claim detail `damagedParts[].partId`), plus pricing, `daghi`, and optional `factorNeeded`. Every supplied monetary field (`price`, `salary`, `totalPayment`, and `daghi.price`) must be 100,000–10,000,000,000 **Toman**. **Cap:** sum of line `totalPayment` values ≤ 53,000,000 **Toman** (same limit as factor-validation totals across priced + factor lines). Clears any prior `evaluation.ownerInsurerApproval` / `ownerPricedPartsApproval`.\n\n" +
"**Frontend routing by `ClaimCaseStatus` (`status`):**\n" +
"- **All parts `factorNeeded`:** `OWNER_REPAIR_FACTOR_UPLOAD_PENDING`, `claimStatus=NEEDS_REVISION`, `workflow.currentStep=OWNER_UPLOAD_FACTOR_DOCUMENTS`, `workflow.nextStep=EXPERT_COST_EVALUATION` → owner uploads all factors; then `status` becomes **`EXPERT_VALIDATING_REPAIR_FACTORS`**, `claimStatus=UNDER_REVIEW`, `currentStep=EXPERT_COST_EVALUATION` for expert **validate-factors**.\n" +
"- **Mixed (some priced, some factorNeeded):** `INSURER_REVIEW_MIXED_FACTORS_PENDING`, `claimStatus=NEEDS_REVISION`, `currentStep=INSURER_REVIEW`, `nextStep=OWNER_UPLOAD_FACTOR_DOCUMENTS` → owner must call **owner-insurer-approval/sign** first (priced-line acceptance); `currentStep` then moves to `OWNER_UPLOAD_FACTOR_DOCUMENTS` (same case `status` until factors are done).\n" +
@@ -276,6 +278,7 @@ export class ExpertClaimV2Controller {
})
@ApiParam({ name: "claimRequestId" })
@ApiBody({ type: SubmitExpertReplyV2Dto })
@UsePipes(submitExpertReplyValidationPipe)
async submitExpertReplyV2(
@Param("claimRequestId") claimRequestId: string,
@Body() body: SubmitExpertReplyV2Dto,

View File

@@ -0,0 +1,70 @@
import { SubmitExpertReplyV2Dto } from "../dto/expert-claim-v2.dto";
import { TypeOfDamage } from "src/Types&Enums/claim-request-management/type-of-damage.enum";
import { submitExpertReplyValidationPipe } from "./submit-expert-reply-validation.pipe";
describe("submitExpertReplyValidationPipe", () => {
it("converts DTO validation failures into Persian, field-addressable errors", async () => {
await expect(
submitExpertReplyValidationPipe.transform(
{ parts: [] },
{ type: "body", metatype: SubmitExpertReplyV2Dto },
),
).rejects.toMatchObject({
response: {
code: "EXPERT_REPLY_VALIDATION_ERROR",
message: "اطلاعات ارسالی پاسخ کارشناسی معتبر نیست.",
validationErrors: expect.arrayContaining([
expect.objectContaining({ field: "parts" }),
]),
},
});
});
it("accepts a reply without a description", async () => {
await expect(
submitExpertReplyValidationPipe.transform(
{
parts: [
{
partId: 201,
typeOfDamage: TypeOfDamage.Repair,
salary: "100000",
totalPayment: "100000",
factorNeeded: false,
},
],
},
{ type: "body", metatype: SubmitExpertReplyV2Dto },
),
).resolves.toBeInstanceOf(SubmitExpertReplyV2Dto);
});
it("identifies invalid nested price fields for the frontend", async () => {
await expect(
submitExpertReplyValidationPipe.transform(
{
description: "Damage assessment",
parts: [
{
partId: 201,
typeOfDamage: "repair",
salary: "not-a-number",
totalPayment: "100000",
factorNeeded: false,
},
],
},
{ type: "body", metatype: SubmitExpertReplyV2Dto },
),
).rejects.toMatchObject({
response: {
validationErrors: expect.arrayContaining([
{
field: "parts[0].salary",
message: "باید مبلغ صحیح و غیرمنفی به تومان باشد.",
},
]),
},
});
});
});

View File

@@ -0,0 +1,59 @@
import {
BadRequestException,
ValidationError,
ValidationPipe,
} from "@nestjs/common";
type FrontendValidationError = {
field: string;
message: string;
};
const constraintMessages: Record<string, string> = {
isString: "باید به‌صورت متن ارسال شود.",
isNotEmpty: "وارد کردن این فیلد الزامی است.",
isArray: "باید به‌صورت فهرست ارسال شود.",
arrayMinSize: "حداقل یک قطعه آسیب‌دیده باید ارسال شود.",
isBoolean: "باید درست یا نادرست باشد.",
isInt: "باید یک عدد صحیح باشد.",
isEnum: "مقدار واردشده معتبر نیست.",
nestedValidation: "اطلاعات این بخش معتبر نیست.",
isMoneyAmountString: "باید مبلغ صحیح و غیرمنفی به تومان باشد.",
};
function flattenValidationErrors(
errors: ValidationError[],
parentPath = "",
): FrontendValidationError[] {
return errors.flatMap((error) => {
const field = parentPath
? /^\d+$/.test(error.property)
? `${parentPath}[${error.property}]`
: `${parentPath}.${error.property}`
: error.property;
const ownErrors = Object.keys(error.constraints ?? {}).map((constraint) => ({
field,
message: constraintMessages[constraint] ?? "مقدار واردشده معتبر نیست.",
}));
return [...ownErrors, ...flattenValidationErrors(error.children ?? [], field)];
});
}
/**
* Endpoint-scoped because this application deliberately has no global
* ValidationPipe. It preserves DTO validation while returning a stable,
* Persian payload that the frontend can render by field.
*/
export const submitExpertReplyValidationPipe = new ValidationPipe({
transform: true,
whitelist: true,
exceptionFactory: (errors: ValidationError[]) => {
const validationErrors = flattenValidationErrors(errors);
return new BadRequestException({
message: "اطلاعات ارسالی پاسخ کارشناسی معتبر نیست.",
error: "EXPERT_REPLY_VALIDATION_ERROR",
code: "EXPERT_REPLY_VALIDATION_ERROR",
validationErrors,
});
},
});