1
0
forked from Yara724/api
This commit is contained in:
SepehrYahyaee
2026-05-10 11:43:28 +03:30
parent d1bc64bb6d
commit 9c62dc4d3a
5 changed files with 289 additions and 77 deletions

View File

@@ -113,12 +113,24 @@ import {
catalogItemToSelectedPart, catalogItemToSelectedPart,
catalogLikeKeyFromPart, catalogLikeKeyFromPart,
coerceDamagedPartsMediaToArray, coerceDamagedPartsMediaToArray,
type DamageSelectedPartV2,
migrateExpertReplyCarPartDamageToUnified, migrateExpertReplyCarPartDamageToUnified,
normalizeDamageSelectedParts, normalizeDamageSelectedParts,
partIdentityKey, partIdentityKey,
resolvePartCaptureIndex, resolvePartCaptureIndex,
} from "src/helpers/outer-damage-parts"; } 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() @Injectable()
export class ClaimRequestManagementService { export class ClaimRequestManagementService {
private readonly logger = new Logger(ClaimRequestManagementService.name); private readonly logger = new Logger(ClaimRequestManagementService.name);
@@ -163,6 +175,79 @@ export class ClaimRequestManagementService {
private readonly sandHubService: SandHubService, 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( private parsePlateFromCompactString(
plateId: string | undefined, plateId: string | undefined,
): { leftDigits: number; centerAlphabet: string; centerDigits: number; ir: number } | null { ): { leftDigits: number; centerAlphabet: string; centerDigits: number; ir: number } | null {
@@ -219,7 +304,56 @@ export class ClaimRequestManagementService {
return new Promise((resolve) => setTimeout(resolve, ms)); 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) { if (carType) {
return (OUTER_PARTS_BY_CAR_TYPE[carType] || []).map((p) => ({ return (OUTER_PARTS_BY_CAR_TYPE[carType] || []).map((p) => ({
...p, ...p,
@@ -233,10 +367,7 @@ export class ClaimRequestManagementService {
out.push({ ...p, carType: t }); out.push({ ...p, carType: t });
} }
} }
return out.sort((a, b) => { return out;
if (a.carType === b.carType) return a.id - b.id;
return String(a.carType).localeCompare(String(b.carType));
});
} }
private userDamageDetail(blRequest: RequestManagementModel) { private userDamageDetail(blRequest: RequestManagementModel) {
@@ -4396,12 +4527,14 @@ export class ClaimRequestManagementService {
throw new ForbiddenException('Only the claim owner can view capture requirements'); 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 selectedCarType = claimCase.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const catalogForType = const catalogForType =
selectedCarType && OUTER_PARTS_BY_CAR_TYPE[selectedCarType] selectedCarType && OUTER_PARTS_BY_CAR_TYPE[selectedCarType]
? OUTER_PARTS_BY_CAR_TYPE[selectedCarType] ? OUTER_PARTS_BY_CAR_TYPE[selectedCarType]
: this.getOuterPartsCatalogV2(); : this.getOuterPartsRawCatalog();
const catalogByKey = new Map<string, OuterPartCatalogItem>(); const catalogByKey = new Map<string, OuterPartCatalogItem>();
for (const item of catalogForType) { for (const item of catalogForType) {
if (!catalogByKey.has(item.key)) catalogByKey.set(item.key, item); if (!catalogByKey.has(item.key)) catalogByKey.set(item.key, item);
@@ -4437,6 +4570,7 @@ export class ClaimRequestManagementService {
label_en: doc.label_en, label_en: doc.label_en,
category: doc.category, category: doc.category,
uploaded: docData?.uploaded || false, uploaded: docData?.uploaded || false,
preferUploadDuringCapture: isCapturePhaseDamagedPartyDocKey(doc.key),
}; };
}); });
@@ -4655,11 +4789,24 @@ export class ClaimRequestManagementService {
const step = claimCase.workflow?.currentStep; const step = claimCase.workflow?.currentStep;
const isResendUpload = step === ClaimWorkflowStep.USER_EXPERT_RESEND; const isResendUpload = step === ClaimWorkflowStep.USER_EXPERT_RESEND;
if (!isResendUpload && step !== ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS) { const isCapturePhaseDocUpload =
step === ClaimWorkflowStep.CAPTURE_PART_DAMAGES &&
isCapturePhaseDamagedPartyDocKey(body.documentKey);
if (!isResendUpload) {
if (step === ClaimWorkflowStep.CAPTURE_PART_DAMAGES && !isCapturePhaseDocUpload) {
throw new BadRequestException( throw new BadRequestException(
`Invalid workflow step. Expected ${ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS} or ${ClaimWorkflowStep.USER_EXPERT_RESEND}, but current step is ${claimCase.workflow?.currentStep}`, `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 (isResendUpload) {
if (claimCase.status !== ClaimCaseStatus.WAITING_FOR_USER_RESEND) { if (claimCase.status !== ClaimCaseStatus.WAITING_FOR_USER_RESEND) {
throw new BadRequestException( throw new BadRequestException(
@@ -4734,15 +4881,23 @@ export class ClaimRequestManagementService {
let remaining = 0; let remaining = 0;
if (!isResendUpload) { if (!isResendUpload) {
// Check if all documents are uploaded (8 for CAR_BODY, 13 for THIRD_PARTY) if (isCapturePhaseDocUpload) {
const totalDocsRequired = isCarBodyUpload ? 8 : 13; const afterThis = (k: string) =>
const currentDocs = claimCase.requiredDocuments || new Map(); k === body.documentKey || this.isRequiredDocumentUploadedOnClaim(claimCase, k);
const uploadedCount = remaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.filter((k) => !afterThis(k)).length;
(currentDocs instanceof Map ? currentDocs.size : Object.keys(currentDocs).length) + 1; allDocumentsUploaded = false;
allDocumentsUploaded = uploadedCount >= totalDocsRequired; } else {
remaining = totalDocsRequired - uploadedCount; 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) { if (allDocumentsUploaded) {
updateData["status"] = ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT; updateData["status"] = ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT;
updateData["claimStatus"] = ClaimStatus.PENDING; updateData["claimStatus"] = ClaimStatus.PENDING;
@@ -4784,6 +4939,7 @@ export class ClaimRequestManagementService {
ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS; ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS;
} }
} }
}
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, updateData); await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, updateData);
@@ -4808,7 +4964,11 @@ export class ClaimRequestManagementService {
}; };
} }
const message = allDocumentsUploaded 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." ? "All documents uploaded successfully. Your claim is now ready for damage expert review."
: `Document uploaded successfully. ${remaining} documents remaining.`; : `Document uploaded successfully. ${remaining} documents remaining.`;
@@ -4817,7 +4977,9 @@ export class ClaimRequestManagementService {
documentKey: body.documentKey, documentKey: body.documentKey,
fileUrl, fileUrl,
allDocumentsUploaded, allDocumentsUploaded,
currentStep: allDocumentsUploaded currentStep: isCapturePhaseDocUpload
? ClaimWorkflowStep.CAPTURE_PART_DAMAGES
: allDocumentsUploaded
? ClaimWorkflowStep.USER_SUBMISSION_COMPLETE ? ClaimWorkflowStep.USER_SUBMISSION_COMPLETE
: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, : ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
message, message,
@@ -5069,8 +5231,11 @@ export class ClaimRequestManagementService {
); );
}).length; }).length;
const capturePhaseDocsDone = this.capturePhaseDamagedPartyDocsComplete(updatedClaim);
const allCapturesComplete = const allCapturesComplete =
anglesCaptured >= 4 && partsCaptured >= selectedNormAfter.length; anglesCaptured >= 4 &&
partsCaptured >= selectedNormAfter.length &&
capturePhaseDocsDone;
if (allCapturesComplete) { if (allCapturesComplete) {
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
@@ -5090,18 +5255,23 @@ export class ClaimRequestManagementService {
metadata: { metadata: {
stepKey: ClaimWorkflowStep.CAPTURE_PART_DAMAGES, stepKey: ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
description: 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 = const remaining =
(4 - anglesCaptured) + (selectedNormAfter.length - partsCaptured); (4 - anglesCaptured) +
(selectedNormAfter.length - partsCaptured) +
captureDocsRemaining;
const message = allCapturesComplete const message = allCapturesComplete
? 'All captures complete. Please proceed to upload required documents.' ? '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 { return {
claimRequestId: claimCase._id.toString(), 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)", summary: "Get Capture Requirements (V2)",
description: ` description: `
**Get list of what needs to be captured:** **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) - Car angles (4 items: front, back, left, right)
- Damaged parts (based on selected outer parts) - Damaged parts (based on selected outer parts)
Returns status of each item (uploaded/captured or not). 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({ @ApiParam({

View File

@@ -35,6 +35,13 @@ export class RequiredDocumentItem {
enum: ['general', 'damaged_party', 'guilty_party'], enum: ['general', 'damaged_party', 'guilty_party'],
}) })
category: string; 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; 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 { export class OuterPartCatalogItemDto {
@ApiProperty() @ApiProperty({
description: "Static catalog id (unique across all car types)",
example: 102,
})
id: number; id: number;
@ApiProperty() @ApiProperty({
key: string; description: "Side-agnostic part name (matches stored part `name`)",
example: "backWheel",
})
name: string;
@ApiProperty() @ApiProperty({
titleFa: string; description: "Vehicle side / region",
enum: OuterPartSideV2,
example: "left",
})
side: string;
@ApiProperty({ enum: OuterPartSideV2 }) @ApiProperty({
side: OuterPartSideV2; 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; carType?: ClaimVehicleTypeV2;
} }

View File

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