1
0
forked from Yara724/api

YARA-883 + Side ID fixes

This commit is contained in:
SepehrYahyaee
2026-05-18 17:14:45 +03:30
parent e1954cdb37
commit cef684e37f
12 changed files with 1234 additions and 119 deletions

View File

@@ -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<Record<string, unknown>>;
};
};
@ApiPropertyOptional({

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

View File

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

View File

@@ -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])

View File

@@ -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<string, unknown> = {};
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<string>();
const seenCatalogIds = new Set<number>();
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<string, unknown>;
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<string, unknown> = {
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<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.
* 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],

View File

@@ -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",