diff --git a/src/expert-claim/dto/expert-claim-v2.dto.spec.ts b/src/expert-claim/dto/expert-claim-v2.dto.spec.ts index 7f45966..4b15b14 100644 --- a/src/expert-claim/dto/expert-claim-v2.dto.spec.ts +++ b/src/expert-claim/dto/expert-claim-v2.dto.spec.ts @@ -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, diff --git a/src/expert-claim/dto/expert-claim-v2.dto.ts b/src/expert-claim/dto/expert-claim-v2.dto.ts index 9e49507..144add0 100644 --- a/src/expert-claim/dto/expert-claim-v2.dto.ts +++ b/src/expert-claim/dto/expert-claim-v2.dto.ts @@ -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], diff --git a/src/expert-claim/expert-claim.service.spec.ts b/src/expert-claim/expert-claim.service.spec.ts index e9cc395..4884fdc 100644 --- a/src/expert-claim/expert-claim.service.spec.ts +++ b/src/expert-claim/expert-claim.service.spec.ts @@ -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(); + }); }); diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index fed0825..125a182 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -913,6 +913,19 @@ export class ExpertClaimService { ); } + private expertReplySubmissionError( + message: string, + code: string, + details: Record = {}, + ) { + 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", + ), ); } diff --git a/src/expert-claim/expert-claim.v2.controller.ts b/src/expert-claim/expert-claim.v2.controller.ts index 02172be..fcfdb99 100644 --- a/src/expert-claim/expert-claim.v2.controller.ts +++ b/src/expert-claim/expert-claim.v2.controller.ts @@ -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, diff --git a/src/expert-claim/pipes/submit-expert-reply-validation.pipe.spec.ts b/src/expert-claim/pipes/submit-expert-reply-validation.pipe.spec.ts new file mode 100644 index 0000000..f722f25 --- /dev/null +++ b/src/expert-claim/pipes/submit-expert-reply-validation.pipe.spec.ts @@ -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: "باید مبلغ صحیح و غیرمنفی به تومان باشد.", + }, + ]), + }, + }); + }); +}); diff --git a/src/expert-claim/pipes/submit-expert-reply-validation.pipe.ts b/src/expert-claim/pipes/submit-expert-reply-validation.pipe.ts new file mode 100644 index 0000000..bb6f6fe --- /dev/null +++ b/src/expert-claim/pipes/submit-expert-reply-validation.pipe.ts @@ -0,0 +1,59 @@ +import { + BadRequestException, + ValidationError, + ValidationPipe, +} from "@nestjs/common"; + +type FrontendValidationError = { + field: string; + message: string; +}; + +const constraintMessages: Record = { + 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, + }); + }, +}); diff --git a/src/helpers/expert-reply-pricing.spec.ts b/src/helpers/expert-reply-pricing.spec.ts index 8d2ad04..1a9c093 100644 --- a/src/helpers/expert-reply-pricing.spec.ts +++ b/src/helpers/expert-reply-pricing.spec.ts @@ -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 { DaghiOption } from "src/Types&Enums/claim-request-management/daghi-option.enum"; @@ -121,4 +125,41 @@ describe("getExpertReplyPricingValidationError", () => { ]), ).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, + }); + }, + ); }); diff --git a/src/helpers/expert-reply-pricing.ts b/src/helpers/expert-reply-pricing.ts index c60d1a0..d44f1ee 100644 --- a/src/helpers/expert-reply-pricing.ts +++ b/src/helpers/expert-reply-pricing.ts @@ -11,6 +11,34 @@ type ExpertReplyPricingPart = { 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 * damaged-part line. This is deliberately independent of DTO validation: @@ -19,9 +47,33 @@ type ExpertReplyPricingPart = { */ export function getExpertReplyPricingValidationError( 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) { - 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()) { @@ -32,13 +84,22 @@ export function getExpertReplyPricingValidationError( : `Damaged part ${index + 1}`; 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 ( !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; @@ -47,7 +108,12 @@ export function getExpertReplyPricingValidationError( (!daghi || !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); @@ -60,21 +126,96 @@ export function getExpertReplyPricingValidationError( const amountIsValid = (amount: number | null) => amount !== null; const priceIsRequired = part.typeOfDamage === TypeOfDamage.Change; - const priceIsValid = !hasPrice || amountIsValid(price); - if ( - !amountIsValid(salary) || - !amountIsValid(totalPayment) || - (priceIsRequired && !amountIsValid(price)) || - !priceIsValid - ) { + const requiredAmountFields: Array<[ + "price" | "salary" | "totalPayment", + number | null, + boolean, + ]> = [ + ["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.`; } + 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 ( daghi?.option === DaghiOption.RECYCLED_PARTS_VALUE && 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}'.`; } }