Compare commits

...

5 Commits

8 changed files with 356 additions and 11 deletions

View File

@@ -1,4 +1,4 @@
export enum TypeOfDamage { export enum TypeOfDamage {
Repair = "repair", Repair = "تعمیر",
Change = "change", Change = "تعویض",
} }

View File

@@ -25,7 +25,12 @@ export class IsRepairLineAmountTomanConstraint
number, number,
boolean | undefined, boolean | undefined,
]; ];
if (value == null || value === "") return true; // Optional fields are skipped by @IsOptional. A required amount must not
// accept an omitted or blank value, otherwise an empty expert pricing line
// can be submitted as a zero-cost repair.
if (value == null || (typeof value === "string" && value.trim() === "")) {
return false;
}
const amount = parseMoneyAmountToman(value); const amount = parseMoneyAmountToman(value);
if (amount == null) return false; if (amount == null) return false;
if (allowZero && amount === 0) return true; if (allowZero && amount === 0) return true;

View File

@@ -0,0 +1,46 @@
import { plainToInstance } from "class-transformer";
import { validate } from "class-validator";
import { DaghiOption } from "src/Types&Enums/claim-request-management/daghi-option.enum";
import { TypeOfDamage } from "src/Types&Enums/claim-request-management/type-of-damage.enum";
import { SubmitExpertReplyV2Dto } from "./expert-claim-v2.dto";
describe("SubmitExpertReplyV2Dto", () => {
it("rejects a selected damaged part with blank pricing", async () => {
const dto = plainToInstance(SubmitExpertReplyV2Dto, {
description: "Damage assessment",
parts: [
{
partId: 201,
typeOfDamage: TypeOfDamage.Repair,
price: "",
salary: "",
totalPayment: "",
factorNeeded: false,
daghi: { option: DaghiOption.NO_VALUE },
},
],
});
const errors = await validate(dto);
expect(errors).not.toHaveLength(0);
});
it("accepts a replacement part without a price", async () => {
const dto = plainToInstance(SubmitExpertReplyV2Dto, {
description: "Damage assessment",
parts: [
{
partId: 201,
typeOfDamage: TypeOfDamage.Change,
salary: "0",
totalPayment: "0",
factorNeeded: false,
daghi: { option: DaghiOption.NO_VALUE },
},
],
});
expect(await validate(dto)).toHaveLength(0);
});
});

View File

@@ -3,8 +3,10 @@ import {
IsString, IsString,
IsNotEmpty, IsNotEmpty,
IsArray, IsArray,
ArrayMinSize,
IsBoolean, IsBoolean,
IsOptional, IsOptional,
ValidateIf,
ValidateNested, ValidateNested,
IsEnum, IsEnum,
IsInt, IsInt,
@@ -14,6 +16,7 @@ import { IsRepairLineAmountToman } from 'src/common/validators/repair-line-amoun
import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum'; import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum';
import { DamagedPartItem } from 'src/claim-request-management/dto/capture-requirements-v2.dto'; import { DamagedPartItem } from 'src/claim-request-management/dto/capture-requirements-v2.dto';
import { DaghiOption } from 'src/Types&Enums/claim-request-management/daghi-option.enum'; import { DaghiOption } from 'src/Types&Enums/claim-request-management/daghi-option.enum';
import { TypeOfDamage } from 'src/Types&Enums/claim-request-management/type-of-damage.enum';
export class DaghiDetailsV2Dto { export class DaghiDetailsV2Dto {
@ApiProperty({ @ApiProperty({
enum: DaghiOption, enum: DaghiOption,
@@ -26,8 +29,12 @@ export class DaghiDetailsV2Dto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: `Required when option is '${DaghiOption.RECYCLED_PARTS_VALUE}' (Toman)`, description: `Required when option is '${DaghiOption.RECYCLED_PARTS_VALUE}' (Toman)`,
}) })
@IsOptional() @ValidateIf(
(daghi: DaghiDetailsV2Dto) =>
daghi.option === DaghiOption.RECYCLED_PARTS_VALUE,
)
@IsString() @IsString()
@IsNotEmpty()
@IsRepairLineAmountToman() @IsRepairLineAmountToman()
price?: string; price?: string;
@@ -47,15 +54,25 @@ export class PartPricingV2Dto {
@IsInt() @IsInt()
partId: number; partId: number;
@ApiProperty({ example: 'Minor' })
@IsString()
typeOfDamage: string;
@ApiProperty({ @ApiProperty({
example: "5000000", enum: TypeOfDamage,
description: "Part price in Toman (integer string). Use 0 if the full amount is in salary.", description: "'repair' requires price; 'change' may omit it.",
}) })
@IsEnum(TypeOfDamage)
typeOfDamage: TypeOfDamage;
@ApiPropertyOptional({
example: "5000000",
description: "Required for repair lines; omitted for change lines. Use 0 if the full amount is in salary.",
})
@ValidateIf(
(part: PartPricingV2Dto) =>
part.typeOfDamage === TypeOfDamage.Repair ||
(part.price != null &&
(typeof part.price !== 'string' || part.price.trim() !== '')),
)
@IsString() @IsString()
@IsNotEmpty()
@IsRepairLineAmountToman({ allowZero: true }) @IsRepairLineAmountToman({ allowZero: true })
price: string; price: string;
@@ -64,6 +81,7 @@ export class PartPricingV2Dto {
description: "Labor in Toman (integer string). Use 0 if the full amount is in price.", description: "Labor in Toman (integer string). Use 0 if the full amount is in price.",
}) })
@IsString() @IsString()
@IsNotEmpty()
@IsRepairLineAmountToman({ allowZero: true }) @IsRepairLineAmountToman({ allowZero: true })
salary: string; salary: string;
@@ -72,10 +90,12 @@ export class PartPricingV2Dto {
description: "Line total in Toman (integer string).", description: "Line total in Toman (integer string).",
}) })
@IsString() @IsString()
@IsRepairLineAmountToman() @IsNotEmpty()
@IsRepairLineAmountToman({ allowZero: true })
totalPayment: string; totalPayment: string;
@ApiProperty({ type: DaghiDetailsV2Dto }) @ApiProperty({ type: DaghiDetailsV2Dto })
@IsNotEmpty()
@ValidateNested() @ValidateNested()
@Type(() => DaghiDetailsV2Dto) @Type(() => DaghiDetailsV2Dto)
daghi: DaghiDetailsV2Dto; daghi: DaghiDetailsV2Dto;
@@ -101,6 +121,7 @@ export class SubmitExpertReplyV2Dto {
"One line per damaged part (`partId` from claim detail `damagedParts[]`), plus pricing, `daghi`, and `factorNeeded`.", "One line per damaged part (`partId` from claim detail `damagedParts[]`), plus pricing, `daghi`, and `factorNeeded`.",
}) })
@IsArray() @IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@Type(() => PartPricingV2Dto) @Type(() => PartPricingV2Dto)
parts: PartPricingV2Dto[]; parts: PartPricingV2Dto[];

View File

@@ -0,0 +1,73 @@
import { BadRequestException } from "@nestjs/common";
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { DaghiOption } from "src/Types&Enums/claim-request-management/daghi-option.enum";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { TypeOfDamage } from "src/Types&Enums/claim-request-management/type-of-damage.enum";
import { ExpertClaimService } from "./expert-claim.service";
const blankPricingReply = {
description: "Damage assessment",
parts: [
{
partId: 201,
typeOfDamage: TypeOfDamage.Repair,
price: "",
salary: "",
totalPayment: "",
factorNeeded: false,
daghi: { option: DaghiOption.NO_VALUE },
},
],
};
function createService() {
return new (ExpertClaimService as any)(
...new Array(21).fill(undefined),
) as ExpertClaimService;
}
describe("ExpertClaimService expert-reply pricing", () => {
it("rejects a blank pricing line on the legacy submit endpoint before changing status", async () => {
const service = createService() as any;
const findAndUpdate = jest.fn();
service.claimRequestManagementDbService = {
findOne: jest.fn().mockResolvedValue({
_id: "legacy-claim",
actorLocked: { actorId: "expert-1" },
unlockTime: new Date(Date.now() + 60_000),
lockFile: true,
}),
findAndUpdate,
};
await expect(
service.submitReplyRequest("legacy-claim", blankPricingReply, {
sub: "expert-1",
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(findAndUpdate).not.toHaveBeenCalled();
});
it("rejects a blank pricing line on the V2 submit endpoint before completion", 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", blankPricingReply, {
sub: "expert-1",
role: RoleEnum.FIELD_EXPERT,
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(findByIdAndUpdate).not.toHaveBeenCalled();
});
});

View File

@@ -168,6 +168,7 @@ import {
} from "src/helpers/unified-file-status"; } from "src/helpers/unified-file-status";
import { buildEnrichedDamagedParts } from "./dto/claim-damaged-part.enricher"; import { buildEnrichedDamagedParts } from "./dto/claim-damaged-part.enricher";
import { canonicalizeResendDocumentKey } from "src/helpers/claim-resend-document-keys"; import { canonicalizeResendDocumentKey } from "src/helpers/claim-resend-document-keys";
import { getExpertReplyPricingValidationError } from "src/helpers/expert-reply-pricing";
@Injectable() @Injectable()
export class ExpertClaimService { export class ExpertClaimService {
@@ -1658,6 +1659,13 @@ export class ExpertClaimService {
); );
} }
const pricingValidationError = getExpertReplyPricingValidationError(
reply.parts,
);
if (pricingValidationError) {
throw new BadRequestException(pricingValidationError);
}
// Validate total price cap (priced lines sum), when enabled. // Validate total price cap (priced lines sum), when enabled.
const priceCap = getClaimV2TotalPaymentCapToman(); const priceCap = getClaimV2TotalPaymentCapToman();
if (priceCap !== null && reply.parts && reply.parts.length > 0) { if (priceCap !== null && reply.parts && reply.parts.length > 0) {
@@ -3261,6 +3269,13 @@ export class ExpertClaimService {
throw new ForbiddenException("This claim is locked by another expert"); throw new ForbiddenException("This claim is locked by another expert");
} }
const pricingValidationError = getExpertReplyPricingValidationError(
reply.parts,
);
if (pricingValidationError) {
throw new BadRequestException(pricingValidationError);
}
// Price cap validation, when enabled. // Price cap validation, when enabled.
const priceCap = getClaimV2TotalPaymentCapToman(); const priceCap = getClaimV2TotalPaymentCapToman();
if (priceCap !== null) { if (priceCap !== null) {

View File

@@ -0,0 +1,92 @@
import { 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";
describe("getExpertReplyPricingValidationError", () => {
it("rejects a blank pricing line", () => {
expect(
getExpertReplyPricingValidationError([
{
partId: 201,
typeOfDamage: TypeOfDamage.Repair,
price: "",
salary: "",
totalPayment: "",
daghi: { option: DaghiOption.NO_VALUE },
},
]),
).toMatch(/requires valid salary and totalPayment/);
});
it("accepts a fully priced line with a zero-valued split", () => {
expect(
getExpertReplyPricingValidationError([
{
partId: 201,
typeOfDamage: TypeOfDamage.Repair,
price: "0",
salary: "10000",
totalPayment: "10000",
daghi: { option: DaghiOption.NO_VALUE },
},
]),
).toBeNull();
});
it("rejects a repair line without a price", () => {
expect(
getExpertReplyPricingValidationError([
{
partId: 201,
typeOfDamage: TypeOfDamage.Repair,
salary: "0",
totalPayment: "0",
daghi: { option: DaghiOption.NO_VALUE },
},
]),
).toMatch(/price is also required/);
});
it("rejects recycled-value daghi without its price", () => {
expect(
getExpertReplyPricingValidationError([
{
partId: 201,
typeOfDamage: TypeOfDamage.Change,
salary: "0",
totalPayment: "0",
daghi: { option: DaghiOption.RECYCLED_PARTS_VALUE },
},
]),
).toMatch(/requires a valid daghi price/);
});
it("accepts a replacement line without a part price", () => {
expect(
getExpertReplyPricingValidationError([
{
partId: 201,
typeOfDamage: TypeOfDamage.Change,
salary: "0",
totalPayment: "0",
daghi: { option: DaghiOption.NO_VALUE },
},
]),
).toBeNull();
});
it("rejects an invalid price when a replacement line supplies one", () => {
expect(
getExpertReplyPricingValidationError([
{
partId: 201,
typeOfDamage: TypeOfDamage.Change,
price: "not-a-number",
salary: "0",
totalPayment: "0",
daghi: { option: DaghiOption.NO_VALUE },
},
]),
).toMatch(/requires valid salary and totalPayment/);
});
});

View File

@@ -0,0 +1,93 @@
import { REPAIR_LINE_AMOUNT_TOMAN } from "src/constants/repair-amount-limits";
import { DaghiOption } from "src/Types&Enums/claim-request-management/daghi-option.enum";
import { TypeOfDamage } from "src/Types&Enums/claim-request-management/type-of-damage.enum";
import { parseMoneyAmountToman } from "src/utils/unicode-digits";
type ExpertReplyPricingPart = {
partId?: unknown;
typeOfDamage?: unknown;
price?: unknown;
salary?: unknown;
totalPayment?: unknown;
daghi?: { option?: unknown; price?: unknown };
};
/**
* Validates the pricing that an expert must provide for every submitted
* damaged-part line. This is deliberately independent of DTO validation:
* legacy endpoints and deployments without a global ValidationPipe call the
* service directly.
*/
export function getExpertReplyPricingValidationError(
parts: unknown,
): string | null {
if (!Array.isArray(parts) || parts.length === 0) {
return "At least one damaged part with pricing is required to submit an expert reply.";
}
for (const [index, rawPart] of parts.entries()) {
const part = rawPart as ExpertReplyPricingPart | null;
const label =
part && part.partId != null && String(part.partId).trim() !== ""
? `Part ${String(part.partId)}`
: `Damaged part ${index + 1}`;
if (!part || typeof part !== "object" || part.partId == null) {
return `${label} must include a damaged-part partId.`;
}
if (
!Object.values(TypeOfDamage).includes(part.typeOfDamage as TypeOfDamage)
) {
return `${label} requires typeOfDamage to be either '${TypeOfDamage.Repair}' or '${TypeOfDamage.Change}'.`;
}
if (
!part.daghi ||
!Object.values(DaghiOption).includes(part.daghi.option as DaghiOption)
) {
return `${label} requires a valid daghi option.`;
}
const price = parseMoneyAmountToman(part.price);
const salary = parseMoneyAmountToman(part.salary);
const totalPayment = parseMoneyAmountToman(part.totalPayment);
const daghiPrice = parseMoneyAmountToman(part.daghi.price);
const hasPrice =
part.price != null &&
(typeof part.price !== "string" || part.price.trim() !== "");
const splitAmountIsValid = (amount: number | null) =>
amount !== null &&
(amount === 0 ||
(amount >= REPAIR_LINE_AMOUNT_TOMAN.MIN &&
amount <= REPAIR_LINE_AMOUNT_TOMAN.MAX));
const totalIsValid =
totalPayment !== null &&
totalPayment >= 0 &&
totalPayment <= REPAIR_LINE_AMOUNT_TOMAN.MAX;
const priceIsRequired = part.typeOfDamage === TypeOfDamage.Repair;
const priceIsValid =
!hasPrice || (price !== null && splitAmountIsValid(price));
if (
!splitAmountIsValid(salary) ||
!totalIsValid ||
(priceIsRequired && !splitAmountIsValid(price)) ||
!priceIsValid
) {
return `${label} requires valid salary and totalPayment; price is also required for '${TypeOfDamage.Repair}' damage. Price and salary may be 0; totalPayment may be 0.`;
}
if (
part.daghi.option === DaghiOption.RECYCLED_PARTS_VALUE &&
(daghiPrice === null ||
daghiPrice < REPAIR_LINE_AMOUNT_TOMAN.MIN ||
daghiPrice > REPAIR_LINE_AMOUNT_TOMAN.MAX)
) {
return `${label} requires a valid daghi price when option is '${DaghiOption.RECYCLED_PARTS_VALUE}'.`;
}
}
return null;
}