From cef684e37f1c4ea0622349a4ab6b17e53825189e Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Mon, 18 May 2026 17:14:45 +0330 Subject: [PATCH] YARA-883 + Side ID fixes --- .../schema/claim-case.evaluation.schema.ts | 10 +- src/expert-claim/dto/claim-detail-v2.dto.ts | 16 +- .../dto/claim-price-drop-v2.dto.ts | 127 +++++++ src/expert-claim/dto/expert-claim-v2.dto.ts | 11 +- src/expert-claim/dto/factor-validation.dto.ts | 7 +- src/expert-claim/expert-claim.service.ts | 303 ++++++++++++--- .../expert-claim.v2.controller.ts | 46 ++- src/helpers/claim-price-drop.spec.ts | 83 +++++ src/helpers/claim-price-drop.ts | 349 ++++++++++++++++++ src/helpers/claim-v2-expert-reply-workflow.ts | 3 +- .../outer-damage-parts-resolve.spec.ts | 97 ++++- src/helpers/outer-damage-parts.ts | 301 ++++++++++++--- 12 files changed, 1234 insertions(+), 119 deletions(-) create mode 100644 src/expert-claim/dto/claim-price-drop-v2.dto.ts create mode 100644 src/helpers/claim-price-drop.spec.ts create mode 100644 src/helpers/claim-price-drop.ts diff --git a/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts b/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts index cb6d410..61406d3 100644 --- a/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts +++ b/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts @@ -9,8 +9,8 @@ import { UserReplyEnum } from "src/Types&Enums/claim-request-management/userRepl @Schema({ _id: false }) export class ClaimPartPricing { - @Prop({ type: String }) - partId: string; + @Prop({ type: Number }) + partId: number; /** * Unified outer-part snapshot `{ id?, name, side, label_fa, catalogKey? }` (same as @@ -269,6 +269,12 @@ export class ClaimPriceDrop { @Prop({ type: Number }) sumOfSeverity?: number; + + @Prop({ type: Number }) + coefficientYear?: number; + + @Prop({ type: [MongooseSchema.Types.Mixed], default: [] }) + partLines?: Array>; } export const ClaimPriceDropSchema = SchemaFactory.createForClass(ClaimPriceDrop); diff --git a/src/expert-claim/dto/claim-detail-v2.dto.ts b/src/expert-claim/dto/claim-detail-v2.dto.ts index 77bc7c8..8216461 100644 --- a/src/expert-claim/dto/claim-detail-v2.dto.ts +++ b/src/expert-claim/dto/claim-detail-v2.dto.ts @@ -94,9 +94,10 @@ export class ClaimDetailV2ResponseDto { properties: { index: { type: 'number' }, partId: { - type: 'string', + type: 'number', + nullable: true, 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 }, name: { type: 'string' }, @@ -109,7 +110,7 @@ export class ClaimDetailV2ResponseDto { }) damagedParts?: Array<{ index: number; - partId: string; + partId: number | null; id?: number | null; name: string; side: string; @@ -152,6 +153,15 @@ export class ClaimDetailV2ResponseDto { signLink?: string; signedAt?: Date | string; }; + priceDrop?: { + total?: number; + carPrice?: number; + carModel?: number; + carValue?: number[]; + sumOfSeverity?: number; + coefficientYear?: number; + partLines?: Array>; + }; }; @ApiPropertyOptional({ diff --git a/src/expert-claim/dto/claim-price-drop-v2.dto.ts b/src/expert-claim/dto/claim-price-drop-v2.dto.ts new file mode 100644 index 0000000..7fd3055 --- /dev/null +++ b/src/expert-claim/dto/claim-price-drop-v2.dto.ts @@ -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; + + @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>; + + @ApiProperty({ + description: "Claim damaged parts with optional price-drop key mapping", + }) + damagedParts: Array>; +} + +export class ClaimPriceDropResultV2Dto { + @ApiProperty() + claimRequestId: string; + + @ApiProperty() + vehicle: { + carName?: string; + carModel?: string; + carType?: string; + }; + + @ApiProperty() + priceDrop: Record; + + @ApiProperty({ type: "array" }) + partLines: Array>; +} diff --git a/src/expert-claim/dto/expert-claim-v2.dto.ts b/src/expert-claim/dto/expert-claim-v2.dto.ts index fa8a15b..3da7f11 100644 --- a/src/expert-claim/dto/expert-claim-v2.dto.ts +++ b/src/expert-claim/dto/expert-claim-v2.dto.ts @@ -7,6 +7,7 @@ import { IsOptional, ValidateNested, IsEnum, + IsInt, } from 'class-validator'; import { Type } from 'class-transformer'; import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum'; @@ -40,13 +41,11 @@ export class DaghiDetailsV2Dto { export class PartPricingV2Dto { @ApiProperty({ - example: '12', - description: - 'Stable part id from GET claim detail `damagedParts[].partId` or `selectedParts` identity (`id:{catalogId}` or `{side}|{name}`). Required.', + example: 201, + description: 'Numeric catalog part id from claim detail `damagedParts[].partId`.', }) - @IsString() - @IsNotEmpty() - partId: string; + @IsInt() + partId: number; @ApiProperty({ example: 'Minor' }) @IsString() diff --git a/src/expert-claim/dto/factor-validation.dto.ts b/src/expert-claim/dto/factor-validation.dto.ts index 4650fb7..b87aa12 100644 --- a/src/expert-claim/dto/factor-validation.dto.ts +++ b/src/expert-claim/dto/factor-validation.dto.ts @@ -3,6 +3,7 @@ import { Type } from "class-transformer"; import { IsArray, IsEnum, + IsInt, IsOptional, IsString, ValidateNested, @@ -34,9 +35,9 @@ export class FactorValidationDto { /** V2 ClaimCase: expert confirms or overrides part pricing when validating uploaded factors. */ class FactorDecisionV2Dto { - @ApiProperty() - @IsString() - partId: string; + @ApiProperty({ example: 201 }) + @IsInt() + partId: number; @ApiProperty({ enum: [FactorStatus.APPROVED, FactorStatus.REJECTED] }) @IsEnum([FactorStatus.APPROVED, FactorStatus.REJECTED]) diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 1422a76..31339a6 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -50,6 +50,15 @@ import { expertPortfolioFileIdsFromActivityEvents, objectIdsFromStringSet, } 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 { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum"; import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; @@ -87,9 +96,13 @@ import { coerceDamagedPartsMediaToArray, normalizeCarPartDamageForExpertReply, normalizeDamageSelectedParts, - partIdentityKey, - partIdentityKeyFromCarPartDamage, - resolveSelectedPartByPartId, + catalogPartIdFromCarPartDamage, + catalogPartIdFromSelectedPart, + partLookupKey, + resolvePartForExpertReply, + sameCatalogPartId, + sanitizeDamageSelectedPartV2, + type DamageSelectedPartV2, } from "src/helpers/outer-damage-parts"; import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog"; 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`. */ private claimReplyPartLineTotalForCap(part: { - partId?: string; + partId?: number | string; totalPayment?: unknown; price?: unknown; salary?: unknown; @@ -2123,8 +2136,8 @@ export class ExpertClaimService { const $set: Record = {}; for (const decision of body.decisions) { - const partIndex = finalReply.parts.findIndex( - (p) => String(p.partId) === String(decision.partId), + const partIndex = finalReply.parts.findIndex((p) => + sameCatalogPartId(p.partId, decision.partId), ); if (partIndex === -1) { this.logger.warn( @@ -2217,7 +2230,7 @@ export class ExpertClaimService { const line = this.claimReplyPartLineTotalForCap(part); if (isNaN(line)) { 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", }); } @@ -2705,23 +2718,23 @@ export class ExpertClaimService { carTypeSubmit, (claim as any)?.damage?.selectedOuterParts, ); - const ownerSelectedIdentitySet = new Set( - ownerSelectedParts.map((p) => partIdentityKey(p)), - ); - const seenIdentities = new Set(); + const seenCatalogIds = new Set(); + const expertAddedParts: DamageSelectedPartV2[] = []; 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; 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( { id: selected.id, @@ -2733,7 +2746,6 @@ export class ExpertClaimService { carTypeSubmit, ); } catch (err) { - if (err instanceof BadRequestException) throw err; throw new BadRequestException( `Invalid part ${p.partId}: ${ err instanceof Error ? err.message : String(err) @@ -2741,30 +2753,32 @@ export class ExpertClaimService { ); } - const identityKey = partIdentityKeyFromCarPartDamage(carPartDamage); - if (!identityKey) { + const catalogId = catalogPartIdFromCarPartDamage(carPartDamage); + if (catalogId == null) { 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 ( - ownerSelectedIdentitySet.size > 0 && - !ownerSelectedIdentitySet.has(identityKey) - ) { + if (seenCatalogIds.has(catalogId)) { 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)) { - throw new BadRequestException( - `Duplicate part submitted in expert reply: ${identityKey}.`, - ); + const wasOnClaim = ownerSelectedParts.some( + (op) => partLookupKey(op) === partLookupKey(selected), + ); + if (!wasOnClaim) { + expertAddedParts.push(selected); } - seenIdentities.add(identityKey); - return { ...p, partId: identityKey, carPartDamage }; + return { + ...p, + partId: catalogId, + carPartDamage, + }; }); 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 = { status: nextCaseStatus, claimStatus: nextClaimStatus, + ...(expertAddedParts.length > 0 + ? { "damage.selectedParts": mergedSelectedParts } + : {}), 'workflow.locked': false, $unset: { 'workflow.lockedAt': '', @@ -3605,7 +3636,7 @@ export class ExpertClaimService { ) as { url?: string; path?: string } | undefined; return { index, - partId: partIdentityKey(sp), + partId: catalogPartIdFromSelectedPart(sp), id: sp.id, name: sp.name, side: sp.side, @@ -3753,6 +3784,12 @@ export class ExpertClaimService { evaluationForApi.ownerPricedPartsApproval = enrichedEvaluation.ownerPricedPartsApproval; } + if ( + isDamageExpertPhase && + enrichedEvaluation.priceDrop !== undefined + ) { + evaluationForApi.priceDrop = enrichedEvaluation.priceDrop; + } if (Object.keys(evaluationForApi).length === 0) { 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 | undefined; + suggestedCarModelYear = resolveVehicleModelYearFromBlame( + claim, + blame as Parameters[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[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 = {}; + 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. * Allowed only when claim is EXPERT_REVIEWING and locked by this expert. @@ -3850,13 +4061,19 @@ export class ExpertClaimService { previousNorm, ); - const nextNorm = body.selectedParts.map((p) => ({ - id: p.id ?? null, - name: String(p.name || "").trim(), - side: String(p.side || ""), - label_fa: String(p.label_fa || "").trim() || String(p.name || "").trim(), - ...(p.catalogKey?.trim() ? { catalogKey: p.catalogKey.trim() } : {}), - })); + const nextNorm = body.selectedParts.map((p) => + sanitizeDamageSelectedPartV2( + { + id: p.id ?? null, + name: String(p.name || "").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 = ( next: (typeof nextNorm)[0], diff --git a/src/expert-claim/expert-claim.v2.controller.ts b/src/expert-claim/expert-claim.v2.controller.ts index 58f2655..737fe24 100644 --- a/src/expert-claim/expert-claim.v2.controller.ts +++ b/src/expert-claim/expert-claim.v2.controller.ts @@ -18,6 +18,11 @@ import { RoleEnum } from "src/Types&Enums/role.enum"; import { ExpertClaimService } from "./expert-claim.service"; import { ClaimSubmitResendV2Dto, SubmitExpertReplyV2Dto } from "./dto/expert-claim-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 { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service"; import { OuterPartCatalogItemDto } from "src/claim-request-management/dto/select-outer-parts-v2.dto"; @@ -101,7 +106,7 @@ export class ExpertClaimV2Controller { @ApiOperation({ summary: "Get claim request detail for damage expert", 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" }) async getClaimDetailV2( @@ -111,6 +116,45 @@ export class ExpertClaimV2Controller { 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") @ApiOperation({ summary: "Lock a claim request for review", diff --git a/src/helpers/claim-price-drop.spec.ts b/src/helpers/claim-price-drop.spec.ts new file mode 100644 index 0000000..746408f --- /dev/null +++ b/src/helpers/claim-price-drop.spec.ts @@ -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); + }); +}); diff --git a/src/helpers/claim-price-drop.ts b/src/helpers/claim-price-drop.ts new file mode 100644 index 0000000..e7f5731 --- /dev/null +++ b/src/helpers/claim-price-drop.ts @@ -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 = { + 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> +> = { + 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 = { + 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 = { + 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 = { + 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(); +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; +}> { + 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 }; +} diff --git a/src/helpers/claim-v2-expert-reply-workflow.ts b/src/helpers/claim-v2-expert-reply-workflow.ts index a6f7762..894b23f 100644 --- a/src/helpers/claim-v2-expert-reply-workflow.ts +++ b/src/helpers/claim-v2-expert-reply-workflow.ts @@ -4,7 +4,8 @@ import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/clai export interface ClaimPricingPartLite { 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 }): { diff --git a/src/helpers/outer-damage-parts-resolve.spec.ts b/src/helpers/outer-damage-parts-resolve.spec.ts index 7f933e6..0d99140 100644 --- a/src/helpers/outer-damage-parts-resolve.spec.ts +++ b/src/helpers/outer-damage-parts-resolve.spec.ts @@ -1,6 +1,11 @@ +import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog"; +import { buildCoefficientsFromPartSeverities } from "./claim-price-drop"; import { - partIdentityKey, + catalogPartIdFromSelectedPart, + resolveCatalogPartByPartId, + resolvePartForExpertReply, resolveSelectedPartByPartId, + sanitizeDamageSelectedPartV2, type DamageSelectedPartV2, } from "./outer-damage-parts"; @@ -21,17 +26,97 @@ describe("resolveSelectedPartByPartId", () => { }, ]; - it("resolves by partIdentityKey", () => { - expect( - resolveSelectedPartByPartId(partIdentityKey(selected[0]), selected), - ).toBe(selected[0]); + it("resolves by numeric catalog id", () => { + expect(resolveSelectedPartByPartId(12, 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]); }); it("resolves by index:N", () => { 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]); + }); }); diff --git a/src/helpers/outer-damage-parts.ts b/src/helpers/outer-damage-parts.ts index 3a94a51..f1e8e5c 100644 --- a/src/helpers/outer-damage-parts.ts +++ b/src/helpers/outer-damage-parts.ts @@ -17,6 +17,100 @@ export type DamageSelectedPartV2 = { const SIDE_SET = new Set(Object.values(OuterPartSideV2)); +const SIDE_FA_TO_EN: Record = { + چپ: 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. `گلگیر عقب (چپ)`. */ const SIDE_LABEL_FA: Record = { [OuterPartSideV2.LEFT]: "چپ", @@ -290,36 +384,123 @@ export function normalizeDamageSelectedParts( } } } - return out; -} - -export function partIdentityKey(p: DamageSelectedPartV2): string { - if (p.id != null && Number.isFinite(p.id)) return `id:${p.id}`; - return `${p.side}|${p.name}`; + return out.map((p) => sanitizeDamageSelectedPartV2(p, carType)); } /** - * Resolve a client `partId` from claim detail / expert reply to a selected part row. - * Accepts `partIdentityKey` values (`id:12`, `left|backfender`), `id:12`, catalog id - * when unique, or `index:N` for the Nth entry in `selectedParts`. + * Fix legacy / mistaken rows (Persian in `side`, label in `name`, bad `catalogKey`). + * Re-hydrate from catalog when `id` or valid `catalogKey` + `carType` are known. + */ +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( - partId: string, + partId: number | string, selected: DamageSelectedPartV2[], ): 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(); - 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); if (indexColon) { 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; } -/** - * Same canonical identity as `partIdentityKey`, but derived directly from a - * unified `carPartDamage` snapshot (the shape we persist on - * `evaluation.damageExpertReply*.parts[].carPartDamage`). - * - * Returns `null` when the input cannot produce an identity (no `id`, no - * `name`). Callers should treat that as an invalid expert reply line. - */ -export function partIdentityKeyFromCarPartDamage( +/** Resolve a catalog row by numeric id when not on the claim yet. */ +export function resolveCatalogPartByPartId( + partId: number | string, + carType?: ClaimVehicleTypeV2, +): DamageSelectedPartV2 | undefined { + const catalogId = parseCatalogPartIdInput(partId); + if (catalogId == null) return undefined; + + 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, -): string | null { +): number | null { if ( !carPartDamage || typeof carPartDamage !== "object" || @@ -359,21 +559,14 @@ export function partIdentityKeyFromCarPartDamage( return null; } const o = carPartDamage as Record; - const idRaw = o.id; - const id = - 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: "" }); + return parseCatalogPartIdInput( + typeof o.id === "number" || typeof o.id === "string" ? o.id : null, + ); } +/** @deprecated Use {@link catalogPartIdFromCarPartDamage}. */ +export const partIdentityKeyFromCarPartDamage = catalogPartIdFromCarPartDamage; + function normPartSegment(s: string): string { return String(s ?? "").replace(/[^a-z0-9]/gi, "").toLowerCase(); }