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

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