1
0
forked from Yara724/api

Merge pull request 'YARA-877' (#61) from s.yahyaee/yara724-api:main into main

Reviewed-on: Yara724/api#61
This commit is contained in:
2026-05-10 11:44:07 +03:30
5 changed files with 289 additions and 77 deletions

View File

@@ -113,12 +113,24 @@ import {
catalogItemToSelectedPart,
catalogLikeKeyFromPart,
coerceDamagedPartsMediaToArray,
type DamageSelectedPartV2,
migrateExpertReplyCarPartDamageToUnified,
normalizeDamageSelectedParts,
partIdentityKey,
resolvePartCaptureIndex,
} from "src/helpers/outer-damage-parts";
/** Same `requiredDocuments` keys as in getCaptureRequirementsV2; upload allowed during CAPTURE_PART_DAMAGES. */
const CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS = [
"damaged_chassis_number",
"damaged_engine_photo",
"damaged_metal_plate",
] as const;
function isCapturePhaseDamagedPartyDocKey(key: string): boolean {
return (CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS as readonly string[]).includes(key);
}
@Injectable()
export class ClaimRequestManagementService {
private readonly logger = new Logger(ClaimRequestManagementService.name);
@@ -163,6 +175,79 @@ export class ClaimRequestManagementService {
private readonly sandHubService: SandHubService,
) {}
private requiredDocumentKeysV2(isCarBody: boolean): string[] {
const damagedPartyKeys = [
"damaged_driving_license_front",
"damaged_driving_license_back",
"damaged_chassis_number",
"damaged_engine_photo",
"damaged_car_card_front",
"damaged_car_card_back",
"damaged_metal_plate",
];
if (isCarBody) return damagedPartyKeys;
return [
...damagedPartyKeys,
"guilty_driving_license_front",
"guilty_driving_license_back",
"guilty_car_card_front",
"guilty_car_card_back",
"guilty_metal_plate",
];
}
private isRequiredDocumentUploadedOnClaim(claimCase: any, key: string): boolean {
const doc =
(claimCase.requiredDocuments as any)?.get?.(key) ??
(claimCase.requiredDocuments as any)?.[key];
return !!doc?.uploaded;
}
private capturePhaseDamagedPartyDocsComplete(claimCase: any): boolean {
return CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.every((k) =>
this.isRequiredDocumentUploadedOnClaim(claimCase, k),
);
}
private allV2OwnerDocumentsComplete(
claimCase: any,
isCarBody: boolean,
assumeUploadedKey?: string,
): boolean {
for (const k of this.requiredDocumentKeysV2(isCarBody)) {
const ok =
k === assumeUploadedKey ? true : this.isRequiredDocumentUploadedOnClaim(claimCase, k);
if (!ok) return false;
}
if (isCarBody) {
const g = ClaimRequiredDocumentType.CAR_GREEN_CARD;
const okGreen =
assumeUploadedKey === g ? true : this.isRequiredDocumentUploadedOnClaim(claimCase, g);
if (!okGreen) return false;
}
return true;
}
private countRemainingV2OwnerDocuments(
claimCase: any,
isCarBody: boolean,
assumeUploadedKey?: string,
): number {
let n = 0;
for (const k of this.requiredDocumentKeysV2(isCarBody)) {
const ok =
k === assumeUploadedKey ? true : this.isRequiredDocumentUploadedOnClaim(claimCase, k);
if (!ok) n++;
}
if (isCarBody) {
const g = ClaimRequiredDocumentType.CAR_GREEN_CARD;
const okGreen =
assumeUploadedKey === g ? true : this.isRequiredDocumentUploadedOnClaim(claimCase, g);
if (!okGreen) n++;
}
return n;
}
private parsePlateFromCompactString(
plateId: string | undefined,
): { leftDigits: number; centerAlphabet: string; centerDigits: number; ir: number } | null {
@@ -219,7 +304,56 @@ export class ClaimRequestManagementService {
return new Promise((resolve) => setTimeout(resolve, ms));
}
getOuterPartsCatalogV2(carType?: ClaimVehicleTypeV2): OuterPartCatalogItem[] {
/**
* Outer-parts catalog returned to clients (user app + expert panel).
*
* The shape intentionally mirrors how items are persisted under
* `damage.selectedParts` so the front-end can match catalog rows to the
* stored selection by `id` / `catalogKey` without renaming fields:
*
* { id, name, side, label_fa, catalogKey, carType }
*
* - `name` is side-agnostic (`backWheel`, not `left_backWheel`).
* - `label_fa` is disambiguated with the side in parentheses when multiple
* catalog rows share the same Farsi title (e.g. `چرخ عقب (چپ)`).
* - `catalogKey` keeps the original full key (`left_backWheel`) for clients
* that already address parts by it.
*/
getOuterPartsCatalogV2(
carType?: ClaimVehicleTypeV2,
): (DamageSelectedPartV2 & { carType: ClaimVehicleTypeV2 })[] {
const buildForType = (
type: ClaimVehicleTypeV2,
items: OuterPartCatalogItem[],
): (DamageSelectedPartV2 & { carType: ClaimVehicleTypeV2 })[] =>
items.map((p) => ({
...catalogItemToSelectedPart(p, items),
carType: type,
}));
if (carType) {
const items = OUTER_PARTS_BY_CAR_TYPE[carType] || [];
return buildForType(carType, items);
}
const out: (DamageSelectedPartV2 & { carType: ClaimVehicleTypeV2 })[] = [];
for (const [type, items] of Object.entries(OUTER_PARTS_BY_CAR_TYPE)) {
out.push(...buildForType(type as ClaimVehicleTypeV2, items));
}
return out.sort((a, b) => {
if (a.carType === b.carType) return (a.id ?? 0) - (b.id ?? 0);
return String(a.carType).localeCompare(String(b.carType));
});
}
/**
* Internal raw catalog (kept in the original `OuterPartCatalogItem` shape so
* existing internal code that indexes by `key`/`titleFa` keeps working).
* Public API consumers should use `getOuterPartsCatalogV2` instead.
*/
private getOuterPartsRawCatalog(
carType?: ClaimVehicleTypeV2,
): OuterPartCatalogItem[] {
if (carType) {
return (OUTER_PARTS_BY_CAR_TYPE[carType] || []).map((p) => ({
...p,
@@ -233,10 +367,7 @@ export class ClaimRequestManagementService {
out.push({ ...p, carType: t });
}
}
return out.sort((a, b) => {
if (a.carType === b.carType) return a.id - b.id;
return String(a.carType).localeCompare(String(b.carType));
});
return out;
}
private userDamageDetail(blRequest: RequestManagementModel) {
@@ -4396,12 +4527,14 @@ export class ClaimRequestManagementService {
throw new ForbiddenException('Only the claim owner can view capture requirements');
}
// Build car-type aware outer-parts lookup (complete source: static catalog)
// Build car-type aware outer-parts lookup (complete source: static catalog).
// Uses the raw catalog shape (`{id, key, titleFa, side, ...}`) because the
// map below is keyed on the full `key` (e.g. `left_backfender`).
const selectedCarType = claimCase.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const catalogForType =
selectedCarType && OUTER_PARTS_BY_CAR_TYPE[selectedCarType]
? OUTER_PARTS_BY_CAR_TYPE[selectedCarType]
: this.getOuterPartsCatalogV2();
: this.getOuterPartsRawCatalog();
const catalogByKey = new Map<string, OuterPartCatalogItem>();
for (const item of catalogForType) {
if (!catalogByKey.has(item.key)) catalogByKey.set(item.key, item);
@@ -4437,6 +4570,7 @@ export class ClaimRequestManagementService {
label_en: doc.label_en,
category: doc.category,
uploaded: docData?.uploaded || false,
preferUploadDuringCapture: isCapturePhaseDamagedPartyDocKey(doc.key),
};
});
@@ -4655,10 +4789,23 @@ export class ClaimRequestManagementService {
const step = claimCase.workflow?.currentStep;
const isResendUpload = step === ClaimWorkflowStep.USER_EXPERT_RESEND;
if (!isResendUpload && step !== ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS) {
throw new BadRequestException(
`Invalid workflow step. Expected ${ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS} or ${ClaimWorkflowStep.USER_EXPERT_RESEND}, but current step is ${claimCase.workflow?.currentStep}`,
);
const isCapturePhaseDocUpload =
step === ClaimWorkflowStep.CAPTURE_PART_DAMAGES &&
isCapturePhaseDamagedPartyDocKey(body.documentKey);
if (!isResendUpload) {
if (step === ClaimWorkflowStep.CAPTURE_PART_DAMAGES && !isCapturePhaseDocUpload) {
throw new BadRequestException(
`During ${ClaimWorkflowStep.CAPTURE_PART_DAMAGES} only chassis, engine, and damaged metal plate photos may be uploaded here (${CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.join(", ")}).`,
);
}
const allowedInitialUploadStep =
step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS || isCapturePhaseDocUpload;
if (!allowedInitialUploadStep) {
throw new BadRequestException(
`Invalid workflow step. Expected ${ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS}, ${ClaimWorkflowStep.CAPTURE_PART_DAMAGES} (capture-phase documents only), or ${ClaimWorkflowStep.USER_EXPERT_RESEND}, but current step is ${claimCase.workflow?.currentStep}`,
);
}
}
if (isResendUpload) {
if (claimCase.status !== ClaimCaseStatus.WAITING_FOR_USER_RESEND) {
@@ -4734,54 +4881,63 @@ export class ClaimRequestManagementService {
let remaining = 0;
if (!isResendUpload) {
// Check if all documents are uploaded (8 for CAR_BODY, 13 for THIRD_PARTY)
const totalDocsRequired = isCarBodyUpload ? 8 : 13;
const currentDocs = claimCase.requiredDocuments || new Map();
const uploadedCount =
(currentDocs instanceof Map ? currentDocs.size : Object.keys(currentDocs).length) + 1;
allDocumentsUploaded = uploadedCount >= totalDocsRequired;
remaining = totalDocsRequired - uploadedCount;
if (isCapturePhaseDocUpload) {
const afterThis = (k: string) =>
k === body.documentKey || this.isRequiredDocumentUploadedOnClaim(claimCase, k);
remaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.filter((k) => !afterThis(k)).length;
allDocumentsUploaded = false;
} else {
allDocumentsUploaded = this.allV2OwnerDocumentsComplete(
claimCase,
isCarBodyUpload,
body.documentKey,
);
remaining = this.countRemainingV2OwnerDocuments(
claimCase,
isCarBodyUpload,
body.documentKey,
);
// If all documents uploaded, user flow complete (captures were done earlier in v2)
if (allDocumentsUploaded) {
updateData["status"] = ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT;
updateData["claimStatus"] = ClaimStatus.PENDING;
updateData["workflow.currentStep"] =
ClaimWorkflowStep.USER_SUBMISSION_COMPLETE;
updateData["workflow.nextStep"] =
ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT;
updateData.$push.history = [
updateData.$push.history,
{
type: "STEP_COMPLETED",
actor: {
actorId: new Types.ObjectId(currentUserId),
actorName: claimCase.owner?.fullName || "User",
actorType: "user",
if (allDocumentsUploaded) {
updateData["status"] = ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT;
updateData["claimStatus"] = ClaimStatus.PENDING;
updateData["workflow.currentStep"] =
ClaimWorkflowStep.USER_SUBMISSION_COMPLETE;
updateData["workflow.nextStep"] =
ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT;
updateData.$push.history = [
updateData.$push.history,
{
type: "STEP_COMPLETED",
actor: {
actorId: new Types.ObjectId(currentUserId),
actorName: claimCase.owner?.fullName || "User",
actorType: "user",
},
timestamp: new Date(),
metadata: {
stepKey: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
description: "All required documents uploaded",
},
},
timestamp: new Date(),
metadata: {
stepKey: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
description: "All required documents uploaded",
{
type: "STEP_COMPLETED",
actor: {
actorId: new Types.ObjectId(currentUserId),
actorName: claimCase.owner?.fullName || "User",
actorType: "user",
},
timestamp: new Date(),
metadata: {
stepKey: ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
description:
"User submission complete. Claim ready for damage expert review.",
},
},
},
{
type: "STEP_COMPLETED",
actor: {
actorId: new Types.ObjectId(currentUserId),
actorName: claimCase.owner?.fullName || "User",
actorType: "user",
},
timestamp: new Date(),
metadata: {
stepKey: ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
description:
"User submission complete. Claim ready for damage expert review.",
},
},
];
updateData.$push["workflow.completedSteps"] =
ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS;
];
updateData.$push["workflow.completedSteps"] =
ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS;
}
}
}
@@ -4808,18 +4964,24 @@ export class ClaimRequestManagementService {
};
}
const message = allDocumentsUploaded
? "All documents uploaded successfully. Your claim is now ready for damage expert review."
: `Document uploaded successfully. ${remaining} documents remaining.`;
const message = isCapturePhaseDocUpload
? remaining > 0
? `Document saved. ${remaining} capture-phase document(s) still required (chassis, engine, metal plate) before you can finish damage capture.`
: "Document saved. All capture-phase documents are uploaded; finish car angles and part photos to proceed."
: allDocumentsUploaded
? "All documents uploaded successfully. Your claim is now ready for damage expert review."
: `Document uploaded successfully. ${remaining} documents remaining.`;
return {
claimRequestId: claimCase._id.toString(),
documentKey: body.documentKey,
fileUrl,
allDocumentsUploaded,
currentStep: allDocumentsUploaded
? ClaimWorkflowStep.USER_SUBMISSION_COMPLETE
: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
currentStep: isCapturePhaseDocUpload
? ClaimWorkflowStep.CAPTURE_PART_DAMAGES
: allDocumentsUploaded
? ClaimWorkflowStep.USER_SUBMISSION_COMPLETE
: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
message,
};
} catch (error) {
@@ -5069,8 +5231,11 @@ export class ClaimRequestManagementService {
);
}).length;
const capturePhaseDocsDone = this.capturePhaseDamagedPartyDocsComplete(updatedClaim);
const allCapturesComplete =
anglesCaptured >= 4 && partsCaptured >= selectedNormAfter.length;
anglesCaptured >= 4 &&
partsCaptured >= selectedNormAfter.length &&
capturePhaseDocsDone;
if (allCapturesComplete) {
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
@@ -5090,18 +5255,23 @@ export class ClaimRequestManagementService {
metadata: {
stepKey: ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
description:
'All car angles and damaged parts captured. Please upload required documents.',
'Angles, damaged parts, and capture-phase vehicle evidence (chassis, engine, metal plate) are complete. Please upload remaining required documents.',
},
},
},
});
}
const captureDocsRemaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.filter(
(k) => !this.isRequiredDocumentUploadedOnClaim(updatedClaim, k),
).length;
const remaining =
(4 - anglesCaptured) + (selectedNormAfter.length - partsCaptured);
(4 - anglesCaptured) +
(selectedNormAfter.length - partsCaptured) +
captureDocsRemaining;
const message = allCapturesComplete
? 'All captures complete. Please proceed to upload required documents.'
: `${body.captureType === 'angle' ? 'Angle' : 'Part'} captured successfully. ${remaining} captures remaining.`;
: `${body.captureType === 'angle' ? 'Angle' : 'Part'} captured successfully. ${remaining} items remaining (angles, parts, and capture-phase documents).`;
return {
claimRequestId: claimCase._id.toString(),

View File

@@ -638,13 +638,13 @@ Optional: upload car green card file in the same step.
summary: "Get Capture Requirements (V2)",
description: `
**Get list of what needs to be captured:**
- Required documents (13 items)
- Required documents (10 remaining at the documents step for third-party; 3 damaged-party items should be uploaded during capture — see \`preferUploadDuringCapture\` on each item)
- Car angles (4 items: front, back, left, right)
- Damaged parts (based on selected outer parts)
Returns status of each item (uploaded/captured or not).
**V2 order:** Complete angles and part photos first, then required documents.
**V2 order:** During \`CAPTURE_PART_DAMAGES\`, complete angles, part photos, walk-around video, and the three \`preferUploadDuringCapture\` documents (same upload-document API). Then complete the remaining documents in \`UPLOAD_REQUIRED_DOCUMENTS\`.
`,
})
@ApiParam({

View File

@@ -35,6 +35,13 @@ export class RequiredDocumentItem {
enum: ['general', 'damaged_party', 'guilty_party'],
})
category: string;
@ApiPropertyOptional({
description:
'When true, the client should upload this file during CAPTURE_PART_DAMAGES (same POST upload-document endpoint and `requiredDocuments` keys). Capture cannot finish until these are uploaded.',
example: true,
})
preferUploadDuringCapture?: boolean;
}
/**

View File

@@ -140,19 +140,54 @@ export class SetClaimVehicleTypeV2Dto {
carType: ClaimVehicleTypeV2;
}
/**
* Shape returned by `GET .../outer-parts-catalog` (both user and expert
* controllers). It mirrors how items are persisted under
* `damage.selectedParts` (see `DamageSelectedPartV2BodyDto`) so the front-end
* can match catalog rows to stored selections without any field renaming:
* `name` is side-agnostic, `label_fa` is disambiguated with the side in
* parentheses, and the original full catalog key (`left_backfender`) is
* exposed as `catalogKey`.
*/
export class OuterPartCatalogItemDto {
@ApiProperty()
@ApiProperty({
description: "Static catalog id (unique across all car types)",
example: 102,
})
id: number;
@ApiProperty()
key: string;
@ApiProperty({
description: "Side-agnostic part name (matches stored part `name`)",
example: "backWheel",
})
name: string;
@ApiProperty()
titleFa: string;
@ApiProperty({
description: "Vehicle side / region",
enum: OuterPartSideV2,
example: "left",
})
side: string;
@ApiProperty({ enum: OuterPartSideV2 })
side: OuterPartSideV2;
@ApiProperty({
description:
"Display label in Farsi, with side disambiguator in parentheses when needed",
example: "چرخ عقب (چپ)",
})
label_fa: string;
@ApiProperty({ enum: ClaimVehicleTypeV2, required: false })
@ApiProperty({
description: "Original full catalog key (matches stored `catalogKey`)",
example: "left_backWheel",
required: false,
})
catalogKey?: string;
@ApiProperty({
description: "Vehicle type this catalog row belongs to",
enum: ClaimVehicleTypeV2,
required: false,
example: "suv",
})
carType?: ClaimVehicleTypeV2;
}

View File

@@ -2523,7 +2523,7 @@ export class ExpertClaimService {
}
// Price cap validation
const PRICE_CAP = 30_000_000;
const PRICE_CAP = 53_000_000;
let totalPrice = 0;
for (const part of reply.parts || []) {
const parsed = this.parsePersianNumber(String(part.totalPayment ?? '0'));