forked from Yara724/api
Compare commits
5 Commits
5c2b660600
...
eb072ef080
| Author | SHA1 | Date | |
|---|---|---|---|
| eb072ef080 | |||
|
|
e48dffef89 | ||
| 4f7ee9a0de | |||
|
|
feacad58ca | ||
| 70fa49398d |
@@ -1,4 +1,4 @@
|
||||
export enum TypeOfDamage {
|
||||
Repair = "repair",
|
||||
Change = "change",
|
||||
Repair = "تعمیر",
|
||||
Change = "تعویض",
|
||||
}
|
||||
|
||||
@@ -25,7 +25,12 @@ export class IsRepairLineAmountTomanConstraint
|
||||
number,
|
||||
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);
|
||||
if (amount == null) return false;
|
||||
if (allowZero && amount === 0) return true;
|
||||
|
||||
46
src/expert-claim/dto/expert-claim-v2.dto.spec.ts
Normal file
46
src/expert-claim/dto/expert-claim-v2.dto.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,10 @@ import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsArray,
|
||||
ArrayMinSize,
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
IsEnum,
|
||||
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 { DamagedPartItem } from 'src/claim-request-management/dto/capture-requirements-v2.dto';
|
||||
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 {
|
||||
@ApiProperty({
|
||||
enum: DaghiOption,
|
||||
@@ -26,8 +29,12 @@ export class DaghiDetailsV2Dto {
|
||||
@ApiPropertyOptional({
|
||||
description: `Required when option is '${DaghiOption.RECYCLED_PARTS_VALUE}' (Toman)`,
|
||||
})
|
||||
@IsOptional()
|
||||
@ValidateIf(
|
||||
(daghi: DaghiDetailsV2Dto) =>
|
||||
daghi.option === DaghiOption.RECYCLED_PARTS_VALUE,
|
||||
)
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsRepairLineAmountToman()
|
||||
price?: string;
|
||||
|
||||
@@ -47,15 +54,25 @@ export class PartPricingV2Dto {
|
||||
@IsInt()
|
||||
partId: number;
|
||||
|
||||
@ApiProperty({ example: 'Minor' })
|
||||
@IsString()
|
||||
typeOfDamage: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: "5000000",
|
||||
description: "Part price in Toman (integer string). Use 0 if the full amount is in salary.",
|
||||
enum: TypeOfDamage,
|
||||
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()
|
||||
@IsNotEmpty()
|
||||
@IsRepairLineAmountToman({ allowZero: true })
|
||||
price: string;
|
||||
|
||||
@@ -64,6 +81,7 @@ export class PartPricingV2Dto {
|
||||
description: "Labor in Toman (integer string). Use 0 if the full amount is in price.",
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsRepairLineAmountToman({ allowZero: true })
|
||||
salary: string;
|
||||
|
||||
@@ -72,10 +90,12 @@ export class PartPricingV2Dto {
|
||||
description: "Line total in Toman (integer string).",
|
||||
})
|
||||
@IsString()
|
||||
@IsRepairLineAmountToman()
|
||||
@IsNotEmpty()
|
||||
@IsRepairLineAmountToman({ allowZero: true })
|
||||
totalPayment: string;
|
||||
|
||||
@ApiProperty({ type: DaghiDetailsV2Dto })
|
||||
@IsNotEmpty()
|
||||
@ValidateNested()
|
||||
@Type(() => 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`.",
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PartPricingV2Dto)
|
||||
parts: PartPricingV2Dto[];
|
||||
|
||||
73
src/expert-claim/expert-claim.service.spec.ts
Normal file
73
src/expert-claim/expert-claim.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -168,6 +168,7 @@ import {
|
||||
} from "src/helpers/unified-file-status";
|
||||
import { buildEnrichedDamagedParts } from "./dto/claim-damaged-part.enricher";
|
||||
import { canonicalizeResendDocumentKey } from "src/helpers/claim-resend-document-keys";
|
||||
import { getExpertReplyPricingValidationError } from "src/helpers/expert-reply-pricing";
|
||||
|
||||
@Injectable()
|
||||
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.
|
||||
const priceCap = getClaimV2TotalPaymentCapToman();
|
||||
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");
|
||||
}
|
||||
|
||||
const pricingValidationError = getExpertReplyPricingValidationError(
|
||||
reply.parts,
|
||||
);
|
||||
if (pricingValidationError) {
|
||||
throw new BadRequestException(pricingValidationError);
|
||||
}
|
||||
|
||||
// Price cap validation, when enabled.
|
||||
const priceCap = getClaimV2TotalPaymentCapToman();
|
||||
if (priceCap !== null) {
|
||||
|
||||
92
src/helpers/expert-reply-pricing.spec.ts
Normal file
92
src/helpers/expert-reply-pricing.spec.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
93
src/helpers/expert-reply-pricing.ts
Normal file
93
src/helpers/expert-reply-pricing.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user