From 0e2789c209970217c99f6f574e269e3ba6f4409f Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Wed, 15 Apr 2026 10:16:18 +0330 Subject: [PATCH] YARA-789 --- .../claim-request-management.service.ts | 351 +++++++++++++++++- 1 file changed, 349 insertions(+), 2 deletions(-) diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 111e1e6..7f1225f 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -63,7 +63,9 @@ import { PublicIdService } from "src/utils/public-id/public-id.service"; import { ImageRequiredModel } from "./entites/schema/image-required.schema"; import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service"; import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum"; -import { BranchDbService } from "src/client/entities/db-service/branch.db.service";import { RoleEnum } from "src/Types&Enums/role.enum"; +import { BranchDbService } from "src/client/entities/db-service/branch.db.service"; +import { CreationMethod } from "src/request-management/entities/schema/request-management.schema"; +import { RoleEnum } from "src/Types&Enums/role.enum"; import { UserRatingDto } from "./dto/user-rating.dto"; @Injectable() @@ -336,6 +338,166 @@ export class ClaimRequestManagementService { return trueItems; } + /** One section of CarDamagePartDto: either a single object or an array of objects (legacy). */ + private carPartDamageSectionToRows(section: unknown): Record[] { + if (section == null) return []; + if (Array.isArray(section)) { + return section.filter( + (row): row is Record => + !!row && typeof row === "object", + ); + } + if (typeof section === "object") { + return [section as Record]; + } + return []; + } + + private carPartDamageSectionHasTrue(section: unknown): boolean { + for (const row of this.carPartDamageSectionToRows(section)) { + for (const val of Object.values(row)) { + if (val === true) return true; + } + } + return false; + } + + /** + * At most two of left, right, front, back may contain any damaged part (true). + * `top` and claim `otherParts` are not subject to this rule. + */ + private assertCarPartDamageAtMostTwoOfFourSides(dto: CarDamagePartDto): void { + const d = dto as unknown as Record; + const quadrants = [ + { key: "left", section: d.left }, + { key: "right", section: d.right }, + { key: "front", section: d.front }, + { key: "back", section: d.back }, + ]; + const used = quadrants.filter((q) => + this.carPartDamageSectionHasTrue(q.section), + ); + if (used.length > 2) { + throw new BadRequestException( + `At most two of the four sides (left, right, front, back) may include damaged parts. ` + + `Sides with selections: ${used.map((u) => u.key).join(", ")}.`, + ); + } + } + + /** + * Maps legacy expert CarDamagePartDto (nested booleans) to v2 outer part slugs (ClaimCase.damage.selectedParts). + */ + private carDamagePartDtoToOuterPartSlugs(dto: CarDamagePartDto): string[] { + const out = new Set(); + const add = (slug: string | undefined) => { + if (slug) out.add(slug); + }; + const walkMainOnSide = ( + side: "left" | "right", + items: unknown, + map: Record, + ) => { + for (const row of this.carPartDamageSectionToRows(items)) { + for (const [key, val] of Object.entries(row)) { + if (val !== true) continue; + const slug = map[`${side}_${key}`] ?? map[key]; + add(slug); + } + } + }; + // MainParts on left / right (side-specific door / fender) + walkMainOnSide("left", (dto as any).left, { + left_frontDoor: "front_left_door", + left_backDoor: "rear_left_door", + left_frontFender: "front_left_fender", + left_backFender: "rear_left_fender", + frontDoor: "front_left_door", + backDoor: "rear_left_door", + frontFender: "front_left_fender", + backFender: "rear_left_fender", + }); + walkMainOnSide("right", (dto as any).right, { + right_frontDoor: "front_right_door", + right_backDoor: "rear_right_door", + right_frontFender: "front_right_fender", + right_backFender: "rear_right_fender", + frontDoor: "front_right_door", + backDoor: "rear_right_door", + frontFender: "front_right_fender", + backFender: "rear_right_fender", + }); + const walkSimple = (items: unknown, keyToSlug: Record) => { + for (const row of this.carPartDamageSectionToRows(items)) { + for (const [key, val] of Object.entries(row)) { + if (val === true) add(keyToSlug[key]); + } + } + }; + walkSimple((dto as any).front, { + frontBumper: "front_bumper", + carHood: "hood", + }); + walkSimple((dto as any).back, { + backBumper: "rear_bumper", + carTrunk: "trunk", + }); + walkSimple((dto as any).top, { + roof: "roof", + }); + return Array.from(out); + } + + /** Normalize expert DTO otherParts (strings or legacy { label: true }[]) to string[]. */ + private normalizeExpertOtherPartsInput(raw: any[] | undefined): string[] { + if (!raw || !Array.isArray(raw)) return []; + const out: string[] = []; + for (const item of raw) { + if (typeof item === "string" && item.trim()) { + out.push(item.trim()); + continue; + } + if (item && typeof item === "object") { + for (const [k, v] of Object.entries(item)) { + if (v === true && k) out.push(k); + } + } + } + return out; + } + + private async assertFieldExpertInPersonBlameForClaimCase( + claimCase: any, + expert: any, + ): Promise { + const blameRequestId = claimCase?.blameRequestId?.toString?.(); + if (!blameRequestId) { + throw new BadRequestException("Claim case has no linked blame request."); + } + const blameRequest = await this.blameRequestDbService.findById(blameRequestId); + if (!blameRequest) { + throw new NotFoundException("Blame request not found for this claim."); + } + if ( + !blameRequest.expertInitiated || + blameRequest.creationMethod !== CreationMethod.IN_PERSON + ) { + throw new BadRequestException( + "This endpoint is only for expert-initiated IN_PERSON claim files.", + ); + } + if (!blameRequest.initiatedByFieldExpertId) { + throw new BadRequestException( + "Blame request is missing initiating field expert.", + ); + } + if (String(blameRequest.initiatedByFieldExpertId) !== String(expert.sub)) { + throw new ForbiddenException( + "Only the initiating field expert can complete claim data for this file.", + ); + } + } + async setCarPartDamageAndImage(cl, trueItems) { cl.carPartDamage = trueItems; const jsonTrueItems = JSON.parse(JSON.stringify(trueItems)); @@ -389,7 +551,9 @@ export class ClaimRequestManagementService { /** * Field expert completes claim-needed data for expert-initiated IN_PERSON files. - * Only the initiating expert can call this. Accepts car part damage and/or sheba, nationalCodeOfInsurer, otherParts. + * Only the initiating field expert can call this. Accepts car part damage and/or sheba, nationalCodeOfInsurer, otherParts. + * + * V2: Resolves `ClaimCase` in `claimCases` collection (workflow). Legacy v1 claim documents are still supported. */ async expertCompleteClaimData( claimRequestId: string, @@ -401,6 +565,16 @@ export class ClaimRequestManagementService { otherParts?: any[]; }, ): Promise<{ success: boolean; claimRequestId: string }> { + const claimCase = await this.claimCaseDbService.findById(claimRequestId); + if (claimCase) { + return this.expertCompleteClaimDataForClaimCaseV2( + claimRequestId, + expert, + dto, + claimCase, + ); + } + const claim = await this.claimDbService.findOne(claimRequestId); if (!claim) { throw new NotFoundException("Claim file not found"); @@ -422,6 +596,7 @@ export class ClaimRequestManagementService { if (claim.carPartDamage && (claim.carPartDamage as any[]).length > 0) { throw new BadRequestException("Car part damage is already set."); } + this.assertCarPartDamageAtMostTwoOfFourSides(dto.carPartDamage); const trueItems = this.findTrueItems(dto.carPartDamage); const claimDoc = (await this.claimDbService.findOne( claimRequestId, @@ -450,6 +625,178 @@ export class ClaimRequestManagementService { return { success: true, claimRequestId }; } + /** + * V2 ClaimCase: outer parts + optional bank/other parts (expert-initiated IN_PERSON only). + */ + private async expertCompleteClaimDataForClaimCaseV2( + claimRequestId: string, + expert: any, + dto: { + carPartDamage?: CarDamagePartDto; + sheba?: string; + nationalCodeOfInsurer?: string; + otherParts?: any[]; + }, + claimCase: any, + ): Promise<{ success: boolean; claimRequestId: string }> { + await this.assertFieldExpertInPersonBlameForClaimCase(claimCase, expert); + + let working = claimCase; + + if (dto.carPartDamage) { + if (working.workflow?.currentStep !== ClaimWorkflowStep.SELECT_OUTER_PARTS) { + throw new BadRequestException( + `Cannot set outer parts: workflow step must be ${ClaimWorkflowStep.SELECT_OUTER_PARTS}. Current: ${working.workflow?.currentStep}`, + ); + } + if ( + working.damage?.selectedParts && + working.damage.selectedParts.length > 0 + ) { + throw new BadRequestException("Car part damage is already set."); + } + this.assertCarPartDamageAtMostTwoOfFourSides(dto.carPartDamage); + const selectedParts = this.carDamagePartDtoToOuterPartSlugs(dto.carPartDamage); + if (!selectedParts.length) { + throw new BadRequestException( + "No outer damaged parts could be derived from carPartDamage. Check part selections.", + ); + } + + const updated = await this.claimCaseDbService.findByIdAndUpdate( + claimRequestId, + { + "damage.selectedParts": selectedParts, + status: ClaimCaseStatus.SELECTING_OTHER_PARTS, + "workflow.currentStep": ClaimWorkflowStep.SELECT_OTHER_PARTS, + "workflow.nextStep": ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, + $push: { + "workflow.completedSteps": ClaimWorkflowStep.SELECT_OUTER_PARTS, + history: { + type: "STEP_COMPLETED", + actor: { + actorId: new Types.ObjectId(expert.sub), + actorName: + `${expert.firstName || ""} ${expert.lastName || ""}`.trim() || + "field_expert", + actorType: "field_expert", + }, + timestamp: new Date(), + metadata: { + stepKey: ClaimWorkflowStep.SELECT_OUTER_PARTS, + selectedParts, + description: "Field expert submitted outer damage parts", + }, + }, + }, + }, + ); + if (!updated) { + throw new InternalServerErrorException("Failed to update claim case"); + } + working = updated; + } + + const hasBankOrOther = + dto.sheba != null || + dto.nationalCodeOfInsurer != null || + dto.otherParts != null; + if (hasBankOrOther) { + const fresh = await this.claimCaseDbService.findById(claimRequestId); + if (!fresh) throw new NotFoundException("Claim case not found"); + if (fresh.workflow?.currentStep !== ClaimWorkflowStep.SELECT_OTHER_PARTS) { + throw new BadRequestException( + `Bank/other parts can only be set at ${ClaimWorkflowStep.SELECT_OTHER_PARTS}. Current: ${fresh.workflow?.currentStep}`, + ); + } + + const mergedSheba = + dto.sheba != null ? dto.sheba : fresh.money?.sheba; + const mergedNational = + dto.nationalCodeOfInsurer != null + ? dto.nationalCodeOfInsurer + : fresh.money?.nationalCodeOfInsurer; + + if (fresh.money?.sheba && dto.sheba != null && dto.sheba !== fresh.money.sheba) { + throw new ConflictException("Sheba is already set for this claim."); + } + if ( + fresh.money?.nationalCodeOfInsurer && + dto.nationalCodeOfInsurer != null && + dto.nationalCodeOfInsurer !== fresh.money.nationalCodeOfInsurer + ) { + throw new ConflictException( + "National code of insurer is already set for this claim.", + ); + } + + const otherPartsArr = this.normalizeExpertOtherPartsInput(dto.otherParts); + const setPatch: Record = {}; + if (dto.sheba != null) setPatch["money.sheba"] = dto.sheba; + if (dto.nationalCodeOfInsurer != null) { + setPatch["money.nationalCodeOfInsurer"] = dto.nationalCodeOfInsurer; + } + if (dto.otherParts != null) { + setPatch["damage.otherParts"] = + otherPartsArr.length > 0 ? otherPartsArr : []; + } + + const bankComplete = + mergedSheba && + mergedNational && + /^[0-9]{24}$/.test(String(mergedSheba).replace(/\s/g, "")) && + /^[0-9]{10}$/.test(String(mergedNational).replace(/\s/g, "")); + + if (bankComplete) { + const shebaNorm = String(mergedSheba).replace(/\s/g, ""); + const nationalNorm = String(mergedNational).replace(/\s/g, ""); + await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { + "money.sheba": shebaNorm, + "money.nationalCodeOfInsurer": nationalNorm, + ...(dto.otherParts != null + ? { + "damage.otherParts": + otherPartsArr.length > 0 ? otherPartsArr : [], + } + : {}), + status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS, + "workflow.currentStep": ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, + "workflow.nextStep": ClaimWorkflowStep.CAPTURE_PART_DAMAGES, + $push: { + "workflow.completedSteps": ClaimWorkflowStep.SELECT_OTHER_PARTS, + history: { + type: "STEP_COMPLETED", + actor: { + actorId: new Types.ObjectId(expert.sub), + actorName: + `${expert.firstName || ""} ${expert.lastName || ""}`.trim() || + "field_expert", + actorType: "field_expert", + }, + timestamp: new Date(), + metadata: { + stepKey: ClaimWorkflowStep.SELECT_OTHER_PARTS, + description: + "Field expert submitted bank info and optional other parts", + }, + }, + }, + }); + } else { + if (Object.keys(setPatch).length === 0) { + throw new BadRequestException( + "Provide sheba (24 digits) and nationalCodeOfInsurer (10 digits) together to finish this step, or supply partial fields incrementally.", + ); + } + await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { + $set: setPatch, + }); + } + } + + return { success: true, claimRequestId }; + } + async selectCarOtherPartDamage( requestId: string, body: any, // Use your DTO for better type safety