forked from Yara724/api
Merge pull request 'Fixed claim/blame damagedParts' (#55) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#55
This commit is contained in:
@@ -97,8 +97,6 @@ import {
|
||||
hasClaimCarAngleCapture,
|
||||
hasDamagedPartCapture,
|
||||
legacyAngleDamagedPartFieldKeysToUnset,
|
||||
legacyDamagedPartTitleEnFromPartKey,
|
||||
resolveCanonicalDamagedPartStorageKey,
|
||||
type ClaimCarAngleKey,
|
||||
} from "src/helpers/claim-car-angle-media";
|
||||
import {
|
||||
@@ -106,6 +104,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 {
|
||||
@@ -716,17 +724,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,
|
||||
@@ -744,7 +767,7 @@ export class ClaimRequestManagementService {
|
||||
timestamp: new Date(),
|
||||
metadata: {
|
||||
stepKey: ClaimWorkflowStep.SELECT_OUTER_PARTS,
|
||||
selectedParts,
|
||||
selectedParts: selectedPartObjs,
|
||||
description: "Field expert submitted outer damage parts",
|
||||
},
|
||||
},
|
||||
@@ -1922,7 +1945,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) => {
|
||||
@@ -1988,6 +2014,13 @@ export class ClaimRequestManagementService {
|
||||
};
|
||||
}
|
||||
|
||||
if (nextPart.carPartDamage != null) {
|
||||
nextPart.carPartDamage = migrateExpertReplyCarPartDamageToUnified(
|
||||
nextPart.carPartDamage,
|
||||
carType,
|
||||
);
|
||||
}
|
||||
|
||||
return nextPart;
|
||||
});
|
||||
|
||||
@@ -2032,7 +2065,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(
|
||||
@@ -2259,7 +2294,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(),
|
||||
@@ -2396,7 +2431,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),
|
||||
@@ -3980,41 +4017,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`,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -4022,22 +4064,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;
|
||||
@@ -4290,19 +4333,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())
|
||||
@@ -4356,46 +4386,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,
|
||||
),
|
||||
};
|
||||
});
|
||||
@@ -4832,48 +4852,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(
|
||||
@@ -4908,10 +4970,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(
|
||||
@@ -4920,12 +4986,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, {
|
||||
@@ -4952,7 +5023,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.`;
|
||||
@@ -5190,11 +5262,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5212,7 +5302,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': "",
|
||||
@@ -5692,47 +5782,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 =
|
||||
@@ -5773,7 +5872,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(),
|
||||
@@ -5787,7 +5889,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,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
|
||||
import { CarAngle } from 'src/Types&Enums/claim-request-management/required-document-type.enum';
|
||||
|
||||
/**
|
||||
* V2 DTO for capturing car angle or damaged part
|
||||
@@ -18,7 +17,8 @@ export class CapturePartV2Dto {
|
||||
captureType: 'angle' | 'part';
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Key of the angle or part being captured',
|
||||
description:
|
||||
'When captureType is angle: front | back | left | right. When part: catalog id as string (e.g. "101"), 0-based index (e.g. "0"), or full catalog key (e.g. left_backfender). Prefer id or index for parts.',
|
||||
example: 'front',
|
||||
})
|
||||
@IsNotEmpty({ message: 'Capture key is required' })
|
||||
|
||||
@@ -72,10 +72,17 @@ export class CarAngleItem {
|
||||
*/
|
||||
export class DamagedPartItem {
|
||||
@ApiProperty({
|
||||
description: 'Part key',
|
||||
example: 'hood',
|
||||
description:
|
||||
'Side-agnostic part name (matches catalog suffix); use with `side` to disambiguate',
|
||||
example: 'backfender',
|
||||
})
|
||||
key: string;
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Vehicle side / region (left, right, front, back, top)',
|
||||
example: 'left',
|
||||
})
|
||||
side?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Display label in Farsi',
|
||||
@@ -83,6 +90,12 @@ export class DamagedPartItem {
|
||||
})
|
||||
label_fa: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Deprecated: same as `name` (kept for older clients)',
|
||||
example: 'backfender',
|
||||
})
|
||||
key?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Display label in English',
|
||||
example: 'Hood',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { DamageSelectedPartV2BodyDto } from './damage-selected-part-v2.dto';
|
||||
|
||||
/** Suggested HTTP call for the owner UI (`pathTemplate`: replace placeholders). */
|
||||
export class ClaimDetailsOwnerNextActionV2Dto {
|
||||
@@ -119,8 +120,11 @@ export class ClaimDetailsV2ResponseDto {
|
||||
plate?: any;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({ description: 'Selected outer damaged parts' })
|
||||
selectedParts?: string[];
|
||||
@ApiPropertyOptional({
|
||||
description: 'Selected outer damaged parts (ordered objects with id, name, side, label_fa)',
|
||||
type: [DamageSelectedPartV2BodyDto],
|
||||
})
|
||||
selectedParts?: DamageSelectedPartV2BodyDto[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Selected other damaged parts' })
|
||||
otherParts?: string[];
|
||||
@@ -137,8 +141,36 @@ export class ClaimDetailsV2ResponseDto {
|
||||
@ApiPropertyOptional({ description: 'Car angles captured' })
|
||||
carAngles?: Record<string, { captured: boolean; url?: string }>;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Damaged parts captured' })
|
||||
damagedParts?: Record<string, { label_fa: string; captured: boolean; url?: string }>;
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Per-part capture status and URLs (array index aligns with selectedParts; includes id, name, side, label_fa)',
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
index: { type: 'number' },
|
||||
id: { type: 'number', nullable: true },
|
||||
name: { type: 'string' },
|
||||
side: { type: 'string' },
|
||||
label_fa: { type: 'string' },
|
||||
captured: { type: 'boolean' },
|
||||
url: { type: 'string' },
|
||||
path: { type: 'string' },
|
||||
fileName: { type: 'string' },
|
||||
},
|
||||
},
|
||||
})
|
||||
damagedParts?: Array<{
|
||||
index: number;
|
||||
id?: number | null;
|
||||
name: string;
|
||||
side: string;
|
||||
label_fa: string;
|
||||
captured: boolean;
|
||||
url?: string;
|
||||
path?: string;
|
||||
fileName?: string;
|
||||
}>;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsInt, IsOptional, IsString } from "class-validator";
|
||||
|
||||
/** Stored shape / API payload for one selected outer damaged part (V2). */
|
||||
export class DamageSelectedPartV2BodyDto {
|
||||
@ApiPropertyOptional({ description: "Catalog id when from outer-parts catalog" })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
id?: number;
|
||||
|
||||
@ApiProperty({ example: "backfender" })
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ example: "left" })
|
||||
@IsString()
|
||||
side: string;
|
||||
|
||||
@ApiProperty({ example: "گلگیر عقب" })
|
||||
@IsString()
|
||||
label_fa: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Original catalog key, e.g. left_backfender" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
catalogKey?: string;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ClaimVehicleTypeV2,
|
||||
OuterPartSideV2,
|
||||
} from "src/static/outer-car-parts-catalog";
|
||||
import { DamageSelectedPartV2BodyDto } from "./damage-selected-part-v2.dto";
|
||||
|
||||
/**
|
||||
* Enum for valid outer car parts that can be damaged
|
||||
@@ -97,10 +98,11 @@ export class SelectOuterPartsV2ResponseDto {
|
||||
publicId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Array of selected damaged parts',
|
||||
example: ['hood', 'front_right_door', 'rear_bumper'],
|
||||
description:
|
||||
"Ordered selected parts: id, side-agnostic name, side, label_fa (and optional catalogKey)",
|
||||
type: [DamageSelectedPartV2BodyDto],
|
||||
})
|
||||
selectedParts: string[];
|
||||
selectedParts: DamageSelectedPartV2BodyDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Selected part IDs',
|
||||
|
||||
@@ -1,32 +1,15 @@
|
||||
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||
import { Schema as MongooseSchema } from "mongoose";
|
||||
import { CarDamagePartModel, CarDamagePartOtherModel } from "./car-parts.schema";
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class SelectedOuterPartV2 {
|
||||
@Prop({ type: Number })
|
||||
id: number;
|
||||
|
||||
@Prop({ type: String })
|
||||
key: string;
|
||||
|
||||
@Prop({ type: String })
|
||||
side: string;
|
||||
}
|
||||
export const SelectedOuterPartV2Schema =
|
||||
SchemaFactory.createForClass(SelectedOuterPartV2);
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class ClaimDamageSelection {
|
||||
/**
|
||||
* V2: Array of selected damaged outer car part names
|
||||
* Examples: ['hood', 'front_right_door', 'rear_bumper']
|
||||
* V2: Selected outer damaged parts (ordered). Prefer objects
|
||||
* `{ id, name, side, label_fa, catalogKey? }`; legacy string keys are still accepted until migrated.
|
||||
*/
|
||||
@Prop({ type: [String], default: [] })
|
||||
selectedParts?: string[];
|
||||
|
||||
/** Structured selected outer parts with id + side for better downstream handling. */
|
||||
@Prop({ type: [SelectedOuterPartV2Schema], default: [] })
|
||||
selectedOuterParts?: SelectedOuterPartV2[];
|
||||
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
|
||||
selectedParts?: unknown[];
|
||||
|
||||
/**
|
||||
* V2: Array of selected other (non-body) damaged parts
|
||||
@@ -46,4 +29,3 @@ export class ClaimDamageSelection {
|
||||
}
|
||||
export const ClaimDamageSelectionSchema =
|
||||
SchemaFactory.createForClass(ClaimDamageSelection);
|
||||
|
||||
|
||||
@@ -13,11 +13,12 @@ export class ClaimPartPricing {
|
||||
partId: string;
|
||||
|
||||
/**
|
||||
* Legacy string or structured `{ part, side }` from expert reply DTOs (V1/V2).
|
||||
* Unified outer-part snapshot `{ id?, name, side, label_fa, catalogKey? }` (same as
|
||||
* `damage.selectedParts` / capture). Legacy: string or `{ part?, side? }` (expert UI).
|
||||
* Must be Mixed — objects cannot cast to String.
|
||||
*/
|
||||
@Prop({ type: MongooseSchema.Types.Mixed })
|
||||
carPartDamage?: string | { part?: string; side?: string };
|
||||
carPartDamage?: unknown;
|
||||
|
||||
@Prop({ type: String })
|
||||
typeOfDamage?: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||
import { Types } from "mongoose";
|
||||
import { Schema as MongooseSchema, Types } from "mongoose";
|
||||
import { AiImagesModel } from "./ai-image.schema";
|
||||
import { CarGreenCardModel } from "./car-green-card.schema";
|
||||
import { ImageRequiredModel } from "./image-required.schema";
|
||||
@@ -20,6 +20,39 @@ export class CapturedImage {
|
||||
}
|
||||
export const CapturedImageSchema = SchemaFactory.createForClass(CapturedImage);
|
||||
|
||||
/** One row per selected damaged part (index-aligned with `damage.selectedParts`). */
|
||||
@Schema({ _id: false })
|
||||
export class DamagedPartMediaV2Row {
|
||||
@Prop({ type: Number })
|
||||
id?: number;
|
||||
|
||||
@Prop({ type: String })
|
||||
name?: string;
|
||||
|
||||
@Prop({ type: String })
|
||||
side?: string;
|
||||
|
||||
@Prop({ type: String })
|
||||
label_fa?: string;
|
||||
|
||||
@Prop({ type: String })
|
||||
catalogKey?: string;
|
||||
|
||||
@Prop({ type: String })
|
||||
path?: string;
|
||||
|
||||
@Prop({ type: String })
|
||||
fileName?: string;
|
||||
|
||||
@Prop({ type: String })
|
||||
url?: string;
|
||||
|
||||
@Prop({ type: Date })
|
||||
capturedAt?: Date;
|
||||
}
|
||||
export const DamagedPartMediaV2RowSchema =
|
||||
SchemaFactory.createForClass(DamagedPartMediaV2Row);
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class ClaimMedia {
|
||||
@Prop({ type: CarGreenCardModel })
|
||||
@@ -46,15 +79,10 @@ export class ClaimMedia {
|
||||
carAngles?: Map<string, CapturedImage>;
|
||||
|
||||
/**
|
||||
* V2: Damaged parts captures
|
||||
* Map of part key to captured image
|
||||
* V2: Damaged parts captures — prefer an array (index matches `damage.selectedParts`).
|
||||
* Legacy documents may store a plain object map keyed by part slug until migrated on write.
|
||||
*/
|
||||
@Prop({
|
||||
type: Map,
|
||||
of: CapturedImageSchema,
|
||||
default: () => ({}),
|
||||
})
|
||||
damagedParts?: Map<string, CapturedImage>;
|
||||
@Prop({ type: MongooseSchema.Types.Mixed })
|
||||
damagedParts?: DamagedPartMediaV2Row[] | Record<string, unknown>;
|
||||
}
|
||||
export const ClaimMediaSchema = SchemaFactory.createForClass(ClaimMedia);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { BlameRequestType } from 'src/Types&Enums/blame-request-management/blameRequestType.enum';
|
||||
import { DamageSelectedPartV2BodyDto } from 'src/claim-request-management/dto/damage-selected-part-v2.dto';
|
||||
|
||||
export class ClaimDetailV2ResponseDto {
|
||||
@ApiProperty()
|
||||
@@ -64,8 +65,11 @@ export class ClaimDetailV2ResponseDto {
|
||||
@ApiPropertyOptional({ description: 'Blame request number', example: 'BL12345' })
|
||||
blameRequestNo?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Selected outer damaged parts' })
|
||||
selectedParts?: string[];
|
||||
@ApiPropertyOptional({
|
||||
description: 'Selected outer damaged parts (id, name, side, label_fa)',
|
||||
type: [DamageSelectedPartV2BodyDto],
|
||||
})
|
||||
selectedParts?: DamageSelectedPartV2BodyDto[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Selected other damaged parts' })
|
||||
otherParts?: string[];
|
||||
@@ -81,8 +85,32 @@ export class ClaimDetailV2ResponseDto {
|
||||
@ApiPropertyOptional({ description: 'Car angles captured' })
|
||||
carAngles?: Record<string, { captured: boolean; url?: string }>;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Damaged parts captured' })
|
||||
damagedParts?: Record<string, { captured: boolean; url?: string }>;
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Per-part captures (array index matches selectedParts; includes id, name, side, label_fa, captured, url)',
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
index: { type: 'number' },
|
||||
id: { type: 'number', nullable: true },
|
||||
name: { type: 'string' },
|
||||
side: { type: 'string' },
|
||||
label_fa: { type: 'string' },
|
||||
captured: { type: 'boolean' },
|
||||
url: { type: 'string' },
|
||||
},
|
||||
},
|
||||
})
|
||||
damagedParts?: Array<{
|
||||
index: number;
|
||||
id?: number | null;
|
||||
name: string;
|
||||
side: string;
|
||||
label_fa: string;
|
||||
captured: boolean;
|
||||
url?: string;
|
||||
}>;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
|
||||
@@ -7,20 +7,57 @@ 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';
|
||||
import { DamagedPartItem } from 'src/claim-request-management/dto/capture-requirements-v2.dto';
|
||||
import { DaghiOption } from 'src/Types&Enums/claim-request-management/daghi-option.enum';
|
||||
|
||||
export class CarPartDamageV2Dto {
|
||||
@ApiProperty({ example: 'left' })
|
||||
@IsString()
|
||||
side: string;
|
||||
/**
|
||||
* Damage line part reference — same unified shape as `damage.selectedParts` / catalog rows.
|
||||
* Send either `{ name, side, label_fa?, id?, catalogKey? }` or legacy `{ part, side }`
|
||||
* (e.g. part `backFender`, side `left`); the API persists canonical `{ id, name, side, label_fa, catalogKey? }`.
|
||||
*/
|
||||
export class ExpertReplyCarPartDamageV2Dto {
|
||||
@ApiPropertyOptional({ description: 'Catalog id when known' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
id?: number;
|
||||
|
||||
@ApiProperty({ example: 'front_door' })
|
||||
@ApiPropertyOptional({
|
||||
description: 'Side-agnostic catalog segment, e.g. backfender',
|
||||
example: 'backfender',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
part: string;
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'left' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
side?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Farsi label; optional when server can resolve from catalog + vehicle.carType',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
label_fa?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'left_backfender' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
catalogKey?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Legacy expert UI: camelCase segment with `side`, e.g. backFender',
|
||||
example: 'backFender',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
part?: string;
|
||||
}
|
||||
|
||||
/** Same shape as V1 {@link PartsList} `daghi` (damage expert reply). */
|
||||
@@ -54,10 +91,10 @@ export class PartPricingV2Dto {
|
||||
@IsNotEmpty()
|
||||
partId: string;
|
||||
|
||||
@ApiProperty({ type: CarPartDamageV2Dto })
|
||||
@ApiProperty({ type: ExpertReplyCarPartDamageV2Dto })
|
||||
@ValidateNested()
|
||||
@Type(() => CarPartDamageV2Dto)
|
||||
carPartDamage: CarPartDamageV2Dto;
|
||||
@Type(() => ExpertReplyCarPartDamageV2Dto)
|
||||
carPartDamage: ExpertReplyCarPartDamageV2Dto;
|
||||
|
||||
@ApiProperty({ example: 'Minor' })
|
||||
@IsString()
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import {
|
||||
ArrayMinSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { OuterCarPart } from "src/claim-request-management/dto/select-outer-parts-v2.dto";
|
||||
import { Type } from "class-transformer";
|
||||
import { DamageSelectedPartV2BodyDto } from "src/claim-request-management/dto/damage-selected-part-v2.dto";
|
||||
|
||||
export class UpdateClaimDamagedPartsV2Dto {
|
||||
@ApiProperty({
|
||||
description: "Updated list of selected damaged outer parts",
|
||||
enum: OuterCarPart,
|
||||
isArray: true,
|
||||
example: ["hood", "front_bumper", "front_left_fender"],
|
||||
type: [DamageSelectedPartV2BodyDto],
|
||||
description: "Full replacement list of selected damaged outer parts (id, name, side, label_fa)",
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayUnique()
|
||||
@IsEnum(OuterCarPart, { each: true })
|
||||
selectedParts: OuterCarPart[];
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => DamageSelectedPartV2BodyDto)
|
||||
selectedParts: DamageSelectedPartV2BodyDto[];
|
||||
}
|
||||
|
||||
|
||||
@@ -71,8 +71,16 @@ import {
|
||||
import {
|
||||
getClaimCarAngleCaptureBlob,
|
||||
getDamagedPartCaptureBlob,
|
||||
hasDamagedPartCapture,
|
||||
type ClaimCarAngleKey,
|
||||
} from "src/helpers/claim-car-angle-media";
|
||||
import {
|
||||
catalogLikeKeyFromPart,
|
||||
coerceDamagedPartsMediaToArray,
|
||||
normalizeCarPartDamageForExpertReply,
|
||||
normalizeDamageSelectedParts,
|
||||
} from "src/helpers/outer-damage-parts";
|
||||
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||
import { snapshotFromDamageExpert } from "src/helpers/expert-profile-snapshot";
|
||||
import { DamageExpertModel } from "src/users/entities/schema/damage-expert.schema";
|
||||
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
|
||||
@@ -2532,10 +2540,27 @@ export class ExpertClaimService {
|
||||
});
|
||||
}
|
||||
|
||||
const processedParts =
|
||||
const carTypeSubmit = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
|
||||
const daghiNormalized =
|
||||
reply.parts?.length > 0
|
||||
? this.validateAndNormalizeDaghiForExpertReplyV2(reply.parts)
|
||||
: [];
|
||||
const processedParts = daghiNormalized.map((p) => {
|
||||
let carPartDamage: Record<string, unknown>;
|
||||
try {
|
||||
carPartDamage = normalizeCarPartDamageForExpertReply(
|
||||
p.carPartDamage as unknown,
|
||||
carTypeSubmit,
|
||||
);
|
||||
} catch (err) {
|
||||
throw new BadRequestException(
|
||||
`Invalid carPartDamage for part ${p.partId}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
return { ...p, carPartDamage };
|
||||
});
|
||||
|
||||
const { mixedFactorAndPrice, allFactorLines } = classifyV2ExpertPricingParts(
|
||||
processedParts,
|
||||
@@ -3317,40 +3342,35 @@ export class ExpertClaimService {
|
||||
};
|
||||
}
|
||||
|
||||
// Build damaged parts map
|
||||
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 damagedPartsData = claim.media?.damagedParts as any;
|
||||
const damagedParts: Record<
|
||||
string,
|
||||
{ label_fa: string; captured: boolean; url?: string }
|
||||
> = {};
|
||||
for (const p of claim.damage?.selectedParts || []) {
|
||||
const cap = getDamagedPartCaptureBlob(damagedPartsData, p) as
|
||||
| { url?: string; path?: string }
|
||||
| undefined;
|
||||
damagedParts[p] = {
|
||||
label_fa: getPartLabelFa(p),
|
||||
captured: !!cap,
|
||||
// Build damaged parts list (aligned with normalized selected parts)
|
||||
const carTypeExpert = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
|
||||
const selectedNormExpert = normalizeDamageSelectedParts(
|
||||
claim.damage?.selectedParts,
|
||||
carTypeExpert,
|
||||
(claim.damage as any)?.selectedOuterParts,
|
||||
);
|
||||
const damagedPartsDataExpert = claim.media?.damagedParts as any;
|
||||
const damagedParts = selectedNormExpert.map((sp, index) => {
|
||||
const ck = sp.catalogKey ?? catalogLikeKeyFromPart(sp);
|
||||
const cap = getDamagedPartCaptureBlob(
|
||||
damagedPartsDataExpert,
|
||||
ck,
|
||||
selectedNormExpert,
|
||||
) as { url?: string; path?: string } | undefined;
|
||||
return {
|
||||
index,
|
||||
id: sp.id,
|
||||
name: sp.name,
|
||||
side: sp.side,
|
||||
label_fa: sp.label_fa,
|
||||
captured: hasDamagedPartCapture(
|
||||
damagedPartsDataExpert,
|
||||
ck,
|
||||
selectedNormExpert,
|
||||
),
|
||||
url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// --- Combine both branches of the conflict, preserving necessary logic from both ---
|
||||
|
||||
@@ -3480,7 +3500,7 @@ export class ExpertClaimService {
|
||||
blameRequestId: claim.blameRequestId?.toString(),
|
||||
blameRequestNo: claim.blameRequestNo,
|
||||
money: moneyPayload,
|
||||
selectedParts: claim.damage?.selectedParts,
|
||||
selectedParts: selectedNormExpert,
|
||||
otherParts: claim.damage?.otherParts,
|
||||
requiredDocuments:
|
||||
Object.keys(requiredDocumentsStatus).length > 0
|
||||
@@ -3535,35 +3555,70 @@ export class ExpertClaimService {
|
||||
|
||||
const damagedPartsEditSnapshot = await this.snapshotDamageExpert(actor.sub);
|
||||
|
||||
const carType = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
|
||||
const previousNorm = normalizeDamageSelectedParts(
|
||||
(claim as any).damage?.selectedParts,
|
||||
carType,
|
||||
(claim as any).damage?.selectedOuterParts,
|
||||
);
|
||||
const prevMedia = coerceDamagedPartsMediaToArray(
|
||||
(claim as any).media?.damagedParts,
|
||||
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 matchPreviousIndex = (
|
||||
next: (typeof nextNorm)[0],
|
||||
previous: typeof previousNorm,
|
||||
): number => {
|
||||
if (next.id != null) {
|
||||
const byId = previous.findIndex((x) => x.id === next.id);
|
||||
if (byId >= 0) return byId;
|
||||
}
|
||||
if (next.catalogKey) {
|
||||
const byCk = previous.findIndex((x) => x.catalogKey === next.catalogKey);
|
||||
if (byCk >= 0) return byCk;
|
||||
}
|
||||
return previous.findIndex(
|
||||
(x) => x.name === next.name && x.side === next.side,
|
||||
);
|
||||
};
|
||||
|
||||
const nextMedia = nextNorm.map((sp) => {
|
||||
const j = matchPreviousIndex(sp, previousNorm);
|
||||
const row =
|
||||
j >= 0 && prevMedia[j] && typeof prevMedia[j] === "object"
|
||||
? (prevMedia[j] as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
...(sp.id != null ? { id: sp.id } : {}),
|
||||
name: sp.name,
|
||||
side: sp.side,
|
||||
label_fa: sp.label_fa,
|
||||
...(sp.catalogKey ? { catalogKey: sp.catalogKey } : {}),
|
||||
...(row.path ? { path: row.path } : {}),
|
||||
...(row.fileName ? { fileName: row.fileName } : {}),
|
||||
...(row.url ? { url: row.url } : {}),
|
||||
...(row.capturedAt ? { capturedAt: row.capturedAt } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
const previous = Array.isArray((claim as any).damage?.selectedParts)
|
||||
? [...(claim as any).damage.selectedParts]
|
||||
: [];
|
||||
const selectedParts = body.selectedParts as string[];
|
||||
|
||||
const $set: Record<string, unknown> = {
|
||||
"damage.selectedParts": selectedParts,
|
||||
"damage.selectedParts": nextNorm,
|
||||
"media.damagedParts": nextMedia,
|
||||
};
|
||||
|
||||
const damagedPartsMedia = (claim as any).media?.damagedParts;
|
||||
if (damagedPartsMedia) {
|
||||
const selectedSet = new Set(selectedParts);
|
||||
if (damagedPartsMedia instanceof Map) {
|
||||
const m = new Map(damagedPartsMedia as Map<string, unknown>);
|
||||
for (const key of Array.from(m.keys())) {
|
||||
if (!selectedSet.has(key)) m.delete(key);
|
||||
}
|
||||
$set["media.damagedParts"] = Object.fromEntries(m.entries());
|
||||
} else {
|
||||
const o = {
|
||||
...(damagedPartsMedia as Record<string, unknown>),
|
||||
};
|
||||
for (const key of Object.keys(o)) {
|
||||
if (!selectedSet.has(key)) delete o[key];
|
||||
}
|
||||
$set["media.damagedParts"] = o;
|
||||
}
|
||||
}
|
||||
|
||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||
$set,
|
||||
$push: {
|
||||
@@ -3577,7 +3632,7 @@ export class ExpertClaimService {
|
||||
timestamp: new Date(),
|
||||
metadata: {
|
||||
previousSelectedParts: previous,
|
||||
selectedParts,
|
||||
selectedParts: nextNorm,
|
||||
...(damagedPartsEditSnapshot && {
|
||||
expertProfileSnapshot: damagedPartsEditSnapshot,
|
||||
}),
|
||||
@@ -3588,7 +3643,7 @@ export class ExpertClaimService {
|
||||
|
||||
return {
|
||||
claimRequestId: claim._id.toString(),
|
||||
selectedParts,
|
||||
selectedParts: nextNorm,
|
||||
previousSelectedParts: previous,
|
||||
message: "Damaged parts updated successfully.",
|
||||
};
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
* into {@code media.damagedParts} and broke step-completion checks.
|
||||
*/
|
||||
|
||||
import type { DamageSelectedPartV2 } from "src/helpers/outer-damage-parts";
|
||||
import {
|
||||
damageSelectedPartsToLookupStrings,
|
||||
mediaRowHasCapture,
|
||||
resolvePartCaptureIndex,
|
||||
} from "src/helpers/outer-damage-parts";
|
||||
|
||||
export const CLAIM_CAR_ANGLE_KEYS = ["front", "back", "left", "right"] as const;
|
||||
export type ClaimCarAngleKey = (typeof CLAIM_CAR_ANGLE_KEYS)[number];
|
||||
|
||||
@@ -54,9 +61,17 @@ export function hasClaimCarAngleCapture(
|
||||
export function legacyAngleDamagedPartFieldKeysToUnset(
|
||||
canonical: ClaimCarAngleKey,
|
||||
rawCaptureKey: string,
|
||||
selectedParts: string[],
|
||||
selectedParts: unknown,
|
||||
carType?: import("src/static/outer-car-parts-catalog").ClaimVehicleTypeV2,
|
||||
selectedOuterPartsLegacy?: unknown,
|
||||
): string[] {
|
||||
const selected = new Set((selectedParts ?? []).map((p) => String(p)));
|
||||
const selected = new Set(
|
||||
damageSelectedPartsToLookupStrings(
|
||||
selectedParts,
|
||||
carType,
|
||||
selectedOuterPartsLegacy,
|
||||
),
|
||||
);
|
||||
const variants = new Set(
|
||||
[
|
||||
rawCaptureKey,
|
||||
@@ -89,9 +104,23 @@ function normalizeDamagedPartKeyLoose(s: string): string {
|
||||
export function getDamagedPartCaptureBlob(
|
||||
damagedPartsData: unknown,
|
||||
partKey: string,
|
||||
selectedNormalized?: DamageSelectedPartV2[],
|
||||
): unknown {
|
||||
const pk = String(partKey ?? "").trim();
|
||||
if (!pk || damagedPartsData == null) return undefined;
|
||||
if (Array.isArray(damagedPartsData)) {
|
||||
const norm = selectedNormalized ?? [];
|
||||
const idx = resolvePartCaptureIndex(
|
||||
pk,
|
||||
norm,
|
||||
damagedPartsData as Record<string, unknown>[],
|
||||
);
|
||||
if (idx >= 0) {
|
||||
const row = (damagedPartsData as unknown[])[idx];
|
||||
if (mediaRowHasCapture(row)) return row;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
let b = getCaptureBlob(damagedPartsData, pk);
|
||||
if (b) return b;
|
||||
const titleEn = legacyDamagedPartTitleEnFromPartKey(pk);
|
||||
@@ -120,8 +149,12 @@ export function getDamagedPartCaptureBlob(
|
||||
export function hasDamagedPartCapture(
|
||||
damagedPartsData: unknown,
|
||||
partKey: string,
|
||||
selectedNormalized?: DamageSelectedPartV2[],
|
||||
): boolean {
|
||||
return getDamagedPartCaptureBlob(damagedPartsData, partKey) != null;
|
||||
return (
|
||||
getDamagedPartCaptureBlob(damagedPartsData, partKey, selectedNormalized) !=
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,11 +162,17 @@ export function hasDamagedPartCapture(
|
||||
*/
|
||||
export function resolveCanonicalDamagedPartStorageKey(
|
||||
captureKeyRaw: string,
|
||||
selectedParts: string[],
|
||||
selectedParts: unknown,
|
||||
carType?: import("src/static/outer-car-parts-catalog").ClaimVehicleTypeV2,
|
||||
selectedOuterPartsLegacy?: unknown,
|
||||
): string {
|
||||
const raw = String(captureKeyRaw ?? "").trim();
|
||||
if (!raw) return raw;
|
||||
const parts = (selectedParts ?? []).map((p) => String(p).trim()).filter(Boolean);
|
||||
const parts = damageSelectedPartsToLookupStrings(
|
||||
selectedParts,
|
||||
carType,
|
||||
selectedOuterPartsLegacy,
|
||||
);
|
||||
if (parts.includes(raw)) return raw;
|
||||
const n = normalizeDamagedPartKeyLoose(raw);
|
||||
const matches = parts.filter((p) => normalizeDamagedPartKeyLoose(p) === n);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
||||
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
|
||||
import { getDamagedPartCaptureBlob } from "./claim-car-angle-media";
|
||||
import { normalizeDamageSelectedParts } from "./outer-damage-parts";
|
||||
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||
import { canonicalizeResendDocumentKey } from "./claim-resend-document-keys";
|
||||
|
||||
export function resendRequestHasPayload(r: {
|
||||
@@ -59,8 +62,12 @@ export function isRequiredDocumentUploaded(claim: any, documentKey: string): boo
|
||||
export function hasDamagedPartCapture(claim: any, partKey: string): boolean {
|
||||
const data = claim?.media?.damagedParts;
|
||||
if (!data) return false;
|
||||
const cap = data instanceof Map ? data.get(partKey) : data[partKey];
|
||||
return !!cap;
|
||||
const norm = normalizeDamageSelectedParts(
|
||||
claim?.damage?.selectedParts,
|
||||
claim?.vehicle?.carType as ClaimVehicleTypeV2 | undefined,
|
||||
claim?.damage?.selectedOuterParts,
|
||||
);
|
||||
return getDamagedPartCaptureBlob(data, partKey, norm) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
700
src/helpers/outer-damage-parts.ts
Normal file
700
src/helpers/outer-damage-parts.ts
Normal file
@@ -0,0 +1,700 @@
|
||||
import {
|
||||
ClaimVehicleTypeV2,
|
||||
OUTER_PARTS_BY_CAR_TYPE,
|
||||
OuterPartCatalogItem,
|
||||
OuterPartSideV2,
|
||||
} from "src/static/outer-car-parts-catalog";
|
||||
|
||||
/** Stored on `damage.selectedParts` and used when resolving captures / APIs. */
|
||||
export type DamageSelectedPartV2 = {
|
||||
id: number | null;
|
||||
name: string;
|
||||
side: string;
|
||||
label_fa: string;
|
||||
/** Original catalog key when known (e.g. `left_backfender`). */
|
||||
catalogKey?: string;
|
||||
};
|
||||
|
||||
const SIDE_SET = new Set<string>(Object.values(OuterPartSideV2));
|
||||
|
||||
/** Farsi side suffix for disambiguated labels, e.g. `گلگیر عقب (چپ)`. */
|
||||
const SIDE_LABEL_FA: Record<string, string> = {
|
||||
[OuterPartSideV2.LEFT]: "چپ",
|
||||
[OuterPartSideV2.RIGHT]: "راست",
|
||||
[OuterPartSideV2.FRONT]: "جلو",
|
||||
[OuterPartSideV2.BACK]: "عقب",
|
||||
[OuterPartSideV2.TOP]: "بالا",
|
||||
};
|
||||
|
||||
function outerCatalogListForItem(item: OuterPartCatalogItem): OuterPartCatalogItem[] {
|
||||
for (const list of Object.values(OUTER_PARTS_BY_CAR_TYPE)) {
|
||||
if (list.some((x) => x.key === item.key || x.id === item.id)) {
|
||||
return list;
|
||||
}
|
||||
}
|
||||
return [item];
|
||||
}
|
||||
|
||||
/**
|
||||
* When multiple catalog rows share the same `titleFa` (e.g. left/right rear fender),
|
||||
* append ` (side)` in Farsi so clients can display `label_fa` alone.
|
||||
*/
|
||||
export function disambiguateOuterPartLabelFa(
|
||||
baseTitleFa: string,
|
||||
side: string,
|
||||
sameTypeCatalog: OuterPartCatalogItem[],
|
||||
): string {
|
||||
const t = String(baseTitleFa ?? "").trim();
|
||||
const s = String(side ?? "").trim();
|
||||
if (!t || !sameTypeCatalog?.length) return t;
|
||||
const norm = (x: string) => x.trim();
|
||||
const siblings = sameTypeCatalog.filter((p) => norm(p.titleFa) === norm(t));
|
||||
if (siblings.length <= 1) return t;
|
||||
const sideFa = SIDE_LABEL_FA[s] || s;
|
||||
if (!sideFa) return t;
|
||||
return `${t} (${sideFa})`;
|
||||
}
|
||||
|
||||
const CATALOG_ITEM_BY_ID = new Map<number, OuterPartCatalogItem>();
|
||||
for (const list of Object.values(OUTER_PARTS_BY_CAR_TYPE)) {
|
||||
for (const it of list) {
|
||||
CATALOG_ITEM_BY_ID.set(it.id, it);
|
||||
}
|
||||
}
|
||||
|
||||
function findCatalogItemByKey(key: string): OuterPartCatalogItem | undefined {
|
||||
for (const list of Object.values(OUTER_PARTS_BY_CAR_TYPE)) {
|
||||
const hit = list.find((x) => x.key === key);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Strip leading `left_|right_|…` when present so `name` is side-agnostic for clients. */
|
||||
export function splitCatalogKeyToNameAndSide(
|
||||
fullKey: string,
|
||||
): { name: string; side: string } {
|
||||
const t = String(fullKey ?? "").trim();
|
||||
const i = t.indexOf("_");
|
||||
if (i <= 0) return { name: t, side: "" };
|
||||
const head = t.slice(0, i);
|
||||
if (SIDE_SET.has(head)) {
|
||||
return { side: head, name: t.slice(i + 1) };
|
||||
}
|
||||
return { name: t, side: "" };
|
||||
}
|
||||
|
||||
export function catalogLikeKeyFromPart(
|
||||
part: Pick<DamageSelectedPartV2, "side" | "name">,
|
||||
): string {
|
||||
if (part.side) return `${part.side}_${part.name}`;
|
||||
return part.name;
|
||||
}
|
||||
|
||||
export function catalogItemToSelectedPart(
|
||||
item: OuterPartCatalogItem,
|
||||
sameTypeCatalog?: OuterPartCatalogItem[],
|
||||
): DamageSelectedPartV2 {
|
||||
const { name, side } = splitCatalogKeyToNameAndSide(item.key);
|
||||
const sideEff = String(side || item.side || "");
|
||||
const list = sameTypeCatalog ?? outerCatalogListForItem(item);
|
||||
const label_fa = disambiguateOuterPartLabelFa(item.titleFa, sideEff, list);
|
||||
return {
|
||||
id: item.id,
|
||||
name,
|
||||
side: sideEff,
|
||||
label_fa,
|
||||
catalogKey: item.key,
|
||||
};
|
||||
}
|
||||
|
||||
const LEGACY_SLUG_LABEL_FA: 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: "گلگیر عقب چپ",
|
||||
};
|
||||
|
||||
function labelFaForUnknownSlug(slug: string): string {
|
||||
return LEGACY_SLUG_LABEL_FA[slug] || slug;
|
||||
}
|
||||
|
||||
function toNum(v: unknown): number | null {
|
||||
if (v == null || v === "") return null;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize `damage.selectedParts` from DB (strings, legacy objects, or new objects)
|
||||
* into a stable list for APIs and capture resolution.
|
||||
*/
|
||||
export function normalizeDamageSelectedParts(
|
||||
raw: unknown,
|
||||
carType?: ClaimVehicleTypeV2,
|
||||
selectedOuterPartsLegacy?: unknown,
|
||||
): DamageSelectedPartV2[] {
|
||||
const arr = Array.isArray(raw) ? raw : [];
|
||||
const legacyOuter = Array.isArray(selectedOuterPartsLegacy)
|
||||
? selectedOuterPartsLegacy
|
||||
: [];
|
||||
const byKeyFromLegacy = new Map<string, { id?: number; side?: string }>();
|
||||
for (const o of legacyOuter) {
|
||||
if (o && typeof o === "object" && "key" in (o as object)) {
|
||||
const rec = o as { key?: string; id?: number; side?: string };
|
||||
const k = String(rec.key || "").trim();
|
||||
if (k) byKeyFromLegacy.set(k, { id: rec.id, side: rec.side });
|
||||
}
|
||||
}
|
||||
const catalog = carType ? OUTER_PARTS_BY_CAR_TYPE[carType] : undefined;
|
||||
const byCatalogKey = catalog
|
||||
? new Map(catalog.map((c) => [c.key, c]))
|
||||
: undefined;
|
||||
|
||||
const out: DamageSelectedPartV2[] = [];
|
||||
for (const el of arr) {
|
||||
if (el == null) continue;
|
||||
if (typeof el === "string") {
|
||||
const s = el.trim();
|
||||
if (!s) continue;
|
||||
let catItem = byCatalogKey?.get(s) ?? findCatalogItemByKey(s);
|
||||
if (!catItem && /^\d+$/.test(s)) {
|
||||
catItem = CATALOG_ITEM_BY_ID.get(Number(s));
|
||||
}
|
||||
if (catItem) {
|
||||
const list = catalog ?? outerCatalogListForItem(catItem);
|
||||
out.push(catalogItemToSelectedPart(catItem, list));
|
||||
continue;
|
||||
}
|
||||
const hint = byKeyFromLegacy.get(s);
|
||||
if (hint?.side || hint?.id != null) {
|
||||
const split = splitCatalogKeyToNameAndSide(s);
|
||||
if (split.side) {
|
||||
const sideEff = hint?.side ?? split.side;
|
||||
const baseFa = labelFaForUnknownSlug(s);
|
||||
const list = catalog ?? [];
|
||||
const label_fa =
|
||||
catalog?.length && baseFa
|
||||
? disambiguateOuterPartLabelFa(baseFa, String(sideEff), catalog)
|
||||
: baseFa;
|
||||
out.push({
|
||||
id: hint?.id ?? null,
|
||||
name: split.name,
|
||||
side: sideEff,
|
||||
label_fa,
|
||||
catalogKey: s,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push({
|
||||
id: hint?.id ?? null,
|
||||
name: s,
|
||||
side: hint?.side ?? "",
|
||||
label_fa: labelFaForUnknownSlug(s),
|
||||
catalogKey: s,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (typeof el === "object") {
|
||||
const o = el as Record<string, unknown>;
|
||||
if (typeof o.name === "string" && o.name.trim()) {
|
||||
let name = o.name.trim();
|
||||
const inner = splitCatalogKeyToNameAndSide(name);
|
||||
if (inner.side && inner.name) {
|
||||
name = inner.name;
|
||||
}
|
||||
const side = typeof o.side === "string" ? o.side : inner.side || "";
|
||||
const id = toNum(o.id);
|
||||
const ck =
|
||||
typeof o.catalogKey === "string" && o.catalogKey.trim()
|
||||
? o.catalogKey.trim()
|
||||
: undefined;
|
||||
let catItem: OuterPartCatalogItem | undefined;
|
||||
if (catalog && byCatalogKey) {
|
||||
if (ck) catItem = byCatalogKey.get(ck);
|
||||
if (!catItem && id != null) catItem = catalog.find((c) => c.id === id);
|
||||
if (!catItem && side && name) {
|
||||
catItem = byCatalogKey.get(catalogLikeKeyFromPart({ side, name }));
|
||||
}
|
||||
} else {
|
||||
if (ck) catItem = findCatalogItemByKey(ck);
|
||||
if (!catItem && id != null) {
|
||||
catItem = CATALOG_ITEM_BY_ID.get(id!) ?? undefined;
|
||||
}
|
||||
if (!catItem && side && name) {
|
||||
catItem = findCatalogItemByKey(
|
||||
catalogLikeKeyFromPart({ side, name }),
|
||||
);
|
||||
}
|
||||
}
|
||||
const fallbackLabel =
|
||||
typeof o.label_fa === "string" && o.label_fa.trim()
|
||||
? o.label_fa.trim()
|
||||
: typeof o.labelFa === "string" && o.labelFa.trim()
|
||||
? (o.labelFa as string).trim()
|
||||
: name;
|
||||
const listForDis = catalog?.length
|
||||
? catalog
|
||||
: catItem
|
||||
? outerCatalogListForItem(catItem)
|
||||
: [];
|
||||
const label_fa =
|
||||
catItem && listForDis.length
|
||||
? disambiguateOuterPartLabelFa(
|
||||
catItem.titleFa,
|
||||
catItem.side,
|
||||
listForDis,
|
||||
)
|
||||
: fallbackLabel;
|
||||
out.push({
|
||||
id,
|
||||
name,
|
||||
side,
|
||||
label_fa,
|
||||
catalogKey: ck,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (typeof o.key === "string" && o.key.trim()) {
|
||||
const k = o.key.trim();
|
||||
const cat =
|
||||
byCatalogKey?.get(k) ??
|
||||
findCatalogItemByKey(k) ??
|
||||
(toNum(o.id) != null ? CATALOG_ITEM_BY_ID.get(toNum(o.id)!) : undefined);
|
||||
if (cat) {
|
||||
const list = catalog ?? outerCatalogListForItem(cat);
|
||||
out.push(catalogItemToSelectedPart(cat, list));
|
||||
continue;
|
||||
}
|
||||
const split = splitCatalogKeyToNameAndSide(k);
|
||||
out.push({
|
||||
id: toNum(o.id),
|
||||
name: split.side ? split.name : k,
|
||||
side: typeof o.side === "string" ? o.side : split.side,
|
||||
label_fa:
|
||||
typeof o.label_fa === "string" && o.label_fa.trim()
|
||||
? (o.label_fa as string).trim()
|
||||
: labelFaForUnknownSlug(k),
|
||||
catalogKey: k,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
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}`;
|
||||
}
|
||||
|
||||
function normPartSegment(s: string): string {
|
||||
return String(s ?? "").replace(/[^a-z0-9]/gi, "").toLowerCase();
|
||||
}
|
||||
|
||||
function looseCatalogNameMatch(
|
||||
catalogItem: OuterPartCatalogItem,
|
||||
expertPart: string,
|
||||
): boolean {
|
||||
const { name } = splitCatalogKeyToNameAndSide(catalogItem.key);
|
||||
return normPartSegment(name) === normPartSegment(expertPart);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map legacy expert reply `{ part: "backFender", side: "left" }` to a catalog row
|
||||
* when `carType` is known (or scan all types if omitted).
|
||||
*/
|
||||
export function findCatalogItemFromLegacyExpertPart(
|
||||
part: string,
|
||||
side: string,
|
||||
carType?: ClaimVehicleTypeV2,
|
||||
): OuterPartCatalogItem | undefined {
|
||||
const s = String(side ?? "").toLowerCase().trim();
|
||||
const lists = carType
|
||||
? [OUTER_PARTS_BY_CAR_TYPE[carType]].filter(Boolean) as OuterPartCatalogItem[][]
|
||||
: (Object.values(OUTER_PARTS_BY_CAR_TYPE) as OuterPartCatalogItem[][]);
|
||||
for (const catalog of lists) {
|
||||
if (!catalog?.length) continue;
|
||||
for (const it of catalog) {
|
||||
if (String(it.side).toLowerCase() !== s) continue;
|
||||
if (looseCatalogNameMatch(it, part)) return it;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function selectedPartToStoredRecord(p: DamageSelectedPartV2): Record<string, unknown> {
|
||||
const r: Record<string, unknown> = {
|
||||
name: p.name,
|
||||
side: String(p.side || "").toLowerCase(),
|
||||
label_fa: p.label_fa,
|
||||
};
|
||||
if (p.id != null && Number.isFinite(p.id)) r.id = p.id;
|
||||
if (p.catalogKey) r.catalogKey = p.catalogKey;
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical shape for `evaluation.damageExpertReply*.parts[].carPartDamage`
|
||||
* (same fields as owner `damage.selectedParts` items).
|
||||
*/
|
||||
export function normalizeCarPartDamageForExpertReply(
|
||||
raw: unknown,
|
||||
carType?: ClaimVehicleTypeV2,
|
||||
): Record<string, unknown> {
|
||||
if (raw == null) {
|
||||
throw new Error("carPartDamage is required");
|
||||
}
|
||||
if (typeof raw === "string") {
|
||||
const s = raw.trim();
|
||||
if (!s) throw new Error("carPartDamage is empty");
|
||||
const fromKey = findCatalogItemByKey(s);
|
||||
if (fromKey) {
|
||||
const list = outerCatalogListForItem(fromKey);
|
||||
return selectedPartToStoredRecord(
|
||||
catalogItemToSelectedPart(fromKey, list),
|
||||
);
|
||||
}
|
||||
return { name: s, side: "", label_fa: s };
|
||||
}
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
||||
throw new Error("Invalid carPartDamage");
|
||||
}
|
||||
const o = raw as Record<string, unknown>;
|
||||
const legacyPart =
|
||||
typeof o.part === "string" && o.part.trim() ? o.part.trim() : "";
|
||||
const legacySide =
|
||||
typeof o.side === "string" && o.side.trim()
|
||||
? o.side.trim().toLowerCase()
|
||||
: "";
|
||||
const name = typeof o.name === "string" && o.name.trim() ? o.name.trim() : "";
|
||||
const sideIn =
|
||||
typeof o.side === "string" && o.side.trim()
|
||||
? o.side.trim().toLowerCase()
|
||||
: "";
|
||||
|
||||
if (name && sideIn) {
|
||||
const id = toNum(o.id);
|
||||
const ck =
|
||||
typeof o.catalogKey === "string" && o.catalogKey.trim()
|
||||
? o.catalogKey.trim()
|
||||
: undefined;
|
||||
const catalog = carType ? OUTER_PARTS_BY_CAR_TYPE[carType] : undefined;
|
||||
let catItem: OuterPartCatalogItem | undefined;
|
||||
if (catalog) {
|
||||
const byKey = new Map(catalog.map((c) => [c.key, c]));
|
||||
if (ck) catItem = byKey.get(ck);
|
||||
if (!catItem && id != null) catItem = catalog.find((c) => c.id === id);
|
||||
if (!catItem) {
|
||||
catItem = byKey.get(catalogLikeKeyFromPart({ side: sideIn, name }));
|
||||
}
|
||||
}
|
||||
if (!catItem && id != null) {
|
||||
catItem = CATALOG_ITEM_BY_ID.get(id!) ?? undefined;
|
||||
}
|
||||
const list = catalog?.length
|
||||
? catalog
|
||||
: catItem
|
||||
? outerCatalogListForItem(catItem)
|
||||
: [];
|
||||
if (catItem && list.length) {
|
||||
return selectedPartToStoredRecord(
|
||||
catalogItemToSelectedPart(catItem, list),
|
||||
);
|
||||
}
|
||||
const label_fa =
|
||||
typeof o.label_fa === "string" && o.label_fa.trim()
|
||||
? o.label_fa.trim()
|
||||
: name;
|
||||
const out: Record<string, unknown> = {
|
||||
name,
|
||||
side: sideIn,
|
||||
label_fa,
|
||||
};
|
||||
if (id != null) out.id = id;
|
||||
if (ck) out.catalogKey = ck;
|
||||
return out;
|
||||
}
|
||||
|
||||
if (legacyPart && legacySide) {
|
||||
const catItem = findCatalogItemFromLegacyExpertPart(
|
||||
legacyPart,
|
||||
legacySide,
|
||||
carType,
|
||||
);
|
||||
if (catItem) {
|
||||
const list = outerCatalogListForItem(catItem);
|
||||
return selectedPartToStoredRecord(
|
||||
catalogItemToSelectedPart(catItem, list),
|
||||
);
|
||||
}
|
||||
const sideFa = SIDE_LABEL_FA[legacySide] || legacySide;
|
||||
return {
|
||||
name: legacyPart,
|
||||
side: legacySide,
|
||||
label_fa: `${legacyPart} (${sideFa})`,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("carPartDamage must include side and (name or part)");
|
||||
}
|
||||
|
||||
/** Best-effort migration for API output; never throws. */
|
||||
export function migrateExpertReplyCarPartDamageToUnified(
|
||||
raw: unknown,
|
||||
carType?: ClaimVehicleTypeV2,
|
||||
): Record<string, unknown> {
|
||||
try {
|
||||
return normalizeCarPartDamageForExpertReply(raw, carType);
|
||||
} catch {
|
||||
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
||||
return { ...(raw as Record<string, unknown>) };
|
||||
}
|
||||
return { name: String(raw ?? ""), side: "", label_fa: String(raw ?? "") };
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable string for factor DB rows / logs from unified or legacy `carPartDamage`. */
|
||||
export function carPartDamageLabelForFactorRecord(cd: unknown): string {
|
||||
if (cd == null) return "";
|
||||
if (typeof cd === "string") return cd.trim();
|
||||
if (typeof cd === "object") {
|
||||
const o = cd as Record<string, unknown>;
|
||||
if (typeof o.label_fa === "string" && o.label_fa.trim()) {
|
||||
return o.label_fa.trim();
|
||||
}
|
||||
if (typeof o.part === "string" && o.part.trim()) {
|
||||
const side = typeof o.side === "string" ? o.side.trim() : "";
|
||||
const sideFa = side ? SIDE_LABEL_FA[side.toLowerCase()] || side : "";
|
||||
return sideFa ? `${o.part.trim()} (${sideFa})` : o.part.trim();
|
||||
}
|
||||
if (typeof o.name === "string" && o.name.trim()) {
|
||||
const side = typeof o.side === "string" ? o.side.trim() : "";
|
||||
const sideFa = side ? SIDE_LABEL_FA[side.toLowerCase()] || side : "";
|
||||
return sideFa ? `${o.name.trim()} (${sideFa})` : o.name.trim();
|
||||
}
|
||||
}
|
||||
return String(cd);
|
||||
}
|
||||
|
||||
export function mediaRowHasCapture(row: unknown): boolean {
|
||||
if (!row || typeof row !== "object") return false;
|
||||
const r = row as { path?: string; url?: string };
|
||||
return !!(r.path || r.url);
|
||||
}
|
||||
|
||||
/** Whether `media.damagedParts` is the new array shape (possibly empty). */
|
||||
export function isDamagedPartsMediaArray(data: unknown): boolean {
|
||||
return Array.isArray(data);
|
||||
}
|
||||
|
||||
function getLegacyMapRecord(
|
||||
data: unknown,
|
||||
): Record<string, unknown> | null {
|
||||
if (data == null) return null;
|
||||
if (data instanceof Map) {
|
||||
return Object.fromEntries((data as Map<string, unknown>).entries());
|
||||
}
|
||||
if (typeof data === "object" && !Array.isArray(data)) {
|
||||
return data as Record<string, unknown>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getCaptureBlobLoose(
|
||||
legacyMap: Record<string, unknown>,
|
||||
partKey: string,
|
||||
): unknown {
|
||||
const pk = String(partKey ?? "").trim();
|
||||
if (!pk) return undefined;
|
||||
let b = legacyMap[pk];
|
||||
if (b) return b;
|
||||
const target = pk.toLowerCase().replace(/[\s_-]/g, "");
|
||||
for (const k of Object.keys(legacyMap)) {
|
||||
if (k.toLowerCase().replace(/[\s_-]/g, "") === target) {
|
||||
return legacyMap[k];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Align legacy `media.damagedParts` map with `selected` order → array rows
|
||||
* (metadata + any existing capture blobs).
|
||||
*/
|
||||
export function migrateLegacyDamagedPartsMapToArray(
|
||||
legacyMap: Record<string, unknown>,
|
||||
selected: DamageSelectedPartV2[],
|
||||
): Record<string, unknown>[] {
|
||||
return selected.map((sp) => {
|
||||
const keysTry = [
|
||||
sp.catalogKey,
|
||||
catalogLikeKeyFromPart(sp),
|
||||
sp.name,
|
||||
].filter(Boolean) as string[];
|
||||
let blob: unknown;
|
||||
for (const k of keysTry) {
|
||||
blob = getCaptureBlobLoose(legacyMap, k);
|
||||
if (blob) break;
|
||||
}
|
||||
const base = {
|
||||
id: sp.id ?? undefined,
|
||||
name: sp.name,
|
||||
side: sp.side,
|
||||
label_fa: sp.label_fa,
|
||||
};
|
||||
if (blob && typeof blob === "object") {
|
||||
const c = blob as Record<string, unknown>;
|
||||
return {
|
||||
...base,
|
||||
...(c.path ? { path: c.path } : {}),
|
||||
...(c.fileName ? { fileName: c.fileName } : {}),
|
||||
...(c.url ? { url: c.url } : {}),
|
||||
...(c.capturedAt ? { capturedAt: c.capturedAt } : {}),
|
||||
};
|
||||
}
|
||||
return { ...base };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Always returns an array view: migrates legacy map → array when needed.
|
||||
*/
|
||||
export function coerceDamagedPartsMediaToArray(
|
||||
damagedPartsData: unknown,
|
||||
selected: DamageSelectedPartV2[],
|
||||
): Record<string, unknown>[] {
|
||||
if (Array.isArray(damagedPartsData)) {
|
||||
const arr = damagedPartsData.map((x) =>
|
||||
typeof x === "object" && x ? { ...(x as object) } : {},
|
||||
) as Record<string, unknown>[];
|
||||
while (arr.length < selected.length) {
|
||||
arr.push({});
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
const legacy = getLegacyMapRecord(damagedPartsData);
|
||||
if (legacy && selected.length) {
|
||||
return migrateLegacyDamagedPartsMapToArray(legacy, selected);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `captureKey` to an index into `selected` / `media.damagedParts`.
|
||||
*
|
||||
* Supported keys: catalog id (number string), array index when unambiguous,
|
||||
* full catalog key (`left_backfender`), or `side_name` matching stored part.
|
||||
*/
|
||||
export function resolvePartCaptureIndex(
|
||||
captureKeyRaw: string,
|
||||
selected: DamageSelectedPartV2[],
|
||||
damagedPartsArray?: Record<string, unknown>[],
|
||||
): number {
|
||||
const raw = String(captureKeyRaw ?? "").trim();
|
||||
if (!raw) return -1;
|
||||
|
||||
if (selected.length) {
|
||||
for (let i = 0; i < selected.length; i++) {
|
||||
const sp = selected[i];
|
||||
if (sp.catalogKey === raw) return i;
|
||||
if (catalogLikeKeyFromPart(sp) === raw) return i;
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(raw)) {
|
||||
const n = Number(raw);
|
||||
const byId = selected.findIndex((p) => p.id === n);
|
||||
if (byId >= 0) return byId;
|
||||
const hasIdCollision = selected.some((p) => p.id === n);
|
||||
if (!hasIdCollision && n >= 0 && n < selected.length) return n;
|
||||
}
|
||||
|
||||
const loose = raw.toLowerCase().replace(/[\s_-]/g, "");
|
||||
for (let i = 0; i < selected.length; i++) {
|
||||
const sp = selected[i];
|
||||
const cand = [
|
||||
sp.catalogKey,
|
||||
catalogLikeKeyFromPart(sp),
|
||||
sp.name,
|
||||
].filter(Boolean) as string[];
|
||||
for (const c of cand) {
|
||||
if (c.toLowerCase().replace(/[\s_-]/g, "") === loose) return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (damagedPartsArray && damagedPartsArray.length) {
|
||||
for (let i = 0; i < damagedPartsArray.length; i++) {
|
||||
const row = damagedPartsArray[i];
|
||||
if (!row || typeof row !== "object") continue;
|
||||
const id = toNum((row as { id?: unknown }).id);
|
||||
if (id != null && String(id) === raw) return i;
|
||||
const ck = String((row as { catalogKey?: unknown }).catalogKey || "");
|
||||
if (ck && ck === raw) return i;
|
||||
const name = String((row as { name?: unknown }).name || "");
|
||||
const side = String((row as { side?: unknown }).side || "");
|
||||
if (side && name && `${side}_${name}` === raw) return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function mediaRowOrLegacyHasCapture(
|
||||
damagedPartsData: unknown,
|
||||
partKey: string,
|
||||
selected: DamageSelectedPartV2[],
|
||||
): boolean {
|
||||
if (damagedPartsData == null) return false;
|
||||
if (Array.isArray(damagedPartsData)) {
|
||||
const idx = resolvePartCaptureIndex(partKey, selected, damagedPartsData as any[]);
|
||||
if (idx < 0) return false;
|
||||
return mediaRowHasCapture((damagedPartsData as unknown[])[idx]);
|
||||
}
|
||||
const legacy = getLegacyMapRecord(damagedPartsData);
|
||||
if (!legacy) return false;
|
||||
return !!getCaptureBlobLoose(legacy, partKey);
|
||||
}
|
||||
|
||||
export function selectedPartsLegacyStringsForAngles(
|
||||
selected: DamageSelectedPartV2[],
|
||||
): string[] {
|
||||
const s = new Set<string>();
|
||||
for (const p of selected) {
|
||||
if (p.catalogKey) s.add(p.catalogKey);
|
||||
s.add(catalogLikeKeyFromPart(p));
|
||||
if (p.name) s.add(p.name);
|
||||
}
|
||||
return [...s];
|
||||
}
|
||||
|
||||
/** String keys that might appear under legacy `media.damagedParts` for this selection. */
|
||||
export function damageSelectedPartsToLookupStrings(
|
||||
raw: unknown,
|
||||
carType?: ClaimVehicleTypeV2,
|
||||
selectedOuterPartsLegacy?: unknown,
|
||||
): string[] {
|
||||
const norm = normalizeDamageSelectedParts(
|
||||
raw,
|
||||
carType,
|
||||
selectedOuterPartsLegacy,
|
||||
);
|
||||
if (norm.length) return selectedPartsLegacyStringsForAngles(norm);
|
||||
if (Array.isArray(raw)) {
|
||||
return (raw as unknown[])
|
||||
.filter((x): x is string => typeof x === "string")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
Reference in New Issue
Block a user