1
0
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:
2026-05-20 11:09:25 +03:30
18 changed files with 467 additions and 60 deletions

View File

@@ -1,5 +1,7 @@
import { join } from "node:path"; 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 { MongooseModule } from "@nestjs/mongoose";
import { ScheduleModule } from "@nestjs/schedule"; import { ScheduleModule } from "@nestjs/schedule";
import { ServeStaticModule } from "@nestjs/serve-static"; import { ServeStaticModule } from "@nestjs/serve-static";
@@ -70,6 +72,19 @@ dotenv.config({ path: `.${process.env.NODE_ENV}.env` });
WorkflowStepManagementModule, WorkflowStepManagementModule,
], ],
controllers: [], controllers: [],
providers: [], providers: [
{
provide: APP_INTERCEPTOR,
useClass: UnicodeDigitsNormalizeInterceptor,
},
{
provide: APP_PIPE,
useValue: new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: false,
}),
},
],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -116,9 +116,11 @@ import {
type DamageSelectedPartV2, type DamageSelectedPartV2,
migrateExpertReplyCarPartDamageToUnified, migrateExpertReplyCarPartDamageToUnified,
normalizeDamageSelectedParts, normalizeDamageSelectedParts,
partIdentityKey, parseCatalogPartIdInput,
partLookupKey,
resolvePartCaptureIndex, resolvePartCaptureIndex,
} from "src/helpers/outer-damage-parts"; } from "src/helpers/outer-damage-parts";
import { normalizeMoneyAmountString } from "src/utils/unicode-digits";
import { import {
CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS, CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS,
@@ -5544,19 +5546,20 @@ export class ClaimRequestManagementService {
); );
} }
const pricedPartIdSet = const pricedCatalogIds =
activeExpert?.parts?.length ? activeExpert?.parts?.length ?
new Set( new Set(
activeExpert.parts activeExpert.parts
.filter((p) => p.factorNeeded !== true) .filter((p) => p.factorNeeded !== true)
.map((p) => String((p as { partId?: string }).partId ?? "")) .map((p) => parseCatalogPartIdInput(p.partId))
.filter(Boolean), .filter((id): id is number => id != null),
) )
: new Set<string>(); : new Set<number>();
if (partsIn.length && pricingObjectionEligible) { if (partsIn.length && pricingObjectionEligible) {
for (const op of partsIn) { 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( throw new BadRequestException(
`Only priced repair lines can be disputed (${String(op.partId)} is factor-only or unknown).`, `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) => ({ const objectionParts = partsIn.map((p) => ({
partId: p.partId, partId: parseCatalogPartIdInput(p.partId)!,
reason: p.reason, reason: p.reason?.trim() || undefined,
partPrice: p.partPrice, partPrice:
partSalary: p.partSalary, 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, typeOfDamage: p.typeOfDamage != null ? String(p.typeOfDamage) : undefined,
carPartDamage: p.carPartDamage, carPartDamage: p.carPartDamage?.trim() || undefined,
side: p.side, side: p.side?.trim() || undefined,
})); }));
const newParts = newPartsIn.map((p) => ({ const newParts = newPartsIn.map((p) => ({
partId: p.partId ? String(p.partId) : new Types.ObjectId().toString(), partId:
partName: p.partName, p.partId != null && parseCatalogPartIdInput(p.partId) != null
side: p.side, ? parseCatalogPartIdInput(p.partId)!
: new Types.ObjectId().toString(),
partName: p.partName.trim(),
side: p.side?.trim() || undefined,
})); }));
const objectionCarType = claimCase.vehicle?.carType as const objectionCarType = claimCase.vehicle?.carType as
@@ -5588,7 +5600,7 @@ export class ClaimRequestManagementService {
objectionCarType, objectionCarType,
(claimCase.damage as any)?.selectedOuterParts, (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) { for (const np of newParts) {
const name = np.partName?.trim(); const name = np.partName?.trim();
if (!name) continue; if (!name) continue;
@@ -5599,7 +5611,7 @@ export class ClaimRequestManagementService {
side, side,
label_fa: name, label_fa: name,
}; };
const k = partIdentityKey(row); const k = partLookupKey(row);
if (!mergedKeys.has(k)) { if (!mergedKeys.has(k)) {
mergedNorm.push(row); mergedNorm.push(row);
mergedKeys.add(k); mergedKeys.add(k);
@@ -6112,7 +6124,7 @@ export class ClaimRequestManagementService {
); );
const displayParts = [...selectedNormDetails]; const displayParts = [...selectedNormDetails];
const seenDetailKeys = new Set( const seenDetailKeys = new Set(
selectedNormDetails.map((p) => partIdentityKey(p)), selectedNormDetails.map((p) => partLookupKey(p)),
); );
for (const rk of resendPartKeys) { for (const rk of resendPartKeys) {
const extra = const extra =
@@ -6124,7 +6136,7 @@ export class ClaimRequestManagementService {
label_fa: rk, label_fa: rk,
catalogKey: rk, catalogKey: rk,
}; };
const k = partIdentityKey(extra); const k = partLookupKey(extra);
if (!seenDetailKeys.has(k)) { if (!seenDetailKeys.has(k)) {
displayParts.push(extra); displayParts.push(extra);
seenDetailKeys.add(k); seenDetailKeys.add(k);

View File

@@ -1,14 +1,21 @@
import { ApiPropertyOptional } from "@nestjs/swagger"; import { ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer"; 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"; 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. * with nested validation enabled for the v2 controller pipeline.
*/ */
export class UserObjectionV2Dto { export class UserObjectionV2Dto {
@ApiPropertyOptional({ type: [UserObjectionPartDto] }) @ApiPropertyOptional({ type: [UserObjectionPartDto] })
@Validate(HasObjectionEntriesConstraint)
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@@ -16,6 +23,7 @@ export class UserObjectionV2Dto {
objectionParts?: UserObjectionPartDto[]; objectionParts?: UserObjectionPartDto[];
@ApiPropertyOptional({ type: [NewPartDto] }) @ApiPropertyOptional({ type: [NewPartDto] })
@Validate(HasObjectionEntriesConstraint)
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@ValidateNested({ each: true }) @ValidateNested({ each: true })

View File

@@ -1,47 +1,113 @@
import { ApiProperty } from "@nestjs/swagger"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer"; 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"; import { TypeOfDamage } from "src/Types&Enums/claim-request-management/type-of-damage.enum";
export class UserObjectionPartDto { export class UserObjectionPartDto {
@ApiProperty() @ApiProperty({
partId: string; 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; reason?: string;
@ApiProperty({ required: false }) @ApiPropertyOptional({
description: "Owner-proposed part price (Toman, integer string).",
})
@IsOptional()
@IsString()
@MaxLength(32)
@IsRepairLineAmountToman()
partPrice?: string; partPrice?: string;
@ApiProperty({ required: false }) @ApiPropertyOptional({
description: "Owner-proposed salary / labor (Toman, integer string).",
})
@IsOptional()
@IsString()
@MaxLength(32)
@IsRepairLineAmountToman()
partSalary?: string; partSalary?: string;
@ApiProperty({ required: false }) @ApiPropertyOptional({ enum: TypeOfDamage })
@IsOptional()
@IsEnum(TypeOfDamage)
typeOfDamage?: TypeOfDamage; typeOfDamage?: TypeOfDamage;
@ApiProperty({ required: false }) @ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(500)
carPartDamage?: string; carPartDamage?: string;
@ApiProperty({ required: false }) @ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(64)
side?: string; side?: string;
} }
export class NewPartDto { export class NewPartDto {
@ApiProperty({ required: false, nullable: true }) @ApiPropertyOptional({
partId: string | null; 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; partName: string;
@ApiProperty({ required: false }) @ApiPropertyOptional({ example: "front" })
@IsOptional()
@IsString()
@MaxLength(64)
side?: string; side?: string;
} }
export class UserObjectionDto { export class UserObjectionDto {
@ApiProperty({ type: [UserObjectionPartDto], required: false }) @ApiPropertyOptional({ type: [UserObjectionPartDto] })
@Validate(HasObjectionEntriesConstraint)
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => UserObjectionPartDto) @Type(() => UserObjectionPartDto)
objectionParts?: UserObjectionPartDto[]; objectionParts?: UserObjectionPartDto[];
@ApiProperty({ type: [NewPartDto], required: false }) @ApiPropertyOptional({ type: [NewPartDto] })
@Validate(HasObjectionEntriesConstraint)
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => NewPartDto) @Type(() => NewPartDto)
newParts?: NewPartDto[]; newParts?: NewPartDto[];
} }

View File

@@ -141,8 +141,8 @@ export const ClaimExpertReplySchema =
/** One line item the user disputes on an expert-priced part */ /** One line item the user disputes on an expert-priced part */
@Schema({ _id: false }) @Schema({ _id: false })
export class ClaimUserObjectionPart { export class ClaimUserObjectionPart {
@Prop({ type: String, required: true }) @Prop({ type: Number, required: true })
partId: string; partId: number;
@Prop({ type: String }) @Prop({ type: String })
reason?: string; reason?: string;
@@ -167,8 +167,9 @@ export const ClaimUserObjectionPartSchema =
@Schema({ _id: false }) @Schema({ _id: false })
export class ClaimUserObjectionNewPart { export class ClaimUserObjectionNewPart {
@Prop({ type: String }) /** Catalog id when known; otherwise a generated string id for the new line. */
partId?: string; @Prop({ type: MongooseSchema.Types.Mixed })
partId?: number | string;
@Prop({ type: String, required: true }) @Prop({ type: String, required: true })
partName: string; partName: string;

View File

@@ -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();
}
}

View 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.";
}
}

View 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,
});
};
}

View 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.";
}
}

View 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,
});
};
}

View 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;

View File

@@ -44,7 +44,7 @@ export class UpsertClaimPriceDropV2Dto {
@ApiProperty({ @ApiProperty({
example: 450000000, example: 450000000,
description: "Market price of the car (Rials)", description: "Market price of the car (Toman)",
}) })
carPrice: number | string; carPrice: number | string;

View File

@@ -10,11 +10,10 @@ import {
IsInt, IsInt,
} from 'class-validator'; } from 'class-validator';
import { Type } from 'class-transformer'; 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 { 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';
/** Same shape as V1 {@link PartsList} `daghi` (damage expert reply). */
export class DaghiDetailsV2Dto { export class DaghiDetailsV2Dto {
@ApiProperty({ @ApiProperty({
enum: DaghiOption, enum: DaghiOption,
@@ -25,10 +24,11 @@ export class DaghiDetailsV2Dto {
option: DaghiOption; option: DaghiOption;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: `Required when option is '${DaghiOption.RECYCLED_PARTS_VALUE}'`, description: `Required when option is '${DaghiOption.RECYCLED_PARTS_VALUE}' (Toman)`,
}) })
@IsOptional() @IsOptional()
@IsString() @IsString()
@IsRepairLineAmountToman()
price?: string; price?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
@@ -51,16 +51,28 @@ export class PartPricingV2Dto {
@IsString() @IsString()
typeOfDamage: string; 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() @IsString()
@IsRepairLineAmountToman({ allowZero: true })
price: string; price: string;
@ApiProperty({ example: '2000000' }) @ApiProperty({
example: "2000000",
description: "Labor in Toman (integer string). Use 0 if the full amount is in price.",
})
@IsString() @IsString()
@IsRepairLineAmountToman({ allowZero: true })
salary: string; salary: string;
@ApiProperty({ example: '7000000' }) @ApiProperty({
example: "7000000",
description: "Line total in Toman (integer string).",
})
@IsString() @IsString()
@IsRepairLineAmountToman()
totalPayment: string; totalPayment: string;
@ApiProperty({ type: DaghiDetailsV2Dto }) @ApiProperty({ type: DaghiDetailsV2Dto })

View File

@@ -8,6 +8,7 @@ import {
IsString, IsString,
ValidateNested, ValidateNested,
} from "class-validator"; } 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"; import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum";
class FactorDecisionDto { class FactorDecisionDto {
@@ -50,26 +51,29 @@ class FactorDecisionV2Dto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 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** (experts replacement pricing).", "Part price (Toman). With totalPayment or price+salary for APPROVED/REJECTED factor lines. Use 0 if unused.",
}) })
@IsOptional() @IsOptional()
@IsString() @IsString()
@IsRepairLineAmountToman({ allowZero: true })
price?: string; price?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 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() @IsOptional()
@IsString() @IsString()
@IsRepairLineAmountToman({ allowZero: true })
salary?: string; salary?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 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() @IsOptional()
@IsString() @IsString()
@IsRepairLineAmountToman()
totalPayment?: string; totalPayment?: string;
} }

View File

@@ -114,8 +114,10 @@ import {
ExpertFileKind, ExpertFileKind,
} from "src/users/entities/schema/expert-file-activity.schema"; } from "src/users/entities/schema/expert-file-activity.schema";
/** Maximum sum of line `totalPayment` across the claim (priced parts + factor lines after validation). */ /** Maximum sum of line `totalPayment` across the claim (Toman; priced parts + factor lines after validation). */
const CLAIM_V2_TOTAL_PAYMENT_CAP = 53_000_000; 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() @Injectable()
export class ExpertClaimService { export class ExpertClaimService {
@@ -1523,7 +1525,7 @@ export class ExpertClaimService {
if (totalPrice > PRICE_CAP) { if (totalPrice > PRICE_CAP) {
throw new BadRequestException({ 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", error: "PRICE_CAP_ERROR",
code: "PRICE_CAP_ERROR", code: "PRICE_CAP_ERROR",
totalPrice: totalPrice, totalPrice: totalPrice,
@@ -2089,7 +2091,7 @@ export class ExpertClaimService {
* Preconditions: all `factorNeeded` parts have `factorLink`; case is UNDER_REVIEW at EXPERT_COST_EVALUATION. * 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). * — 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). * — 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`. * Response: `claimStatus` = `ClaimStatus`; `caseStatus` = `ClaimCaseStatus`.
*/ */
async validateClaimFactorsV2( async validateClaimFactorsV2(
@@ -2238,7 +2240,7 @@ export class ExpertClaimService {
} }
if (totalPrice > PRICE_CAP) { if (totalPrice > PRICE_CAP) {
throw new BadRequestException({ 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", error: "PRICE_CAP_ERROR",
totalPrice, totalPrice,
priceCap: PRICE_CAP, priceCap: PRICE_CAP,
@@ -2700,7 +2702,7 @@ export class ExpertClaimService {
} }
if (totalPrice > PRICE_CAP) { if (totalPrice > PRICE_CAP) {
throw new BadRequestException({ 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', error: 'PRICE_CAP_ERROR',
totalPrice, totalPrice,
priceCap: PRICE_CAP, priceCap: PRICE_CAP,

View File

@@ -176,7 +176,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 (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" + "**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" +
@@ -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" + "**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" + "**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" + "**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" + "**Outcomes:**\n" +
"- **All approved:** `caseStatus=COMPLETED`, `claimStatus=APPROVED`, workflow `CLAIM_COMPLETED` — no owner signature.\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" + "- **Any rejected (repriced):** same auto-complete for now (owner acceptance may be added later).\n" +

View 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();
});
});

View 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;
}