forked from Yara724/api
Merge pull request 'Validation for prices and objection' (#76) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#76
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import { join } from "node:path";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { Module, ValidationPipe } from "@nestjs/common";
|
||||
import { APP_INTERCEPTOR, APP_PIPE } from "@nestjs/core";
|
||||
import { UnicodeDigitsNormalizeInterceptor } from "./common/interceptors/unicode-digits-normalize.interceptor";
|
||||
import { MongooseModule } from "@nestjs/mongoose";
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { ServeStaticModule } from "@nestjs/serve-static";
|
||||
@@ -70,6 +72,19 @@ dotenv.config({ path: `.${process.env.NODE_ENV}.env` });
|
||||
WorkflowStepManagementModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: UnicodeDigitsNormalizeInterceptor,
|
||||
},
|
||||
{
|
||||
provide: APP_PIPE,
|
||||
useValue: new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: false,
|
||||
}),
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -116,9 +116,11 @@ import {
|
||||
type DamageSelectedPartV2,
|
||||
migrateExpertReplyCarPartDamageToUnified,
|
||||
normalizeDamageSelectedParts,
|
||||
partIdentityKey,
|
||||
parseCatalogPartIdInput,
|
||||
partLookupKey,
|
||||
resolvePartCaptureIndex,
|
||||
} from "src/helpers/outer-damage-parts";
|
||||
import { normalizeMoneyAmountString } from "src/utils/unicode-digits";
|
||||
|
||||
import {
|
||||
CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS,
|
||||
@@ -5544,19 +5546,20 @@ export class ClaimRequestManagementService {
|
||||
);
|
||||
}
|
||||
|
||||
const pricedPartIdSet =
|
||||
const pricedCatalogIds =
|
||||
activeExpert?.parts?.length ?
|
||||
new Set(
|
||||
activeExpert.parts
|
||||
.filter((p) => p.factorNeeded !== true)
|
||||
.map((p) => String((p as { partId?: string }).partId ?? ""))
|
||||
.filter(Boolean),
|
||||
.map((p) => parseCatalogPartIdInput(p.partId))
|
||||
.filter((id): id is number => id != null),
|
||||
)
|
||||
: new Set<string>();
|
||||
: new Set<number>();
|
||||
|
||||
if (partsIn.length && pricingObjectionEligible) {
|
||||
for (const op of partsIn) {
|
||||
if (!pricedPartIdSet.has(String(op.partId))) {
|
||||
const catalogId = parseCatalogPartIdInput(op.partId);
|
||||
if (catalogId == null || !pricedCatalogIds.has(catalogId)) {
|
||||
throw new BadRequestException(
|
||||
`Only priced repair lines can be disputed (${String(op.partId)} is factor-only or unknown).`,
|
||||
);
|
||||
@@ -5565,19 +5568,28 @@ export class ClaimRequestManagementService {
|
||||
}
|
||||
|
||||
const objectionParts = partsIn.map((p) => ({
|
||||
partId: p.partId,
|
||||
reason: p.reason,
|
||||
partPrice: p.partPrice,
|
||||
partSalary: p.partSalary,
|
||||
partId: parseCatalogPartIdInput(p.partId)!,
|
||||
reason: p.reason?.trim() || undefined,
|
||||
partPrice:
|
||||
p.partPrice != null && String(p.partPrice).trim() !== ""
|
||||
? normalizeMoneyAmountString(String(p.partPrice))
|
||||
: undefined,
|
||||
partSalary:
|
||||
p.partSalary != null && String(p.partSalary).trim() !== ""
|
||||
? normalizeMoneyAmountString(String(p.partSalary))
|
||||
: undefined,
|
||||
typeOfDamage: p.typeOfDamage != null ? String(p.typeOfDamage) : undefined,
|
||||
carPartDamage: p.carPartDamage,
|
||||
side: p.side,
|
||||
carPartDamage: p.carPartDamage?.trim() || undefined,
|
||||
side: p.side?.trim() || undefined,
|
||||
}));
|
||||
|
||||
const newParts = newPartsIn.map((p) => ({
|
||||
partId: p.partId ? String(p.partId) : new Types.ObjectId().toString(),
|
||||
partName: p.partName,
|
||||
side: p.side,
|
||||
partId:
|
||||
p.partId != null && parseCatalogPartIdInput(p.partId) != null
|
||||
? parseCatalogPartIdInput(p.partId)!
|
||||
: new Types.ObjectId().toString(),
|
||||
partName: p.partName.trim(),
|
||||
side: p.side?.trim() || undefined,
|
||||
}));
|
||||
|
||||
const objectionCarType = claimCase.vehicle?.carType as
|
||||
@@ -5588,7 +5600,7 @@ export class ClaimRequestManagementService {
|
||||
objectionCarType,
|
||||
(claimCase.damage as any)?.selectedOuterParts,
|
||||
);
|
||||
const mergedKeys = new Set(mergedNorm.map((p) => partIdentityKey(p)));
|
||||
const mergedKeys = new Set(mergedNorm.map((p) => partLookupKey(p)));
|
||||
for (const np of newParts) {
|
||||
const name = np.partName?.trim();
|
||||
if (!name) continue;
|
||||
@@ -5599,7 +5611,7 @@ export class ClaimRequestManagementService {
|
||||
side,
|
||||
label_fa: name,
|
||||
};
|
||||
const k = partIdentityKey(row);
|
||||
const k = partLookupKey(row);
|
||||
if (!mergedKeys.has(k)) {
|
||||
mergedNorm.push(row);
|
||||
mergedKeys.add(k);
|
||||
@@ -6112,7 +6124,7 @@ export class ClaimRequestManagementService {
|
||||
);
|
||||
const displayParts = [...selectedNormDetails];
|
||||
const seenDetailKeys = new Set(
|
||||
selectedNormDetails.map((p) => partIdentityKey(p)),
|
||||
selectedNormDetails.map((p) => partLookupKey(p)),
|
||||
);
|
||||
for (const rk of resendPartKeys) {
|
||||
const extra =
|
||||
@@ -6124,7 +6136,7 @@ export class ClaimRequestManagementService {
|
||||
label_fa: rk,
|
||||
catalogKey: rk,
|
||||
};
|
||||
const k = partIdentityKey(extra);
|
||||
const k = partLookupKey(extra);
|
||||
if (!seenDetailKeys.has(k)) {
|
||||
displayParts.push(extra);
|
||||
seenDetailKeys.add(k);
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsArray, IsOptional, ValidateNested } from "class-validator";
|
||||
import {
|
||||
IsArray,
|
||||
IsOptional,
|
||||
Validate,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { HasObjectionEntriesConstraint } from "src/common/validators/has-objection-entries.validator";
|
||||
import { NewPartDto, UserObjectionPartDto } from "./user-objection.dto";
|
||||
|
||||
/**
|
||||
* V2 user objection body — same shape as v1 {@link import("./user-objection.dto").UserObjectionDto}
|
||||
* V2 user objection body — same shape as v1 {@link UserObjectionDto}
|
||||
* with nested validation enabled for the v2 controller pipeline.
|
||||
*/
|
||||
export class UserObjectionV2Dto {
|
||||
@ApiPropertyOptional({ type: [UserObjectionPartDto] })
|
||||
@Validate(HasObjectionEntriesConstraint)
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@@ -16,6 +23,7 @@ export class UserObjectionV2Dto {
|
||||
objectionParts?: UserObjectionPartDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: [NewPartDto] })
|
||||
@Validate(HasObjectionEntriesConstraint)
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
|
||||
@@ -1,47 +1,113 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
Validate,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { HasObjectionEntriesConstraint } from "src/common/validators/has-objection-entries.validator";
|
||||
import { IsRepairLineAmountToman } from "src/common/validators/repair-line-amount-toman.validator";
|
||||
import { ObjectionPartHasContentConstraint } from "src/common/validators/objection-part-content.validator";
|
||||
import { TypeOfDamage } from "src/Types&Enums/claim-request-management/type-of-damage.enum";
|
||||
|
||||
export class UserObjectionPartDto {
|
||||
@ApiProperty()
|
||||
partId: string;
|
||||
@ApiProperty({
|
||||
example: 201,
|
||||
description: "Numeric catalog part id of the priced line being disputed.",
|
||||
})
|
||||
@Validate(ObjectionPartHasContentConstraint)
|
||||
@IsInt()
|
||||
partId: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Why the owner disagrees (min 3 chars when provided). Required unless partPrice or partSalary is sent.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
reason?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@ApiPropertyOptional({
|
||||
description: "Owner-proposed part price (Toman, integer string).",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
@IsRepairLineAmountToman()
|
||||
partPrice?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@ApiPropertyOptional({
|
||||
description: "Owner-proposed salary / labor (Toman, integer string).",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
@IsRepairLineAmountToman()
|
||||
partSalary?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@ApiPropertyOptional({ enum: TypeOfDamage })
|
||||
@IsOptional()
|
||||
@IsEnum(TypeOfDamage)
|
||||
typeOfDamage?: TypeOfDamage;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
carPartDamage?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
side?: string;
|
||||
}
|
||||
|
||||
export class NewPartDto {
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
partId: string | null;
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: "Optional catalog id when the new part exists in the outer catalog.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
partId?: number | null;
|
||||
|
||||
@ApiProperty()
|
||||
@ApiProperty({ example: "سپر جلو" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
partName: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@ApiPropertyOptional({ example: "front" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
side?: string;
|
||||
}
|
||||
|
||||
export class UserObjectionDto {
|
||||
@ApiProperty({ type: [UserObjectionPartDto], required: false })
|
||||
@ApiPropertyOptional({ type: [UserObjectionPartDto] })
|
||||
@Validate(HasObjectionEntriesConstraint)
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => UserObjectionPartDto)
|
||||
objectionParts?: UserObjectionPartDto[];
|
||||
|
||||
@ApiProperty({ type: [NewPartDto], required: false })
|
||||
@ApiPropertyOptional({ type: [NewPartDto] })
|
||||
@Validate(HasObjectionEntriesConstraint)
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => NewPartDto)
|
||||
newParts?: NewPartDto[];
|
||||
}
|
||||
|
||||
@@ -141,8 +141,8 @@ export const ClaimExpertReplySchema =
|
||||
/** One line item the user disputes on an expert-priced part */
|
||||
@Schema({ _id: false })
|
||||
export class ClaimUserObjectionPart {
|
||||
@Prop({ type: String, required: true })
|
||||
partId: string;
|
||||
@Prop({ type: Number, required: true })
|
||||
partId: number;
|
||||
|
||||
@Prop({ type: String })
|
||||
reason?: string;
|
||||
@@ -167,8 +167,9 @@ export const ClaimUserObjectionPartSchema =
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class ClaimUserObjectionNewPart {
|
||||
@Prop({ type: String })
|
||||
partId?: string;
|
||||
/** Catalog id when known; otherwise a generated string id for the new line. */
|
||||
@Prop({ type: MongooseSchema.Types.Mixed })
|
||||
partId?: number | string;
|
||||
|
||||
@Prop({ type: String, required: true })
|
||||
partName: string;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from "@nestjs/common";
|
||||
import { Observable } from "rxjs";
|
||||
import { normalizeUnicodeDigitsDeep } from "src/utils/unicode-digits";
|
||||
|
||||
/**
|
||||
* Normalizes Unicode decimal digits in JSON request bodies to ASCII before
|
||||
* validation and handlers run (Persian/Arabic-Indic → 0-9).
|
||||
*/
|
||||
@Injectable()
|
||||
export class UnicodeDigitsNormalizeInterceptor implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const contentType = String(req.headers?.["content-type"] ?? "");
|
||||
|
||||
if (
|
||||
req.body &&
|
||||
typeof req.body === "object" &&
|
||||
!Buffer.isBuffer(req.body) &&
|
||||
!contentType.includes("multipart/form-data")
|
||||
) {
|
||||
req.body = normalizeUnicodeDigitsDeep(req.body);
|
||||
}
|
||||
|
||||
return next.handle();
|
||||
}
|
||||
}
|
||||
24
src/common/validators/has-objection-entries.validator.ts
Normal file
24
src/common/validators/has-objection-entries.validator.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
ValidationArguments,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
} from "class-validator";
|
||||
|
||||
@ValidatorConstraint({ name: "hasObjectionEntries", async: false })
|
||||
export class HasObjectionEntriesConstraint
|
||||
implements ValidatorConstraintInterface
|
||||
{
|
||||
validate(_value: unknown, args: ValidationArguments): boolean {
|
||||
const o = args.object as {
|
||||
objectionParts?: unknown[];
|
||||
newParts?: unknown[];
|
||||
};
|
||||
const partsLen = Array.isArray(o.objectionParts) ? o.objectionParts.length : 0;
|
||||
const newLen = Array.isArray(o.newParts) ? o.newParts.length : 0;
|
||||
return partsLen > 0 || newLen > 0;
|
||||
}
|
||||
|
||||
defaultMessage(): string {
|
||||
return "Provide at least one entry in objectionParts and/or newParts.";
|
||||
}
|
||||
}
|
||||
37
src/common/validators/money-amount-string.validator.ts
Normal file
37
src/common/validators/money-amount-string.validator.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
registerDecorator,
|
||||
ValidationArguments,
|
||||
ValidationOptions,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
} from "class-validator";
|
||||
import { normalizeMoneyAmountString } from "src/utils/unicode-digits";
|
||||
|
||||
@ValidatorConstraint({ name: "isMoneyAmountString", async: false })
|
||||
export class IsMoneyAmountStringConstraint
|
||||
implements ValidatorConstraintInterface
|
||||
{
|
||||
validate(value: unknown): boolean {
|
||||
if (value == null || value === "") return true;
|
||||
if (typeof value !== "string") return false;
|
||||
const n = normalizeMoneyAmountString(value);
|
||||
if (!n) return false;
|
||||
return /^\d+(\.\d+)?$/.test(n);
|
||||
}
|
||||
|
||||
defaultMessage(): string {
|
||||
return "Must be a non-negative amount (digits only, optional decimal).";
|
||||
}
|
||||
}
|
||||
|
||||
export function IsMoneyAmountString(validationOptions?: ValidationOptions) {
|
||||
return (object: object, propertyName: string) => {
|
||||
registerDecorator({
|
||||
target: object.constructor,
|
||||
propertyName,
|
||||
options: validationOptions,
|
||||
constraints: [],
|
||||
validator: IsMoneyAmountStringConstraint,
|
||||
});
|
||||
};
|
||||
}
|
||||
29
src/common/validators/objection-part-content.validator.ts
Normal file
29
src/common/validators/objection-part-content.validator.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
ValidationArguments,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
} from "class-validator";
|
||||
|
||||
@ValidatorConstraint({ name: "objectionPartHasContent", async: false })
|
||||
export class ObjectionPartHasContentConstraint
|
||||
implements ValidatorConstraintInterface
|
||||
{
|
||||
validate(_value: unknown, args: ValidationArguments): boolean {
|
||||
const o = args.object as {
|
||||
reason?: string;
|
||||
partPrice?: string;
|
||||
partSalary?: string;
|
||||
};
|
||||
const hasReason =
|
||||
typeof o.reason === "string" && o.reason.trim().length >= 3;
|
||||
const hasPrice =
|
||||
o.partPrice != null && String(o.partPrice).trim() !== "";
|
||||
const hasSalary =
|
||||
o.partSalary != null && String(o.partSalary).trim() !== "";
|
||||
return hasReason || hasPrice || hasSalary;
|
||||
}
|
||||
|
||||
defaultMessage(): string {
|
||||
return "Each objection line needs a reason (min 3 characters) and/or proposed partPrice / partSalary.";
|
||||
}
|
||||
}
|
||||
66
src/common/validators/repair-line-amount-toman.validator.ts
Normal file
66
src/common/validators/repair-line-amount-toman.validator.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
registerDecorator,
|
||||
ValidationArguments,
|
||||
ValidationOptions,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
} from "class-validator";
|
||||
import {
|
||||
REPAIR_LINE_AMOUNT_TOMAN,
|
||||
} from "src/constants/repair-amount-limits";
|
||||
import { parseMoneyAmountToman } from "src/utils/unicode-digits";
|
||||
|
||||
export type RepairLineAmountTomanOptions = {
|
||||
/** When true, `0` is valid (e.g. expert `price` / `salary` split where one side is zero). */
|
||||
allowZero?: boolean;
|
||||
};
|
||||
|
||||
@ValidatorConstraint({ name: "isRepairLineAmountToman", async: false })
|
||||
export class IsRepairLineAmountTomanConstraint
|
||||
implements ValidatorConstraintInterface
|
||||
{
|
||||
validate(value: unknown, args: ValidationArguments): boolean {
|
||||
const [minToman, maxToman, allowZero] = args.constraints as [
|
||||
number,
|
||||
number,
|
||||
boolean | undefined,
|
||||
];
|
||||
if (value == null || value === "") return true;
|
||||
const amount = parseMoneyAmountToman(value);
|
||||
if (amount == null) return false;
|
||||
if (allowZero && amount === 0) return true;
|
||||
return amount >= minToman && amount <= maxToman;
|
||||
}
|
||||
|
||||
defaultMessage(args: ValidationArguments): string {
|
||||
const [minToman, maxToman, allowZero] = args.constraints as [
|
||||
number,
|
||||
number,
|
||||
boolean | undefined,
|
||||
];
|
||||
const zeroHint = allowZero ? " Use 0 where one side of price/salary is unused." : "";
|
||||
return (
|
||||
`Amount must be in Toman between ${minToman.toLocaleString("en-US")} and ${maxToman.toLocaleString("en-US")}.${zeroHint}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Optional repair-line amount in Toman (digits only after unicode normalize). */
|
||||
export function IsRepairLineAmountToman(
|
||||
options?: ValidationOptions & RepairLineAmountTomanOptions,
|
||||
) {
|
||||
const { allowZero, ...validationOptions } = options ?? {};
|
||||
return (object: object, propertyName: string) => {
|
||||
registerDecorator({
|
||||
target: object.constructor,
|
||||
propertyName,
|
||||
options: validationOptions,
|
||||
constraints: [
|
||||
REPAIR_LINE_AMOUNT_TOMAN.MIN,
|
||||
REPAIR_LINE_AMOUNT_TOMAN.MAX,
|
||||
allowZero === true,
|
||||
],
|
||||
validator: IsRepairLineAmountTomanConstraint,
|
||||
});
|
||||
};
|
||||
}
|
||||
12
src/constants/repair-amount-limits.ts
Normal file
12
src/constants/repair-amount-limits.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Per-line and total caps for repair money. All values are **Toman** (no unit conversion in the API).
|
||||
*/
|
||||
export const REPAIR_LINE_AMOUNT_TOMAN = {
|
||||
/** Below this is not credible for a priced repair line (e.g. 1,000 Toman). */
|
||||
MIN: 10_000,
|
||||
/** Aligns with the total assessment cap; rejects absurd values (e.g. 100bn). */
|
||||
MAX: 53_000_000,
|
||||
} as const;
|
||||
|
||||
/** Max sum of all priced + factor lines in one expert reply / validation (Toman). */
|
||||
export const CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN = REPAIR_LINE_AMOUNT_TOMAN.MAX;
|
||||
@@ -44,7 +44,7 @@ export class UpsertClaimPriceDropV2Dto {
|
||||
|
||||
@ApiProperty({
|
||||
example: 450000000,
|
||||
description: "Market price of the car (Rials)",
|
||||
description: "Market price of the car (Toman)",
|
||||
})
|
||||
carPrice: number | string;
|
||||
|
||||
|
||||
@@ -10,11 +10,10 @@ import {
|
||||
IsInt,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsRepairLineAmountToman } from 'src/common/validators/repair-line-amount-toman.validator';
|
||||
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';
|
||||
|
||||
/** Same shape as V1 {@link PartsList} `daghi` (damage expert reply). */
|
||||
export class DaghiDetailsV2Dto {
|
||||
@ApiProperty({
|
||||
enum: DaghiOption,
|
||||
@@ -25,10 +24,11 @@ export class DaghiDetailsV2Dto {
|
||||
option: DaghiOption;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: `Required when option is '${DaghiOption.RECYCLED_PARTS_VALUE}'`,
|
||||
description: `Required when option is '${DaghiOption.RECYCLED_PARTS_VALUE}' (Toman)`,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsRepairLineAmountToman()
|
||||
price?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
@@ -51,16 +51,28 @@ export class PartPricingV2Dto {
|
||||
@IsString()
|
||||
typeOfDamage: string;
|
||||
|
||||
@ApiProperty({ example: '5000000' })
|
||||
@ApiProperty({
|
||||
example: "5000000",
|
||||
description: "Part price in Toman (integer string). Use 0 if the full amount is in salary.",
|
||||
})
|
||||
@IsString()
|
||||
@IsRepairLineAmountToman({ allowZero: true })
|
||||
price: string;
|
||||
|
||||
@ApiProperty({ example: '2000000' })
|
||||
@ApiProperty({
|
||||
example: "2000000",
|
||||
description: "Labor in Toman (integer string). Use 0 if the full amount is in price.",
|
||||
})
|
||||
@IsString()
|
||||
@IsRepairLineAmountToman({ allowZero: true })
|
||||
salary: string;
|
||||
|
||||
@ApiProperty({ example: '7000000' })
|
||||
@ApiProperty({
|
||||
example: "7000000",
|
||||
description: "Line total in Toman (integer string).",
|
||||
})
|
||||
@IsString()
|
||||
@IsRepairLineAmountToman()
|
||||
totalPayment: string;
|
||||
|
||||
@ApiProperty({ type: DaghiDetailsV2Dto })
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { IsRepairLineAmountToman } from "src/common/validators/repair-line-amount-toman.validator";
|
||||
import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum";
|
||||
|
||||
class FactorDecisionDto {
|
||||
@@ -50,26 +51,29 @@ class FactorDecisionV2Dto {
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Part price (Persian/ASCII digits). For each factor line, send totalPayment **or** both price and salary. Required for **APPROVED** (accepted amount read from factor / expert) and **REJECTED** (expert’s replacement pricing).",
|
||||
"Part price (Toman). With totalPayment or price+salary for APPROVED/REJECTED factor lines. Use 0 if unused.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsRepairLineAmountToman({ allowZero: true })
|
||||
price?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Salary portion when not using a single totalPayment (must be sent together with price for APPROVED/REJECTED factor lines).",
|
||||
"Salary in Toman. Send with price when not using only totalPayment. Use 0 if unused.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsRepairLineAmountToman({ allowZero: true })
|
||||
salary?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Line total payment; preferred when the expert enters one number. Required together with APPROVED/REJECTED unless both price and salary are sent.",
|
||||
"Line total in Toman; or send both price and salary.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsRepairLineAmountToman()
|
||||
totalPayment?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -114,8 +114,10 @@ import {
|
||||
ExpertFileKind,
|
||||
} from "src/users/entities/schema/expert-file-activity.schema";
|
||||
|
||||
/** Maximum sum of line `totalPayment` across the claim (priced parts + factor lines after validation). */
|
||||
const CLAIM_V2_TOTAL_PAYMENT_CAP = 53_000_000;
|
||||
/** Maximum sum of line `totalPayment` across the claim (Toman; priced parts + factor lines after validation). */
|
||||
import { CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN } from "src/constants/repair-amount-limits";
|
||||
|
||||
const CLAIM_V2_TOTAL_PAYMENT_CAP = CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN;
|
||||
|
||||
@Injectable()
|
||||
export class ExpertClaimService {
|
||||
@@ -1523,7 +1525,7 @@ export class ExpertClaimService {
|
||||
|
||||
if (totalPrice > PRICE_CAP) {
|
||||
throw new BadRequestException({
|
||||
message: `You have reached the maximum acceptable total price. The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${PRICE_CAP.toLocaleString()}).`,
|
||||
message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${PRICE_CAP.toLocaleString()}).`,
|
||||
error: "PRICE_CAP_ERROR",
|
||||
code: "PRICE_CAP_ERROR",
|
||||
totalPrice: totalPrice,
|
||||
@@ -2089,7 +2091,7 @@ export class ExpertClaimService {
|
||||
* Preconditions: all `factorNeeded` parts have `factorLink`; case is UNDER_REVIEW at EXPERT_COST_EVALUATION.
|
||||
* — All approved → COMPLETED + APPROVED (expert-entered line totals; no extra owner signature).
|
||||
* — Any rejected (repriced) → COMPLETED + APPROVED (auto-close for now; owner sign may be added later).
|
||||
* Total of all repair lines must be ≤ `CLAIM_V2_TOTAL_PAYMENT_CAP` (53_000_000; same as initial expert reply).
|
||||
* Total of all repair lines must be ≤ `CLAIM_V2_TOTAL_PAYMENT_CAP` Toman (53_000_000; same as initial expert reply).
|
||||
* Response: `claimStatus` = `ClaimStatus`; `caseStatus` = `ClaimCaseStatus`.
|
||||
*/
|
||||
async validateClaimFactorsV2(
|
||||
@@ -2238,7 +2240,7 @@ export class ExpertClaimService {
|
||||
}
|
||||
if (totalPrice > PRICE_CAP) {
|
||||
throw new BadRequestException({
|
||||
message: `You have reached the maximum acceptable total price. The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${PRICE_CAP.toLocaleString()}).`,
|
||||
message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${PRICE_CAP.toLocaleString()}).`,
|
||||
error: "PRICE_CAP_ERROR",
|
||||
totalPrice,
|
||||
priceCap: PRICE_CAP,
|
||||
@@ -2700,7 +2702,7 @@ export class ExpertClaimService {
|
||||
}
|
||||
if (totalPrice > PRICE_CAP) {
|
||||
throw new BadRequestException({
|
||||
message: `You have reached the maximum acceptable total price. The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${PRICE_CAP.toLocaleString()}).`,
|
||||
message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${PRICE_CAP.toLocaleString()}).`,
|
||||
error: 'PRICE_CAP_ERROR',
|
||||
totalPrice,
|
||||
priceCap: PRICE_CAP,
|
||||
|
||||
@@ -176,7 +176,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 (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`. **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" +
|
||||
@@ -239,7 +239,7 @@ export class ExpertClaimV2Controller {
|
||||
"**Response:** `claimStatus` = `ClaimStatus` (e.g. APPROVED). `caseStatus` = `ClaimCaseStatus` (e.g. COMPLETED vs insurer-review) — they are not interchangeable.\n\n" +
|
||||
"**Preconditions:** `status=EXPERT_VALIDATING_REPAIR_FACTORS` (or legacy `WAITING_FOR_INSURER_APPROVAL`), `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_COST_EVALUATION`, every `factorNeeded` line has `factorLink`.\n\n" +
|
||||
"**Decisions:** each factor line gets `APPROVED` or `REJECTED`. **Every** decided line must include expert-entered `totalPayment` **or** both `price` and `salary` (factor photos are not read for amounts).\n\n" +
|
||||
"**Cap (when every factor line is decided):** sum of **all** reply lines (priced parts + factor lines) must be ≤ **53,000,000**; otherwise `PRICE_CAP_ERROR` with message that the maximum acceptable total was exceeded.\n\n" +
|
||||
"**Cap (when every factor line is decided):** sum of **all** reply lines (priced parts + factor lines) must be ≤ **53,000,000 Toman**; otherwise `PRICE_CAP_ERROR` with message that the maximum acceptable total was exceeded.\n\n" +
|
||||
"**Outcomes:**\n" +
|
||||
"- **All approved:** `caseStatus=COMPLETED`, `claimStatus=APPROVED`, workflow `CLAIM_COMPLETED` — no owner signature.\n" +
|
||||
"- **Any rejected (repriced):** same auto-complete for now (owner acceptance may be added later).\n" +
|
||||
|
||||
34
src/utils/unicode-digits.spec.ts
Normal file
34
src/utils/unicode-digits.spec.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
normalizeMoneyAmountString,
|
||||
normalizeUnicodeDigitsDeep,
|
||||
normalizeUnicodeDigitsToEnglish,
|
||||
parseMoneyAmountToman,
|
||||
} from "./unicode-digits";
|
||||
|
||||
describe("unicode-digits", () => {
|
||||
it("converts Persian digits to ASCII", () => {
|
||||
expect(normalizeUnicodeDigitsToEnglish("۱۲۳۴۵۶۷۸۹۰")).toBe("1234567890");
|
||||
});
|
||||
|
||||
it("normalizes nested request bodies", () => {
|
||||
expect(
|
||||
normalizeUnicodeDigitsDeep({
|
||||
partPrice: "۵۰۰۰۰۰۰",
|
||||
nested: [{ total: "۱۰۰" }],
|
||||
}),
|
||||
).toEqual({
|
||||
partPrice: "5000000",
|
||||
nested: [{ total: "100" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("strips grouping separators for money strings", () => {
|
||||
expect(normalizeMoneyAmountString("۵,۰۰۰,۰۰۰")).toBe("5000000");
|
||||
});
|
||||
|
||||
it("parses integer Toman amount strings", () => {
|
||||
expect(parseMoneyAmountToman("۵۳۰۰۰۰۰")).toBe(5300000);
|
||||
expect(parseMoneyAmountToman("1000")).toBe(1000);
|
||||
expect(parseMoneyAmountToman("12.5")).toBeNull();
|
||||
});
|
||||
});
|
||||
54
src/utils/unicode-digits.ts
Normal file
54
src/utils/unicode-digits.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
function unicodeCharToAsciiDigit(ch: string): string {
|
||||
if (ch >= "0" && ch <= "9") return ch;
|
||||
const cp = ch.codePointAt(0)!;
|
||||
if (cp >= 0x06f0 && cp <= 0x06f9) return String(cp - 0x06f0);
|
||||
if (cp >= 0x0660 && cp <= 0x0669) return String(cp - 0x0660);
|
||||
if (cp >= 0x0966 && cp <= 0x096f) return String(cp - 0x0966);
|
||||
if (cp >= 0xff10 && cp <= 0xff19) return String(cp - 0xff10);
|
||||
const folded = ch.normalize("NFKC");
|
||||
if (folded.length === 1 && folded >= "0" && folded <= "9") return folded;
|
||||
return ch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert decimal digits in any Unicode script (Persian, Arabic-Indic, etc.)
|
||||
* to ASCII `0-9`. Other characters are left unchanged.
|
||||
*/
|
||||
export function normalizeUnicodeDigitsToEnglish(input: string): string {
|
||||
return Array.from(input).map(unicodeCharToAsciiDigit).join("");
|
||||
}
|
||||
|
||||
/** Strip grouping separators after digit normalization (for money fields). */
|
||||
export function normalizeMoneyAmountString(input: string): string {
|
||||
return normalizeUnicodeDigitsToEnglish(input)
|
||||
.trim()
|
||||
.replace(/[,،\u066C\u060C]/g, "");
|
||||
}
|
||||
|
||||
/** Parse a money string to a whole **Toman** amount, or `null` if invalid. */
|
||||
export function parseMoneyAmountToman(value: unknown): number | null {
|
||||
if (value == null || value === "") return null;
|
||||
const n = normalizeMoneyAmountString(String(value));
|
||||
if (!/^\d+$/.test(n)) return null;
|
||||
const num = Number(n);
|
||||
if (!Number.isFinite(num)) return null;
|
||||
return num;
|
||||
}
|
||||
|
||||
export function normalizeUnicodeDigitsDeep<T>(value: T): T {
|
||||
if (value == null) return value;
|
||||
if (typeof value === "string") {
|
||||
return normalizeUnicodeDigitsToEnglish(value) as T;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => normalizeUnicodeDigitsDeep(item)) as T;
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[k] = normalizeUnicodeDigitsDeep(v);
|
||||
}
|
||||
return out as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
Reference in New Issue
Block a user