This commit is contained in:
SepehrYahyaee
2026-04-15 10:16:18 +03:30
parent a2143fe1c5
commit 0e2789c209

View File

@@ -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<string, unknown>[] {
if (section == null) return [];
if (Array.isArray(section)) {
return section.filter(
(row): row is Record<string, unknown> =>
!!row && typeof row === "object",
);
}
if (typeof section === "object") {
return [section as Record<string, unknown>];
}
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<string, unknown>;
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<string>();
const add = (slug: string | undefined) => {
if (slug) out.add(slug);
};
const walkMainOnSide = (
side: "left" | "right",
items: unknown,
map: Record<string, string>,
) => {
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<string, string>) => {
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<void> {
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<string, unknown> = {};
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