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

View File

@@ -27,7 +27,7 @@ export class DaghiDetailsV2Dto {
option: DaghiOption; option: DaghiOption;
@ApiPropertyOptional({ @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( @ValidateIf(
(daghi: DaghiDetailsV2Dto) => (daghi: DaghiDetailsV2Dto) =>
@@ -63,7 +63,8 @@ export class PartPricingV2Dto {
@ApiPropertyOptional({ @ApiPropertyOptional({
example: "5000000", 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( @ValidateIf(
(part: PartPricingV2Dto) => (part: PartPricingV2Dto) =>
@@ -78,7 +79,8 @@ export class PartPricingV2Dto {
@ApiProperty({ @ApiProperty({
example: "2000000", 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() @IsString()
@IsNotEmpty() @IsNotEmpty()
@@ -87,7 +89,8 @@ export class PartPricingV2Dto {
@ApiProperty({ @ApiProperty({
example: "7000000", example: "7000000",
description: "Line total in Toman (integer string).", description:
"Line total in Toman (integer string; 100,000 to 10,000,000,000).",
}) })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@@ -116,10 +119,13 @@ export class PartPricingV2Dto {
} }
export class SubmitExpertReplyV2Dto { 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() @IsString()
@IsNotEmpty() description?: string;
description: string;
@ApiProperty({ @ApiProperty({
type: [PartPricingV2Dto], type: [PartPricingV2Dto],

View File

@@ -95,4 +95,45 @@ describe("ExpertClaimService expert-reply pricing", () => {
expect(findByIdAndUpdate).not.toHaveBeenCalled(); 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). */ /** Factor validation: expert must send a line amount (OCR is not used for factor photos). */
private expertFactorValidationDecisionHasLinePricing(decision: { private expertFactorValidationDecisionHasLinePricing(decision: {
totalPayment?: string; totalPayment?: string;
@@ -948,28 +961,53 @@ export class ExpertClaimService {
private validateAndNormalizeDaghiForExpertReplyV2( private validateAndNormalizeDaghiForExpertReplyV2(
parts: import("./dto/expert-claim-v2.dto").PartPricingV2Dto[], 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.typeOfDamage === TypeOfDamage.Repair) continue;
if (!part.daghi || !part.daghi.option) { if (!part.daghi || !part.daghi.option) {
throw new BadRequestException( 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.option === DaghiOption.RECYCLED_PARTS_VALUE) {
if (!part.daghi.price) { if (!part.daghi.price) {
throw new BadRequestException( 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) { } else if (part.daghi.option === DaghiOption.DELIVER_DAMAGED_PART) {
if (!part.daghi.branchId) { if (!part.daghi.branchId) {
throw new BadRequestException( 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)) { if (!Types.ObjectId.isValid(part.daghi.branchId)) {
throw new BadRequestException( 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); const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) { if (!claim) {
throw new NotFoundException("Claim request not found"); throw new NotFoundException(
this.expertReplySubmissionError(
"پرونده خسارت یافت نشد.",
"CLAIM_NOT_FOUND",
),
);
} }
await this.assertExpertActorOnClaim(claim, actor); await this.assertExpertActorOnClaim(claim, actor);
@@ -3265,29 +3308,47 @@ export class ExpertClaimService {
const claim = await this.claimCaseDbService.findById(claimRequestId); const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) { if (!claim) {
throw new NotFoundException("Claim request not found"); throw new NotFoundException(
this.expertReplySubmissionError(
"پرونده خسارت یافت نشد.",
"CLAIM_NOT_FOUND",
),
);
} }
await this.assertExpertActorOnClaim(claim, actor); await this.assertExpertActorOnClaim(claim, actor);
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) { if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
throw new BadRequestException( 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) { if (!claim.workflow?.locked) {
throw new ForbiddenException( 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) { 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( const pricingValidationError = getExpertReplyPricingValidationError(
reply.parts, reply.parts,
{ locale: "fa", enforceAmountBounds: true },
); );
if (pricingValidationError) { if (pricingValidationError) {
throw new BadRequestException(pricingValidationError); throw new BadRequestException(pricingValidationError);
@@ -3305,8 +3366,9 @@ export class ExpertClaimService {
} }
if (totalPrice > priceCap) { if (totalPrice > priceCap) {
throw new BadRequestException({ 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", error: "PRICE_CAP_ERROR",
code: "PRICE_CAP_ERROR",
totalPrice, totalPrice,
priceCap, priceCap,
}); });
@@ -3337,7 +3399,11 @@ export class ExpertClaimService {
); );
if (!selected) { if (!selected) {
throw new BadRequestException( 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) { } catch (err) {
throw new BadRequestException( throw new BadRequestException(
`Invalid part ${p.partId}: ${ this.expertReplySubmissionError(
err instanceof Error ? err.message : String(err) `اطلاعات قطعه ${p.partId} معتبر نیست.`,
}`, "PART_INVALID",
{ field: "partId", partId: p.partId },
),
); );
} }
const catalogId = catalogPartIdFromCarPartDamage(carPartDamage); const catalogId = catalogPartIdFromCarPartDamage(carPartDamage);
if (catalogId == null) { if (catalogId == null) {
throw new BadRequestException( 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)) { if (seenCatalogIds.has(catalogId)) {
throw new BadRequestException( throw new BadRequestException(
`Duplicate part submitted in expert reply: ${catalogId}.`, this.expertReplySubmissionError(
`قطعه ${catalogId} بیش از یک‌بار ارسال شده است.`,
"DUPLICATE_PART",
{ field: "partId", partId: catalogId },
),
); );
} }
seenCatalogIds.add(catalogId); seenCatalogIds.add(catalogId);
@@ -3399,7 +3475,10 @@ export class ExpertClaimService {
if (objectionSubmitted && hasFinalReply) { if (objectionSubmitted && hasFinalReply) {
throw new ConflictException( 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, Post,
Put, Put,
UseGuards, UseGuards,
UsePipes,
Query, Query,
Res, Res,
} from "@nestjs/common"; } from "@nestjs/common";
@@ -50,6 +51,7 @@ import {
import { FactorValidationV2Dto } from "./dto/factor-validation.dto"; import { FactorValidationV2Dto } from "./dto/factor-validation.dto";
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service"; 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 { OuterPartCatalogItemDto } from "src/claim-request-management/dto/select-outer-parts-v2.dto";
import { submitExpertReplyValidationPipe } from "./pipes/submit-expert-reply-validation.pipe";
class InPersonVisitV2Dto { class InPersonVisitV2Dto {
@ApiPropertyOptional({ example: "Paint damage requires physical inspection" }) @ApiPropertyOptional({ example: "Paint damage requires physical inspection" })
@@ -266,7 +268,7 @@ export class ExpertClaimV2Controller {
@ApiOperation({ @ApiOperation({
summary: "Submit expert damage assessment reply", summary: "Submit expert damage assessment reply",
description: 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" + "**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" + "- **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" + "- **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" }) @ApiParam({ name: "claimRequestId" })
@ApiBody({ type: SubmitExpertReplyV2Dto }) @ApiBody({ type: SubmitExpertReplyV2Dto })
@UsePipes(submitExpertReplyValidationPipe)
async submitExpertReplyV2( async submitExpertReplyV2(
@Param("claimRequestId") claimRequestId: string, @Param("claimRequestId") claimRequestId: string,
@Body() body: SubmitExpertReplyV2Dto, @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,
});
},
});

View File

@@ -1,4 +1,8 @@
import { getExpertReplyPricingValidationError } from "./expert-reply-pricing"; import {
EXPERT_REPLY_MAX_AMOUNT_TOMAN,
EXPERT_REPLY_MIN_AMOUNT_TOMAN,
getExpertReplyPricingValidationError,
} from "./expert-reply-pricing";
import { TypeOfDamage } from "src/Types&Enums/claim-request-management/type-of-damage.enum"; import { TypeOfDamage } from "src/Types&Enums/claim-request-management/type-of-damage.enum";
import { DaghiOption } from "src/Types&Enums/claim-request-management/daghi-option.enum"; import { DaghiOption } from "src/Types&Enums/claim-request-management/daghi-option.enum";
@@ -121,4 +125,41 @@ describe("getExpertReplyPricingValidationError", () => {
]), ]),
).toBeNull(); ).toBeNull();
}); });
it.each([
["price", "99,999", "parts[0].price"],
["salary", "10,000,000,001", "parts[0].salary"],
["totalPayment", "99,999", "parts[0].totalPayment"],
["daghi.price", "10,000,000,001", "parts[0].daghi.price"],
])(
"enforces the amount range for %s",
(field, invalidValue, expectedField) => {
const part = {
partId: 201,
typeOfDamage: TypeOfDamage.Change,
price: "100000",
salary: "100000",
totalPayment: "100000",
daghi: {
option: DaghiOption.RECYCLED_PARTS_VALUE,
price: "100000",
},
};
if (field === "daghi.price") part.daghi.price = invalidValue;
else part[field] = invalidValue;
expect(
getExpertReplyPricingValidationError([part], {
locale: "fa",
enforceAmountBounds: true,
}),
).toMatchObject({
code: "EXPERT_REPLY_VALIDATION_ERROR",
field: expectedField,
rule: "amount_out_of_range",
minAmount: EXPERT_REPLY_MIN_AMOUNT_TOMAN,
maxAmount: EXPERT_REPLY_MAX_AMOUNT_TOMAN,
});
},
);
}); });

View File

@@ -11,6 +11,34 @@ type ExpertReplyPricingPart = {
daghi?: { option?: unknown; price?: unknown }; daghi?: { option?: unknown; price?: unknown };
}; };
export const EXPERT_REPLY_MIN_AMOUNT_TOMAN = 100_000;
export const EXPERT_REPLY_MAX_AMOUNT_TOMAN = 10_000_000_000;
export type ExpertReplyPricingValidationError = {
message: string;
error: "EXPERT_REPLY_VALIDATION_ERROR";
code: "EXPERT_REPLY_VALIDATION_ERROR";
field: string;
partId?: string | number;
rule:
| "required"
| "invalid_value"
| "invalid_amount"
| "amount_out_of_range";
minAmount?: number;
maxAmount?: number;
};
type ValidationOptions = {
/** Preserve legacy English strings unless the V2 endpoint explicitly asks for a localized response. */
locale?: "fa";
enforceAmountBounds?: boolean;
};
function toPersianAmount(amount: number): string {
return amount.toLocaleString("fa-IR");
}
/** /**
* Validates the pricing that an expert must provide for every submitted * Validates the pricing that an expert must provide for every submitted
* damaged-part line. This is deliberately independent of DTO validation: * damaged-part line. This is deliberately independent of DTO validation:
@@ -19,9 +47,33 @@ type ExpertReplyPricingPart = {
*/ */
export function getExpertReplyPricingValidationError( export function getExpertReplyPricingValidationError(
parts: unknown, parts: unknown,
): string | null { options: ValidationOptions = {},
): string | ExpertReplyPricingValidationError | null {
const localizedError = (
message: string,
field: string,
rule: ExpertReplyPricingValidationError["rule"],
partId?: string | number,
range?: { minAmount: number; maxAmount: number },
): string | ExpertReplyPricingValidationError => {
if (options.locale !== "fa") return message;
return {
message,
error: "EXPERT_REPLY_VALIDATION_ERROR",
code: "EXPERT_REPLY_VALIDATION_ERROR",
field,
...(partId != null && { partId }),
rule,
...(range ?? {}),
};
};
if (!Array.isArray(parts) || parts.length === 0) { if (!Array.isArray(parts) || parts.length === 0) {
return "At least one damaged part with pricing is required to submit an expert reply."; return localizedError(
"برای ثبت پاسخ کارشناسی، حداقل یک قطعه آسیب‌دیده همراه با مبلغ لازم است.",
"parts",
"required",
);
} }
for (const [index, rawPart] of parts.entries()) { for (const [index, rawPart] of parts.entries()) {
@@ -32,13 +84,22 @@ export function getExpertReplyPricingValidationError(
: `Damaged part ${index + 1}`; : `Damaged part ${index + 1}`;
if (!part || typeof part !== "object" || part.partId == null) { if (!part || typeof part !== "object" || part.partId == null) {
return `${label} must include a damaged-part partId.`; return localizedError(
`شناسه قطعه در ردیف ${index + 1} الزامی است.`,
`parts[${index}].partId`,
"required",
);
} }
if ( if (
!Object.values(TypeOfDamage).includes(part.typeOfDamage as TypeOfDamage) !Object.values(TypeOfDamage).includes(part.typeOfDamage as TypeOfDamage)
) { ) {
return `${label} requires typeOfDamage to be either '${TypeOfDamage.Repair}' or '${TypeOfDamage.Change}'.`; return localizedError(
`نوع خسارت قطعه ${String(part.partId)} معتبر نیست.`,
`parts[${index}].typeOfDamage`,
"invalid_value",
String(part.partId),
);
} }
const daghi = part.daghi; const daghi = part.daghi;
@@ -47,7 +108,12 @@ export function getExpertReplyPricingValidationError(
(!daghi || (!daghi ||
!Object.values(DaghiOption).includes(daghi.option as DaghiOption)) !Object.values(DaghiOption).includes(daghi.option as DaghiOption))
) { ) {
return `${label} requires a valid daghi option.`; return localizedError(
`گزینه داغی برای قطعه ${String(part.partId)} الزامی و باید معتبر باشد.`,
`parts[${index}].daghi.option`,
"required",
String(part.partId),
);
} }
const price = parseMoneyAmountToman(part.price); const price = parseMoneyAmountToman(part.price);
@@ -60,21 +126,96 @@ export function getExpertReplyPricingValidationError(
const amountIsValid = (amount: number | null) => amount !== null; const amountIsValid = (amount: number | null) => amount !== null;
const priceIsRequired = part.typeOfDamage === TypeOfDamage.Change; const priceIsRequired = part.typeOfDamage === TypeOfDamage.Change;
const priceIsValid = !hasPrice || amountIsValid(price);
if ( const requiredAmountFields: Array<[
!amountIsValid(salary) || "price" | "salary" | "totalPayment",
!amountIsValid(totalPayment) || number | null,
(priceIsRequired && !amountIsValid(price)) || boolean,
!priceIsValid ]> = [
) { ["price", price, priceIsRequired || hasPrice],
["salary", salary, true],
["totalPayment", totalPayment, true],
];
const invalidAmount = requiredAmountFields.find(
([, amount, required]) => required && !amountIsValid(amount),
);
if (invalidAmount) {
const field = invalidAmount[0] as "price" | "salary" | "totalPayment";
if (options.locale === "fa") {
const fieldName =
field === "price"
? "قیمت قطعه"
: field === "salary"
? "دستمزد"
: "مبلغ کل";
return localizedError(
`${fieldName} قطعه ${String(part.partId)} باید به‌صورت مبلغ صحیح و غیرمنفی (تومان) وارد شود.`,
`parts[${index}].${field}`,
"invalid_amount",
String(part.partId),
);
}
return `${label} requires valid salary and totalPayment; price is also required for '${TypeOfDamage.Change}' damage. Price, salary, and totalPayment may be 0.`; return `${label} requires valid salary and totalPayment; price is also required for '${TypeOfDamage.Change}' damage. Price, salary, and totalPayment may be 0.`;
} }
if (options.enforceAmountBounds) {
const monetaryFields: Array<[
"price" | "salary" | "totalPayment" | "daghi.price",
number | null,
boolean,
]> = [
["price", price, priceIsRequired || hasPrice],
["salary", salary, true],
["totalPayment", totalPayment, true],
[
"daghi.price",
daghiPrice,
daghi?.option === DaghiOption.RECYCLED_PARTS_VALUE,
],
];
const outOfRange = monetaryFields.find(
([, amount, required]) =>
required &&
amount !== null &&
(amount < EXPERT_REPLY_MIN_AMOUNT_TOMAN ||
amount > EXPERT_REPLY_MAX_AMOUNT_TOMAN),
);
if (outOfRange) {
const [field] = outOfRange;
const fieldName =
field === "price"
? "قیمت قطعه"
: field === "salary"
? "دستمزد"
: field === "totalPayment"
? "مبلغ کل"
: "قیمت داغی";
return localizedError(
`${fieldName} قطعه ${String(part.partId)} باید حداقل ${toPersianAmount(EXPERT_REPLY_MIN_AMOUNT_TOMAN)} و حداکثر ${toPersianAmount(EXPERT_REPLY_MAX_AMOUNT_TOMAN)} تومان باشد.`,
`parts[${index}].${field}`,
"amount_out_of_range",
String(part.partId),
{
minAmount: EXPERT_REPLY_MIN_AMOUNT_TOMAN,
maxAmount: EXPERT_REPLY_MAX_AMOUNT_TOMAN,
},
);
}
}
if ( if (
daghi?.option === DaghiOption.RECYCLED_PARTS_VALUE && daghi?.option === DaghiOption.RECYCLED_PARTS_VALUE &&
daghiPrice === null daghiPrice === null
) { ) {
if (options.locale === "fa") {
return localizedError(
`قیمت داغی قطعه ${String(part.partId)} باید به‌صورت مبلغ صحیح و غیرمنفی (تومان) وارد شود.`,
`parts[${index}].daghi.price`,
"invalid_amount",
String(part.partId),
);
}
return `${label} requires a valid daghi price when option is '${DaghiOption.RECYCLED_PARTS_VALUE}'.`; return `${label} requires a valid daghi price when option is '${DaghiOption.RECYCLED_PARTS_VALUE}'.`;
} }
} }