1
0
forked from Yara724/api

Merge pull request 'YARA-948, YARA-977, + Bugs' (#107) from s.yahyaee/yara724-api:main into main

Reviewed-on: Yara724/api#107
This commit is contained in:
2026-06-03 12:31:44 +03:30
12 changed files with 570 additions and 219 deletions

View File

@@ -22,7 +22,7 @@ export class UserAuthController {
@Post("/send-otp") @Post("/send-otp")
@ApiBody({ @ApiBody({
type: UserLoginDto, type: UserLoginDto,
description: "user login api -- call this api and send otp", description: "Users can ask for OTP via this API and receive it",
}) })
@ApiAcceptedResponse() @ApiAcceptedResponse()
async sendOtpRq(@Body() body: UserLoginDto) { async sendOtpRq(@Body() body: UserLoginDto) {
@@ -40,7 +40,8 @@ export class UserAuthController {
@UseGuards(LocalUserAuthGuard) @UseGuards(LocalUserAuthGuard)
@ApiBody({ @ApiBody({
type: UserVerifyOtp, type: UserVerifyOtp,
description: "user verify otp -- call this api and get a tokens", description:
"Users can send their credentials and get their access token to server",
}) })
@ApiAcceptedResponse() @ApiAcceptedResponse()
async login(@Body() body, @Req() req, @CurrentUser() user) { async login(@Body() body, @Req() req, @CurrentUser() user) {

View File

@@ -143,6 +143,7 @@ import {
} from "src/helpers/claim-capture-phase"; } from "src/helpers/claim-capture-phase";
import { HttpService } from "@nestjs/axios"; import { HttpService } from "@nestjs/axios";
import { firstValueFrom } from "rxjs"; import { firstValueFrom } from "rxjs";
import { buildEnrichedDamagedParts } from "src/expert-claim/dto/claim-damaged-part.enricher";
@Injectable() @Injectable()
export class ClaimRequestManagementService { export class ClaimRequestManagementService {
@@ -3983,16 +3984,28 @@ export class ClaimRequestManagementService {
owner: { owner: {
userId: new Types.ObjectId(currentUserId), userId: new Types.ObjectId(currentUserId),
userRole: currentUserParty.role as any, userRole: currentUserParty.role as any,
...(currentUserParty.person?.clientId fullName: currentUserParty.person?.fullName,
? { userClientKey: currentUserParty.person?.clientId
clientId: new Types.ObjectId( ? String(currentUserParty.person.clientId)
String(blameRequest?.expert?.decision?.guiltyPartyId) === : undefined,
String(blameRequest?.parties[0]?.person?.userId) // clientId must be the GUILTY party's clientId — their insurer pays the claim
? blameRequest?.parties[0]?.person?.clientId ...(() => {
: blameRequest?.parties[1]?.person?.clientId, if (isCarBody) {
), // CAR_BODY has no guilty party — use the first party's own clientId
} const firstParty = parties.find((p) => p.role === "FIRST");
: {}), const cid = firstParty?.person?.clientId;
return cid ? { clientId: new Types.ObjectId(String(cid)) } : {};
}
const guiltyPartyId = String(
blameRequest?.expert?.decision?.guiltyPartyId ?? "",
);
const guiltyParty = parties.find(
(p) => String(p.person?.userId) === guiltyPartyId,
);
const cid = guiltyParty?.person?.clientId;
return cid ? { clientId: new Types.ObjectId(String(cid)) } : {};
})(),
}, },
history: [ history: [
{ {
@@ -4660,7 +4673,7 @@ export class ClaimRequestManagementService {
"money.sheba": shebaNumber, "money.sheba": shebaNumber,
"money.nationalCodeOfInsurer": nationalCode, "money.nationalCodeOfInsurer": nationalCode,
status: ClaimWorkflowStep.CAPTURE_PART_DAMAGES, status: ClaimCaseStatus.CAPTURING_PART_DAMAGES,
"workflow.currentStep": ClaimWorkflowStep.CAPTURE_PART_DAMAGES, "workflow.currentStep": ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
"workflow.nextStep": ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, "workflow.nextStep": ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
@@ -6074,6 +6087,9 @@ export class ClaimRequestManagementService {
"workflow.nextStep": ClaimWorkflowStep.EXPERT_COST_EVALUATION, "workflow.nextStep": ClaimWorkflowStep.EXPERT_COST_EVALUATION,
"evaluation.objection": objectionPayload, "evaluation.objection": objectionPayload,
"damage.selectedParts": mergedNorm, "damage.selectedParts": mergedNorm,
...(pendingResendGate && {
"evaluation.damageExpertResend.fulfilledAt": new Date(),
}),
}, },
$unset: { $unset: {
"evaluation.ownerPricedPartsApproval": "", "evaluation.ownerPricedPartsApproval": "",
@@ -6657,24 +6673,15 @@ export class ClaimRequestManagementService {
} }
} }
const damagedParts = displayParts.map((sp, index) => { const damagedParts = buildEnrichedDamagedParts({
const ck = sp.catalogKey ?? catalogLikeKeyFromPart(sp); selectedParts: displayParts,
const cap = getDamagedPartCaptureBlob( damagedPartsData: damagedPartsData,
damagedPartsData, evaluationBlock: (claim as any).evaluation,
ck, expertAddedParts: (claim.damage as any)?.expertAddedParts ?? [],
selectedNormDetails, getDamagedPartCaptureBlob,
) as { url?: string; path?: string; fileName?: string } | undefined; hasDamagedPartCapture,
return { catalogLikeKeyFromPart,
index, buildFileLink,
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 er = claim.evaluation?.damageExpertResend;

View File

@@ -10,7 +10,14 @@ import {
Query, Query,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from "@nestjs/swagger"; import {
ApiBearerAuth,
ApiBody,
ApiOperation,
ApiParam,
ApiResponse,
ApiTags,
} from "@nestjs/swagger";
import { ExpertFileAssignResultDto } from "src/common/dto/expert-file-assign-result.dto"; import { ExpertFileAssignResultDto } from "src/common/dto/expert-file-assign-result.dto";
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard"; import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
import { RolesGuard } from "src/auth/guards/role.guard"; import { RolesGuard } from "src/auth/guards/role.guard";
@@ -29,7 +36,7 @@ import { ResendRequestDto } from "./dto/resend.dto";
@UseGuards(LocalActorAuthGuard, RolesGuard) @UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.EXPERT, RoleEnum.FIELD_EXPERT) @Roles(RoleEnum.EXPERT, RoleEnum.FIELD_EXPERT)
export class ExpertBlameV2Controller { export class ExpertBlameV2Controller {
constructor(private readonly expertBlameService: ExpertBlameService) { } constructor(private readonly expertBlameService: ExpertBlameService) {}
@Get() @Get()
@ApiOperation({ @ApiOperation({
@@ -63,7 +70,9 @@ export class ExpertBlameV2Controller {
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
throw new InternalServerErrorException( throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to load blame status report", error instanceof Error
? error.message
: "Failed to load blame status report",
); );
} }
} }
@@ -76,7 +85,9 @@ export class ExpertBlameV2Controller {
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
throw new InternalServerErrorException( throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to get blame case details", error instanceof Error
? error.message
: "Failed to get blame case details",
); );
} }
} }
@@ -96,7 +107,10 @@ export class ExpertBlameV2Controller {
}) })
async assignForReview(@Param("id") id: string, @CurrentUser() actor: any) { async assignForReview(@Param("id") id: string, @CurrentUser() actor: any) {
try { try {
return await this.expertBlameService.assignBlameCaseForReviewV2(id, actor); return await this.expertBlameService.assignBlameCaseForReviewV2(
id,
actor,
);
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
throw new InternalServerErrorException( throw new InternalServerErrorException(
@@ -110,6 +124,7 @@ export class ExpertBlameV2Controller {
summary: "Lock blame case for review (legacy)", summary: "Lock blame case for review (legacy)",
description: description:
"Same assignment logic as POST `:id/assign`, but returns the legacy `{ _id, lock }` shape and maps conflicts to 400 instead of 409.", "Same assignment logic as POST `:id/assign`, but returns the legacy `{ _id, lock }` shape and maps conflicts to 400 instead of 409.",
deprecated: true,
}) })
@ApiParam({ name: "id", description: "Blame case request id" }) @ApiParam({ name: "id", description: "Blame case request id" })
async lockRequest(@Param("id") id: string, @CurrentUser() actor: any) { async lockRequest(@Param("id") id: string, @CurrentUser() actor: any) {
@@ -156,7 +171,9 @@ export class ExpertBlameV2Controller {
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
throw new InternalServerErrorException( throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to submit expert reply", error instanceof Error
? error.message
: "Failed to submit expert reply",
); );
} }
} }
@@ -174,7 +191,9 @@ export class ExpertBlameV2Controller {
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
throw new InternalServerErrorException( throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to submit expert reply for in person", error instanceof Error
? error.message
: "Failed to submit expert reply for in person",
); );
} }
} }

View File

@@ -66,6 +66,16 @@ export class UpsertClaimPriceDropV2Dto {
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@Type(() => PriceDropPartSeverityV2Dto) @Type(() => PriceDropPartSeverityV2Dto)
partSeverities: PriceDropPartSeverityV2Dto[]; partSeverities: PriceDropPartSeverityV2Dto[];
@ApiPropertyOptional({
description:
"Override: expert manually sets the final price-drop amount (in Rials). " +
"When provided, carPrice and partSeverities are not required and calculation is skipped entirely.",
example: 150000000,
})
@IsOptional()
@IsNumber()
manualPriceDrop?: number;
} }
export class ClaimPriceDropContextV2Dto { export class ClaimPriceDropContextV2Dto {
@@ -99,7 +109,9 @@ export class ClaimPriceDropContextV2Dto {
}) })
severityOptions: Array<{ value: string; label_fa: string }>; severityOptions: Array<{ value: string; label_fa: string }>;
@ApiProperty({ description: "Full coefficient table for all price-drop parts" }) @ApiProperty({
description: "Full coefficient table for all price-drop parts",
})
priceDropCatalog: Array<Record<string, unknown>>; priceDropCatalog: Array<Record<string, unknown>>;
@ApiProperty({ @ApiProperty({

View File

@@ -4226,6 +4226,49 @@ export class ExpertClaimService {
} }
this.assertExpertCanEditClaimDuringReviewV2(claim, actor); this.assertExpertCanEditClaimDuringReviewV2(claim, actor);
const vehicleSet: Record<string, string> = {};
if (body.carName != null && String(body.carName).trim()) {
vehicleSet["vehicle.carName"] = String(body.carName).trim();
}
if (body.carModel != null && String(body.carModel).trim()) {
vehicleSet["vehicle.carModel"] = String(body.carModel).trim();
}
// Manual override path — expert knows the number, skip calculation entirely
if (body.manualPriceDrop != null) {
if (!Number.isFinite(body.manualPriceDrop) || body.manualPriceDrop < 0) {
throw new BadRequestException(
"manualPriceDrop must be a non-negative finite number.",
);
}
const manualPayload = {
manualOverride: true,
totalPriceDrop: body.manualPriceDrop,
partLines: [],
};
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: {
...vehicleSet,
"evaluation.priceDrop": manualPayload,
},
});
const updated = await this.claimCaseDbService.findById(claimRequestId);
return {
claimRequestId,
vehicle: {
carName: updated?.vehicle?.carName,
carModel: updated?.vehicle?.carModel,
carType: updated?.vehicle?.carType,
},
priceDrop: manualPayload,
partLines: [],
};
}
// Calculated path — existing logic unchanged
const carType = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined; const carType = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const selectedNorm = normalizeDamageSelectedParts( const selectedNorm = normalizeDamageSelectedParts(
claim.damage?.selectedParts, claim.damage?.selectedParts,
@@ -4235,7 +4278,7 @@ export class ExpertClaimService {
if (!body.partSeverities?.length) { if (!body.partSeverities?.length) {
throw new BadRequestException( throw new BadRequestException(
"Provide at least one partSeverities line for a damaged part.", "Provide at least one partSeverities line, or use manualPriceDrop for a direct override.",
); );
} }
@@ -4290,14 +4333,6 @@ export class ExpertClaimService {
partLines: lines, partLines: lines,
}; };
const vehicleSet: Record<string, string> = {};
if (body.carName != null && String(body.carName).trim()) {
vehicleSet["vehicle.carName"] = String(body.carName).trim();
}
if (body.carModel != null && String(body.carModel).trim()) {
vehicleSet["vehicle.carModel"] = String(body.carModel).trim();
}
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: { $set: {
...vehicleSet, ...vehicleSet,
@@ -4306,7 +4341,6 @@ export class ExpertClaimService {
}); });
const updated = await this.claimCaseDbService.findById(claimRequestId); const updated = await this.claimCaseDbService.findById(claimRequestId);
return { return {
claimRequestId, claimRequestId,
vehicle: { vehicle: {

View File

@@ -1,4 +1,17 @@
import { Body, Controller, Get, Header, Param, Headers, Patch, Post, Put, UseGuards, Query, Res } from "@nestjs/common"; import {
Body,
Controller,
Get,
Header,
Param,
Headers,
Patch,
Post,
Put,
UseGuards,
Query,
Res,
} from "@nestjs/common";
import { import {
ApiBearerAuth, ApiBearerAuth,
ApiBody, ApiBody,
@@ -19,7 +32,10 @@ import { ExpertFileAssignResultDto } from "src/common/dto/expert-file-assign-res
import { ExpertClaimService } from "./expert-claim.service"; import { ExpertClaimService } from "./expert-claim.service";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
import { GetClaimListV2ResponseDto } from "./dto/claim-list-v2.dto"; import { GetClaimListV2ResponseDto } from "./dto/claim-list-v2.dto";
import { ClaimSubmitResendV2Dto, SubmitExpertReplyV2Dto } from "./dto/expert-claim-v2.dto"; import {
ClaimSubmitResendV2Dto,
SubmitExpertReplyV2Dto,
} from "./dto/expert-claim-v2.dto";
import { UpdateClaimDamagedPartsV2Dto } from "./dto/update-claim-damaged-parts-v2.dto"; import { UpdateClaimDamagedPartsV2Dto } from "./dto/update-claim-damaged-parts-v2.dto";
import { import {
ClaimPriceDropContextV2Dto, ClaimPriceDropContextV2Dto,
@@ -32,7 +48,7 @@ import { OuterPartCatalogItemDto } from "src/claim-request-management/dto/select
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog"; import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
class InPersonVisitV2Dto { class InPersonVisitV2Dto {
@ApiPropertyOptional({ example: 'Paint damage requires physical inspection' }) @ApiPropertyOptional({ example: "Paint damage requires physical inspection" })
@IsOptional() @IsOptional()
@IsString() @IsString()
note?: string; note?: string;
@@ -120,7 +136,10 @@ export class ExpertClaimV2Controller {
@Param("claimRequestId") claimRequestId: string, @Param("claimRequestId") claimRequestId: string,
@CurrentUser() actor, @CurrentUser() actor,
) { ) {
return await this.expertClaimService.getClaimDetailV2(claimRequestId, actor); return await this.expertClaimService.getClaimDetailV2(
claimRequestId,
actor,
);
} }
@Get("request/:claimRequestId/price-drop") @Get("request/:claimRequestId/price-drop")
@@ -193,13 +212,17 @@ export class ExpertClaimV2Controller {
"1. **Damage review queue** — claim status `WAITING_FOR_DAMAGE_EXPERT`. Locking advances the claim to `status=EXPERT_REVIEWING`, `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_DAMAGE_ASSESSMENT`.\n" + "1. **Damage review queue** — claim status `WAITING_FOR_DAMAGE_EXPERT`. Locking advances the claim to `status=EXPERT_REVIEWING`, `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_DAMAGE_ASSESSMENT`.\n" +
"2. **Factor validation queue** — claim status `EXPERT_VALIDATING_REPAIR_FACTORS` (or legacy `WAITING_FOR_INSURER_APPROVAL`) with `claimStatus=UNDER_REVIEW` and `workflow.currentStep=EXPERT_COST_EVALUATION`. Locking only sets the workflow lock fields; status/claimStatus/currentStep are left untouched so the claim stays in the factor-validation queue when the lock expires.\n\n" + "2. **Factor validation queue** — claim status `EXPERT_VALIDATING_REPAIR_FACTORS` (or legacy `WAITING_FOR_INSURER_APPROVAL`) with `claimStatus=UNDER_REVIEW` and `workflow.currentStep=EXPERT_COST_EVALUATION`. Locking only sets the workflow lock fields; status/claimStatus/currentStep are left untouched so the claim stays in the factor-validation queue when the lock expires.\n\n" +
"Only one expert can hold the lock at a time. Re-locking by the same expert is idempotent. Same assignment logic as POST `assign/:claimRequestId`, but returns the legacy lock shape and maps conflicts to 400 instead of 409.", "Only one expert can hold the lock at a time. Re-locking by the same expert is idempotent. Same assignment logic as POST `assign/:claimRequestId`, but returns the legacy lock shape and maps conflicts to 400 instead of 409.",
deprecated: true,
}) })
@ApiParam({ name: "claimRequestId" }) @ApiParam({ name: "claimRequestId" })
async lockClaimRequestV2( async lockClaimRequestV2(
@Param("claimRequestId") claimRequestId: string, @Param("claimRequestId") claimRequestId: string,
@CurrentUser() actor, @CurrentUser() actor,
) { ) {
return await this.expertClaimService.lockClaimRequestV2(claimRequestId, actor); return await this.expertClaimService.lockClaimRequestV2(
claimRequestId,
actor,
);
} }
@Put("reply/submit/:claimRequestId") @Put("reply/submit/:claimRequestId")
@@ -221,7 +244,11 @@ export class ExpertClaimV2Controller {
@Body() body: SubmitExpertReplyV2Dto, @Body() body: SubmitExpertReplyV2Dto,
@CurrentUser() actor, @CurrentUser() actor,
) { ) {
return await this.expertClaimService.submitExpertReplyV2(claimRequestId, body, actor); return await this.expertClaimService.submitExpertReplyV2(
claimRequestId,
body,
actor,
);
} }
@Put("reply/resend/:claimRequestId") @Put("reply/resend/:claimRequestId")
@@ -239,7 +266,11 @@ export class ExpertClaimV2Controller {
@Body() body: ClaimSubmitResendV2Dto, @Body() body: ClaimSubmitResendV2Dto,
@CurrentUser() actor, @CurrentUser() actor,
) { ) {
return await this.expertClaimService.submitResendDocsV2(claimRequestId, body, actor); return await this.expertClaimService.submitResendDocsV2(
claimRequestId,
body,
actor,
);
} }
@Patch(":claimRequestId/visit") @Patch(":claimRequestId/visit")

View File

@@ -51,6 +51,10 @@ import {
} from "src/helpers/outer-damage-parts"; } from "src/helpers/outer-damage-parts";
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog"; import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
import { ClaimSignDbService } from "src/claim-request-management/entites/db-service/claim-sign.db.service"; import { ClaimSignDbService } from "src/claim-request-management/entites/db-service/claim-sign.db.service";
import {
extractExpertNamesFromBlame,
extractExpertNamesFromClaim,
} from "./helper/insurer.helper";
@Injectable() @Injectable()
export class ExpertInsurerService { export class ExpertInsurerService {
@@ -70,7 +74,8 @@ export class ExpertInsurerService {
) {} ) {}
private getClientId(actorOrId: any): Types.ObjectId { private getClientId(actorOrId: any): Types.ObjectId {
const raw = typeof actorOrId === "string" ? actorOrId : actorOrId?.clientKey; const raw =
typeof actorOrId === "string" ? actorOrId : actorOrId?.clientKey;
if (!raw || !Types.ObjectId.isValid(raw)) { if (!raw || !Types.ObjectId.isValid(raw)) {
throw new BadRequestException("Client key is required"); throw new BadRequestException("Client key is required");
} }
@@ -91,15 +96,22 @@ export class ExpertInsurerService {
tenantId: Types.ObjectId, tenantId: Types.ObjectId,
expertIds: string[], expertIds: string[],
): Promise<Record<string, { totalHandled: number; totalChecked: number }>> { ): Promise<Record<string, { totalHandled: number; totalChecked: number }>> {
const events = await this.expertFileActivityDbService.findByTenantAndExperts( const events =
tenantId, await this.expertFileActivityDbService.findByTenantAndExperts(
expertIds, tenantId,
); expertIds,
const stateByExpertFile = new Map<string, { checked: boolean; handled: boolean }>(); );
const stateByExpertFile = new Map<
string,
{ checked: boolean; handled: boolean }
>();
for (const e of events) { for (const e of events) {
const key = `${String(e.expertId)}:${String(e.fileId)}`; const key = `${String(e.expertId)}:${String(e.fileId)}`;
const prev = stateByExpertFile.get(key) ?? { checked: false, handled: false }; const prev = stateByExpertFile.get(key) ?? {
checked: false,
handled: false,
};
if (e.eventType === ExpertFileActivityType.CHECKED) { if (e.eventType === ExpertFileActivityType.CHECKED) {
if (!prev.handled) prev.checked = true; if (!prev.handled) prev.checked = true;
} else if (e.eventType === ExpertFileActivityType.UNCHECKED) { } else if (e.eventType === ExpertFileActivityType.UNCHECKED) {
@@ -111,13 +123,17 @@ export class ExpertInsurerService {
stateByExpertFile.set(key, prev); stateByExpertFile.set(key, prev);
} }
const result: Record<string, { totalHandled: number; totalChecked: number }> = {}; const result: Record<
string,
{ totalHandled: number; totalChecked: number }
> = {};
for (const expertId of expertIds) { for (const expertId of expertIds) {
result[expertId] = { totalHandled: 0, totalChecked: 0 }; result[expertId] = { totalHandled: 0, totalChecked: 0 };
} }
for (const [key, st] of stateByExpertFile.entries()) { for (const [key, st] of stateByExpertFile.entries()) {
const [expertId] = key.split(":"); const [expertId] = key.split(":");
if (!result[expertId]) result[expertId] = { totalHandled: 0, totalChecked: 0 }; if (!result[expertId])
result[expertId] = { totalHandled: 0, totalChecked: 0 };
if (st.handled) result[expertId].totalHandled += 1; if (st.handled) result[expertId].totalHandled += 1;
else if (st.checked) result[expertId].totalChecked += 1; else if (st.checked) result[expertId].totalChecked += 1;
} }
@@ -265,18 +281,18 @@ export class ExpertInsurerService {
return { return {
...d, ...d,
guiltyPartyId: guiltyPartyId:
guilty != null && typeof (guilty as { toString?: () => string }).toString === "function" guilty != null &&
typeof (guilty as { toString?: () => string }).toString === "function"
? String(guilty) ? String(guilty)
: guilty, : guilty,
decidedByExpertId: decidedByExpertId:
decidedBy != null && decidedBy != null &&
typeof (decidedBy as { toString?: () => string }).toString === "function" typeof (decidedBy as { toString?: () => string }).toString ===
"function"
? String(decidedBy) ? String(decidedBy)
: decidedBy, : decidedBy,
decidedAt: decidedAt:
decidedAt instanceof Date decidedAt instanceof Date ? decidedAt.toISOString() : decidedAt,
? decidedAt.toISOString()
: decidedAt,
}; };
} }
@@ -300,7 +316,8 @@ export class ExpertInsurerService {
if (!Array.isArray(parties) || parties.length === 0) return out; if (!Array.isArray(parties) || parties.length === 0) return out;
const first = const first =
(parties as any[]).find((p: any) => p?.role === PartyRole.FIRST) ?? (parties as any[])[0]; (parties as any[]).find((p: any) => p?.role === PartyRole.FIRST) ??
(parties as any[])[0];
const cbf = first?.carBodyFirstForm; const cbf = first?.carBodyFirstForm;
delete out.blameStatus; delete out.blameStatus;
if (cbf && typeof cbf === "object") { if (cbf && typeof cbf === "object") {
@@ -340,7 +357,8 @@ export class ExpertInsurerService {
) { ) {
if (!person || typeof person !== "object") return person; if (!person || typeof person !== "object") return person;
const cid = const cid =
person.clientId?.toString?.() ?? (person.clientId != null ? String(person.clientId) : undefined); person.clientId?.toString?.() ??
(person.clientId != null ? String(person.clientId) : undefined);
return { return {
...person, ...person,
userId: person.userId?.toString?.() ?? undefined, userId: person.userId?.toString?.() ?? undefined,
@@ -412,7 +430,11 @@ export class ExpertInsurerService {
side: sp.side, side: sp.side,
label_fa: sp.label_fa, label_fa: sp.label_fa,
catalogKey: sp.catalogKey, catalogKey: sp.catalogKey,
captured: hasDamagedPartCapture(damagedPartsDataExpert, ck, selectedNormExpert), captured: hasDamagedPartCapture(
damagedPartsDataExpert,
ck,
selectedNormExpert,
),
path: cap?.path, path: cap?.path,
fileName: cap?.fileName, fileName: cap?.fileName,
url, url,
@@ -439,7 +461,9 @@ export class ExpertInsurerService {
}); });
} }
private async claimSignLinkFromId(signDetailId: unknown): Promise<string | undefined> { private async claimSignLinkFromId(
signDetailId: unknown,
): Promise<string | undefined> {
if (signDetailId == null || signDetailId === "") return undefined; if (signDetailId == null || signDetailId === "") return undefined;
const id = String(signDetailId); const id = String(signDetailId);
if (!Types.ObjectId.isValid(id)) return undefined; if (!Types.ObjectId.isValid(id)) return undefined;
@@ -454,7 +478,10 @@ export class ExpertInsurerService {
evaluation: Record<string, unknown> | undefined, evaluation: Record<string, unknown> | undefined,
): Promise<Record<string, unknown> | undefined> { ): Promise<Record<string, unknown> | undefined> {
if (!evaluation || typeof evaluation !== "object") return evaluation; if (!evaluation || typeof evaluation !== "object") return evaluation;
const ev = JSON.parse(JSON.stringify(evaluation)) as Record<string, unknown>; const ev = JSON.parse(JSON.stringify(evaluation)) as Record<
string,
unknown
>;
const enrichReply = async (reply: any) => { const enrichReply = async (reply: any) => {
if (!reply || typeof reply !== "object") return; if (!reply || typeof reply !== "object") return;
@@ -487,14 +514,18 @@ export class ExpertInsurerService {
const evidence = party.evidence as Record<string, unknown>; const evidence = party.evidence as Record<string, unknown>;
if (evidence.videoId) { if (evidence.videoId) {
const videoDoc = await this.blameVideoDbService.findById(String(evidence.videoId)); const videoDoc = await this.blameVideoDbService.findById(
String(evidence.videoId),
);
if (videoDoc?.path) evidence.videoUrl = buildFileLink(videoDoc.path); if (videoDoc?.path) evidence.videoUrl = buildFileLink(videoDoc.path);
} }
if (evidence.voices && Array.isArray(evidence.voices)) { if (evidence.voices && Array.isArray(evidence.voices)) {
const voiceUrls: string[] = []; const voiceUrls: string[] = [];
for (const voiceId of evidence.voices) { for (const voiceId of evidence.voices) {
const voiceDoc = await this.blameVoiceDbService.findById(String(voiceId)); const voiceDoc = await this.blameVoiceDbService.findById(
String(voiceId),
);
if (voiceDoc?.path) voiceUrls.push(buildFileLink(voiceDoc.path)); if (voiceDoc?.path) voiceUrls.push(buildFileLink(voiceDoc.path));
} }
evidence.voiceUrls = voiceUrls; evidence.voiceUrls = voiceUrls;
@@ -511,14 +542,20 @@ export class ExpertInsurerService {
doc.parties = enrichBlamePartiesForAgreementView(doc); doc.parties = enrichBlamePartiesForAgreementView(doc);
const mergedParties = doc.parties as any[]; const mergedParties = doc.parties as any[];
const clientIds = mergedParties.map((p) => p?.person?.clientId?.toString?.()).filter(Boolean); const clientIds = mergedParties
.map((p) => p?.person?.clientId?.toString?.())
.filter(Boolean);
const clientNames = await this.clientNamesByClientIds(clientIds); const clientNames = await this.clientNamesByClientIds(clientIds);
doc.parties = mergedParties.map((p) => this.mapPartyForInsurerFull(p, clientNames)); doc.parties = mergedParties.map((p) =>
this.mapPartyForInsurerFull(p, clientNames),
);
this.enrichPartiesConfirmationSignatures(doc.parties as any[]); this.enrichPartiesConfirmationSignatures(doc.parties as any[]);
if (doc.expert && typeof doc.expert === "object") { if (doc.expert && typeof doc.expert === "object") {
const ex = { ...(doc.expert as Record<string, unknown>) }; const ex = { ...(doc.expert as Record<string, unknown>) };
const serialized = this.serializeBlameExpertDecisionForInsurer(ex.decision); const serialized = this.serializeBlameExpertDecisionForInsurer(
ex.decision,
);
if (serialized !== undefined) ex.decision = serialized; if (serialized !== undefined) ex.decision = serialized;
doc.expert = ex; doc.expert = ex;
} }
@@ -551,7 +588,8 @@ export class ExpertInsurerService {
? Array.from(requiredDocs.keys()) ? Array.from(requiredDocs.keys())
: Object.keys(requiredDocs); : Object.keys(requiredDocs);
for (const k of keys as string[]) { for (const k of keys as string[]) {
const row = requiredDocs instanceof Map ? requiredDocs.get(k) : requiredDocs[k]; const row =
requiredDocs instanceof Map ? requiredDocs.get(k) : requiredDocs[k];
requiredDocumentsStatus[k] = { requiredDocumentsStatus[k] = {
uploaded: !!row?.uploaded, uploaded: !!row?.uploaded,
fileId: row?.fileId?.toString?.(), fileId: row?.fileId?.toString?.(),
@@ -576,7 +614,9 @@ export class ExpertInsurerService {
}; };
} }
const carTypeExpert = (claim.vehicle as any)?.carType as ClaimVehicleTypeV2 | undefined; const carTypeExpert = (claim.vehicle as any)?.carType as
| ClaimVehicleTypeV2
| undefined;
const selectedNormExpert = normalizeDamageSelectedParts( const selectedNormExpert = normalizeDamageSelectedParts(
(claim.damage as any)?.selectedParts, (claim.damage as any)?.selectedParts,
carTypeExpert, carTypeExpert,
@@ -646,13 +686,17 @@ export class ExpertInsurerService {
}; };
} }
const evaluationEnriched = await this.enrichClaimEvaluationForInsurer(evaluation); const evaluationEnriched =
await this.enrichClaimEvaluationForInsurer(evaluation);
const vehicleSanitized = claim.vehicle const vehicleSanitized = claim.vehicle
? this.sanitizeVehicleInquiry(claim.vehicle as any) ? this.sanitizeVehicleInquiry(claim.vehicle as any)
: undefined; : undefined;
const vehicleOut = vehicleSanitized const vehicleOut = vehicleSanitized
? (() => { ? (() => {
const { carType: _omit, ...rest } = vehicleSanitized as Record<string, unknown>; const { carType: _omit, ...rest } = vehicleSanitized as Record<
string,
unknown
>;
return rest; return rest;
})() })()
: undefined; : undefined;
@@ -705,9 +749,11 @@ export class ExpertInsurerService {
).filter((v): v is number => typeof v === "number" && !isNaN(v)) ).filter((v): v is number => typeof v === "number" && !isNaN(v))
: []; : [];
const userValues = userRating const userValues = userRating
? [userRating.progressSpeed, userRating.registrationEase, userRating.overallEvaluation].filter( ? [
(v) => typeof v === "number" && !isNaN(v), userRating.progressSpeed,
) userRating.registrationEase,
userRating.overallEvaluation,
].filter((v) => typeof v === "number" && !isNaN(v))
: []; : [];
const insurerAvg = insurerValues.length const insurerAvg = insurerValues.length
? insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length ? insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length
@@ -715,30 +761,50 @@ export class ExpertInsurerService {
const userAvg = userValues.length const userAvg = userValues.length
? userValues.reduce((a, b) => a + b, 0) / userValues.length ? userValues.reduce((a, b) => a + b, 0) / userValues.length
: null; : null;
const scores = [insurerAvg, userAvg].filter((v): v is number => typeof v === "number"); const scores = [insurerAvg, userAvg].filter(
(v): v is number => typeof v === "number",
);
if (!scores.length) return null; if (!scores.length) return null;
return parseFloat((scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(2)); return parseFloat(
(scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(2),
);
} }
private async getClientBlameFiles(clientObjectId: Types.ObjectId): Promise<any[]> { private async getClientBlameFiles(
const all = (await this.blameRequestDbService.find({}, { lean: true })) as any[]; clientObjectId: Types.ObjectId,
): Promise<any[]> {
const all = (await this.blameRequestDbService.find(
{},
{ lean: true },
)) as any[];
const idStr = String(clientObjectId); const idStr = String(clientObjectId);
return all return all
.filter((f) => .filter((f) =>
(f?.parties || []).some((p) => String(p?.person?.clientId || "") === idStr), (f?.parties || []).some(
(p) => String(p?.person?.clientId || "") === idStr,
),
) )
.map((f) => this.normalizeBlameCase(f)); .map((f) => this.normalizeBlameCase(f));
} }
private async getClientClaimFiles(clientObjectId: Types.ObjectId): Promise<any[]> { private async getClientClaimFiles(
const all = (await this.claimCaseDbService.find({}, { lean: true })) as any[]; clientObjectId: Types.ObjectId,
): Promise<any[]> {
const all = (await this.claimCaseDbService.find(
{},
{ lean: true },
)) as any[];
const idStr = String(clientObjectId); const idStr = String(clientObjectId);
return all return all
.filter((f) => claimCaseTouchesClient(f, idStr)) .filter((f) => claimCaseTouchesClient(f, idStr))
.map((f) => this.normalizeClaimCase(f)); .map((f) => this.normalizeClaimCase(f));
} }
async retrieveAllExpertsOfClient(actor, currentPage: number, countPerPage: number) { async retrieveAllExpertsOfClient(
actor,
currentPage: number,
countPerPage: number,
) {
const clientObjectId = this.getClientId(actor); const clientObjectId = this.getClientId(actor);
const ckFilter = this.clientKeyScopeFilter(clientObjectId); const ckFilter = this.clientKeyScopeFilter(clientObjectId);
const [experts, damageExperts, blameFiles, claimFiles] = await Promise.all([ const [experts, damageExperts, blameFiles, claimFiles] = await Promise.all([
@@ -755,18 +821,23 @@ export class ExpertInsurerService {
expertIds, expertIds,
); );
const expertTotalRatingsMap: Record<string, number[]> = {}; const expertTotalRatingsMap: Record<string, number[]> = {};
const expertRatingsByCategoryMap: Record<string, Record<string, number[]>> = {}; const expertRatingsByCategoryMap: Record<
string,
Record<string, number[]>
> = {};
const processRatings = (expertId: string | undefined, file: any) => { const processRatings = (expertId: string | undefined, file: any) => {
if (!expertId) return; if (!expertId) return;
const rating = file?.rating; const rating = file?.rating;
const combinedScore = this.getCombinedFileScore(file); const combinedScore = this.getCombinedFileScore(file);
if (combinedScore !== null) { if (combinedScore !== null) {
if (!expertTotalRatingsMap[expertId]) expertTotalRatingsMap[expertId] = []; if (!expertTotalRatingsMap[expertId])
expertTotalRatingsMap[expertId] = [];
expertTotalRatingsMap[expertId].push(combinedScore); expertTotalRatingsMap[expertId].push(combinedScore);
} }
if (!rating || typeof rating !== "object") return; if (!rating || typeof rating !== "object") return;
if (!expertRatingsByCategoryMap[expertId]) expertRatingsByCategoryMap[expertId] = {}; if (!expertRatingsByCategoryMap[expertId])
expertRatingsByCategoryMap[expertId] = {};
for (const [category, value] of Object.entries(rating)) { for (const [category, value] of Object.entries(rating)) {
if (category === "botRating") continue; if (category === "botRating") continue;
if (typeof value === "number" && !isNaN(value)) { if (typeof value === "number" && !isNaN(value)) {
@@ -790,7 +861,9 @@ export class ExpertInsurerService {
const totalRatings = expertTotalRatingsMap[expertIdStr] || []; const totalRatings = expertTotalRatingsMap[expertIdStr] || [];
const overallAverageRating = totalRatings.length const overallAverageRating = totalRatings.length
? parseFloat( ? parseFloat(
(totalRatings.reduce((a, b) => a + b, 0) / totalRatings.length).toFixed(2), (
totalRatings.reduce((a, b) => a + b, 0) / totalRatings.length
).toFixed(2),
) )
: null; : null;
const averageRatingsByCategory: Record<string, number> = {}; const averageRatingsByCategory: Record<string, number> = {};
@@ -869,7 +942,9 @@ export class ExpertInsurerService {
* combined insurer + user ratings. * combined insurer + user ratings.
*/ */
async getTopFilesForClient(insurerId: string): Promise<any[]> { async getTopFilesForClient(insurerId: string): Promise<any[]> {
const claimFiles = await this.getClientClaimFiles(this.getClientId(insurerId)); const claimFiles = await this.getClientClaimFiles(
this.getClientId(insurerId),
);
const scored = claimFiles const scored = claimFiles
.map((file) => { .map((file) => {
const combinedScore = this.getCombinedFileScore(file); const combinedScore = this.getCombinedFileScore(file);
@@ -877,10 +952,15 @@ export class ExpertInsurerService {
return { ...file, combinedScore }; return { ...file, combinedScore };
}) })
.filter((f) => f !== null); .filter((f) => f !== null);
return scored.sort((a, b) => b.combinedScore - a.combinedScore).slice(0, 10); return scored
.sort((a, b) => b.combinedScore - a.combinedScore)
.slice(0, 10);
} }
async getAllFilesForInsurerExpert(expertId: string, insurerClientKey: string) { async getAllFilesForInsurerExpert(
expertId: string,
insurerClientKey: string,
) {
const expertObjectId = this.parseObjectId(expertId, "expert id"); const expertObjectId = this.parseObjectId(expertId, "expert id");
const clientOid = this.getClientId(insurerClientKey); const clientOid = this.getClientId(insurerClientKey);
const ckFilter = this.clientKeyScopeFilter(clientOid); const ckFilter = this.clientKeyScopeFilter(clientOid);
@@ -899,7 +979,10 @@ export class ExpertInsurerService {
const clientKeyStr = String(clientOid); const clientKeyStr = String(clientOid);
if (onBlameRoster) { if (onBlameRoster) {
const blames = (await this.blameRequestDbService.find({}, { lean: true })) as any[]; const blames = (await this.blameRequestDbService.find(
{},
{ lean: true },
)) as any[];
return blames return blames
.filter( .filter(
(b) => (b) =>
@@ -909,7 +992,10 @@ export class ExpertInsurerService {
.map((b) => this.mapBlameFileSummaryForInsurerExpert(b)); .map((b) => this.mapBlameFileSummaryForInsurerExpert(b));
} }
if (onClaimRoster) { if (onClaimRoster) {
const claims = (await this.claimCaseDbService.find({}, { lean: true })) as any[]; const claims = (await this.claimCaseDbService.find(
{},
{ lean: true },
)) as any[];
return claims return claims
.filter( .filter(
(c) => (c) =>
@@ -932,7 +1018,9 @@ export class ExpertInsurerService {
for (const key of required) { for (const key of required) {
const value = rating?.[key]; const value = rating?.[key];
if (typeof value !== "number" || value < 0 || value > 5) { if (typeof value !== "number" || value < 0 || value > 5) {
throw new BadRequestException(`${key} must be a number between 0 and 5`); throw new BadRequestException(
`${key} must be a number between 0 and 5`,
);
} }
} }
} }
@@ -957,8 +1045,12 @@ export class ExpertInsurerService {
this.getClientClaimFiles(id), this.getClientClaimFiles(id),
]); ]);
const blame = blameFiles.find((b) => String((b as any).publicId) === publicId); const blame = blameFiles.find(
const claim = claimFiles.find((c) => String((c as any).publicId) === publicId); (b) => String((b as any).publicId) === publicId,
);
const claim = claimFiles.find(
(c) => String((c as any).publicId) === publicId,
);
if (!blame && !claim) { if (!blame && !claim) {
throw new NotFoundException("File not found for this publicId"); throw new NotFoundException("File not found for this publicId");
@@ -971,7 +1063,10 @@ export class ExpertInsurerService {
} = { publicId }; } = { publicId };
if (claim) { if (claim) {
const claimId = this.parseObjectId(String((claim as any)._id), "claim id"); const claimId = this.parseObjectId(
String((claim as any)._id),
"claim id",
);
const updated = await this.claimCaseDbService.findByIdAndUpdate(claimId, { const updated = await this.claimCaseDbService.findByIdAndUpdate(claimId, {
$set: { "evaluation.rating": rating }, $set: { "evaluation.rating": rating },
}); });
@@ -983,10 +1078,16 @@ export class ExpertInsurerService {
} }
if (blame) { if (blame) {
const blameId = this.parseObjectId(String((blame as any)._id), "blame id"); const blameId = this.parseObjectId(
const updated = await this.blameRequestDbService.findByIdAndUpdate(blameId, { String((blame as any)._id),
$set: { "expert.rating": rating }, "blame id",
}); );
const updated = await this.blameRequestDbService.findByIdAndUpdate(
blameId,
{
$set: { "expert.rating": rating },
},
);
if (!updated) throw new NotFoundException("Blame file not found"); if (!updated) throw new NotFoundException("Blame file not found");
out.blame = { out.blame = {
requestId: String((updated as any)._id), requestId: String((updated as any)._id),
@@ -1034,6 +1135,7 @@ export class ExpertInsurerService {
plate?: string; plate?: string;
}; };
}>; }>;
expertNames?: string[];
createdAt?: Date | string; createdAt?: Date | string;
updatedAt?: Date | string; updatedAt?: Date | string;
}; };
@@ -1043,6 +1145,7 @@ export class ExpertInsurerService {
status?: string; status?: string;
claimStatus?: string; claimStatus?: string;
currentStep?: string; currentStep?: string;
expertNames?: string[];
createdAt?: Date | string; createdAt?: Date | string;
updatedAt?: Date | string; updatedAt?: Date | string;
}; };
@@ -1058,10 +1161,13 @@ export class ExpertInsurerService {
const partiesPreview = this.buildPartiesPreview((b as any).parties); const partiesPreview = this.buildPartiesPreview((b as any).parties);
prev.fileType = prev.fileType ?? (b as any).type; prev.fileType = prev.fileType ?? (b as any).type;
prev.blame = { prev.blame = {
requestId: (b as any)?._id?.toString?.() || String((b as any)?._id || ""), requestId:
requestNo: (b as any).requestNo || String((b as any).requestNumber || ""), (b as any)?._id?.toString?.() || String((b as any)?._id || ""),
requestNo:
(b as any).requestNo || String((b as any).requestNumber || ""),
status: (b as any).status, status: (b as any).status,
parties: partiesPreview, parties: partiesPreview,
expertNames: extractExpertNamesFromBlame(b), // ← add
createdAt: (b as any).createdAt, createdAt: (b as any).createdAt,
updatedAt: (b as any).updatedAt, updatedAt: (b as any).updatedAt,
}; };
@@ -1075,14 +1181,19 @@ export class ExpertInsurerService {
const key = String((c as any).publicId || (c as any)._id || ""); const key = String((c as any).publicId || (c as any)._id || "");
if (!key) continue; if (!key) continue;
const prev = byPublicId.get(key) ?? { publicId: key }; const prev = byPublicId.get(key) ?? { publicId: key };
const claimPartiesPreview = this.buildPartiesPreview((c as any)?.snapshot?.parties); const claimPartiesPreview = this.buildPartiesPreview(
(c as any)?.snapshot?.parties,
);
prev.fileType = prev.fileType ?? (c as any)?.snapshot?.accident?.type; prev.fileType = prev.fileType ?? (c as any)?.snapshot?.accident?.type;
prev.claim = { prev.claim = {
requestId: (c as any)?._id?.toString?.() || String((c as any)?._id || ""), requestId:
requestNo: (c as any).requestNo || String((c as any).requestNumber || ""), (c as any)?._id?.toString?.() || String((c as any)?._id || ""),
requestNo:
(c as any).requestNo || String((c as any).requestNumber || ""),
status: (c as any).status, status: (c as any).status,
claimStatus: (c as any).claimStatus, claimStatus: (c as any).claimStatus,
currentStep: (c as any).currentStep, currentStep: (c as any).currentStep,
expertNames: extractExpertNamesFromClaim(c), // ← add
createdAt: (c as any).createdAt, createdAt: (c as any).createdAt,
updatedAt: (c as any).updatedAt, updatedAt: (c as any).updatedAt,
}; };
@@ -1127,8 +1238,12 @@ export class ExpertInsurerService {
this.getClientClaimFiles(id), this.getClientClaimFiles(id),
]); ]);
const blame = blameFiles.find((b) => String((b as any).publicId) === publicId); const blame = blameFiles.find(
const claim = claimFiles.find((c) => String((c as any).publicId) === publicId); (b) => String((b as any).publicId) === publicId,
);
const claim = claimFiles.find(
(c) => String((c as any).publicId) === publicId,
);
if (!blame && !claim) { if (!blame && !claim) {
throw new NotFoundException("File not found for this publicId"); throw new NotFoundException("File not found for this publicId");
@@ -1303,13 +1418,17 @@ export class ExpertInsurerService {
const email = payload.email.trim().toLowerCase(); const email = payload.email.trim().toLowerCase();
const existingByEmail = await this.expertDbService.findOne({ email }); const existingByEmail = await this.expertDbService.findOne({ email });
const existingDamageByEmail = await this.damageExpertDbService.findOne({ email }); const existingDamageByEmail = await this.damageExpertDbService.findOne({
email,
});
if (existingByEmail || existingDamageByEmail) { if (existingByEmail || existingDamageByEmail) {
throw new ConflictException("An expert with this email already exists."); throw new ConflictException("An expert with this email already exists.");
} }
const nationalCode = payload.nationalCode.trim(); const nationalCode = payload.nationalCode.trim();
const existingByNational = await this.expertDbService.findOne({ nationalCode }); const existingByNational = await this.expertDbService.findOne({
nationalCode,
});
const existingDamageByNational = await this.damageExpertDbService.findOne({ const existingDamageByNational = await this.damageExpertDbService.findOne({
nationalCode, nationalCode,
}); });
@@ -1388,7 +1507,14 @@ export class ExpertInsurerService {
// Calculate current month date range // Calculate current month date range
const now = new Date(); const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59); const monthEnd = new Date(
now.getFullYear(),
now.getMonth() + 1,
0,
23,
59,
59,
);
// Filter files created this month // Filter files created this month
const filesThisMonth = claimFiles.filter((file) => { const filesThisMonth = claimFiles.filter((file) => {
@@ -1416,7 +1542,9 @@ export class ExpertInsurerService {
insurerRating.evaluationTimeliness, insurerRating.evaluationTimeliness,
insurerRating.accidentCauseAccuracy, insurerRating.accidentCauseAccuracy,
insurerRating.guiltyVehicleIdentification, insurerRating.guiltyVehicleIdentification,
].filter((val): val is number => typeof val === "number" && !isNaN(val)); ].filter(
(val): val is number => typeof val === "number" && !isNaN(val),
);
if (insurerValues.length > 0) { if (insurerValues.length > 0) {
filesWithInsurerRating++; filesWithInsurerRating++;
@@ -1428,7 +1556,11 @@ export class ExpertInsurerService {
// Check for bot rating (if botRating field exists in rating object) // Check for bot rating (if botRating field exists in rating object)
const botRating = (insurerRating as any)?.botRating; const botRating = (insurerRating as any)?.botRating;
if (botRating !== undefined && !isNaN(botRating) && typeof botRating === "number") { if (
botRating !== undefined &&
!isNaN(botRating) &&
typeof botRating === "number"
) {
filesWithBotRating++; filesWithBotRating++;
totalBotRatings += botRating; totalBotRatings += botRating;
} }
@@ -1446,7 +1578,7 @@ export class ExpertInsurerService {
// Calculate percentages // Calculate percentages
const totalFiles = claimFiles.length; const totalFiles = claimFiles.length;
// Calculate average insurer rating (excluding botRating) and average bot rating // Calculate average insurer rating (excluding botRating) and average bot rating
const averageInsurerRating = const averageInsurerRating =
filesWithInsurerRating > 0 filesWithInsurerRating > 0
@@ -1511,12 +1643,15 @@ export class ExpertInsurerService {
const clientObjectId = this.getClientId(insuranceId); const clientObjectId = this.getClientId(insuranceId);
const { fromDate, toDate } = this.parseDateRange(opts?.from, opts?.to); const { fromDate, toDate } = this.parseDateRange(opts?.from, opts?.to);
const isActiveFilter = this.parseOptionalBoolean(opts?.isActive); const isActiveFilter = this.parseOptionalBoolean(opts?.isActive);
const branches = await this.branchDbService.findAllWithFilters(String(clientObjectId), { const branches = await this.branchDbService.findAllWithFilters(
search: opts?.search, String(clientObjectId),
from: fromDate, {
to: toDate, search: opts?.search,
isActive: isActiveFilter, from: fromDate,
}); to: toDate,
isActive: isActiveFilter,
},
);
const branchIdSet = new Set(branches.map((b: any) => String(b._id))); const branchIdSet = new Set(branches.map((b: any) => String(b._id)));
const ckFilter = this.clientKeyScopeFilter(clientObjectId); const ckFilter = this.clientKeyScopeFilter(clientObjectId);
@@ -1536,7 +1671,8 @@ export class ExpertInsurerService {
const eid = String(e?._id || ""); const eid = String(e?._id || "");
if (!eid) continue; if (!eid) continue;
expertBranchById.set(eid, bid); expertBranchById.set(eid, bid);
if (!activeExpertsByBranch.has(bid)) activeExpertsByBranch.set(bid, new Set()); if (!activeExpertsByBranch.has(bid))
activeExpertsByBranch.set(bid, new Set());
activeExpertsByBranch.get(bid)!.add(eid); activeExpertsByBranch.get(bid)!.add(eid);
} }
for (const e of damageExperts as any[]) { for (const e of damageExperts as any[]) {
@@ -1545,14 +1681,20 @@ export class ExpertInsurerService {
const eid = String(e?._id || ""); const eid = String(e?._id || "");
if (!eid) continue; if (!eid) continue;
claimExpertBranchById.set(eid, bid); claimExpertBranchById.set(eid, bid);
if (!activeExpertsByBranch.has(bid)) activeExpertsByBranch.set(bid, new Set()); if (!activeExpertsByBranch.has(bid))
activeExpertsByBranch.set(bid, new Set());
activeExpertsByBranch.get(bid)!.add(eid); activeExpertsByBranch.get(bid)!.add(eid);
} }
const completedByBranch = new Map<string, Set<string>>(); const completedByBranch = new Map<string, Set<string>>();
const handlingByBranch = new Map<string, Set<string>>(); const handlingByBranch = new Map<string, Set<string>>();
const { blameInHandling, claimInHandling } = this.buildHandlingBranchStatusSets(); const { blameInHandling, claimInHandling } =
const addPublicId = (map: Map<string, Set<string>>, branchId: string, fileKey: string) => { this.buildHandlingBranchStatusSets();
const addPublicId = (
map: Map<string, Set<string>>,
branchId: string,
fileKey: string,
) => {
if (!map.has(branchId)) map.set(branchId, new Set()); if (!map.has(branchId)) map.set(branchId, new Set());
map.get(branchId)!.add(fileKey); map.get(branchId)!.add(fileKey);
}; };
@@ -1565,8 +1707,10 @@ export class ExpertInsurerService {
const fileKey = String(b?.publicId || b?._id || ""); const fileKey = String(b?.publicId || b?._id || "");
if (!fileKey) continue; if (!fileKey) continue;
const status = String(b?.status || ""); const status = String(b?.status || "");
if (status === CaseStatus.COMPLETED) addPublicId(completedByBranch, bid, fileKey); if (status === CaseStatus.COMPLETED)
if (blameInHandling.has(status)) addPublicId(handlingByBranch, bid, fileKey); addPublicId(completedByBranch, bid, fileKey);
if (blameInHandling.has(status))
addPublicId(handlingByBranch, bid, fileKey);
} }
for (const c of claimFiles as any[]) { for (const c of claimFiles as any[]) {
@@ -1577,8 +1721,10 @@ export class ExpertInsurerService {
const fileKey = String(c?.publicId || c?._id || ""); const fileKey = String(c?.publicId || c?._id || "");
if (!fileKey) continue; if (!fileKey) continue;
const status = String(c?.status || ""); const status = String(c?.status || "");
if (status === ClaimCaseStatus.COMPLETED) addPublicId(completedByBranch, bid, fileKey); if (status === ClaimCaseStatus.COMPLETED)
if (claimInHandling.has(status)) addPublicId(handlingByBranch, bid, fileKey); addPublicId(completedByBranch, bid, fileKey);
if (claimInHandling.has(status))
addPublicId(handlingByBranch, bid, fileKey);
} }
const list = branches.map((branch: any) => { const list = branches.map((branch: any) => {
@@ -1654,10 +1800,7 @@ export class ExpertInsurerService {
this.isInDateRange((f as any)?.createdAt, fromDate, toDate), this.isInDateRange((f as any)?.createdAt, fromDate, toDate),
); );
const fileMap = new Map< const fileMap = new Map<string, { blame?: any; claim?: any }>();
string,
{ blame?: any; claim?: any }
>();
for (const b of blameFiles) { for (const b of blameFiles) {
const key = String((b as any).publicId || (b as any)._id || ""); const key = String((b as any).publicId || (b as any)._id || "");
@@ -1686,8 +1829,7 @@ export class ExpertInsurerService {
completed += 1; completed += 1;
} }
const blameUnderReview = const blameUnderReview = blameStatus === CaseStatus.WAITING_FOR_EXPERT;
blameStatus === CaseStatus.WAITING_FOR_EXPERT;
const claimUnderReview = const claimUnderReview =
claimStatus === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT || claimStatus === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT ||
claimStatus === ClaimCaseStatus.EXPERT_REVIEWING; claimStatus === ClaimCaseStatus.EXPERT_REVIEWING;

View File

@@ -0,0 +1,42 @@
export function extractExpertNamesFromBlame(b: any): string[] {
const names = new Set<string>();
const add = (v: unknown) => {
if (typeof v === "string" && v.trim()) names.add(v.trim());
};
add(b?.workflow?.assignedForReviewBy?.actorName);
add(b?.workflow?.lockedBy?.actorName);
// History entries from expert actions
for (const h of b?.history ?? []) {
if (
h?.actor?.actorType === "field_expert" ||
h?.actor?.actorType === "damage_expert"
) {
add(h?.actor?.actorName);
}
}
return [...names];
}
export function extractExpertNamesFromClaim(c: any): string[] {
const names = new Set<string>();
const add = (v: unknown) => {
if (typeof v === "string" && v.trim()) names.add(v.trim());
};
add(c?.workflow?.assignedForReviewBy?.actorName);
add(c?.workflow?.lockedBy?.actorName);
// Evaluation snapshots
add(c?.evaluation?.damageExpertResend?.expertProfileSnapshot?.fullName);
add(c?.evaluation?.damageExpertReply?.expertProfileSnapshot?.fullName);
add(c?.evaluation?.damageExpertReplyFinal?.expertProfileSnapshot?.fullName);
add(c?.evaluation?.factorValidationExpertProfileSnapshot?.fullName);
add(c?.evaluation?.inPersonVisitExpertProfileSnapshot?.fullName);
return [...names];
}

View File

@@ -32,13 +32,17 @@ export function resendRequestHasPayload(r: {
}): boolean { }): boolean {
if (!r) return false; if (!r) return false;
if (String(r.resendDescription || "").trim() !== "") return true; if (String(r.resendDescription || "").trim() !== "") return true;
if (Array.isArray(r.resendDocuments) && r.resendDocuments.length > 0) return true; if (Array.isArray(r.resendDocuments) && r.resendDocuments.length > 0)
if (Array.isArray(r.resendCarParts) && r.resendCarParts.length > 0) return true; return true;
if (Array.isArray(r.resendCarParts) && r.resendCarParts.length > 0)
return true;
return false; return false;
} }
/** Normalize expert-requested document keys (strings or `{ key }`) to canonical enum strings. */ /** Normalize expert-requested document keys (strings or `{ key }`) to canonical enum strings. */
export function normalizeResendDocumentKeys(docs: unknown[] | undefined): string[] { export function normalizeResendDocumentKeys(
docs: unknown[] | undefined,
): string[] {
if (!Array.isArray(docs)) return []; if (!Array.isArray(docs)) return [];
const keys: string[] = []; const keys: string[] = [];
for (const d of docs) { for (const d of docs) {
@@ -61,10 +65,7 @@ export function normalizeResendPartKeys(
selectedParts?: unknown, selectedParts?: unknown,
): string[] { ): string[] {
if (!Array.isArray(parts)) return []; if (!Array.isArray(parts)) return [];
const selectedNorm = normalizeDamageSelectedParts( const selectedNorm = normalizeDamageSelectedParts(selectedParts, carType);
selectedParts,
carType,
);
const keys: string[] = []; const keys: string[] = [];
for (const p of parts) { for (const p of parts) {
const enriched = const enriched =
@@ -72,8 +73,7 @@ export function normalizeResendPartKeys(
matchResendPartFromSelected(p, selectedNorm); matchResendPartFromSelected(p, selectedNorm);
if (enriched) { if (enriched) {
const ck = const ck =
enriched.catalogKey?.trim() || enriched.catalogKey?.trim() || catalogLikeKeyFromPart(enriched);
catalogLikeKeyFromPart(enriched);
if (ck) keys.push(ck); if (ck) keys.push(ck);
continue; continue;
} }
@@ -108,7 +108,10 @@ function resolveResendPartRow(
const fromSelected = matchResendPartFromSelected(raw, selectedNorm ?? []); const fromSelected = matchResendPartFromSelected(raw, selectedNorm ?? []);
if (fromSelected) return fromSelected; if (fromSelected) return fromSelected;
if (fromRaw) return fromRaw; if (fromRaw) return fromRaw;
const o = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>; const o = (raw && typeof raw === "object" ? raw : {}) as Record<
string,
unknown
>;
const fallbackKey = const fallbackKey =
typeof o.key === "string" && o.key.trim() typeof o.key === "string" && o.key.trim()
? o.key.trim() ? o.key.trim()
@@ -123,8 +126,7 @@ function resolveResendPartRow(
typeof o.label_fa === "string" && o.label_fa.trim() typeof o.label_fa === "string" && o.label_fa.trim()
? o.label_fa.trim() ? o.label_fa.trim()
: fallbackKey, : fallbackKey,
catalogKey: catalogKey: typeof o.catalogKey === "string" ? o.catalogKey : undefined,
typeof o.catalogKey === "string" ? o.catalogKey : undefined,
}; };
} }
@@ -149,8 +151,7 @@ export function mapExpertResendCarPartsForClient(
const stored = const stored =
raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {}; raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
const sp = resolveResendPartRow(raw, options.carType, selectedNorm); const sp = resolveResendPartRow(raw, options.carType, selectedNorm);
const catalogKey = const catalogKey = sp.catalogKey?.trim() || catalogLikeKeyFromPart(sp);
sp.catalogKey?.trim() || catalogLikeKeyFromPart(sp);
const captureKey = catalogKey || partLookupKey(sp); const captureKey = catalogKey || partLookupKey(sp);
const cap = getDamagedPartCaptureBlob( const cap = getDamagedPartCaptureBlob(
options.damagedPartsData, options.damagedPartsData,
@@ -193,8 +194,7 @@ export function normalizeResendCarPartsForStorage(
const stored = const stored =
raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {}; raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
const sp = resolveResendPartRow(raw, carType, selectedNorm); const sp = resolveResendPartRow(raw, carType, selectedNorm);
const catalogKey = const catalogKey = sp.catalogKey?.trim() || catalogLikeKeyFromPart(sp);
sp.catalogKey?.trim() || catalogLikeKeyFromPart(sp);
const row: Record<string, unknown> = { const row: Record<string, unknown> = {
id: sp.id, id: sp.id,
name: sp.name, name: sp.name,
@@ -217,8 +217,13 @@ function getRequiredDocEntry(claim: any, documentKey: string): unknown {
return rd instanceof Map ? rd.get(documentKey) : rd[documentKey]; return rd instanceof Map ? rd.get(documentKey) : rd[documentKey];
} }
export function isRequiredDocumentUploaded(claim: any, documentKey: string): boolean { export function isRequiredDocumentUploaded(
const doc = getRequiredDocEntry(claim, documentKey) as { uploaded?: boolean } | undefined; claim: any,
documentKey: string,
): boolean {
const doc = getRequiredDocEntry(claim, documentKey) as
| { uploaded?: boolean }
| undefined;
return !!doc?.uploaded; return !!doc?.uploaded;
} }
@@ -241,7 +246,8 @@ export function hasDamagedPartCapture(claim: any, partKey: string): boolean {
export function canFinalizeExpertResend(claim: any): boolean { export function canFinalizeExpertResend(claim: any): boolean {
if (!claim) return false; if (!claim) return false;
if (claim.status !== ClaimCaseStatus.WAITING_FOR_USER_RESEND) return false; if (claim.status !== ClaimCaseStatus.WAITING_FOR_USER_RESEND) return false;
if (claim.workflow?.currentStep !== ClaimWorkflowStep.USER_EXPERT_RESEND) return false; if (claim.workflow?.currentStep !== ClaimWorkflowStep.USER_EXPERT_RESEND)
return false;
const r = claim.evaluation?.damageExpertResend; const r = claim.evaluation?.damageExpertResend;
if (!r || r.fulfilledAt) return false; if (!r || r.fulfilledAt) return false;
@@ -279,16 +285,39 @@ export function documentKeyAllowedForExpertResend(
return allowed.has(normalized); return allowed.has(normalized);
} }
export function partKeyAllowedForExpertResend(claim: any, partKey: string): boolean { export function partKeyAllowedForExpertResend(
claim: any,
partKey: string,
): boolean {
const r = claim?.evaluation?.damageExpertResend; const r = claim?.evaluation?.damageExpertResend;
if (!r || r.fulfilledAt) return false; if (!r || r.fulfilledAt) return false;
const carType = claim?.vehicle?.carType as ClaimVehicleTypeV2 | undefined; const carType = claim?.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const allowed = new Set( const selectedParts = claim?.damage?.selectedParts;
normalizeResendPartKeys(
r.resendCarParts, // Normalize the stored resend parts to their canonical catalog keys
carType, const allowedKeys = new Set(
claim?.damage?.selectedParts, normalizeResendPartKeys(r.resendCarParts, carType, selectedParts),
),
); );
return allowed.has(partKey);
// Normalize the incoming partKey the same way so the comparison is apples-to-apples
const resolvedKeys = normalizeResendPartKeys(
[partKey],
carType,
selectedParts,
);
const resolvedKey = resolvedKeys[0];
// Also try direct catalogKey/name match against the stored objects as fallback
const directMatch = ((r.resendCarParts as any[]) ?? []).some((p: any) => {
if (!p || typeof p !== "object") return String(p) === partKey;
return (
p.catalogKey === partKey ||
p.key === partKey ||
p.name === partKey ||
(p.id != null && String(p.id) === partKey)
);
});
return directMatch || (resolvedKey != null && allowedKeys.has(resolvedKey));
} }

View File

@@ -8,20 +8,27 @@ const REQUIRED_VALUES = new Set<string>(
* Non-canonical document keys that may still appear on stored * Non-canonical document keys that may still appear on stored
* `evaluation.damageExpertResend.resendDocuments` → {@link ClaimRequiredDocumentType}. * `evaluation.damageExpertResend.resendDocuments` → {@link ClaimRequiredDocumentType}.
*/ */
const RESEND_DOCUMENT_KEY_ALIASES: Readonly< const RESEND_DOCUMENT_KEY_ALIASES = {
Record<string, ClaimRequiredDocumentType> // existing entries
> = {
nationalCertificate: ClaimRequiredDocumentType.NATIONAL_CARD, nationalCertificate: ClaimRequiredDocumentType.NATIONAL_CARD,
NationalCertificate: ClaimRequiredDocumentType.NATIONAL_CARD, NationalCertificate: ClaimRequiredDocumentType.NATIONAL_CARD,
national_card: ClaimRequiredDocumentType.NATIONAL_CARD, // ← add
drivingLicense: ClaimRequiredDocumentType.DAMAGED_DRIVING_LICENSE_FRONT, drivingLicense: ClaimRequiredDocumentType.DAMAGED_DRIVING_LICENSE_FRONT,
DrivingLicense: ClaimRequiredDocumentType.DAMAGED_DRIVING_LICENSE_FRONT, DrivingLicense: ClaimRequiredDocumentType.DAMAGED_DRIVING_LICENSE_FRONT,
driving_license: ClaimRequiredDocumentType.DAMAGED_DRIVING_LICENSE_FRONT, // ← add
carGreenCard: ClaimRequiredDocumentType.CAR_GREEN_CARD, carGreenCard: ClaimRequiredDocumentType.CAR_GREEN_CARD,
CarGreenCard: ClaimRequiredDocumentType.CAR_GREEN_CARD, CarGreenCard: ClaimRequiredDocumentType.CAR_GREEN_CARD,
car_green_card: ClaimRequiredDocumentType.CAR_GREEN_CARD, // ← add (redundant but safe)
carCertificate: ClaimRequiredDocumentType.DAMAGED_CAR_CARD_FRONT, carCertificate: ClaimRequiredDocumentType.DAMAGED_CAR_CARD_FRONT,
CarCertificate: ClaimRequiredDocumentType.DAMAGED_CAR_CARD_FRONT, CarCertificate: ClaimRequiredDocumentType.DAMAGED_CAR_CARD_FRONT,
car_certificate: ClaimRequiredDocumentType.CAR_CERTIFICATE, // ← add
plate: ClaimRequiredDocumentType.DAMAGED_METAL_PLATE, plate: ClaimRequiredDocumentType.DAMAGED_METAL_PLATE,
carPlate: ClaimRequiredDocumentType.DAMAGED_METAL_PLATE, carPlate: ClaimRequiredDocumentType.DAMAGED_METAL_PLATE,
CarPlate: ClaimRequiredDocumentType.DAMAGED_METAL_PLATE, CarPlate: ClaimRequiredDocumentType.DAMAGED_METAL_PLATE,
// capture-phase docs — already in REQUIRED_VALUES so these are just safety nets
damaged_chassis_number: ClaimRequiredDocumentType.DAMAGED_CHASSIS_NUMBER,
damaged_engine_photo: ClaimRequiredDocumentType.DAMAGED_ENGINE_PHOTO,
damaged_metal_plate: ClaimRequiredDocumentType.DAMAGED_METAL_PLATE,
}; };
/** Normalize a single key to a {@link ClaimRequiredDocumentType} value, or null if unknown. */ /** Normalize a single key to a {@link ClaimRequiredDocumentType} value, or null if unknown. */

View File

@@ -62,7 +62,9 @@ export function findCatalogItemByPersianLabel(
/** Non-catalog lines (expert-added interior / free-text); never use catalog numeric ids. */ /** Non-catalog lines (expert-added interior / free-text); never use catalog numeric ids. */
export function isInternalDamageSide(side: string): boolean { export function isInternalDamageSide(side: string): boolean {
const s = String(side ?? "").trim().toLowerCase(); const s = String(side ?? "")
.trim()
.toLowerCase();
if (!s || s === "internal" || s === "other") return true; if (!s || s === "internal" || s === "other") return true;
return !SIDE_SET.has(s); return !SIDE_SET.has(s);
} }
@@ -120,7 +122,9 @@ const SIDE_LABEL_FA: Record<string, string> = {
[OuterPartSideV2.TOP]: "بالا", [OuterPartSideV2.TOP]: "بالا",
}; };
function outerCatalogListForItem(item: OuterPartCatalogItem): OuterPartCatalogItem[] { function outerCatalogListForItem(
item: OuterPartCatalogItem,
): OuterPartCatalogItem[] {
for (const list of Object.values(OUTER_PARTS_BY_CAR_TYPE)) { for (const list of Object.values(OUTER_PARTS_BY_CAR_TYPE)) {
if (list.some((x) => x.key === item.key || x.id === item.id)) { if (list.some((x) => x.key === item.key || x.id === item.id)) {
return list; return list;
@@ -165,9 +169,10 @@ function findCatalogItemByKey(key: string): OuterPartCatalogItem | undefined {
} }
/** Strip leading `left_|right_|…` when present so `name` is side-agnostic for clients. */ /** Strip leading `left_|right_|…` when present so `name` is side-agnostic for clients. */
export function splitCatalogKeyToNameAndSide( export function splitCatalogKeyToNameAndSide(fullKey: string): {
fullKey: string, name: string;
): { name: string; side: string } { side: string;
} {
const t = String(fullKey ?? "").trim(); const t = String(fullKey ?? "").trim();
const i = t.indexOf("_"); const i = t.indexOf("_");
if (i <= 0) return { name: t, side: "" }; if (i <= 0) return { name: t, side: "" };
@@ -329,7 +334,8 @@ export function normalizeDamageSelectedParts(
let catItem: OuterPartCatalogItem | undefined; let catItem: OuterPartCatalogItem | undefined;
if (catalog && byCatalogKey) { if (catalog && byCatalogKey) {
if (ck) catItem = byCatalogKey.get(ck); if (ck) catItem = byCatalogKey.get(ck);
if (!catItem && id != null) catItem = catalog.find((c) => c.id === id); if (!catItem && id != null)
catItem = catalog.find((c) => c.id === id);
if (!catItem && side && name) { if (!catItem && side && name) {
catItem = byCatalogKey.get(catalogLikeKeyFromPart({ side, name })); catItem = byCatalogKey.get(catalogLikeKeyFromPart({ side, name }));
} }
@@ -377,7 +383,9 @@ export function normalizeDamageSelectedParts(
const cat = const cat =
byCatalogKey?.get(k) ?? byCatalogKey?.get(k) ??
findCatalogItemByKey(k) ?? findCatalogItemByKey(k) ??
(toNum(o.id) != null ? CATALOG_ITEM_BY_ID.get(toNum(o.id)!) : undefined); (toNum(o.id) != null
? CATALOG_ITEM_BY_ID.get(toNum(o.id)!)
: undefined);
if (cat) { if (cat) {
const list = catalog ?? outerCatalogListForItem(cat); const list = catalog ?? outerCatalogListForItem(cat);
out.push(catalogItemToSelectedPart(cat, list)); out.push(catalogItemToSelectedPart(cat, list));
@@ -581,7 +589,9 @@ export function catalogPartIdFromCarPartDamage(
export const partIdentityKeyFromCarPartDamage = catalogPartIdFromCarPartDamage; export const partIdentityKeyFromCarPartDamage = catalogPartIdFromCarPartDamage;
function normPartSegment(s: string): string { function normPartSegment(s: string): string {
return String(s ?? "").replace(/[^a-z0-9]/gi, "").toLowerCase(); return String(s ?? "")
.replace(/[^a-z0-9]/gi, "")
.toLowerCase();
} }
function looseCatalogNameMatch( function looseCatalogNameMatch(
@@ -601,9 +611,13 @@ export function findCatalogItemFromLegacyExpertPart(
side: string, side: string,
carType?: ClaimVehicleTypeV2, carType?: ClaimVehicleTypeV2,
): OuterPartCatalogItem | undefined { ): OuterPartCatalogItem | undefined {
const s = String(side ?? "").toLowerCase().trim(); const s = String(side ?? "")
.toLowerCase()
.trim();
const lists = carType const lists = carType
? [OUTER_PARTS_BY_CAR_TYPE[carType]].filter(Boolean) as OuterPartCatalogItem[][] ? ([OUTER_PARTS_BY_CAR_TYPE[carType]].filter(
Boolean,
) as OuterPartCatalogItem[][])
: (Object.values(OUTER_PARTS_BY_CAR_TYPE) as OuterPartCatalogItem[][]); : (Object.values(OUTER_PARTS_BY_CAR_TYPE) as OuterPartCatalogItem[][]);
for (const catalog of lists) { for (const catalog of lists) {
if (!catalog?.length) continue; if (!catalog?.length) continue;
@@ -615,7 +629,9 @@ export function findCatalogItemFromLegacyExpertPart(
return undefined; return undefined;
} }
function selectedPartToStoredRecord(p: DamageSelectedPartV2): Record<string, unknown> { function selectedPartToStoredRecord(
p: DamageSelectedPartV2,
): Record<string, unknown> {
const r: Record<string, unknown> = { const r: Record<string, unknown> = {
name: p.name, name: p.name,
side: String(p.side || "").toLowerCase(), side: String(p.side || "").toLowerCase(),
@@ -780,9 +796,7 @@ export function isDamagedPartsMediaArray(data: unknown): boolean {
return Array.isArray(data); return Array.isArray(data);
} }
function getLegacyMapRecord( function getLegacyMapRecord(data: unknown): Record<string, unknown> | null {
data: unknown,
): Record<string, unknown> | null {
if (data == null) return null; if (data == null) return null;
if (data instanceof Map) { if (data instanceof Map) {
return Object.fromEntries((data as Map<string, unknown>).entries()); return Object.fromEntries((data as Map<string, unknown>).entries());
@@ -819,11 +833,9 @@ export function migrateLegacyDamagedPartsMapToArray(
selected: DamageSelectedPartV2[], selected: DamageSelectedPartV2[],
): Record<string, unknown>[] { ): Record<string, unknown>[] {
return selected.map((sp) => { return selected.map((sp) => {
const keysTry = [ const keysTry = [sp.catalogKey, catalogLikeKeyFromPart(sp), sp.name].filter(
sp.catalogKey, Boolean,
catalogLikeKeyFromPart(sp), ) as string[];
sp.name,
].filter(Boolean) as string[];
let blob: unknown; let blob: unknown;
for (const k of keysTry) { for (const k of keysTry) {
blob = getCaptureBlobLoose(legacyMap, k); blob = getCaptureBlobLoose(legacyMap, k);
@@ -887,12 +899,14 @@ export function resolvePartCaptureIndex(
if (!raw) return -1; if (!raw) return -1;
if (selected.length) { if (selected.length) {
// Pass 1: exact matches on catalogKey and catalogLikeKey
for (let i = 0; i < selected.length; i++) { for (let i = 0; i < selected.length; i++) {
const sp = selected[i]; const sp = selected[i];
if (sp.catalogKey === raw) return i; if (sp.catalogKey === raw) return i;
if (catalogLikeKeyFromPart(sp) === raw) return i; if (catalogLikeKeyFromPart(sp) === raw) return i;
} }
// Pass 2: numeric id or array index
if (/^\d+$/.test(raw)) { if (/^\d+$/.test(raw)) {
const n = Number(raw); const n = Number(raw);
const byId = selected.findIndex((p) => p.id === n); const byId = selected.findIndex((p) => p.id === n);
@@ -901,31 +915,35 @@ export function resolvePartCaptureIndex(
if (!hasIdCollision && n >= 0 && n < selected.length) return n; if (!hasIdCollision && n >= 0 && n < selected.length) return n;
} }
// Pass 3: loose normalized match (handles spaces, underscores, case)
const loose = raw.toLowerCase().replace(/[\s_-]/g, ""); const loose = raw.toLowerCase().replace(/[\s_-]/g, "");
for (let i = 0; i < selected.length; i++) { for (let i = 0; i < selected.length; i++) {
const sp = selected[i]; const sp = selected[i];
const cand = [ const candidates = [
sp.catalogKey, sp.catalogKey,
catalogLikeKeyFromPart(sp), catalogLikeKeyFromPart(sp),
sp.name, sp.name,
].filter(Boolean) as string[]; ].filter(Boolean) as string[];
for (const c of cand) { for (const c of candidates) {
if (c.toLowerCase().replace(/[\s_-]/g, "") === loose) return i; if (c.toLowerCase().replace(/[\s_-]/g, "") === loose) return i;
} }
} }
}
if (damagedPartsArray && damagedPartsArray.length) { // Pass 4: suffix match — handles car-type-prefixed catalog keys
for (let i = 0; i < damagedPartsArray.length; i++) { // e.g. "sedan_front_hood" should match sp.catalogKey="front_hood"
const row = damagedPartsArray[i]; // or normalizeResendPartKeys output "sedan_front_hood" vs stored "front_hood"
if (!row || typeof row !== "object") continue; for (let i = 0; i < selected.length; i++) {
const id = toNum((row as { id?: unknown }).id); const sp = selected[i];
if (id != null && String(id) === raw) return i; const candidates = [
const ck = String((row as { catalogKey?: unknown }).catalogKey || ""); sp.catalogKey,
if (ck && ck === raw) return i; catalogLikeKeyFromPart(sp),
const name = String((row as { name?: unknown }).name || ""); sp.name,
const side = String((row as { side?: unknown }).side || ""); ].filter(Boolean) as string[];
if (side && name && `${side}_${name}` === raw) return i; for (const c of candidates) {
const cLoose = c.toLowerCase().replace(/[\s_-]/g, "");
// raw ends with the candidate (prefix stripped)
if (loose.endsWith(cLoose) || cLoose.endsWith(loose)) return i;
}
} }
} }
@@ -939,7 +957,11 @@ export function mediaRowOrLegacyHasCapture(
): boolean { ): boolean {
if (damagedPartsData == null) return false; if (damagedPartsData == null) return false;
if (Array.isArray(damagedPartsData)) { if (Array.isArray(damagedPartsData)) {
const idx = resolvePartCaptureIndex(partKey, selected, damagedPartsData as any[]); const idx = resolvePartCaptureIndex(
partKey,
selected,
damagedPartsData as any[],
);
if (idx < 0) return false; if (idx < 0) return false;
return mediaRowHasCapture((damagedPartsData as unknown[])[idx]); return mediaRowHasCapture((damagedPartsData as unknown[])[idx]);
} }

View File

@@ -41,7 +41,10 @@ export class SmsOrchestrationService implements OnModuleInit {
return `${process.env.URL}/${frontendRoute}?token=${requestId}`; return `${process.env.URL}/${frontendRoute}?token=${requestId}`;
} }
buildBlamePartyLink(requestId: string, partyRole: "FIRST" | "SECOND"): string { buildBlamePartyLink(
requestId: string,
partyRole: "FIRST" | "SECOND",
): string {
const route = partyRole === "SECOND" ? "user2" : "user"; const route = partyRole === "SECOND" ? "user2" : "user";
return `${process.env.URL}/${route}?token=${requestId}`; return `${process.env.URL}/${route}?token=${requestId}`;
} }
@@ -50,7 +53,11 @@ export class SmsOrchestrationService implements OnModuleInit {
return `${process.env.URL}/caseClaim?token=${claimRequestId}`; return `${process.env.URL}/caseClaim?token=${claimRequestId}`;
} }
async sendInviteLink(phoneNumber: string, publicId: string, link: string): Promise<boolean> { async sendInviteLink(
phoneNumber: string,
publicId: string,
link: string,
): Promise<boolean> {
return this.sendTemplate({ return this.sendTemplate({
template: "yara724-invite-link", template: "yara724-invite-link",
receptor: phoneNumber, receptor: phoneNumber,
@@ -132,14 +139,12 @@ export class SmsOrchestrationService implements OnModuleInit {
}); });
} }
async sendThirdPartyExpertStartedReviewNotice( async sendThirdPartyExpertStartedReviewNotice(params: {
params: { receptor: string;
receptor: string; fileKind: "blame" | "claim";
fileKind: "blame" | "claim"; publicId: string;
publicId: string; expertLastName: string;
expertLastName: string; }): Promise<boolean> {
},
): Promise<boolean> {
return this.sendTemplate({ return this.sendTemplate({
template: "yara-expert-lock", template: "yara-expert-lock",
receptor: params.receptor, receptor: params.receptor,