forked from Yara724/api
YARA-883 + Side ID fixes
This commit is contained in:
@@ -9,8 +9,8 @@ import { UserReplyEnum } from "src/Types&Enums/claim-request-management/userRepl
|
|||||||
|
|
||||||
@Schema({ _id: false })
|
@Schema({ _id: false })
|
||||||
export class ClaimPartPricing {
|
export class ClaimPartPricing {
|
||||||
@Prop({ type: String })
|
@Prop({ type: Number })
|
||||||
partId: string;
|
partId: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unified outer-part snapshot `{ id?, name, side, label_fa, catalogKey? }` (same as
|
* Unified outer-part snapshot `{ id?, name, side, label_fa, catalogKey? }` (same as
|
||||||
@@ -269,6 +269,12 @@ export class ClaimPriceDrop {
|
|||||||
|
|
||||||
@Prop({ type: Number })
|
@Prop({ type: Number })
|
||||||
sumOfSeverity?: number;
|
sumOfSeverity?: number;
|
||||||
|
|
||||||
|
@Prop({ type: Number })
|
||||||
|
coefficientYear?: number;
|
||||||
|
|
||||||
|
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
|
||||||
|
partLines?: Array<Record<string, unknown>>;
|
||||||
}
|
}
|
||||||
export const ClaimPriceDropSchema = SchemaFactory.createForClass(ClaimPriceDrop);
|
export const ClaimPriceDropSchema = SchemaFactory.createForClass(ClaimPriceDrop);
|
||||||
|
|
||||||
|
|||||||
@@ -94,9 +94,10 @@ export class ClaimDetailV2ResponseDto {
|
|||||||
properties: {
|
properties: {
|
||||||
index: { type: 'number' },
|
index: { type: 'number' },
|
||||||
partId: {
|
partId: {
|
||||||
type: 'string',
|
type: 'number',
|
||||||
|
nullable: true,
|
||||||
description:
|
description:
|
||||||
'Send this in PUT reply/submit parts[] — stable id (`id:{catalogId}` or `{side}|{name}`)',
|
'Numeric catalog part id for outer parts (`null` for non-catalog lines); use in reply/submit and price-drop',
|
||||||
},
|
},
|
||||||
id: { type: 'number', nullable: true },
|
id: { type: 'number', nullable: true },
|
||||||
name: { type: 'string' },
|
name: { type: 'string' },
|
||||||
@@ -109,7 +110,7 @@ export class ClaimDetailV2ResponseDto {
|
|||||||
})
|
})
|
||||||
damagedParts?: Array<{
|
damagedParts?: Array<{
|
||||||
index: number;
|
index: number;
|
||||||
partId: string;
|
partId: number | null;
|
||||||
id?: number | null;
|
id?: number | null;
|
||||||
name: string;
|
name: string;
|
||||||
side: string;
|
side: string;
|
||||||
@@ -152,6 +153,15 @@ export class ClaimDetailV2ResponseDto {
|
|||||||
signLink?: string;
|
signLink?: string;
|
||||||
signedAt?: Date | string;
|
signedAt?: Date | string;
|
||||||
};
|
};
|
||||||
|
priceDrop?: {
|
||||||
|
total?: number;
|
||||||
|
carPrice?: number;
|
||||||
|
carModel?: number;
|
||||||
|
carValue?: number[];
|
||||||
|
sumOfSeverity?: number;
|
||||||
|
coefficientYear?: number;
|
||||||
|
partLines?: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
|
|||||||
127
src/expert-claim/dto/claim-price-drop-v2.dto.ts
Normal file
127
src/expert-claim/dto/claim-price-drop-v2.dto.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
ValidateNested,
|
||||||
|
} from "class-validator";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import type { PriceDropSeverity } from "src/helpers/claim-price-drop";
|
||||||
|
|
||||||
|
export class PriceDropPartSeverityV2Dto {
|
||||||
|
@ApiProperty({
|
||||||
|
example: 12,
|
||||||
|
description: "From claim detail `damagedParts[].partId`",
|
||||||
|
})
|
||||||
|
@IsInt()
|
||||||
|
partId: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
enum: ["Minor", "Moderate", "Severe"],
|
||||||
|
description: "Severity: جزئی / متوسط / شدید",
|
||||||
|
})
|
||||||
|
@IsIn(["Minor", "Moderate", "Severe"])
|
||||||
|
severity: PriceDropSeverity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpsertClaimPriceDropV2Dto {
|
||||||
|
@ApiPropertyOptional({ example: "پژو 206" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
carName?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: "پژو 206 تیپ 5",
|
||||||
|
description: "Display model string stored on `claim.vehicle.carModel`",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
carModel?: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 450000000,
|
||||||
|
description: "Market price of the car (Rials)",
|
||||||
|
})
|
||||||
|
carPrice: number | string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 1394,
|
||||||
|
description:
|
||||||
|
"Jalali production year (coefficient). Defaults from linked blame plate inquiry when omitted.",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
carModelYear?: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: [PriceDropPartSeverityV2Dto],
|
||||||
|
description:
|
||||||
|
"One row per damaged part on the claim; coefficients come from the price-drop table × severity.",
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => PriceDropPartSeverityV2Dto)
|
||||||
|
partSeverities: PriceDropPartSeverityV2Dto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClaimPriceDropContextV2Dto {
|
||||||
|
@ApiProperty()
|
||||||
|
claimRequestId: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
vehicle?: {
|
||||||
|
carName?: string;
|
||||||
|
carModel?: string;
|
||||||
|
carType?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "Jalali year from blame plate inquiry (ModelField / ModelCii)",
|
||||||
|
})
|
||||||
|
suggestedCarModelYear?: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
priceDrop?: Record<string, unknown>;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
value: { type: "string" },
|
||||||
|
label_fa: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
severityOptions: Array<{ value: string; label_fa: string }>;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Full coefficient table for all price-drop parts" })
|
||||||
|
priceDropCatalog: Array<Record<string, unknown>>;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Claim damaged parts with optional price-drop key mapping",
|
||||||
|
})
|
||||||
|
damagedParts: Array<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClaimPriceDropResultV2Dto {
|
||||||
|
@ApiProperty()
|
||||||
|
claimRequestId: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
vehicle: {
|
||||||
|
carName?: string;
|
||||||
|
carModel?: string;
|
||||||
|
carType?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
priceDrop: Record<string, unknown>;
|
||||||
|
|
||||||
|
@ApiProperty({ type: "array" })
|
||||||
|
partLines: Array<Record<string, unknown>>;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
IsOptional,
|
IsOptional,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
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';
|
||||||
@@ -40,13 +41,11 @@ export class DaghiDetailsV2Dto {
|
|||||||
|
|
||||||
export class PartPricingV2Dto {
|
export class PartPricingV2Dto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: '12',
|
example: 201,
|
||||||
description:
|
description: 'Numeric catalog part id from claim detail `damagedParts[].partId`.',
|
||||||
'Stable part id from GET claim detail `damagedParts[].partId` or `selectedParts` identity (`id:{catalogId}` or `{side}|{name}`). Required.',
|
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsInt()
|
||||||
@IsNotEmpty()
|
partId: number;
|
||||||
partId: string;
|
|
||||||
|
|
||||||
@ApiProperty({ example: 'Minor' })
|
@ApiProperty({ example: 'Minor' })
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Type } from "class-transformer";
|
|||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
@@ -34,9 +35,9 @@ export class FactorValidationDto {
|
|||||||
|
|
||||||
/** V2 ClaimCase: expert confirms or overrides part pricing when validating uploaded factors. */
|
/** V2 ClaimCase: expert confirms or overrides part pricing when validating uploaded factors. */
|
||||||
class FactorDecisionV2Dto {
|
class FactorDecisionV2Dto {
|
||||||
@ApiProperty()
|
@ApiProperty({ example: 201 })
|
||||||
@IsString()
|
@IsInt()
|
||||||
partId: string;
|
partId: number;
|
||||||
|
|
||||||
@ApiProperty({ enum: [FactorStatus.APPROVED, FactorStatus.REJECTED] })
|
@ApiProperty({ enum: [FactorStatus.APPROVED, FactorStatus.REJECTED] })
|
||||||
@IsEnum([FactorStatus.APPROVED, FactorStatus.REJECTED])
|
@IsEnum([FactorStatus.APPROVED, FactorStatus.REJECTED])
|
||||||
|
|||||||
@@ -50,6 +50,15 @@ import {
|
|||||||
expertPortfolioFileIdsFromActivityEvents,
|
expertPortfolioFileIdsFromActivityEvents,
|
||||||
objectIdsFromStringSet,
|
objectIdsFromStringSet,
|
||||||
} from "src/helpers/expert-portfolio";
|
} from "src/helpers/expert-portfolio";
|
||||||
|
import {
|
||||||
|
buildCoefficientsFromPartSeverities,
|
||||||
|
buildDamagedPartsPriceDropLines,
|
||||||
|
buildPriceDropCatalogForApi,
|
||||||
|
calculateClaimPriceDrop,
|
||||||
|
PRICE_DROP_SEVERITY_OPTIONS,
|
||||||
|
resolveVehicleModelYearFromBlame,
|
||||||
|
} from "src/helpers/claim-price-drop";
|
||||||
|
import { UpsertClaimPriceDropV2Dto } from "./dto/claim-price-drop-v2.dto";
|
||||||
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
|
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
|
||||||
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 { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||||||
@@ -87,9 +96,13 @@ import {
|
|||||||
coerceDamagedPartsMediaToArray,
|
coerceDamagedPartsMediaToArray,
|
||||||
normalizeCarPartDamageForExpertReply,
|
normalizeCarPartDamageForExpertReply,
|
||||||
normalizeDamageSelectedParts,
|
normalizeDamageSelectedParts,
|
||||||
partIdentityKey,
|
catalogPartIdFromCarPartDamage,
|
||||||
partIdentityKeyFromCarPartDamage,
|
catalogPartIdFromSelectedPart,
|
||||||
resolveSelectedPartByPartId,
|
partLookupKey,
|
||||||
|
resolvePartForExpertReply,
|
||||||
|
sameCatalogPartId,
|
||||||
|
sanitizeDamageSelectedPartV2,
|
||||||
|
type DamageSelectedPartV2,
|
||||||
} from "src/helpers/outer-damage-parts";
|
} from "src/helpers/outer-damage-parts";
|
||||||
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||||
import { snapshotFromDamageExpert } from "src/helpers/expert-profile-snapshot";
|
import { snapshotFromDamageExpert } from "src/helpers/expert-profile-snapshot";
|
||||||
@@ -760,7 +773,7 @@ export class ExpertClaimService {
|
|||||||
|
|
||||||
/** Price-cap total for one reply line: `totalPayment` if set, otherwise `price` + `salary`. */
|
/** Price-cap total for one reply line: `totalPayment` if set, otherwise `price` + `salary`. */
|
||||||
private claimReplyPartLineTotalForCap(part: {
|
private claimReplyPartLineTotalForCap(part: {
|
||||||
partId?: string;
|
partId?: number | string;
|
||||||
totalPayment?: unknown;
|
totalPayment?: unknown;
|
||||||
price?: unknown;
|
price?: unknown;
|
||||||
salary?: unknown;
|
salary?: unknown;
|
||||||
@@ -2123,8 +2136,8 @@ export class ExpertClaimService {
|
|||||||
|
|
||||||
const $set: Record<string, unknown> = {};
|
const $set: Record<string, unknown> = {};
|
||||||
for (const decision of body.decisions) {
|
for (const decision of body.decisions) {
|
||||||
const partIndex = finalReply.parts.findIndex(
|
const partIndex = finalReply.parts.findIndex((p) =>
|
||||||
(p) => String(p.partId) === String(decision.partId),
|
sameCatalogPartId(p.partId, decision.partId),
|
||||||
);
|
);
|
||||||
if (partIndex === -1) {
|
if (partIndex === -1) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
@@ -2217,7 +2230,7 @@ export class ExpertClaimService {
|
|||||||
const line = this.claimReplyPartLineTotalForCap(part);
|
const line = this.claimReplyPartLineTotalForCap(part);
|
||||||
if (isNaN(line)) {
|
if (isNaN(line)) {
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
message: `Part ${String((part as { partId?: string }).partId ?? "")}: invalid amount for price total — use totalPayment or price and salary.`,
|
message: `Part ${String(part.partId ?? "")}: invalid amount for price total — use totalPayment or price and salary.`,
|
||||||
error: "PRICE_CAP_ERROR",
|
error: "PRICE_CAP_ERROR",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2705,23 +2718,23 @@ export class ExpertClaimService {
|
|||||||
carTypeSubmit,
|
carTypeSubmit,
|
||||||
(claim as any)?.damage?.selectedOuterParts,
|
(claim as any)?.damage?.selectedOuterParts,
|
||||||
);
|
);
|
||||||
const ownerSelectedIdentitySet = new Set(
|
const seenCatalogIds = new Set<number>();
|
||||||
ownerSelectedParts.map((p) => partIdentityKey(p)),
|
const expertAddedParts: DamageSelectedPartV2[] = [];
|
||||||
);
|
|
||||||
const seenIdentities = new Set<string>();
|
|
||||||
|
|
||||||
const processedParts = daghiNormalized.map((p) => {
|
const processedParts = daghiNormalized.map((p) => {
|
||||||
|
const selected = resolvePartForExpertReply(
|
||||||
|
p.partId,
|
||||||
|
ownerSelectedParts,
|
||||||
|
carTypeSubmit,
|
||||||
|
);
|
||||||
|
if (!selected) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Unknown partId "${p.partId}". Use numeric catalog id from claim detail damagedParts.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let carPartDamage: Record<string, unknown>;
|
let carPartDamage: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
const selected = resolveSelectedPartByPartId(
|
|
||||||
p.partId,
|
|
||||||
ownerSelectedParts,
|
|
||||||
);
|
|
||||||
if (!selected) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Unknown partId "${p.partId}". Use partId from claim detail damagedParts (after editing parts on the claim).`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
carPartDamage = normalizeCarPartDamageForExpertReply(
|
carPartDamage = normalizeCarPartDamageForExpertReply(
|
||||||
{
|
{
|
||||||
id: selected.id,
|
id: selected.id,
|
||||||
@@ -2733,7 +2746,6 @@ export class ExpertClaimService {
|
|||||||
carTypeSubmit,
|
carTypeSubmit,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof BadRequestException) throw err;
|
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Invalid part ${p.partId}: ${
|
`Invalid part ${p.partId}: ${
|
||||||
err instanceof Error ? err.message : String(err)
|
err instanceof Error ? err.message : String(err)
|
||||||
@@ -2741,30 +2753,32 @@ export class ExpertClaimService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const identityKey = partIdentityKeyFromCarPartDamage(carPartDamage);
|
const catalogId = catalogPartIdFromCarPartDamage(carPartDamage);
|
||||||
if (!identityKey) {
|
if (catalogId == null) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Invalid part ${p.partId}: cannot derive a stable identity (need a catalog id or both name and side).`,
|
`Invalid part ${p.partId}: a numeric catalog id is required.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (seenCatalogIds.has(catalogId)) {
|
||||||
ownerSelectedIdentitySet.size > 0 &&
|
|
||||||
!ownerSelectedIdentitySet.has(identityKey)
|
|
||||||
) {
|
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Part "${identityKey}" is not in the owner's selected damaged parts; only parts the user picked (or added via objection) can be priced.`,
|
`Duplicate part submitted in expert reply: ${catalogId}.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
seenCatalogIds.add(catalogId);
|
||||||
|
|
||||||
if (seenIdentities.has(identityKey)) {
|
const wasOnClaim = ownerSelectedParts.some(
|
||||||
throw new BadRequestException(
|
(op) => partLookupKey(op) === partLookupKey(selected),
|
||||||
`Duplicate part submitted in expert reply: ${identityKey}.`,
|
);
|
||||||
);
|
if (!wasOnClaim) {
|
||||||
|
expertAddedParts.push(selected);
|
||||||
}
|
}
|
||||||
seenIdentities.add(identityKey);
|
|
||||||
|
|
||||||
return { ...p, partId: identityKey, carPartDamage };
|
return {
|
||||||
|
...p,
|
||||||
|
partId: catalogId,
|
||||||
|
carPartDamage,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mixedFactorAndPrice, allFactorLines } = classifyV2ExpertPricingParts(
|
const { mixedFactorAndPrice, allFactorLines } = classifyV2ExpertPricingParts(
|
||||||
@@ -2820,9 +2834,26 @@ export class ExpertClaimService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mergedSelectedParts = [...ownerSelectedParts];
|
||||||
|
if (expertAddedParts.length > 0) {
|
||||||
|
const mergedKeys = new Set(
|
||||||
|
mergedSelectedParts.map((sp) => partLookupKey(sp)),
|
||||||
|
);
|
||||||
|
for (const sp of expertAddedParts) {
|
||||||
|
const k = partLookupKey(sp);
|
||||||
|
if (!mergedKeys.has(k)) {
|
||||||
|
mergedKeys.add(k);
|
||||||
|
mergedSelectedParts.push(sp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updatePayload: Record<string, unknown> = {
|
const updatePayload: Record<string, unknown> = {
|
||||||
status: nextCaseStatus,
|
status: nextCaseStatus,
|
||||||
claimStatus: nextClaimStatus,
|
claimStatus: nextClaimStatus,
|
||||||
|
...(expertAddedParts.length > 0
|
||||||
|
? { "damage.selectedParts": mergedSelectedParts }
|
||||||
|
: {}),
|
||||||
'workflow.locked': false,
|
'workflow.locked': false,
|
||||||
$unset: {
|
$unset: {
|
||||||
'workflow.lockedAt': '',
|
'workflow.lockedAt': '',
|
||||||
@@ -3605,7 +3636,7 @@ export class ExpertClaimService {
|
|||||||
) as { url?: string; path?: string } | undefined;
|
) as { url?: string; path?: string } | undefined;
|
||||||
return {
|
return {
|
||||||
index,
|
index,
|
||||||
partId: partIdentityKey(sp),
|
partId: catalogPartIdFromSelectedPart(sp),
|
||||||
id: sp.id,
|
id: sp.id,
|
||||||
name: sp.name,
|
name: sp.name,
|
||||||
side: sp.side,
|
side: sp.side,
|
||||||
@@ -3753,6 +3784,12 @@ export class ExpertClaimService {
|
|||||||
evaluationForApi.ownerPricedPartsApproval =
|
evaluationForApi.ownerPricedPartsApproval =
|
||||||
enrichedEvaluation.ownerPricedPartsApproval;
|
enrichedEvaluation.ownerPricedPartsApproval;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
isDamageExpertPhase &&
|
||||||
|
enrichedEvaluation.priceDrop !== undefined
|
||||||
|
) {
|
||||||
|
evaluationForApi.priceDrop = enrichedEvaluation.priceDrop;
|
||||||
|
}
|
||||||
if (Object.keys(evaluationForApi).length === 0) {
|
if (Object.keys(evaluationForApi).length === 0) {
|
||||||
evaluationForApi = undefined;
|
evaluationForApi = undefined;
|
||||||
}
|
}
|
||||||
@@ -3808,6 +3845,180 @@ export class ExpertClaimService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** V2 price-drop: claim locked by this expert during damage assessment. */
|
||||||
|
private assertExpertCanEditClaimDuringReviewV2(claim: any, actor: any): void {
|
||||||
|
requireActorClientKey(actor);
|
||||||
|
assertClaimCaseForTenant(claim, actor);
|
||||||
|
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Claim must be EXPERT_REVIEWING to edit price drop. Current status: ${claim.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!claim.workflow?.locked) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"You must lock the claim before entering price-drop data",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (claim.workflow.lockedBy?.actorId?.toString() !== String(actor.sub)) {
|
||||||
|
throw new ForbiddenException("This claim is locked by another expert");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2: Context for manual price-drop (vehicle, inquiry year, catalog, damaged parts).
|
||||||
|
*/
|
||||||
|
async getPriceDropContextV2(claimRequestId: string, actor: any) {
|
||||||
|
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
|
if (!claim) {
|
||||||
|
throw new NotFoundException("Claim request not found");
|
||||||
|
}
|
||||||
|
this.assertExpertCanEditClaimDuringReviewV2(claim, actor);
|
||||||
|
|
||||||
|
const carType = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
|
||||||
|
const selectedNorm = normalizeDamageSelectedParts(
|
||||||
|
claim.damage?.selectedParts,
|
||||||
|
carType,
|
||||||
|
(claim.damage as any)?.selectedOuterParts,
|
||||||
|
);
|
||||||
|
|
||||||
|
let suggestedCarModelYear: number | null = null;
|
||||||
|
if (claim.blameRequestId) {
|
||||||
|
const blameRows = await this.blameRequestDbService.find(
|
||||||
|
{ _id: new Types.ObjectId(claim.blameRequestId.toString()) },
|
||||||
|
{
|
||||||
|
lean: true,
|
||||||
|
select: "parties.role parties.person.userId parties.vehicle.inquiry",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const blame = blameRows[0] as Record<string, unknown> | undefined;
|
||||||
|
suggestedCarModelYear = resolveVehicleModelYearFromBlame(
|
||||||
|
claim,
|
||||||
|
blame as Parameters<typeof resolveVehicleModelYearFromBlame>[1],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = (claim as any).evaluation?.priceDrop;
|
||||||
|
|
||||||
|
return {
|
||||||
|
claimRequestId: String(claim._id),
|
||||||
|
vehicle: {
|
||||||
|
carName: claim.vehicle?.carName,
|
||||||
|
carModel: claim.vehicle?.carModel,
|
||||||
|
carType: claim.vehicle?.carType,
|
||||||
|
},
|
||||||
|
suggestedCarModelYear,
|
||||||
|
priceDrop: existing ?? null,
|
||||||
|
severityOptions: PRICE_DROP_SEVERITY_OPTIONS,
|
||||||
|
priceDropCatalog: buildPriceDropCatalogForApi(),
|
||||||
|
damagedParts: buildDamagedPartsPriceDropLines(selectedNorm),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2: Expert enters vehicle + per-part severities; persists `vehicle` and `evaluation.priceDrop`.
|
||||||
|
*/
|
||||||
|
async upsertPriceDropV2(
|
||||||
|
claimRequestId: string,
|
||||||
|
body: UpsertClaimPriceDropV2Dto,
|
||||||
|
actor: any,
|
||||||
|
) {
|
||||||
|
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
|
if (!claim) {
|
||||||
|
throw new NotFoundException("Claim request not found");
|
||||||
|
}
|
||||||
|
this.assertExpertCanEditClaimDuringReviewV2(claim, actor);
|
||||||
|
|
||||||
|
const carType = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
|
||||||
|
const selectedNorm = normalizeDamageSelectedParts(
|
||||||
|
claim.damage?.selectedParts,
|
||||||
|
carType,
|
||||||
|
(claim.damage as any)?.selectedOuterParts,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!body.partSeverities?.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Provide at least one partSeverities line for a damaged part.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let carModelYear = body.carModelYear;
|
||||||
|
if (carModelYear == null && claim.blameRequestId) {
|
||||||
|
const blameRows = await this.blameRequestDbService.find(
|
||||||
|
{ _id: new Types.ObjectId(claim.blameRequestId.toString()) },
|
||||||
|
{
|
||||||
|
lean: true,
|
||||||
|
select: "parties.role parties.person.userId parties.vehicle.inquiry",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
carModelYear =
|
||||||
|
resolveVehicleModelYearFromBlame(
|
||||||
|
claim,
|
||||||
|
blameRows[0] as Parameters<typeof resolveVehicleModelYearFromBlame>[1],
|
||||||
|
) ?? undefined;
|
||||||
|
}
|
||||||
|
if (carModelYear == null || !Number.isFinite(carModelYear)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"carModelYear is required when it cannot be resolved from the linked blame plate inquiry.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { coefficients, lines, errors } = buildCoefficientsFromPartSeverities(
|
||||||
|
selectedNorm,
|
||||||
|
body.partSeverities.map((p) => ({
|
||||||
|
partId: p.partId,
|
||||||
|
severity: p.severity,
|
||||||
|
})),
|
||||||
|
carType,
|
||||||
|
);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw new BadRequestException(errors.join(" "));
|
||||||
|
}
|
||||||
|
if (coefficients.length === 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"No valid severity coefficients could be derived from partSeverities.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculated = calculateClaimPriceDrop(
|
||||||
|
body.carPrice,
|
||||||
|
carModelYear,
|
||||||
|
coefficients,
|
||||||
|
);
|
||||||
|
|
||||||
|
const priceDropPayload = {
|
||||||
|
...calculated,
|
||||||
|
partLines: lines,
|
||||||
|
};
|
||||||
|
|
||||||
|
const vehicleSet: Record<string, string> = {};
|
||||||
|
if (body.carName != null && String(body.carName).trim()) {
|
||||||
|
vehicleSet["vehicle.carName"] = String(body.carName).trim();
|
||||||
|
}
|
||||||
|
if (body.carModel != null && String(body.carModel).trim()) {
|
||||||
|
vehicleSet["vehicle.carModel"] = String(body.carModel).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||||
|
$set: {
|
||||||
|
...vehicleSet,
|
||||||
|
"evaluation.priceDrop": priceDropPayload,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
claimRequestId,
|
||||||
|
vehicle: {
|
||||||
|
carName: updated?.vehicle?.carName,
|
||||||
|
carModel: updated?.vehicle?.carModel,
|
||||||
|
carType: updated?.vehicle?.carType,
|
||||||
|
},
|
||||||
|
priceDrop: priceDropPayload,
|
||||||
|
partLines: lines,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V2: Damage expert can modify selected damaged parts before final submit.
|
* V2: Damage expert can modify selected damaged parts before final submit.
|
||||||
* Allowed only when claim is EXPERT_REVIEWING and locked by this expert.
|
* Allowed only when claim is EXPERT_REVIEWING and locked by this expert.
|
||||||
@@ -3850,13 +4061,19 @@ export class ExpertClaimService {
|
|||||||
previousNorm,
|
previousNorm,
|
||||||
);
|
);
|
||||||
|
|
||||||
const nextNorm = body.selectedParts.map((p) => ({
|
const nextNorm = body.selectedParts.map((p) =>
|
||||||
id: p.id ?? null,
|
sanitizeDamageSelectedPartV2(
|
||||||
name: String(p.name || "").trim(),
|
{
|
||||||
side: String(p.side || ""),
|
id: p.id ?? null,
|
||||||
label_fa: String(p.label_fa || "").trim() || String(p.name || "").trim(),
|
name: String(p.name || "").trim(),
|
||||||
...(p.catalogKey?.trim() ? { catalogKey: p.catalogKey.trim() } : {}),
|
side: String(p.side || ""),
|
||||||
}));
|
label_fa:
|
||||||
|
String(p.label_fa || "").trim() || String(p.name || "").trim(),
|
||||||
|
...(p.catalogKey?.trim() ? { catalogKey: p.catalogKey.trim() } : {}),
|
||||||
|
},
|
||||||
|
carType,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const matchPreviousIndex = (
|
const matchPreviousIndex = (
|
||||||
next: (typeof nextNorm)[0],
|
next: (typeof nextNorm)[0],
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ import { RoleEnum } from "src/Types&Enums/role.enum";
|
|||||||
import { ExpertClaimService } from "./expert-claim.service";
|
import { ExpertClaimService } from "./expert-claim.service";
|
||||||
import { ClaimSubmitResendV2Dto, SubmitExpertReplyV2Dto } from "./dto/expert-claim-v2.dto";
|
import { ClaimSubmitResendV2Dto, SubmitExpertReplyV2Dto } from "./dto/expert-claim-v2.dto";
|
||||||
import { UpdateClaimDamagedPartsV2Dto } from "./dto/update-claim-damaged-parts-v2.dto";
|
import { UpdateClaimDamagedPartsV2Dto } from "./dto/update-claim-damaged-parts-v2.dto";
|
||||||
|
import {
|
||||||
|
ClaimPriceDropContextV2Dto,
|
||||||
|
ClaimPriceDropResultV2Dto,
|
||||||
|
UpsertClaimPriceDropV2Dto,
|
||||||
|
} from "./dto/claim-price-drop-v2.dto";
|
||||||
import { FactorValidationV2Dto } from "./dto/factor-validation.dto";
|
import { FactorValidationV2Dto } from "./dto/factor-validation.dto";
|
||||||
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
|
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
|
||||||
import { OuterPartCatalogItemDto } from "src/claim-request-management/dto/select-outer-parts-v2.dto";
|
import { OuterPartCatalogItemDto } from "src/claim-request-management/dto/select-outer-parts-v2.dto";
|
||||||
@@ -101,7 +106,7 @@ export class ExpertClaimV2Controller {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Get claim request detail for damage expert",
|
summary: "Get claim request detail for damage expert",
|
||||||
description:
|
description:
|
||||||
"Returns full claim details including captured images, required documents, damage selections, `videoCapture` (from claim-video-capture via media.videoCaptureId), and `blameCase` (linked blameCases document with party video/voice URLs like expert-blame detail). Allowed when status is WAITING_FOR_DAMAGE_EXPERT (if locked, only the locking expert) or when awaiting factor validation.",
|
"Returns full claim details including captured images, required documents, damage selections, `evaluation.priceDrop` during damage review, `videoCapture` (from claim-video-capture via media.videoCaptureId), and `blameCase` (linked blameCases document with party video/voice URLs like expert-blame detail). Allowed when status is WAITING_FOR_DAMAGE_EXPERT (if locked, only the locking expert) or when awaiting factor validation.",
|
||||||
})
|
})
|
||||||
@ApiParam({ name: "claimRequestId" })
|
@ApiParam({ name: "claimRequestId" })
|
||||||
async getClaimDetailV2(
|
async getClaimDetailV2(
|
||||||
@@ -111,6 +116,45 @@ export class ExpertClaimV2Controller {
|
|||||||
return await this.expertClaimService.getClaimDetailV2(claimRequestId, actor);
|
return await this.expertClaimService.getClaimDetailV2(claimRequestId, actor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("request/:claimRequestId/price-drop")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Price-drop context for manual expert entry (V2)",
|
||||||
|
description:
|
||||||
|
"While the claim is locked in EXPERT_REVIEWING: severity labels (جزیی / متوسط / شدید), coefficient catalog, damaged parts with price-drop mapping, suggested Jalali year from linked blame plate inquiry (`ModelField` / `ModelCii`), and saved `evaluation.priceDrop` if any.",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "claimRequestId" })
|
||||||
|
@ApiResponse({ status: 200, type: ClaimPriceDropContextV2Dto })
|
||||||
|
async getPriceDropContextV2(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@CurrentUser() actor,
|
||||||
|
) {
|
||||||
|
return await this.expertClaimService.getPriceDropContextV2(
|
||||||
|
claimRequestId,
|
||||||
|
actor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put("request/:claimRequestId/price-drop")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Calculate and save price drop (V2)",
|
||||||
|
description:
|
||||||
|
"Expert supplies car market price, optional `vehicle.carName` / `vehicle.carModel`, per-damaged-part severity (`partId` from claim detail), and optional `carModelYear` (defaults from blame inquiry). Persists `evaluation.priceDrop` using `(carPrice × yearCoefficient × sumOfCoefficients) / 400`.",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "claimRequestId" })
|
||||||
|
@ApiBody({ type: UpsertClaimPriceDropV2Dto })
|
||||||
|
@ApiResponse({ status: 200, type: ClaimPriceDropResultV2Dto })
|
||||||
|
async upsertPriceDropV2(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@Body() body: UpsertClaimPriceDropV2Dto,
|
||||||
|
@CurrentUser() actor,
|
||||||
|
) {
|
||||||
|
return await this.expertClaimService.upsertPriceDropV2(
|
||||||
|
claimRequestId,
|
||||||
|
body,
|
||||||
|
actor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Put("lock/:claimRequestId")
|
@Put("lock/:claimRequestId")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Lock a claim request for review",
|
summary: "Lock a claim request for review",
|
||||||
|
|||||||
83
src/helpers/claim-price-drop.spec.ts
Normal file
83
src/helpers/claim-price-drop.spec.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import {
|
||||||
|
buildCoefficientsFromPartSeverities,
|
||||||
|
calculateClaimPriceDrop,
|
||||||
|
resolvePriceDropPartKeyFromDamagePart,
|
||||||
|
resolveVehicleModelYearFromBlame,
|
||||||
|
} from "./claim-price-drop";
|
||||||
|
|
||||||
|
describe("claim-price-drop", () => {
|
||||||
|
it("maps catalog segment to price drop key", () => {
|
||||||
|
expect(
|
||||||
|
resolvePriceDropPartKeyFromDamagePart({
|
||||||
|
name: "backfender",
|
||||||
|
catalogKey: "left_backfender",
|
||||||
|
}),
|
||||||
|
).toBe("backFender");
|
||||||
|
expect(
|
||||||
|
resolvePriceDropPartKeyFromDamagePart({
|
||||||
|
name: "hood",
|
||||||
|
catalogKey: "front_carHood",
|
||||||
|
}),
|
||||||
|
).toBe("Hood");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates total from formula", () => {
|
||||||
|
const r = calculateClaimPriceDrop(400_000_000, 1394, [2, 3]);
|
||||||
|
expect(r.sumOfSeverity).toBe(5);
|
||||||
|
expect(r.coefficientYear).toBe(2.1);
|
||||||
|
expect(r.total).toBe((400_000_000 * 2.1 * 5) / 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads model year from blame inquiry", () => {
|
||||||
|
const year = resolveVehicleModelYearFromBlame(
|
||||||
|
{ owner: { userId: "u1" } },
|
||||||
|
{
|
||||||
|
parties: [
|
||||||
|
{
|
||||||
|
role: "FIRST",
|
||||||
|
person: { userId: "u1" },
|
||||||
|
vehicle: {
|
||||||
|
inquiry: { mapped: { ModelField: "1395" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(year).toBe(1395);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds coefficients from expert severities", () => {
|
||||||
|
const { coefficients, errors } = buildCoefficientsFromPartSeverities(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "backfender",
|
||||||
|
side: "left",
|
||||||
|
label_fa: "گلگیر",
|
||||||
|
catalogKey: "left_backfender",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[{ partId: 1, severity: "Moderate" }],
|
||||||
|
);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
expect(coefficients).toEqual([3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts numeric catalog id (e.g. 201)", () => {
|
||||||
|
const { coefficients, errors, lines } = buildCoefficientsFromPartSeverities(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 201,
|
||||||
|
name: "backfender",
|
||||||
|
side: "left",
|
||||||
|
label_fa: "گلگیر عقب (چپ)",
|
||||||
|
catalogKey: "left_backfender",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[{ partId: 201, severity: "Moderate" }],
|
||||||
|
);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
expect(coefficients).toEqual([3]);
|
||||||
|
expect(lines[0]?.partId).toBe(201);
|
||||||
|
});
|
||||||
|
});
|
||||||
349
src/helpers/claim-price-drop.ts
Normal file
349
src/helpers/claim-price-drop.ts
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
import {
|
||||||
|
ClaimVehicleTypeV2,
|
||||||
|
} from "src/static/outer-car-parts-catalog";
|
||||||
|
import {
|
||||||
|
catalogPartIdFromSelectedPart,
|
||||||
|
parseCatalogPartIdInput,
|
||||||
|
resolvePartForExpertReply,
|
||||||
|
type DamageSelectedPartV2,
|
||||||
|
} from "src/helpers/outer-damage-parts";
|
||||||
|
|
||||||
|
/** Canonical severity values for expert input (API). */
|
||||||
|
export type PriceDropSeverity = "Minor" | "Moderate" | "Severe";
|
||||||
|
|
||||||
|
export const PRICE_DROP_SEVERITY_FA: Record<PriceDropSeverity, string> = {
|
||||||
|
Minor: "جزیی",
|
||||||
|
Moderate: "متوسط",
|
||||||
|
Severe: "شدید",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PRICE_DROP_SEVERITY_OPTIONS = (
|
||||||
|
Object.entries(PRICE_DROP_SEVERITY_FA) as [PriceDropSeverity, string][]
|
||||||
|
).map(([value, label_fa]) => ({ value, label_fa }));
|
||||||
|
|
||||||
|
/** Part → severity → coefficient (same table as V1 expert-claim). */
|
||||||
|
export const PRICE_DROP_PART_TABLE: Record<
|
||||||
|
string,
|
||||||
|
Partial<Record<string, number>>
|
||||||
|
> = {
|
||||||
|
backFender: { Minor: 2, Moderate: 3, Severe: 5 },
|
||||||
|
backDoor: { Minor: 1, Moderate: 2, Severe: 3 },
|
||||||
|
frontDoor: { Minor: 1, Moderate: 2, Severe: 3 },
|
||||||
|
frontFender: { Minor: 1, Moderate: 2, Severe: 3 },
|
||||||
|
frontBumper: { Minor: 1, Moderate: 2, Severe: 3 },
|
||||||
|
Hood: { Minor: 2, moderate: 3, Severe: 4 },
|
||||||
|
Trunk: { minor: 1, moderate: 3, Severe: 5 },
|
||||||
|
Roof: { Minor: 3, Moderate: 5, Severe: 7 },
|
||||||
|
coil: { Minor: 2, Moderate: 3, Severe: 4 },
|
||||||
|
column: { Minor: 2, Moderate: 3, Severe: 4 },
|
||||||
|
frontTray: { Minor: 1, Moderate: 2, Severe: 3 },
|
||||||
|
backTray: { Minor: 2, moderate: 4, Severe: 5 },
|
||||||
|
frontChassis: { Minor: 3, Moderate: 5, Severe: 7 },
|
||||||
|
backChassis: { Minor: 2, Moderate: 4, Severe: 6 },
|
||||||
|
carFootrest: { Minor: 1, Moderate: 2, Severe: 3 },
|
||||||
|
carFloor: { Minor: 4, moderate: 6, Severe: 8 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Jalali model year → year coefficient (V1). */
|
||||||
|
export const PRICE_DROP_YEAR_COEFFICIENT: Record<number, number> = {
|
||||||
|
1393: 2.05,
|
||||||
|
1394: 2.1,
|
||||||
|
1395: 2.2,
|
||||||
|
1396: 2.3,
|
||||||
|
1397: 2.4,
|
||||||
|
1398: 2.5,
|
||||||
|
1399: 2.6,
|
||||||
|
1400: 2.7,
|
||||||
|
1401: 2.8,
|
||||||
|
1402: 2.9,
|
||||||
|
1403: 3,
|
||||||
|
1404: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRICE_DROP_PART_LABEL_FA: Record<string, string> = {
|
||||||
|
backFender: "گلگیر عقب",
|
||||||
|
backDoor: "در عقب",
|
||||||
|
frontDoor: "در جلو",
|
||||||
|
frontFender: "گلگیر جلو",
|
||||||
|
frontBumper: "سپر جلو",
|
||||||
|
Hood: "کاپوت",
|
||||||
|
Trunk: "صندوق عقب",
|
||||||
|
Roof: "سقف",
|
||||||
|
coil: "لوله",
|
||||||
|
column: "ستون",
|
||||||
|
frontTray: "سینی جلو",
|
||||||
|
backTray: "سینی عقب",
|
||||||
|
frontChassis: "شاسی جلو",
|
||||||
|
backChassis: "شاسی عقب",
|
||||||
|
carFootrest: "پا رکاب",
|
||||||
|
carFloor: "کف خودرو",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Catalog / damage `name` segments → price-drop part key. */
|
||||||
|
const SEGMENT_ALIASES: Record<string, string> = {
|
||||||
|
backfender: "backFender",
|
||||||
|
backdoor: "backDoor",
|
||||||
|
frontdoor: "frontDoor",
|
||||||
|
frontfender: "frontFender",
|
||||||
|
frontbumper: "frontBumper",
|
||||||
|
backbumper: "frontBumper",
|
||||||
|
carhood: "Hood",
|
||||||
|
hood: "Hood",
|
||||||
|
cartrunk: "Trunk",
|
||||||
|
trunk: "Trunk",
|
||||||
|
roof: "Roof",
|
||||||
|
coil: "coil",
|
||||||
|
column: "column",
|
||||||
|
fronttray: "frontTray",
|
||||||
|
backtray: "backTray",
|
||||||
|
frontchassis: "frontChassis",
|
||||||
|
backchassis: "backChassis",
|
||||||
|
carfootrest: "carFootrest",
|
||||||
|
carfloor: "carFloor",
|
||||||
|
};
|
||||||
|
|
||||||
|
const NORM_TO_PART_KEY = new Map<string, string>();
|
||||||
|
for (const key of Object.keys(PRICE_DROP_PART_TABLE)) {
|
||||||
|
NORM_TO_PART_KEY.set(normalizePriceDropKey(key), key);
|
||||||
|
}
|
||||||
|
for (const [seg, key] of Object.entries(SEGMENT_ALIASES)) {
|
||||||
|
NORM_TO_PART_KEY.set(seg, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePriceDropKey(str: string): string {
|
||||||
|
return String(str ?? "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePriceDropNumber(input: number | string): number {
|
||||||
|
if (typeof input === "number" && Number.isFinite(input)) return input;
|
||||||
|
return Number(
|
||||||
|
String(input)
|
||||||
|
.replace(/[۰-۹]/g, (d) => String.fromCharCode(d.charCodeAt(0) - 1728))
|
||||||
|
.replace(/,/g, "")
|
||||||
|
.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSeverityCoefficient(
|
||||||
|
partKey: string,
|
||||||
|
severity: string,
|
||||||
|
): number | undefined {
|
||||||
|
const row = PRICE_DROP_PART_TABLE[partKey];
|
||||||
|
if (!row) return undefined;
|
||||||
|
const sev = String(severity ?? "").trim();
|
||||||
|
if (row[sev] !== undefined) return row[sev];
|
||||||
|
const cap = sev.charAt(0).toUpperCase() + sev.slice(1).toLowerCase();
|
||||||
|
if (row[cap] !== undefined) return row[cap];
|
||||||
|
const low = sev.toLowerCase();
|
||||||
|
if (row[low] !== undefined) return row[low];
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateClaimPriceDrop(
|
||||||
|
carPrice: number | string,
|
||||||
|
carModelYear: number,
|
||||||
|
coefficients: number[],
|
||||||
|
): {
|
||||||
|
carPrice: number;
|
||||||
|
carModel: number;
|
||||||
|
carValue: number[];
|
||||||
|
sumOfSeverity: number;
|
||||||
|
coefficientYear: number;
|
||||||
|
total: number;
|
||||||
|
} {
|
||||||
|
const normalizePrice = parsePriceDropNumber(carPrice);
|
||||||
|
const coefficient = PRICE_DROP_YEAR_COEFFICIENT[carModelYear] ?? 1;
|
||||||
|
const carValue = coefficients.filter((n) => Number.isFinite(n));
|
||||||
|
const sumOfSeverity = carValue.reduce((a, c) => a + c, 0);
|
||||||
|
const total = (normalizePrice * coefficient * sumOfSeverity) / 400;
|
||||||
|
|
||||||
|
return {
|
||||||
|
carPrice: normalizePrice,
|
||||||
|
carModel: carModelYear,
|
||||||
|
carValue,
|
||||||
|
sumOfSeverity,
|
||||||
|
coefficientYear: coefficient,
|
||||||
|
total: total || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPriceDropCatalogForApi(): Array<{
|
||||||
|
key: string;
|
||||||
|
label_fa: string;
|
||||||
|
severities: Record<PriceDropSeverity, number | undefined>;
|
||||||
|
}> {
|
||||||
|
return Object.keys(PRICE_DROP_PART_TABLE).map((key) => {
|
||||||
|
const row = PRICE_DROP_PART_TABLE[key];
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label_fa: PRICE_DROP_PART_LABEL_FA[key] ?? key,
|
||||||
|
severities: {
|
||||||
|
Minor: resolveSeverityCoefficient(key, "Minor"),
|
||||||
|
Moderate: resolveSeverityCoefficient(key, "Moderate"),
|
||||||
|
Severe: resolveSeverityCoefficient(key, "Severe"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePriceDropPartKeyFromDamagePart(part: {
|
||||||
|
name?: string;
|
||||||
|
catalogKey?: string;
|
||||||
|
}): string | null {
|
||||||
|
const candidates: string[] = [];
|
||||||
|
if (part.catalogKey) {
|
||||||
|
for (const seg of String(part.catalogKey).split("_")) {
|
||||||
|
if (seg) candidates.push(seg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (part.name) candidates.push(String(part.name));
|
||||||
|
|
||||||
|
for (const raw of candidates) {
|
||||||
|
const norm = normalizePriceDropKey(raw);
|
||||||
|
const hit = NORM_TO_PART_KEY.get(norm);
|
||||||
|
if (hit && PRICE_DROP_PART_TABLE[hit]) return hit;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDamagedPartsPriceDropLines(
|
||||||
|
selectedParts: DamageSelectedPartV2[],
|
||||||
|
): Array<{
|
||||||
|
partId: number | null;
|
||||||
|
name: string;
|
||||||
|
side: string;
|
||||||
|
label_fa: string;
|
||||||
|
priceDropPartKey: string | null;
|
||||||
|
mappable: boolean;
|
||||||
|
}> {
|
||||||
|
return selectedParts.map((sp) => {
|
||||||
|
const priceDropPartKey = resolvePriceDropPartKeyFromDamagePart(sp);
|
||||||
|
return {
|
||||||
|
partId: catalogPartIdFromSelectedPart(sp),
|
||||||
|
name: sp.name,
|
||||||
|
side: sp.side,
|
||||||
|
label_fa: sp.label_fa,
|
||||||
|
priceDropPartKey,
|
||||||
|
mappable: priceDropPartKey != null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve Jalali year from linked blame party inquiry (owner party preferred). */
|
||||||
|
export function resolveVehicleModelYearFromBlame(
|
||||||
|
claim: { owner?: { userId?: unknown } },
|
||||||
|
blame: {
|
||||||
|
parties?: Array<{
|
||||||
|
role?: string;
|
||||||
|
person?: { userId?: unknown };
|
||||||
|
vehicle?: {
|
||||||
|
inquiry?: {
|
||||||
|
mapped?: { ModelField?: unknown; ModelCii?: unknown };
|
||||||
|
ModelField?: unknown;
|
||||||
|
ModelCii?: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
} | null,
|
||||||
|
): number | null {
|
||||||
|
if (!blame?.parties?.length) return null;
|
||||||
|
const ownerId =
|
||||||
|
claim.owner?.userId != null ? String(claim.owner.userId) : "";
|
||||||
|
let party =
|
||||||
|
blame.parties.find(
|
||||||
|
(p) =>
|
||||||
|
ownerId &&
|
||||||
|
p.person?.userId != null &&
|
||||||
|
String(p.person.userId) === ownerId,
|
||||||
|
) ?? blame.parties.find((p) => p.role === "FIRST");
|
||||||
|
if (!party) party = blame.parties[0];
|
||||||
|
|
||||||
|
const inquiry = party?.vehicle?.inquiry;
|
||||||
|
const mapped = inquiry?.mapped ?? inquiry;
|
||||||
|
const raw =
|
||||||
|
(mapped as { ModelField?: unknown })?.ModelField ??
|
||||||
|
(mapped as { ModelCii?: unknown })?.ModelCii;
|
||||||
|
if (raw == null || raw === "") return null;
|
||||||
|
const n =
|
||||||
|
typeof raw === "number" ? raw : parseInt(String(raw).trim(), 10);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PriceDropPartSeverityInput {
|
||||||
|
partId: number | string;
|
||||||
|
severity: PriceDropSeverity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCoefficientsFromPartSeverities(
|
||||||
|
selectedParts: DamageSelectedPartV2[],
|
||||||
|
partSeverities: PriceDropPartSeverityInput[],
|
||||||
|
carType?: ClaimVehicleTypeV2,
|
||||||
|
): {
|
||||||
|
coefficients: number[];
|
||||||
|
lines: Array<{
|
||||||
|
partId: number;
|
||||||
|
priceDropPartKey: string;
|
||||||
|
label_fa: string;
|
||||||
|
severity: PriceDropSeverity;
|
||||||
|
coefficient: number;
|
||||||
|
}>;
|
||||||
|
errors: string[];
|
||||||
|
} {
|
||||||
|
const coefficients: number[] = [];
|
||||||
|
const lines: Array<{
|
||||||
|
partId: number;
|
||||||
|
priceDropPartKey: string;
|
||||||
|
label_fa: string;
|
||||||
|
severity: PriceDropSeverity;
|
||||||
|
coefficient: number;
|
||||||
|
}> = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (const row of partSeverities) {
|
||||||
|
const catalogId = parseCatalogPartIdInput(row.partId);
|
||||||
|
if (catalogId == null) {
|
||||||
|
errors.push(`Unknown partId "${row.partId}".`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const sp = resolvePartForExpertReply(
|
||||||
|
catalogId,
|
||||||
|
selectedParts,
|
||||||
|
carType,
|
||||||
|
);
|
||||||
|
if (!sp) {
|
||||||
|
errors.push(`Unknown partId "${row.partId}".`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const partKey = resolvePriceDropPartKeyFromDamagePart(sp);
|
||||||
|
if (!partKey) {
|
||||||
|
errors.push(
|
||||||
|
`Part "${row.partId}" (${sp.label_fa}) has no price-drop mapping.`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const coef = resolveSeverityCoefficient(partKey, row.severity);
|
||||||
|
if (coef === undefined) {
|
||||||
|
errors.push(
|
||||||
|
`Invalid severity "${row.severity}" for part key "${partKey}".`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
coefficients.push(coef);
|
||||||
|
const linePartId = catalogPartIdFromSelectedPart(sp);
|
||||||
|
if (linePartId == null) {
|
||||||
|
errors.push(`Part "${row.partId}" has no catalog id.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lines.push({
|
||||||
|
partId: linePartId,
|
||||||
|
priceDropPartKey: partKey,
|
||||||
|
label_fa: sp.label_fa,
|
||||||
|
severity: row.severity,
|
||||||
|
coefficient: coef,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { coefficients, lines, errors };
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@ import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/clai
|
|||||||
|
|
||||||
export interface ClaimPricingPartLite {
|
export interface ClaimPricingPartLite {
|
||||||
factorNeeded?: boolean;
|
factorNeeded?: boolean;
|
||||||
partId?: string;
|
/** Numeric catalog id (legacy string values may still exist in old documents). */
|
||||||
|
partId?: number | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getActiveV2ExpertReply(claim: { evaluation?: Record<string, unknown> }): {
|
export function getActiveV2ExpertReply(claim: { evaluation?: Record<string, unknown> }): {
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
|
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||||
|
import { buildCoefficientsFromPartSeverities } from "./claim-price-drop";
|
||||||
import {
|
import {
|
||||||
partIdentityKey,
|
catalogPartIdFromSelectedPart,
|
||||||
|
resolveCatalogPartByPartId,
|
||||||
|
resolvePartForExpertReply,
|
||||||
resolveSelectedPartByPartId,
|
resolveSelectedPartByPartId,
|
||||||
|
sanitizeDamageSelectedPartV2,
|
||||||
type DamageSelectedPartV2,
|
type DamageSelectedPartV2,
|
||||||
} from "./outer-damage-parts";
|
} from "./outer-damage-parts";
|
||||||
|
|
||||||
@@ -21,17 +26,97 @@ describe("resolveSelectedPartByPartId", () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
it("resolves by partIdentityKey", () => {
|
it("resolves by numeric catalog id", () => {
|
||||||
expect(
|
expect(resolveSelectedPartByPartId(12, selected)).toBe(selected[0]);
|
||||||
resolveSelectedPartByPartId(partIdentityKey(selected[0]), selected),
|
|
||||||
).toBe(selected[0]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves by id:N catalog prefix", () => {
|
it("resolves legacy id:N string input", () => {
|
||||||
expect(resolveSelectedPartByPartId("id:12", selected)).toBe(selected[0]);
|
expect(resolveSelectedPartByPartId("id:12", selected)).toBe(selected[0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves by index:N", () => {
|
it("resolves by index:N", () => {
|
||||||
expect(resolveSelectedPartByPartId("index:1", selected)).toBe(selected[1]);
|
expect(resolveSelectedPartByPartId("index:1", selected)).toBe(selected[1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("exposes catalog id as numeric partId", () => {
|
||||||
|
expect(catalogPartIdFromSelectedPart(selected[0])).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves catalog id from outer catalog when not on claim", () => {
|
||||||
|
const hit = resolveCatalogPartByPartId(201, ClaimVehicleTypeV2.HATCHBACK);
|
||||||
|
expect(hit?.id).toBe(201);
|
||||||
|
expect(hit?.side).toBe("left");
|
||||||
|
expect(hit?.catalogKey).toBe("left_backfender");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolvePartForExpertReply uses catalog for new expert line", () => {
|
||||||
|
const hit = resolvePartForExpertReply(
|
||||||
|
201,
|
||||||
|
[],
|
||||||
|
ClaimVehicleTypeV2.HATCHBACK,
|
||||||
|
);
|
||||||
|
expect(hit?.id).toBe(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sanitize fixes Persian side and re-hydrates from catalog id", () => {
|
||||||
|
const fixed = sanitizeDamageSelectedPartV2(
|
||||||
|
{
|
||||||
|
id: 201,
|
||||||
|
name: "گلگیر عقب (چپ)",
|
||||||
|
side: "چپ",
|
||||||
|
label_fa: "",
|
||||||
|
catalogKey: "چپ",
|
||||||
|
},
|
||||||
|
ClaimVehicleTypeV2.HATCHBACK,
|
||||||
|
);
|
||||||
|
expect(fixed.side).toBe("left");
|
||||||
|
expect(fixed.name).toBe("backfender");
|
||||||
|
expect(fixed.catalogKey).toBe("left_backfender");
|
||||||
|
expect(catalogPartIdFromSelectedPart(fixed)).toBe(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("internal parts have null catalog partId", () => {
|
||||||
|
const row = sanitizeDamageSelectedPartV2({
|
||||||
|
id: 99,
|
||||||
|
name: "قاب موتور",
|
||||||
|
side: "internal",
|
||||||
|
label_fa: "قاب موتور",
|
||||||
|
});
|
||||||
|
expect(row.id).toBeNull();
|
||||||
|
expect(catalogPartIdFromSelectedPart(row)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers outer part wrongly stored as internal with Persian label", () => {
|
||||||
|
const fixed = sanitizeDamageSelectedPartV2(
|
||||||
|
{
|
||||||
|
id: null,
|
||||||
|
name: "گلگیر عقب (چپ)",
|
||||||
|
side: "internal",
|
||||||
|
label_fa: "گلگیر عقب (چپ)",
|
||||||
|
},
|
||||||
|
ClaimVehicleTypeV2.HATCHBACK,
|
||||||
|
);
|
||||||
|
expect(fixed.id).toBe(201);
|
||||||
|
expect(fixed.side).toBe("left");
|
||||||
|
expect(fixed.name).toBe("backfender");
|
||||||
|
expect(fixed.catalogKey).toBe("left_backfender");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("price drop resolves catalog id when claim row is corrupt", () => {
|
||||||
|
const corrupt: DamageSelectedPartV2[] = [
|
||||||
|
{
|
||||||
|
id: null,
|
||||||
|
name: "گلگیر عقب (چپ)",
|
||||||
|
side: "internal",
|
||||||
|
label_fa: "گلگیر عقب (چپ)",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const { coefficients, errors } = buildCoefficientsFromPartSeverities(
|
||||||
|
corrupt,
|
||||||
|
[{ partId: 201, severity: "Minor" }],
|
||||||
|
ClaimVehicleTypeV2.HATCHBACK,
|
||||||
|
);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
expect(coefficients).toEqual([2]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,100 @@ export type DamageSelectedPartV2 = {
|
|||||||
|
|
||||||
const SIDE_SET = new Set<string>(Object.values(OuterPartSideV2));
|
const SIDE_SET = new Set<string>(Object.values(OuterPartSideV2));
|
||||||
|
|
||||||
|
const SIDE_FA_TO_EN: Record<string, OuterPartSideV2> = {
|
||||||
|
چپ: OuterPartSideV2.LEFT,
|
||||||
|
راست: OuterPartSideV2.RIGHT,
|
||||||
|
جلو: OuterPartSideV2.FRONT,
|
||||||
|
عقب: OuterPartSideV2.BACK,
|
||||||
|
بالا: OuterPartSideV2.TOP,
|
||||||
|
};
|
||||||
|
|
||||||
|
function containsPersianText(s: string): boolean {
|
||||||
|
return /[\u0600-\u06FF]/.test(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** e.g. `گلگیر عقب (چپ)` → title + optional side in Farsi. */
|
||||||
|
function parsePersianPartLabel(label: string): {
|
||||||
|
titleFa: string;
|
||||||
|
sideFa?: string;
|
||||||
|
} {
|
||||||
|
const t = String(label ?? "").trim();
|
||||||
|
const m = t.match(/^(.+?)\s*\(([^)]+)\)\s*$/);
|
||||||
|
if (m) return { titleFa: m[1].trim(), sideFa: m[2].trim() };
|
||||||
|
return { titleFa: t };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Match disambiguated Farsi labels back to a catalog row for this car type. */
|
||||||
|
export function findCatalogItemByPersianLabel(
|
||||||
|
label: string,
|
||||||
|
carType: ClaimVehicleTypeV2,
|
||||||
|
): OuterPartCatalogItem | undefined {
|
||||||
|
const catalog = OUTER_PARTS_BY_CAR_TYPE[carType];
|
||||||
|
if (!catalog?.length) return undefined;
|
||||||
|
const { titleFa, sideFa } = parsePersianPartLabel(label);
|
||||||
|
if (!titleFa) return undefined;
|
||||||
|
const sideEn = sideFa ? SIDE_FA_TO_EN[sideFa] : undefined;
|
||||||
|
const candidates = catalog.filter(
|
||||||
|
(c) => String(c.titleFa).trim() === titleFa,
|
||||||
|
);
|
||||||
|
if (!candidates.length) return undefined;
|
||||||
|
if (sideEn) {
|
||||||
|
return candidates.find((c) => c.side === sideEn) ?? candidates[0];
|
||||||
|
}
|
||||||
|
return candidates.length === 1 ? candidates[0] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Non-catalog lines (expert-added interior / free-text); never use catalog numeric ids. */
|
||||||
|
export function isInternalDamageSide(side: string): boolean {
|
||||||
|
const s = String(side ?? "").trim().toLowerCase();
|
||||||
|
if (!s || s === "internal" || s === "other") return true;
|
||||||
|
return !SIDE_SET.has(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse API / DB part id (number, `"201"`, or legacy `"id:201"`) → catalog id. */
|
||||||
|
export function parseCatalogPartIdInput(
|
||||||
|
partId: number | string | null | undefined,
|
||||||
|
): number | null {
|
||||||
|
if (partId == null || partId === "") return null;
|
||||||
|
if (typeof partId === "number" && Number.isFinite(partId)) {
|
||||||
|
return Math.trunc(partId);
|
||||||
|
}
|
||||||
|
const raw = String(partId).trim();
|
||||||
|
const legacy = raw.match(/^id:(\d+)$/i);
|
||||||
|
if (legacy) return Number(legacy[1]);
|
||||||
|
if (/^\d+$/.test(raw)) return Number(raw);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sameCatalogPartId(
|
||||||
|
a: number | string | null | undefined,
|
||||||
|
b: number | string | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
const na = parseCatalogPartIdInput(a);
|
||||||
|
const nb = parseCatalogPartIdInput(b);
|
||||||
|
return na != null && nb != null && na === nb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** API part id: numeric catalog id only (`null` for internal / non-catalog lines). */
|
||||||
|
export function catalogPartIdFromSelectedPart(
|
||||||
|
sp: DamageSelectedPartV2,
|
||||||
|
): number | null {
|
||||||
|
if (isInternalDamageSide(sp.side)) return null;
|
||||||
|
if (sp.id != null && Number.isFinite(sp.id)) return sp.id;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Internal dedupe / merge key — not for client `partId`. */
|
||||||
|
export function partLookupKey(p: DamageSelectedPartV2): string {
|
||||||
|
const catalogId = catalogPartIdFromSelectedPart(p);
|
||||||
|
if (catalogId != null) return `catalog:${catalogId}`;
|
||||||
|
if (isInternalDamageSide(p.side)) return `internal:${p.name}`;
|
||||||
|
return `legacy:${p.side}|${p.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use {@link partLookupKey} for maps or {@link catalogPartIdFromSelectedPart} for APIs. */
|
||||||
|
export const partIdentityKey = partLookupKey;
|
||||||
|
|
||||||
/** Farsi side suffix for disambiguated labels, e.g. `گلگیر عقب (چپ)`. */
|
/** Farsi side suffix for disambiguated labels, e.g. `گلگیر عقب (چپ)`. */
|
||||||
const SIDE_LABEL_FA: Record<string, string> = {
|
const SIDE_LABEL_FA: Record<string, string> = {
|
||||||
[OuterPartSideV2.LEFT]: "چپ",
|
[OuterPartSideV2.LEFT]: "چپ",
|
||||||
@@ -290,36 +384,123 @@ export function normalizeDamageSelectedParts(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out.map((p) => sanitizeDamageSelectedPartV2(p, carType));
|
||||||
}
|
|
||||||
|
|
||||||
export function partIdentityKey(p: DamageSelectedPartV2): string {
|
|
||||||
if (p.id != null && Number.isFinite(p.id)) return `id:${p.id}`;
|
|
||||||
return `${p.side}|${p.name}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a client `partId` from claim detail / expert reply to a selected part row.
|
* Fix legacy / mistaken rows (Persian in `side`, label in `name`, bad `catalogKey`).
|
||||||
* Accepts `partIdentityKey` values (`id:12`, `left|backfender`), `id:12`, catalog id
|
* Re-hydrate from catalog when `id` or valid `catalogKey` + `carType` are known.
|
||||||
* when unique, or `index:N` for the Nth entry in `selectedParts`.
|
*/
|
||||||
|
export function sanitizeDamageSelectedPartV2(
|
||||||
|
row: DamageSelectedPartV2,
|
||||||
|
carType?: ClaimVehicleTypeV2,
|
||||||
|
): DamageSelectedPartV2 {
|
||||||
|
let { id, name, side, label_fa, catalogKey } = row;
|
||||||
|
const sideTrim = String(side ?? "").trim();
|
||||||
|
const nameTrim = String(name ?? "").trim();
|
||||||
|
let labelTrim = String(label_fa ?? "").trim();
|
||||||
|
|
||||||
|
if (SIDE_FA_TO_EN[sideTrim]) {
|
||||||
|
side = SIDE_FA_TO_EN[sideTrim];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
containsPersianText(nameTrim) &&
|
||||||
|
!containsPersianText(labelTrim) &&
|
||||||
|
labelTrim &&
|
||||||
|
SIDE_SET.has(labelTrim.toLowerCase())
|
||||||
|
) {
|
||||||
|
const swappedSide = labelTrim.toLowerCase();
|
||||||
|
labelTrim = nameTrim;
|
||||||
|
name = nameTrim;
|
||||||
|
side = swappedSide;
|
||||||
|
} else if (containsPersianText(nameTrim) && !labelTrim) {
|
||||||
|
labelTrim = nameTrim;
|
||||||
|
name = normPartSegment(nameTrim) || nameTrim;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (catalogKey && !String(catalogKey).includes("_")) {
|
||||||
|
if (SIDE_FA_TO_EN[String(catalogKey).trim()]) {
|
||||||
|
catalogKey = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalog = carType ? OUTER_PARTS_BY_CAR_TYPE[carType] : undefined;
|
||||||
|
|
||||||
|
if (isInternalDamageSide(side) && catalog?.length) {
|
||||||
|
const persianLabel = labelTrim || nameTrim;
|
||||||
|
if (persianLabel && containsPersianText(persianLabel)) {
|
||||||
|
const recovered = findCatalogItemByPersianLabel(persianLabel, carType!);
|
||||||
|
if (recovered) {
|
||||||
|
return catalogItemToSelectedPart(recovered, catalog);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (id != null && Number.isFinite(id)) {
|
||||||
|
const byId = catalog.find((c) => c.id === id);
|
||||||
|
if (byId) return catalogItemToSelectedPart(byId, catalog);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInternalDamageSide(side)) {
|
||||||
|
return {
|
||||||
|
id: null,
|
||||||
|
name: nameTrim || labelTrim || name,
|
||||||
|
side: "internal",
|
||||||
|
label_fa: labelTrim || nameTrim || name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (catalog?.length) {
|
||||||
|
if (id != null && Number.isFinite(id)) {
|
||||||
|
const byId = catalog.find((c) => c.id === id);
|
||||||
|
if (byId) return catalogItemToSelectedPart(byId, catalog);
|
||||||
|
}
|
||||||
|
const ck = catalogKey?.trim();
|
||||||
|
if (ck && ck.includes("_")) {
|
||||||
|
const byKey = catalog.find((c) => c.key === ck);
|
||||||
|
if (byKey) return catalogItemToSelectedPart(byKey, catalog);
|
||||||
|
}
|
||||||
|
if (SIDE_SET.has(String(side).toLowerCase()) && nameTrim) {
|
||||||
|
const byComposite = catalog.find(
|
||||||
|
(c) => c.key === catalogLikeKeyFromPart({ side, name: nameTrim }),
|
||||||
|
);
|
||||||
|
if (byComposite) return catalogItemToSelectedPart(byComposite, catalog);
|
||||||
|
}
|
||||||
|
const persianLabel = labelTrim || nameTrim;
|
||||||
|
if (persianLabel && containsPersianText(persianLabel)) {
|
||||||
|
const recovered = findCatalogItemByPersianLabel(persianLabel, carType!);
|
||||||
|
if (recovered) return catalogItemToSelectedPart(recovered, catalog);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id ?? null,
|
||||||
|
name: nameTrim,
|
||||||
|
side: String(side ?? "").trim(),
|
||||||
|
label_fa: labelTrim || nameTrim,
|
||||||
|
...(catalogKey && String(catalogKey).includes("_")
|
||||||
|
? { catalogKey: String(catalogKey).trim() }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a client `partId` (numeric catalog id) to a selected part row.
|
||||||
*/
|
*/
|
||||||
export function resolveSelectedPartByPartId(
|
export function resolveSelectedPartByPartId(
|
||||||
partId: string,
|
partId: number | string,
|
||||||
selected: DamageSelectedPartV2[],
|
selected: DamageSelectedPartV2[],
|
||||||
): DamageSelectedPartV2 | undefined {
|
): DamageSelectedPartV2 | undefined {
|
||||||
|
if (!selected.length) return undefined;
|
||||||
|
|
||||||
|
const catalogId = parseCatalogPartIdInput(partId);
|
||||||
|
if (catalogId != null) {
|
||||||
|
const hits = selected.filter((p) => p.id === catalogId);
|
||||||
|
if (hits.length === 1) return hits[0];
|
||||||
|
if (hits.length > 1) return hits[0];
|
||||||
|
}
|
||||||
|
|
||||||
const raw = String(partId ?? "").trim();
|
const raw = String(partId ?? "").trim();
|
||||||
if (!raw || !selected.length) return undefined;
|
|
||||||
|
|
||||||
for (const p of selected) {
|
|
||||||
if (partIdentityKey(p) === raw) return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
const idColon = raw.match(/^id:(\d+)$/i);
|
|
||||||
if (idColon) {
|
|
||||||
const id = Number(idColon[1]);
|
|
||||||
return selected.find((p) => p.id === id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const indexColon = raw.match(/^index:(\d+)$/i);
|
const indexColon = raw.match(/^index:(\d+)$/i);
|
||||||
if (indexColon) {
|
if (indexColon) {
|
||||||
const i = Number(indexColon[1]);
|
const i = Number(indexColon[1]);
|
||||||
@@ -328,29 +509,48 @@ export function resolveSelectedPartByPartId(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/^\d+$/.test(raw)) {
|
|
||||||
const n = Number(raw);
|
|
||||||
const byCatalogId = selected.filter((p) => p.id === n);
|
|
||||||
if (byCatalogId.length === 1) return byCatalogId[0];
|
|
||||||
if (Number.isInteger(n) && n >= 0 && n < selected.length) {
|
|
||||||
return selected[n];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Resolve a catalog row by numeric id when not on the claim yet. */
|
||||||
* Same canonical identity as `partIdentityKey`, but derived directly from a
|
export function resolveCatalogPartByPartId(
|
||||||
* unified `carPartDamage` snapshot (the shape we persist on
|
partId: number | string,
|
||||||
* `evaluation.damageExpertReply*.parts[].carPartDamage`).
|
carType?: ClaimVehicleTypeV2,
|
||||||
*
|
): DamageSelectedPartV2 | undefined {
|
||||||
* Returns `null` when the input cannot produce an identity (no `id`, no
|
const catalogId = parseCatalogPartIdInput(partId);
|
||||||
* `name`). Callers should treat that as an invalid expert reply line.
|
if (catalogId == null) return undefined;
|
||||||
*/
|
|
||||||
export function partIdentityKeyFromCarPartDamage(
|
if (carType) {
|
||||||
|
const list = OUTER_PARTS_BY_CAR_TYPE[carType];
|
||||||
|
const item = list?.find((c) => c.id === catalogId);
|
||||||
|
if (item) return catalogItemToSelectedPart(item, list);
|
||||||
|
}
|
||||||
|
const global = CATALOG_ITEM_BY_ID.get(catalogId);
|
||||||
|
if (global) {
|
||||||
|
return catalogItemToSelectedPart(global, outerCatalogListForItem(global));
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve expert reply / price-drop line by numeric catalog `partId`. */
|
||||||
|
export function resolvePartForExpertReply(
|
||||||
|
partId: number | string,
|
||||||
|
selected: DamageSelectedPartV2[],
|
||||||
|
carType?: ClaimVehicleTypeV2,
|
||||||
|
): DamageSelectedPartV2 | undefined {
|
||||||
|
const catalogId = parseCatalogPartIdInput(partId);
|
||||||
|
if (catalogId == null) return undefined;
|
||||||
|
|
||||||
|
const fromSelected = resolveSelectedPartByPartId(catalogId, selected);
|
||||||
|
if (fromSelected) return fromSelected;
|
||||||
|
|
||||||
|
return resolveCatalogPartByPartId(catalogId, carType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Numeric catalog id from a persisted `carPartDamage` snapshot. */
|
||||||
|
export function catalogPartIdFromCarPartDamage(
|
||||||
carPartDamage: unknown,
|
carPartDamage: unknown,
|
||||||
): string | null {
|
): number | null {
|
||||||
if (
|
if (
|
||||||
!carPartDamage ||
|
!carPartDamage ||
|
||||||
typeof carPartDamage !== "object" ||
|
typeof carPartDamage !== "object" ||
|
||||||
@@ -359,21 +559,14 @@ export function partIdentityKeyFromCarPartDamage(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const o = carPartDamage as Record<string, unknown>;
|
const o = carPartDamage as Record<string, unknown>;
|
||||||
const idRaw = o.id;
|
return parseCatalogPartIdInput(
|
||||||
const id =
|
typeof o.id === "number" || typeof o.id === "string" ? o.id : null,
|
||||||
typeof idRaw === "number" && Number.isFinite(idRaw)
|
);
|
||||||
? idRaw
|
|
||||||
: typeof idRaw === "string" &&
|
|
||||||
idRaw.trim() &&
|
|
||||||
Number.isFinite(Number(idRaw))
|
|
||||||
? Number(idRaw)
|
|
||||||
: null;
|
|
||||||
const name = typeof o.name === "string" ? o.name.trim() : "";
|
|
||||||
const side = typeof o.side === "string" ? o.side.trim() : "";
|
|
||||||
if (id == null && !name) return null;
|
|
||||||
return partIdentityKey({ id, name, side, label_fa: "" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use {@link catalogPartIdFromCarPartDamage}. */
|
||||||
|
export const partIdentityKeyFromCarPartDamage = catalogPartIdFromCarPartDamage;
|
||||||
|
|
||||||
function normPartSegment(s: string): string {
|
function normPartSegment(s: string): string {
|
||||||
return String(s ?? "").replace(/[^a-z0-9]/gi, "").toLowerCase();
|
return String(s ?? "").replace(/[^a-z0-9]/gi, "").toLowerCase();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user