forked from Yara724/api
Merge pull request 'Better error handling and validation and min/max prices added for expert submit' (#301) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#301
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: "باید مبلغ صحیح و غیرمنفی به تومان باشد.",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}'.`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user