1
0
forked from Yara724/api

merge upstream

This commit is contained in:
2026-05-05 10:13:11 +03:30
16 changed files with 1347 additions and 299 deletions

View File

@@ -99,8 +99,6 @@ import {
hasClaimCarAngleCapture,
hasDamagedPartCapture,
legacyAngleDamagedPartFieldKeysToUnset,
legacyDamagedPartTitleEnFromPartKey,
resolveCanonicalDamagedPartStorageKey,
type ClaimCarAngleKey,
} from "src/helpers/claim-car-angle-media";
import {
@@ -108,6 +106,16 @@ import {
OUTER_PARTS_BY_CAR_TYPE,
OuterPartCatalogItem,
} from "src/static/outer-car-parts-catalog";
import {
carPartDamageLabelForFactorRecord,
catalogItemToSelectedPart,
catalogLikeKeyFromPart,
coerceDamagedPartsMediaToArray,
migrateExpertReplyCarPartDamageToUnified,
normalizeDamageSelectedParts,
partIdentityKey,
resolvePartCaptureIndex,
} from "src/helpers/outer-damage-parts";
@Injectable()
export class ClaimRequestManagementService {
@@ -718,17 +726,32 @@ export class ClaimRequestManagementService {
throw new BadRequestException("Car part damage is already set.");
}
this.assertCarPartDamageAtMostTwoOfFourSides(dto.carPartDamage);
const selectedParts = this.carDamagePartDtoToOuterPartSlugs(dto.carPartDamage);
if (!selectedParts.length) {
const selectedSlugStrings = this.carDamagePartDtoToOuterPartSlugs(
dto.carPartDamage,
);
if (!selectedSlugStrings.length) {
throw new BadRequestException(
"No outer damaged parts could be derived from carPartDamage. Check part selections.",
);
}
const selectedPartObjs = normalizeDamageSelectedParts(
selectedSlugStrings,
working.vehicle?.carType as ClaimVehicleTypeV2 | undefined,
undefined,
);
const damagedPartsStubs = selectedPartObjs.map((p) => ({
id: p.id ?? undefined,
name: p.name,
side: p.side,
label_fa: p.label_fa,
...(p.catalogKey ? { catalogKey: p.catalogKey } : {}),
}));
const updated = await this.claimCaseDbService.findByIdAndUpdate(
claimRequestId,
{
"damage.selectedParts": selectedParts,
"damage.selectedParts": selectedPartObjs,
"media.damagedParts": damagedPartsStubs,
status: ClaimCaseStatus.SELECTING_OTHER_PARTS,
"workflow.currentStep": ClaimWorkflowStep.SELECT_OTHER_PARTS,
"workflow.nextStep": ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
@@ -746,7 +769,7 @@ export class ClaimRequestManagementService {
timestamp: new Date(),
metadata: {
stepKey: ClaimWorkflowStep.SELECT_OUTER_PARTS,
selectedParts,
selectedParts: selectedPartObjs,
description: "Field expert submitted outer damage parts",
},
},
@@ -1924,7 +1947,10 @@ export class ClaimRequestManagementService {
* - convert factorLink ObjectId -> public URL
* - enrich daghi.branchId with branchName when available
*/
private async mapEvaluationForClient(evaluation: any): Promise<any | undefined> {
private async mapEvaluationForClient(
evaluation: any,
carType?: ClaimVehicleTypeV2,
): Promise<any | undefined> {
if (!evaluation) return undefined;
const mapReply = async (reply: any) => {
@@ -1990,6 +2016,13 @@ export class ClaimRequestManagementService {
};
}
if (nextPart.carPartDamage != null) {
nextPart.carPartDamage = migrateExpertReplyCarPartDamageToUnified(
nextPart.carPartDamage,
carType,
);
}
return nextPart;
});
@@ -2034,7 +2067,9 @@ export class ClaimRequestManagementService {
const parts = finalReply?.parts || [];
const missingFactors = parts
.filter((p) => p.factorNeeded === true && !p.factorLink)
.map((p) => p.carPartDamage || p.partId);
.map((p) =>
carPartDamageLabelForFactorRecord(p.carPartDamage) || p.partId,
);
if (missingFactors.length > 0) {
throw new HttpException(
@@ -2261,7 +2296,7 @@ export class ClaimRequestManagementService {
const factorRecord = await this.claimFactorsImageDbService.create({
claimId,
partId,
partName: part.carPartDamage,
partName: carPartDamageLabelForFactorRecord(part.carPartDamage),
path: file.path,
fileName: file.filename,
uploadedAt: new Date(),
@@ -2398,7 +2433,9 @@ export class ClaimRequestManagementService {
);
}
const partNameForRecord = part.carPartDamage ?? part.partId;
const partNameForRecord =
carPartDamageLabelForFactorRecord(part.carPartDamage) ||
String(part.partId ?? "");
const factorRecord = await this.claimFactorsImageDbService.create({
claimId: new Types.ObjectId(claimRequestId),
@@ -3983,41 +4020,46 @@ export class ClaimRequestManagementService {
);
}
selectedParts = selectedItems.map((p) => p.key);
const selectedPartIds = selectedItems.map((p) => p.id);
const selectedOuterParts = selectedItems.map((p) => ({
id: p.id,
key: p.key,
const selectedPartDocs = selectedItems.map((p) =>
catalogItemToSelectedPart(p, catalog),
);
const damagedPartsInitial = selectedPartDocs.map((p) => ({
id: p.id ?? undefined,
name: p.name,
side: p.side,
label_fa: p.label_fa,
...(p.catalogKey ? { catalogKey: p.catalogKey } : {}),
}));
const selectedPartIds = selectedItems.map((p) => p.id);
// 6. Update claim case with selected parts and move to next step
const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate(
claimRequestId,
{
'damage.selectedParts': selectedParts,
'damage.selectedOuterParts': selectedOuterParts,
'vehicle.carType': selectedCarType,
'status': ClaimCaseStatus.SELECTING_OTHER_PARTS,
'workflow.currentStep': ClaimWorkflowStep.SELECT_OTHER_PARTS,
'workflow.nextStep': ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
"damage.selectedParts": selectedPartDocs,
"media.damagedParts": damagedPartsInitial,
"vehicle.carType": selectedCarType,
status: ClaimCaseStatus.SELECTING_OTHER_PARTS,
"workflow.currentStep": ClaimWorkflowStep.SELECT_OTHER_PARTS,
"workflow.nextStep": ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
$unset: { "damage.selectedOuterParts": "" },
$push: {
'workflow.completedSteps': ClaimWorkflowStep.SELECT_OUTER_PARTS,
"workflow.completedSteps": ClaimWorkflowStep.SELECT_OUTER_PARTS,
history: {
type: 'STEP_COMPLETED',
type: "STEP_COMPLETED",
actor: {
actorId: new Types.ObjectId(currentUserId),
actorName: claimCase.owner?.fullName || 'User',
actorType: 'user',
actorName: claimCase.owner?.fullName || "User",
actorType: "user",
},
timestamp: new Date(),
metadata: {
stepKey: ClaimWorkflowStep.SELECT_OUTER_PARTS,
selectedParts: selectedParts,
selectedParts: selectedPartDocs,
selectedPartIds,
carType: selectedCarType,
partsCount: selectedParts?.length,
description: `User selected ${selectedParts?.length} damaged outer parts`,
partsCount: selectedPartDocs?.length,
description: `User selected ${selectedPartDocs?.length} damaged outer parts`,
},
},
},
@@ -4025,22 +4067,23 @@ export class ClaimRequestManagementService {
);
if (!updatedClaim) {
throw new InternalServerErrorException('Failed to update claim case');
throw new InternalServerErrorException("Failed to update claim case");
}
this.logger.log(
`Outer parts selected for claim ${claimRequestId}: ${selectedParts?.length} parts`,
`Outer parts selected for claim ${claimRequestId}: ${selectedPartDocs?.length} parts`,
);
// 7. Return response
return {
claimRequestId: updatedClaim._id.toString(),
publicId: updatedClaim.publicId,
selectedParts: selectedParts,
selectedParts: selectedPartDocs,
selectedPartIds,
currentStep: ClaimWorkflowStep.SELECT_OTHER_PARTS,
nextStep: ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
message: 'Outer parts selected successfully. Please proceed to select other parts and provide bank information.',
message:
"Outer parts selected successfully. Please proceed to select other parts and provide bank information.",
};
} catch (error) {
if (error instanceof HttpException) throw error;
@@ -4293,19 +4336,6 @@ export class ClaimRequestManagementService {
if (!catalogByKey.has(item.key)) catalogByKey.set(item.key, item);
}
const getPartLabel = (key: string): { fa: string; en: string } => {
const catalogItem = catalogByKey.get(key);
if (catalogItem) {
const en = key
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/_/g, " ")
.trim()
.replace(/\b\w/g, (c) => c.toUpperCase());
return { fa: catalogItem.titleFa, en };
}
return { fa: key, en: key };
};
// Required documents: CAR_BODY needs 7 (damaged + green card); THIRD_PARTY needs 13 (add guilty_*)
const blameRequest = claimCase.blameRequestId
? await this.blameRequestDbService.findById(claimCase.blameRequestId.toString())
@@ -4359,46 +4389,36 @@ export class ClaimRequestManagementService {
),
}));
// Damaged parts from selected parts
const selectedParts = claimCase.damage?.selectedParts || [];
const selectedOuterParts = Array.isArray(claimCase.damage?.selectedOuterParts)
? claimCase.damage.selectedOuterParts
: [];
const selectedOuterPartByKey = new Map<
string,
{ id?: number; side?: string }
>(
selectedOuterParts.map((p) => [
p.key,
{
id: p.id,
side: p.side,
},
]),
const carType = claimCase.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const selectedNorm = normalizeDamageSelectedParts(
claimCase.damage?.selectedParts,
carType,
(claimCase.damage as any)?.selectedOuterParts,
);
const damagedParts = selectedParts.map(partKey => {
const label = getPartLabel(partKey);
const selectedMeta = selectedOuterPartByKey.get(partKey);
const catalogMeta = catalogByKey.get(partKey);
const side = selectedMeta?.side ?? catalogMeta?.side;
const sideFaMap: Record<string, string> = {
left: "چپ",
right: "راست",
front: "جلو",
back: "عقب",
top: "بالا",
};
const sideFa = side ? sideFaMap[side] : undefined;
const damagedParts = selectedNorm.map((sp) => {
const catalogMeta = sp.catalogKey
? catalogByKey.get(sp.catalogKey)
: sp.name
? catalogByKey.get(catalogLikeKeyFromPart(sp))
: undefined;
const labelEnName = sp.name
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/_/g, " ")
.trim()
.replace(/\b\w/g, (c) => c.toUpperCase());
const ck = sp.catalogKey ?? catalogLikeKeyFromPart(sp);
return {
id: selectedMeta?.id ?? catalogMeta?.id,
key: partKey,
side,
label_fa: sideFa ? `${label.fa} (${sideFa})` : label.fa,
label_en: label.en,
id: sp.id ?? catalogMeta?.id,
key: sp.name,
name: sp.name,
side: sp.side,
label_fa: sp.label_fa,
label_en: labelEnName,
captured: hasDamagedPartCapture(
claimCase.media?.damagedParts as any,
partKey,
ck,
selectedNorm,
),
};
});
@@ -4835,48 +4855,90 @@ export class ClaimRequestManagementService {
},
};
const selectedBefore = (claimCase.damage?.selectedParts || []) as string[];
const carType = claimCase.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const selectedOuterLegacy = (claimCase.damage as any)?.selectedOuterParts;
const selectedBeforeNorm = normalizeDamageSelectedParts(
claimCase.damage?.selectedParts,
carType,
selectedOuterLegacy,
);
let nextSelected = [...selectedBeforeNorm];
let nextMedia = coerceDamagedPartsMediaToArray(
claimCase.media?.damagedParts,
nextSelected,
);
if (treatAsAngle && angleCanon) {
updateData[`media.carAngles.${angleCanon}`] = captureData;
const unsetFieldKeys = legacyAngleDamagedPartFieldKeysToUnset(
angleCanon,
body.captureKey,
selectedBefore,
);
if (unsetFieldKeys.length) {
updateData.$unset = {};
for (const k of unsetFieldKeys) {
updateData.$unset[`media.damagedParts.${k}`] = "";
if (!Array.isArray(claimCase.media?.damagedParts)) {
const unsetFieldKeys = legacyAngleDamagedPartFieldKeysToUnset(
angleCanon,
body.captureKey,
claimCase.damage?.selectedParts,
carType,
selectedOuterLegacy,
);
if (unsetFieldKeys.length) {
updateData.$unset = {};
for (const k of unsetFieldKeys) {
updateData.$unset[`media.damagedParts.${k}`] = "";
}
}
}
} else {
const canonicalPartKey = resolveCanonicalDamagedPartStorageKey(
let idx = resolvePartCaptureIndex(
body.captureKey,
selectedBefore,
nextSelected,
nextMedia,
);
updateData[`media.damagedParts.${canonicalPartKey}`] = captureData;
const raw = String(body.captureKey ?? "").trim();
const titleLegacy =
legacyDamagedPartTitleEnFromPartKey(canonicalPartKey);
const unsetPartKeys = new Set<string>();
if (raw && raw !== canonicalPartKey) unsetPartKeys.add(raw);
if (titleLegacy && titleLegacy !== canonicalPartKey) {
unsetPartKeys.add(titleLegacy);
if (isResendCapture && body.captureType === "part" && idx < 0) {
const added = normalizeDamageSelectedParts(
[body.captureKey],
carType,
undefined,
);
const row =
added[0] || {
id: null,
name: body.captureKey,
side: "",
label_fa: body.captureKey,
catalogKey: body.captureKey,
};
nextSelected.push(row);
idx = nextSelected.length - 1;
nextMedia.push({});
}
if (unsetPartKeys.size) {
updateData.$unset = updateData.$unset || {};
for (const k of unsetPartKeys) {
updateData.$unset[`media.damagedParts.${k}`] = "";
}
if (idx < 0) {
throw new BadRequestException(
`Unknown damaged-part captureKey "${body.captureKey}". Use catalog id, 0-based index, or catalog key.`,
);
}
while (nextMedia.length <= idx) {
nextMedia.push({});
}
const base = nextSelected[idx] || {
id: null,
name: String(body.captureKey),
side: "",
label_fa: String(body.captureKey),
};
nextMedia[idx] = {
...(base.id != null ? { id: base.id } : {}),
name: base.name,
side: base.side,
label_fa: base.label_fa,
...(base.catalogKey ? { catalogKey: base.catalogKey } : {}),
...nextMedia[idx],
...captureData,
};
updateData["media.damagedParts"] = nextMedia;
if (
isResendCapture &&
nextSelected.length !== selectedBeforeNorm.length
) {
updateData["damage.selectedParts"] = nextSelected;
}
}
if (
isResendCapture &&
body.captureType === "part" &&
!selectedBefore.includes(body.captureKey)
) {
updateData.$addToSet = { "damage.selectedParts": body.captureKey };
}
const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate(
@@ -4911,10 +4973,14 @@ export class ClaimRequestManagementService {
data && (data instanceof Map ? data.get(key) : data[key]);
// Use same logic as getCaptureRequirementsV2 to determine completion
const anglesKeys = ['front', 'back', 'left', 'right'];
const anglesKeys = ["front", "back", "left", "right"];
const carAnglesData = updatedClaim?.media?.carAngles as any;
const damagedPartsData = updatedClaim?.media?.damagedParts as any;
const selectedParts = updatedClaim?.damage?.selectedParts || [];
const selectedNormAfter = normalizeDamageSelectedParts(
updatedClaim?.damage?.selectedParts,
updatedClaim?.vehicle?.carType as ClaimVehicleTypeV2,
(updatedClaim?.damage as any)?.selectedOuterParts,
);
const anglesCaptured = anglesKeys.filter((k) =>
hasClaimCarAngleCapture(
@@ -4923,12 +4989,17 @@ export class ClaimRequestManagementService {
k as ClaimCarAngleKey,
),
).length;
const partsCaptured = selectedParts.filter((p) =>
hasDamagedPartCapture(damagedPartsData, p),
).length;
const partsCaptured = selectedNormAfter.filter((sp) => {
const ck = sp.catalogKey ?? catalogLikeKeyFromPart(sp);
return hasDamagedPartCapture(
damagedPartsData,
ck,
selectedNormAfter,
);
}).length;
const allCapturesComplete =
anglesCaptured >= 4 && partsCaptured >= selectedParts.length;
anglesCaptured >= 4 && partsCaptured >= selectedNormAfter.length;
if (allCapturesComplete) {
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
@@ -4955,7 +5026,8 @@ export class ClaimRequestManagementService {
});
}
const remaining = (4 - anglesCaptured) + (selectedParts.length - partsCaptured);
const remaining =
(4 - anglesCaptured) + (selectedNormAfter.length - partsCaptured);
const message = allCapturesComplete
? 'All captures complete. Please proceed to upload required documents.'
: `${body.captureType === 'angle' ? 'Angle' : 'Part'} captured successfully. ${remaining} captures remaining.`;
@@ -5193,11 +5265,29 @@ export class ClaimRequestManagementService {
side: p.side,
}));
const mergedSelected = [...(claimCase.damage?.selectedParts ?? [])];
const objectionCarType = claimCase.vehicle?.carType as
| ClaimVehicleTypeV2
| undefined;
const mergedNorm = normalizeDamageSelectedParts(
claimCase.damage?.selectedParts,
objectionCarType,
(claimCase.damage as any)?.selectedOuterParts,
);
const mergedKeys = new Set(mergedNorm.map((p) => partIdentityKey(p)));
for (const np of newParts) {
const key = np.partName?.trim();
if (key && !mergedSelected.includes(key)) {
mergedSelected.push(key);
const name = np.partName?.trim();
if (!name) continue;
const side = String(np.side ?? "").trim();
const row = {
id: null as number | null,
name,
side,
label_fa: name,
};
const k = partIdentityKey(row);
if (!mergedKeys.has(k)) {
mergedNorm.push(row);
mergedKeys.add(k);
}
}
@@ -5215,7 +5305,7 @@ export class ClaimRequestManagementService {
'workflow.currentStep': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
'workflow.nextStep': ClaimWorkflowStep.EXPERT_COST_EVALUATION,
'evaluation.objection': objectionPayload,
'damage.selectedParts': mergedSelected,
'damage.selectedParts': mergedNorm,
},
$unset: {
'evaluation.ownerPricedPartsApproval': "",
@@ -5695,47 +5785,56 @@ export class ClaimRequestManagementService {
};
}
const getPartLabelFa = (key: string): string => {
const labels: Record<string, string> = {
hood: 'کاپوت',
trunk: 'صندوق عقب',
roof: 'سقف',
front_right_door: 'درب جلو راست',
front_left_door: 'درب جلو چپ',
rear_right_door: 'درب عقب راست',
rear_left_door: 'درب عقب چپ',
front_bumper: 'سپر جلو',
rear_bumper: 'سپر عقب',
front_right_fender: 'گلگیر جلو راست',
front_left_fender: 'گلگیر جلو چپ',
rear_right_fender: 'گلگیر عقب راست',
rear_left_fender: 'گلگیر عقب چپ',
};
return labels[key] || key;
};
const carTypeDetails = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const selectedNormDetails = normalizeDamageSelectedParts(
claim.damage?.selectedParts,
carTypeDetails,
(claim.damage as any)?.selectedOuterParts,
);
const damagedPartsData = claim.media?.damagedParts as any;
const damagedParts: Record<
string,
{ label_fa: string; captured: boolean; url?: string }
> = {};
const resendPartKeys = normalizeResendPartKeys(
claim.evaluation?.damageExpertResend?.resendCarParts,
);
const partKeysForDetails = new Set<string>([
...((claim.damage?.selectedParts || []) as string[]),
...resendPartKeys,
]);
for (const p of partKeysForDetails) {
const cap = getDamagedPartCaptureBlob(damagedPartsData, p) as
| { url?: string; path?: string }
| undefined;
damagedParts[p] = {
label_fa: getPartLabelFa(p),
const displayParts = [...selectedNormDetails];
const seenDetailKeys = new Set(
selectedNormDetails.map((p) => partIdentityKey(p)),
);
for (const rk of resendPartKeys) {
const extra =
normalizeDamageSelectedParts([rk], carTypeDetails, undefined)[0] ||
{
id: null,
name: rk,
side: "",
label_fa: rk,
catalogKey: rk,
};
const k = partIdentityKey(extra);
if (!seenDetailKeys.has(k)) {
displayParts.push(extra);
seenDetailKeys.add(k);
}
}
const damagedParts = displayParts.map((sp, index) => {
const ck = sp.catalogKey ?? catalogLikeKeyFromPart(sp);
const cap = getDamagedPartCaptureBlob(
damagedPartsData,
ck,
selectedNormDetails,
) as { url?: string; path?: string; fileName?: string } | undefined;
return {
index,
id: sp.id,
name: sp.name,
side: sp.side,
label_fa: sp.label_fa,
captured: !!cap,
url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined),
...(cap?.path ? { path: cap.path } : {}),
...(cap?.fileName ? { fileName: cap.fileName } : {}),
};
}
});
const er = claim.evaluation?.damageExpertResend;
const expertResend =
@@ -5776,7 +5875,10 @@ export class ClaimRequestManagementService {
}
: undefined;
const mappedEvaluation = await this.mapEvaluationForClient(claim.evaluation);
const mappedEvaluation = await this.mapEvaluationForClient(
claim.evaluation,
claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined,
);
return {
claimRequestId: claim._id.toString(),
@@ -5790,7 +5892,7 @@ export class ClaimRequestManagementService {
blameRequestNo: claim.blameRequestNo,
...(ownerData ? { owner: ownerData } : {}),
vehicle: claim.vehicle,
selectedParts: claim.damage?.selectedParts,
selectedParts: selectedNormDetails,
otherParts: claim.damage?.otherParts,
money: isExpertViewer ? moneyForExpert : moneyForUser,
requiredDocuments: Object.keys(requiredDocumentsStatus).length > 0 ? requiredDocumentsStatus : undefined,