forked from Yara724/api
Resolve conflicts while keeping field-expert lock/view fixes and user party-access query improvements from the fork.
4771 lines
158 KiB
TypeScript
4771 lines
158 KiB
TypeScript
"use strict";
|
||
import { createReadStream, existsSync, statSync } from "node:fs";
|
||
import { join } from "node:path";
|
||
import { HttpService } from "@nestjs/axios";
|
||
import {
|
||
BadRequestException,
|
||
ConflictException,
|
||
ForbiddenException,
|
||
HttpException,
|
||
HttpStatus,
|
||
Injectable,
|
||
Logger,
|
||
NotFoundException,
|
||
} from "@nestjs/common";
|
||
import { distance as stringDistance } from "fastest-levenshtein"; // حتما نصب بشه
|
||
import { Types } from "mongoose";
|
||
import { lastValueFrom } from "rxjs";
|
||
import { ClaimRequestManagementDbService } from "src/claim-request-management/entites/db-service/claim-request-management.db.service";
|
||
import {
|
||
ClaimRequestManagementService,
|
||
FanavaranAutoSubmitResult,
|
||
} from "src/claim-request-management/claim-request-management.service";
|
||
import { ClaimSignDbService } from "src/claim-request-management/entites/db-service/claim-sign.db.service";
|
||
import { DamageImageDbService } from "src/claim-request-management/entites/db-service/damage-image.db.service";
|
||
import { VideoCaptureDbService } from "src/claim-request-management/entites/db-service/video-capture.db.service";
|
||
import { ClientDbService } from "src/client/entities/db-service/client.db.service";
|
||
import { buildFileLink, resolveStoredFileUrl } from "src/helpers/urlCreator";
|
||
import { toJalaliDateAndTime } from "src/helpers/date-jalali";
|
||
import { BlameDocumentDbService } from "src/request-management/entities/db-service/blame-document.db.service";
|
||
import { BlameVideoDbService } from "src/request-management/entities/db-service/blame-video.db.service";
|
||
import { BlameVoiceDbService } from "src/request-management/entities/db-service/blame.voice.db.service";
|
||
import { SandHubService } from "src/sand-hub/sand-hub.service";
|
||
import { ReqClaimStatus } from "src/Types&Enums/claim-request-management/status.enum";
|
||
import { ClaimStepsEnum } from "src/Types&Enums/claim-request-management/steps.enum";
|
||
import { UserType } from "src/Types&Enums/userType.enum";
|
||
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
|
||
import { UserDbService } from "src/users/entities/db-service/user.db.service";
|
||
import {
|
||
ExpertFileAssignResultDto,
|
||
ExpertFileAssignStatus,
|
||
} from "src/common/dto/expert-file-assign-result.dto";
|
||
import {
|
||
CLAIM_REVIEW_ASSIGNED_HISTORY_TYPE,
|
||
claimWorkflowAssigneeToPersistOnLockExpiry,
|
||
buildExpertAssignConflictForOtherReviewer,
|
||
resolvePersistentReviewAssigneeId,
|
||
} from "src/helpers/expert-workflow-review-assignee";
|
||
import { ClaimPerIdRs } from "./dto/claim-list-perId-rs.dto";
|
||
import { ClaimListDtoRs } from "./dto/claim-list-rs.dto";
|
||
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
|
||
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
||
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
|
||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||
import {
|
||
assertClaimCaseForTenant,
|
||
assertClaimCaseForExpertActor,
|
||
claimCaseInitiatedByFieldExpert,
|
||
claimCaseTouchesClient,
|
||
requireActorClientKey,
|
||
} from "src/helpers/tenant-scope";
|
||
import {
|
||
claimCaseStatusToReportBucket,
|
||
initialClaimExpertReportBuckets,
|
||
} from "src/helpers/expert-panel-status-report";
|
||
import {
|
||
claimDocInExpertPortfolio,
|
||
expertPortfolioFileIdsFromActivityEvents,
|
||
objectIdsFromStringSet,
|
||
} from "src/helpers/expert-portfolio";
|
||
import {
|
||
buildCoefficientsFromPartSeverities,
|
||
buildDamagedPartsPriceDropLines,
|
||
buildPriceDropCatalogForApi,
|
||
calculateClaimPriceDrop,
|
||
PRICE_DROP_SEVERITY_OPTIONS,
|
||
resolveVehicleModelYearFromBlame,
|
||
} from "src/helpers/claim-price-drop";
|
||
import { UpsertClaimPriceDropV2Dto } from "./dto/claim-price-drop-v2.dto";
|
||
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
|
||
import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
|
||
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
|
||
import {
|
||
GetClaimListV2ResponseDto,
|
||
ClaimListItemV2Dto,
|
||
} from "./dto/claim-list-v2.dto";
|
||
import { ClaimDetailV2ResponseDto } from "./dto/claim-detail-v2.dto";
|
||
import { ClaimSubmitReplyDto, ClaimSubmitResendDto } from "./dto/reply.dto";
|
||
import { UpdateClaimDamagedPartsV2Dto } from "./dto/update-claim-damaged-parts-v2.dto";
|
||
import { ClaimFactorsImageDbService } from "src/claim-request-management/entites/db-service/factor-image.db.service";
|
||
import { ClaimRequiredDocumentDbService } from "src/claim-request-management/entites/db-service/claim-required-document.db.service";
|
||
import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum";
|
||
import { DaghiOption } from "src/Types&Enums/claim-request-management/daghi-option.enum";
|
||
import { BranchDbService } from "src/client/entities/db-service/branch.db.service";
|
||
import { ClaimSubmitResendV2Dto } from "./dto/expert-claim-v2.dto";
|
||
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
|
||
import { FactorValidationV2Dto } from "./dto/factor-validation.dto";
|
||
import {
|
||
buildMutualAgreementExpertDecision,
|
||
enrichBlamePartiesForAgreementView,
|
||
} from "src/helpers/blame-party-agreement-decision";
|
||
import {
|
||
normalizeResendDocumentKeys,
|
||
resendRequestHasPayload,
|
||
} from "src/helpers/claim-expert-resend";
|
||
import {
|
||
claimCaseStatusAfterExpertReplyV2,
|
||
classifyV2ExpertPricingParts,
|
||
claimIsAwaitingExpertFactorValidationV2,
|
||
} from "src/helpers/claim-v2-expert-reply-workflow";
|
||
import {
|
||
getClaimCarAngleCaptureBlob,
|
||
getDamagedPartCaptureBlob,
|
||
hasDamagedPartCapture,
|
||
type ClaimCarAngleKey,
|
||
} from "src/helpers/claim-car-angle-media";
|
||
import {
|
||
catalogLikeKeyFromPart,
|
||
coerceDamagedPartsMediaToArray,
|
||
normalizeCarPartDamageForExpertReply,
|
||
normalizeDamageSelectedParts,
|
||
catalogPartIdFromCarPartDamage,
|
||
catalogPartIdFromSelectedPart,
|
||
partLookupKey,
|
||
resolvePartForExpertReply,
|
||
sameCatalogPartId,
|
||
sanitizeDamageSelectedPartV2,
|
||
type DamageSelectedPartV2,
|
||
} from "src/helpers/outer-damage-parts";
|
||
import { normalizeResendCarPartsForStorage } from "src/helpers/claim-expert-resend";
|
||
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||
import { snapshotFromDamageExpert } from "src/helpers/expert-profile-snapshot";
|
||
import { DamageExpertModel } from "src/users/entities/schema/damage-expert.schema";
|
||
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
|
||
import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service";
|
||
import {
|
||
ExpertFileActivityType,
|
||
ExpertFileKind,
|
||
} from "src/users/entities/schema/expert-file-activity.schema";
|
||
|
||
/** Maximum sum of line `totalPayment` across the claim (Toman; priced parts + factor lines after validation). */
|
||
import { getClaimV2TotalPaymentCapToman } from "src/constants/repair-amount-limits";
|
||
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||
import { applyListQueryV2 } from "src/helpers/list-query-v2";
|
||
import { buildEnrichedDamagedParts } from "./dto/claim-damaged-part.enricher";
|
||
import { canonicalizeResendDocumentKey } from "src/helpers/claim-resend-document-keys";
|
||
|
||
@Injectable()
|
||
export class ExpertClaimService {
|
||
private readonly logger = new Logger(ExpertClaimService.name);
|
||
|
||
/** Matches blame v2 `workflow` lock TTL: locker may re-enter until expired, then others may open. */
|
||
private readonly claimV2WorkflowLockTtlMs = 15 * 60 * 1000;
|
||
|
||
private readonly priceDropPart = {
|
||
backFender: {
|
||
Minor: 2,
|
||
Moderate: 3,
|
||
Severe: 5,
|
||
},
|
||
backDoor: {
|
||
Minor: 1,
|
||
Moderate: 2,
|
||
Severe: 3,
|
||
},
|
||
frontDoor: {
|
||
Minor: 1,
|
||
Moderate: 2,
|
||
Severe: 3,
|
||
},
|
||
frontFender: {
|
||
Minor: 1,
|
||
Moderate: 2,
|
||
Severe: 3,
|
||
},
|
||
frontBumper: {
|
||
Minor: 1,
|
||
Moderate: 2,
|
||
Severe: 3,
|
||
},
|
||
Hood: {
|
||
Minor: 2,
|
||
moderate: 3,
|
||
Severe: 4,
|
||
},
|
||
Trunk: {
|
||
minor: 1,
|
||
moderate: 3,
|
||
Severe: 5,
|
||
},
|
||
Roof: {
|
||
Minor: 3,
|
||
Moderate: 5,
|
||
Severe: 7,
|
||
},
|
||
coil: {
|
||
Minor: 2,
|
||
Moderate: 3,
|
||
Severe: 4,
|
||
},
|
||
column: {
|
||
Minor: 2,
|
||
Moderate: 3,
|
||
Severe: 4,
|
||
},
|
||
frontTray: {
|
||
Minor: 1,
|
||
Moderate: 2,
|
||
Severe: 3,
|
||
},
|
||
backTray: {
|
||
Minor: 2,
|
||
moderate: 4,
|
||
Severe: 5,
|
||
},
|
||
frontChassis: {
|
||
Minor: 3,
|
||
Moderate: 5,
|
||
Severe: 7,
|
||
},
|
||
backChassis: {
|
||
Minor: 2,
|
||
Moderate: 4,
|
||
Severe: 6,
|
||
},
|
||
carFootrest: {
|
||
Minor: 1,
|
||
Moderate: 2,
|
||
Severe: 3,
|
||
},
|
||
carFloor: {
|
||
Minor: 4,
|
||
moderate: 6,
|
||
Severe: 8,
|
||
},
|
||
};
|
||
|
||
private readonly yearCoefficient = {
|
||
1393: 2.05,
|
||
1394: 2.1,
|
||
1395: 2.2,
|
||
1396: 2.3,
|
||
1397: 2.4,
|
||
1398: 2.5,
|
||
1399: 2.6,
|
||
1400: 2.7,
|
||
1401: 2.8,
|
||
1402: 2.9,
|
||
1403: 3,
|
||
1404: 3,
|
||
};
|
||
|
||
constructor(
|
||
private readonly blameVoiceDbService: BlameVoiceDbService,
|
||
private readonly blameDocumentDbService: BlameDocumentDbService,
|
||
private readonly claimRequestManagementDbService: ClaimRequestManagementDbService,
|
||
private readonly damageImageDbService: DamageImageDbService,
|
||
private readonly blameVideoDbService: BlameVideoDbService,
|
||
private readonly claimVideoCaptureDbService: VideoCaptureDbService,
|
||
private readonly httpService: HttpService,
|
||
private readonly claimCaseDbService: ClaimCaseDbService,
|
||
private readonly blameRequestDbService: BlameRequestDbService,
|
||
private readonly sandHubService: SandHubService,
|
||
private readonly damageExpertDbService: DamageExpertDbService,
|
||
private readonly clientDbService: ClientDbService,
|
||
private readonly claimFactorsImageDbService: ClaimFactorsImageDbService,
|
||
private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService,
|
||
private readonly branchDbService: BranchDbService,
|
||
private readonly smsOrchestrationService: SmsOrchestrationService,
|
||
private readonly userDbService: UserDbService,
|
||
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
|
||
private readonly claimSignDbService: ClaimSignDbService,
|
||
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
||
) {}
|
||
|
||
private appendFanavaranAutoSubmitToMessage(
|
||
baseMessage: string,
|
||
fanavaran: FanavaranAutoSubmitResult,
|
||
): string {
|
||
if (fanavaran.submitted) {
|
||
return `${baseMessage} The claim was sent to Fanavaran successfully.`;
|
||
}
|
||
if (fanavaran.warning) {
|
||
return `${baseMessage} ${fanavaran.warning}`;
|
||
}
|
||
return baseMessage;
|
||
}
|
||
|
||
/**
|
||
* Resolve a `claim-sign` document id to a downloadable file URL.
|
||
* Mirrors the helper used in `ExpertInsurerService` so signature links are
|
||
* exposed identically across the two expert-facing detail endpoints.
|
||
*/
|
||
private async claimSignLinkFromId(
|
||
signDetailId: unknown,
|
||
): Promise<string | undefined> {
|
||
if (signDetailId == null || signDetailId === "") return undefined;
|
||
const id = String(signDetailId);
|
||
if (!Types.ObjectId.isValid(id)) return undefined;
|
||
const doc = await this.claimSignDbService.findOne({
|
||
_id: new Types.ObjectId(id),
|
||
});
|
||
const path = (doc as any)?.path;
|
||
return path ? buildFileLink(String(path)) : undefined;
|
||
}
|
||
|
||
/**
|
||
* Build a deep-cloned `evaluation` payload with resolved file URLs the
|
||
* front-end can render directly:
|
||
*
|
||
* - `damageExpertReply.userComment.signLink`
|
||
* - `damageExpertReplyFinal.userComment.signLink`
|
||
* - `ownerInsurerApproval.signLink`
|
||
* - `ownerPricedPartsApproval.signLink`
|
||
* - `damageExpertReply.parts[i].factorLink` (ObjectId → URL)
|
||
* - `damageExpertReplyFinal.parts[i].factorLink` (ObjectId → URL)
|
||
*
|
||
* Returns `undefined` when there's no evaluation to enrich.
|
||
*/
|
||
private async enrichClaimEvaluationForExpert(
|
||
evaluation: Record<string, unknown> | undefined | null,
|
||
): Promise<Record<string, unknown> | undefined> {
|
||
if (!evaluation || typeof evaluation !== "object") return undefined;
|
||
const ev = JSON.parse(JSON.stringify(evaluation)) as Record<
|
||
string,
|
||
unknown
|
||
>;
|
||
|
||
const enrichReply = async (key: string) => {
|
||
const reply = ev[key] as Record<string, unknown> | undefined;
|
||
if (!reply) return;
|
||
const uc = reply.userComment as Record<string, unknown> | undefined;
|
||
if (uc?.signDetailId != null) {
|
||
const signLink = await this.claimSignLinkFromId(uc.signDetailId);
|
||
reply.userComment = { ...uc, signLink };
|
||
}
|
||
// Resolve each `parts[].factorLink` ObjectId to a downloadable URL.
|
||
// `populateFactorLinks` mutates in place — safe here because `ev` was
|
||
// deep-cloned via JSON above, so we never touch the live mongoose doc.
|
||
await this.populateFactorLinks(reply);
|
||
};
|
||
await enrichReply("damageExpertReply");
|
||
await enrichReply("damageExpertReplyFinal");
|
||
|
||
const enrichApproval = async (key: string) => {
|
||
const o = ev[key] as Record<string, unknown> | undefined;
|
||
if (o?.signDetailId != null) {
|
||
const signLink = await this.claimSignLinkFromId(o.signDetailId);
|
||
ev[key] = { ...o, signLink };
|
||
}
|
||
};
|
||
await enrichApproval("ownerInsurerApproval");
|
||
await enrichApproval("ownerPricedPartsApproval");
|
||
|
||
return ev;
|
||
}
|
||
|
||
/** Load linked blame when resolving field-expert ownership on a claim. */
|
||
private async loadBlameForClaim(
|
||
claim: { blameRequestId?: unknown } | null | undefined,
|
||
) {
|
||
const id = claim?.blameRequestId?.toString?.();
|
||
if (!id) return null;
|
||
return this.blameRequestDbService.findById(id);
|
||
}
|
||
|
||
private async assertExpertActorOnClaim(claim: any, actor: any): Promise<void> {
|
||
const blame = await this.loadBlameForClaim(claim);
|
||
assertClaimCaseForExpertActor(claim, actor, blame);
|
||
}
|
||
|
||
private async fieldExpertOwnsClaim(
|
||
claim: any,
|
||
actor: { sub: string },
|
||
): Promise<boolean> {
|
||
const blame = await this.loadBlameForClaim(claim);
|
||
return claimCaseInitiatedByFieldExpert(claim, actor, blame);
|
||
}
|
||
|
||
/** Load immutable profile fields from `damage-expert` for the acting expert. */
|
||
private async snapshotDamageExpert(actorId: string) {
|
||
const doc = await this.damageExpertDbService.findById(actorId);
|
||
return snapshotFromDamageExpert(doc as DamageExpertModel);
|
||
}
|
||
|
||
private async recordClaimExpertActivity(args: {
|
||
expertId: string;
|
||
tenantId: string;
|
||
claimId: string;
|
||
eventType: ExpertFileActivityType;
|
||
idempotencyKey?: string;
|
||
}): Promise<void> {
|
||
if (
|
||
!Types.ObjectId.isValid(args.expertId) ||
|
||
!Types.ObjectId.isValid(args.tenantId) ||
|
||
!Types.ObjectId.isValid(args.claimId)
|
||
) {
|
||
return;
|
||
}
|
||
await this.expertFileActivityDbService.recordEvent({
|
||
expertId: args.expertId,
|
||
tenantId: args.tenantId,
|
||
fileId: args.claimId,
|
||
fileType: ExpertFileKind.CLAIM,
|
||
eventType: args.eventType,
|
||
idempotencyKey: args.idempotencyKey,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Insurer tenant id stored on claim file-activity rows. Prefer `owner.userClientKey`
|
||
* so V2 events line up with `expireClaimWorkflowLockV2IfStale` and insurer `findByTenant`.
|
||
*/
|
||
private claimActivityTenantId(
|
||
claim: { owner?: { userClientKey?: unknown } } | null | undefined,
|
||
actor: { clientKey?: string },
|
||
): string {
|
||
const fromOwner = claim?.owner?.userClientKey;
|
||
if (fromOwner != null && String(fromOwner).length > 0) {
|
||
return String(fromOwner);
|
||
}
|
||
return String(actor.clientKey ?? "");
|
||
}
|
||
|
||
/**
|
||
* `preLockQueueSnapshot` must only store {@link ClaimWorkflowStep} values.
|
||
* Legacy / mistaken data sometimes put {@link ClaimCaseStatus} (e.g. WAITING_FOR_DAMAGE_EXPERT)
|
||
* in `workflow.currentStep`, which would fail Mongoose enum validation when snapshotted.
|
||
*/
|
||
private normalizeClaimWorkflowStepForSnapshot(
|
||
value: unknown,
|
||
fallback: ClaimWorkflowStep,
|
||
): ClaimWorkflowStep {
|
||
if (
|
||
typeof value === "string" &&
|
||
(Object.values(ClaimWorkflowStep) as string[]).includes(value)
|
||
) {
|
||
return value as ClaimWorkflowStep;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
/** Owner mobile: linked blame party phone when available, else `users.mobile`. */
|
||
private async resolveClaimOwnerPhone(
|
||
claim: any,
|
||
): Promise<string | undefined> {
|
||
if (!claim?.owner?.userId) return undefined;
|
||
const ownerUserId = String(claim.owner.userId);
|
||
if (claim.blameRequestId) {
|
||
const blame = await this.blameRequestDbService.findById(
|
||
claim.blameRequestId.toString(),
|
||
);
|
||
const ownerParty = (blame?.parties || []).find(
|
||
(p: any) =>
|
||
p?.person?.userId && String(p.person.userId) === ownerUserId,
|
||
);
|
||
const fromParty = ownerParty?.person?.phoneNumber;
|
||
if (fromParty && typeof fromParty === "string") return fromParty;
|
||
}
|
||
const user = await this.userDbService.findOne({
|
||
_id: new Types.ObjectId(ownerUserId),
|
||
});
|
||
const m = user?.mobile;
|
||
if (m && typeof m === "string") return m;
|
||
return undefined;
|
||
}
|
||
|
||
/** Map blame party `Vehicle` (name/model/type) to expert panel shape. */
|
||
private expertVehicleFromPartyVehicle(v: any):
|
||
| {
|
||
carName?: string;
|
||
carModel?: string;
|
||
carType?: string;
|
||
plate?: { plateId?: string };
|
||
}
|
||
| undefined {
|
||
if (!v || typeof v !== "object") return undefined;
|
||
const carName = v.name ?? v.carName;
|
||
const carModel = v.model ?? v.carModel;
|
||
const carType = v.type ?? v.carType;
|
||
const plateId = v.plateId;
|
||
const plate =
|
||
plateId != null && String(plateId).length > 0
|
||
? { plateId: String(plateId) }
|
||
: undefined;
|
||
if (!carName && !carModel && !carType && !plate) return undefined;
|
||
return { carName, carModel, carType, ...(plate ? { plate } : {}) };
|
||
}
|
||
|
||
/** Damaged party vehicle on blame (matches claim owner, or non-guilty party for THIRD_PARTY). */
|
||
private damagedPartyVehicleFromBlame(
|
||
blame: any,
|
||
claim: any,
|
||
):
|
||
| {
|
||
carName?: string;
|
||
carModel?: string;
|
||
carType?: string;
|
||
plate?: { plateId?: string };
|
||
}
|
||
| undefined {
|
||
const parties = blame?.parties;
|
||
if (!Array.isArray(parties) || parties.length === 0) return undefined;
|
||
|
||
const ownerId = claim?.owner?.userId ? String(claim.owner.userId) : "";
|
||
let party = ownerId
|
||
? parties.find(
|
||
(p: any) => p?.person?.userId && String(p.person.userId) === ownerId,
|
||
)
|
||
: undefined;
|
||
|
||
if (!party && blame?.expert?.decision?.guiltyPartyId) {
|
||
const guilty = String(blame.expert.decision.guiltyPartyId);
|
||
party = parties.find(
|
||
(p: any) => p?.person?.userId && String(p.person.userId) !== guilty,
|
||
);
|
||
}
|
||
|
||
if (!party && parties.length === 1) party = parties[0];
|
||
|
||
return this.expertVehicleFromPartyVehicle(party?.vehicle);
|
||
}
|
||
|
||
private vehicleForExpertFromClaimAndBlameMap(
|
||
claim: any,
|
||
blameById: Map<string, any>,
|
||
):
|
||
| {
|
||
carName?: string;
|
||
carModel?: string;
|
||
carType?: string;
|
||
plate?: { plateId?: string };
|
||
}
|
||
| undefined {
|
||
const cv = claim?.vehicle;
|
||
if (cv && (cv.carName || cv.carModel || cv.carType || (cv as any).plate)) {
|
||
return {
|
||
carName: cv.carName,
|
||
carModel: cv.carModel,
|
||
carType: cv.carType,
|
||
...((cv as any).plate ? { plate: (cv as any).plate } : {}),
|
||
};
|
||
}
|
||
const bid = claim?.blameRequestId?.toString();
|
||
if (!bid) return undefined;
|
||
const blame = blameById.get(bid);
|
||
if (!blame) return undefined;
|
||
return this.damagedPartyVehicleFromBlame(blame, claim);
|
||
}
|
||
|
||
/**
|
||
* Blame file kind for damage-expert UI: THIRD_PARTY vs CAR_BODY, and CAR_BODY first-step flags.
|
||
*/
|
||
private blameFileContextForExpert(blame: any): {
|
||
blameRequestType?: BlameRequestType;
|
||
carBodyFirstForm?: { car?: boolean; object?: boolean };
|
||
blameStatus?: string;
|
||
} {
|
||
if (!blame?.type) return {};
|
||
const blameRequestType = blame.type as BlameRequestType;
|
||
const out: {
|
||
blameRequestType?: BlameRequestType;
|
||
carBodyFirstForm?: { car?: boolean; object?: boolean };
|
||
blameStatus?: string;
|
||
} = { blameRequestType };
|
||
out.blameStatus = blame.blameStatus;
|
||
if (blameRequestType !== BlameRequestType.CAR_BODY) return out;
|
||
|
||
const parties = blame.parties;
|
||
if (!Array.isArray(parties) || parties.length === 0) return out;
|
||
|
||
const first =
|
||
parties.find((p: any) => p?.role === PartyRole.FIRST) ?? parties[0];
|
||
const cbf = first?.carBodyFirstForm;
|
||
delete out.blameStatus;
|
||
if (cbf && typeof cbf === "object") {
|
||
out.carBodyFirstForm = {
|
||
...(typeof cbf.car === "boolean" ? { car: cbf.car } : {}),
|
||
...(typeof cbf.object === "boolean" ? { object: cbf.object } : {}),
|
||
};
|
||
}
|
||
return out;
|
||
}
|
||
|
||
private isVisibleToClientType(client: any, actor: any): boolean {
|
||
if (actor.userType === UserType.GENUINE) {
|
||
return true;
|
||
}
|
||
if (
|
||
actor.userType === UserType.LEGAL &&
|
||
String(client._id) === actor.clientKey
|
||
) {
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
private wasHandledByActor(request: any, actorSub: string): boolean {
|
||
type ActorCheckerEntry = { CheckedRequest?: { actorId: string } };
|
||
const actorChecker = request.actorsChecker as ActorCheckerEntry[];
|
||
|
||
if (!Array.isArray(actorChecker)) {
|
||
return false;
|
||
}
|
||
|
||
const matchingEntry = actorChecker.find(
|
||
(entry) => String(entry?.CheckedRequest?.actorId) === actorSub,
|
||
);
|
||
|
||
return !!matchingEntry;
|
||
}
|
||
|
||
async getClaimRequestsListForExpert(actor): Promise<ClaimListDtoRs> {
|
||
// const requests = await this.claimRequestManagementDbService.findAllByStatus(
|
||
// {
|
||
// claimStatus: {
|
||
// $in: [
|
||
// ReqClaimStatus.UnChecked,
|
||
// ReqClaimStatus.ReviewRequest,
|
||
// ReqClaimStatus.CheckAgain,
|
||
// ReqClaimStatus.CloseRequest,
|
||
// ReqClaimStatus.InPersonVisit,
|
||
// ReqClaimStatus.CheckedRequest,
|
||
// ReqClaimStatus.PendingFactorValidation,
|
||
// ],
|
||
// },
|
||
// "damageExpertReply.actorDetail.actorId":actor.sub
|
||
// },
|
||
// );
|
||
|
||
const requests = await this.claimRequestManagementDbService.findAllByStatus(
|
||
{
|
||
$or: [
|
||
{
|
||
claimStatus: ReqClaimStatus.UnChecked,
|
||
},
|
||
{
|
||
claimStatus: {
|
||
$in: [
|
||
ReqClaimStatus.ReviewRequest,
|
||
ReqClaimStatus.CheckAgain,
|
||
ReqClaimStatus.CloseRequest,
|
||
ReqClaimStatus.InPersonVisit,
|
||
ReqClaimStatus.CheckedRequest,
|
||
ReqClaimStatus.PendingFactorValidation,
|
||
],
|
||
},
|
||
"damageExpertReply.actorDetail.actorId": actor.sub,
|
||
},
|
||
],
|
||
},
|
||
);
|
||
|
||
const filteredRequests = [];
|
||
|
||
for (const r of requests) {
|
||
// For expert-initiated blame files, only show to the initiating expert
|
||
if (r.blameFile?.expertInitiated && r.blameFile?.initiatedBy) {
|
||
if (String(r.blameFile.initiatedBy) !== actor.sub) {
|
||
continue; // Skip if not the initiating expert
|
||
}
|
||
// Expert-initiated claim files are always visible to the initiating expert
|
||
filteredRequests.push(r);
|
||
continue;
|
||
}
|
||
|
||
const client = await this.clientDbService.findOne({
|
||
_id: r.userClientKey,
|
||
});
|
||
|
||
if (!client) {
|
||
this.logger.warn(
|
||
`Client not found for claim request with ID: ${r._id}. Skipping.`,
|
||
);
|
||
continue;
|
||
}
|
||
|
||
const specialHandlingStatuses = [
|
||
ReqClaimStatus.CheckAgain,
|
||
ReqClaimStatus.ReviewRequest,
|
||
ReqClaimStatus.PendingFactorValidation,
|
||
];
|
||
|
||
const requiresSpecificActorCheck = specialHandlingStatuses.includes(
|
||
r.claimStatus,
|
||
);
|
||
|
||
if (requiresSpecificActorCheck) {
|
||
if (this.wasHandledByActor(r, actor.sub)) {
|
||
filteredRequests.push(r);
|
||
}
|
||
} else {
|
||
if (this.isVisibleToClientType(client, actor)) {
|
||
filteredRequests.push(r);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (filteredRequests.length === 0) {
|
||
throw new NotFoundException(
|
||
"No claim requests found for you at this time.",
|
||
);
|
||
}
|
||
|
||
return new ClaimListDtoRs(filteredRequests);
|
||
}
|
||
|
||
public unlockApi(request, timer) {
|
||
// ADD to project
|
||
return setTimeout(() => {
|
||
this.claimRequestManagementDbService.findOne(request._id).then((r) => {
|
||
const updateExp = {
|
||
lockFile: false,
|
||
unlockTime: null,
|
||
actorLocked: {},
|
||
};
|
||
if (r?.claimStatus == ReqClaimStatus.ReviewRequest) {
|
||
Object.assign(updateExp, {
|
||
claimStatus: ReqClaimStatus.UnChecked,
|
||
});
|
||
}
|
||
this.claimRequestManagementDbService.findOneAndUpdate(request._id, {
|
||
...updateExp,
|
||
});
|
||
});
|
||
this.logger.log(`unlock this request : ${request._id}`);
|
||
// TODO enum request claimStatus create
|
||
}, timer);
|
||
}
|
||
|
||
async lockClaimRequest(requestId: string, actorDetail) {
|
||
const request =
|
||
await this.claimRequestManagementDbService.findOne(requestId);
|
||
if (!request) throw new BadRequestException("Claim request not found");
|
||
|
||
const isLocked = !!request.lockFile;
|
||
const lockedByCurrent =
|
||
String(request?.actorLocked?.actorId || "") === String(actorDetail.sub);
|
||
const isLockExpired =
|
||
!!request.unlockTime &&
|
||
Date.now() >= new Date(request.unlockTime).getTime();
|
||
|
||
// Idempotent behavior: same expert can re-enter their locked file.
|
||
if (isLocked && lockedByCurrent && !isLockExpired) {
|
||
return { _id: requestId, lock: true, message: "Already locked by you" };
|
||
}
|
||
if (isLocked && !lockedByCurrent && !isLockExpired) {
|
||
throw new BadRequestException(
|
||
"Claim request is locked by another expert",
|
||
);
|
||
}
|
||
|
||
if (request.claimStatus === ReqClaimStatus.UserPending) {
|
||
throw new BadRequestException(
|
||
"Cannot lock request because claimStatus is UserPending",
|
||
);
|
||
}
|
||
|
||
if (
|
||
request.damageExpertReplyFinal &&
|
||
request.claimStatus === ReqClaimStatus.CheckedRequest
|
||
) {
|
||
throw new BadRequestException("Request has damage expert reply");
|
||
}
|
||
|
||
const fifteenMinutes = new Date(Date.now() + 15 * 60 * 1000);
|
||
const lockSnapshot = await this.snapshotDamageExpert(actorDetail.sub);
|
||
|
||
await this.claimRequestManagementDbService.findOneAndUpdate(
|
||
new Types.ObjectId(requestId),
|
||
{
|
||
lockFile: true,
|
||
claimStatus: ReqClaimStatus.ReviewRequest,
|
||
unlockTime: fifteenMinutes,
|
||
lockTime: Date.now(),
|
||
$set: {
|
||
actorLocked: {
|
||
actorId: new Types.ObjectId(actorDetail.sub),
|
||
fullName: actorDetail.fullName,
|
||
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
|
||
},
|
||
},
|
||
$push: {
|
||
actorsChecker: {
|
||
[ReqClaimStatus.ReviewRequest]: {
|
||
fullName: actorDetail.fullName,
|
||
actorId: new Types.ObjectId(actorDetail.sub),
|
||
},
|
||
Date: new Date(),
|
||
},
|
||
},
|
||
},
|
||
);
|
||
|
||
await this.recordClaimExpertActivity({
|
||
expertId: String(actorDetail.sub),
|
||
tenantId: String(request.userClientKey ?? actorDetail.clientKey ?? ""),
|
||
claimId: String(requestId),
|
||
eventType: ExpertFileActivityType.CHECKED,
|
||
idempotencyKey: `claim:${requestId}:checked:${actorDetail.sub}`,
|
||
});
|
||
|
||
this.unlockApi(request, fifteenMinutes.getTime() - Date.now());
|
||
|
||
return { _id: requestId, lock: true };
|
||
}
|
||
|
||
private normalizeText(str: string) {
|
||
if (!str || typeof str !== "string") {
|
||
return "";
|
||
}
|
||
return str
|
||
.replace(/[٠-٩۰-۹]/g, (d) => String.fromCharCode(d.charCodeAt(0) - 1728))
|
||
.replace(/[\u064B-\u065F]/g, "")
|
||
.replace(/\s+/g, " ")
|
||
.trim()
|
||
.toLowerCase();
|
||
}
|
||
|
||
normalizeKey(str: string): string {
|
||
return str.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||
}
|
||
|
||
parsePersianNumber(input: string): number {
|
||
return Number(
|
||
input
|
||
.replace(/[۰-۹]/g, (d) => String.fromCharCode(d.charCodeAt(0) - 1728))
|
||
.replace(/,/g, ""),
|
||
);
|
||
}
|
||
|
||
/** Factor validation: expert must send a line amount (OCR is not used for factor photos). */
|
||
private expertFactorValidationDecisionHasLinePricing(decision: {
|
||
totalPayment?: string;
|
||
price?: string;
|
||
salary?: string;
|
||
}): boolean {
|
||
const tp =
|
||
decision.totalPayment != null &&
|
||
String(decision.totalPayment).trim() !== "";
|
||
const pr = decision.price != null && String(decision.price).trim() !== "";
|
||
const sa = decision.salary != null && String(decision.salary).trim() !== "";
|
||
return tp || (pr && sa);
|
||
}
|
||
|
||
/** Price-cap total for one reply line: `totalPayment` if set, otherwise `price` + `salary`. */
|
||
private claimReplyPartLineTotalForCap(part: {
|
||
partId?: number | string;
|
||
totalPayment?: unknown;
|
||
price?: unknown;
|
||
salary?: unknown;
|
||
}): number {
|
||
const tpRaw =
|
||
part.totalPayment != null ? String(part.totalPayment).trim() : "";
|
||
if (tpRaw !== "") {
|
||
return this.parsePersianNumber(tpRaw);
|
||
}
|
||
const pr = this.parsePersianNumber(String(part.price ?? "0"));
|
||
const sa = this.parsePersianNumber(String(part.salary ?? "0"));
|
||
return pr + sa;
|
||
}
|
||
|
||
/** Same rules as V1 `submitReplyRequest`: daghi option + conditional price / branchId; `branchId` stored as ObjectId. */
|
||
private validateAndNormalizeDaghiForExpertReplyV2(
|
||
parts: import("./dto/expert-claim-v2.dto").PartPricingV2Dto[],
|
||
) {
|
||
for (const part of parts) {
|
||
if (!part.daghi || !part.daghi.option) {
|
||
throw new BadRequestException(
|
||
`Daghi option is required for part ${part.partId}`,
|
||
);
|
||
}
|
||
if (part.daghi.option === DaghiOption.RECYCLED_PARTS_VALUE) {
|
||
if (!part.daghi.price) {
|
||
throw new BadRequestException(
|
||
`Price is required for part ${part.partId} when option is '${DaghiOption.RECYCLED_PARTS_VALUE}'`,
|
||
);
|
||
}
|
||
} else if (part.daghi.option === DaghiOption.DELIVER_DAMAGED_PART) {
|
||
if (!part.daghi.branchId) {
|
||
throw new BadRequestException(
|
||
`Branch ID is required for part ${part.partId} when option is '${DaghiOption.DELIVER_DAMAGED_PART}'`,
|
||
);
|
||
}
|
||
if (!Types.ObjectId.isValid(part.daghi.branchId)) {
|
||
throw new BadRequestException(
|
||
`Invalid branch ID format for part ${part.partId}`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
return parts.map((part) => ({
|
||
...part,
|
||
daghi: {
|
||
option: part.daghi.option,
|
||
...(part.daghi.price && { price: part.daghi.price }),
|
||
...(part.daghi.branchId && {
|
||
branchId: new Types.ObjectId(part.daghi.branchId),
|
||
}),
|
||
},
|
||
}));
|
||
}
|
||
|
||
private findClosestCar(input: string, cars: { carName: string }[]) {
|
||
if (!input || !cars || cars.length === 0) {
|
||
this.logger.debug(
|
||
`[PriceDrop] findClosestCar: Invalid input. input: ${input}, cars length: ${cars?.length || 0}`,
|
||
);
|
||
return null;
|
||
}
|
||
const normalizedInput = this.normalizeText(input);
|
||
if (!normalizedInput) {
|
||
this.logger.debug(
|
||
`[PriceDrop] findClosestCar: Could not normalize input "${input}"`,
|
||
);
|
||
return null;
|
||
}
|
||
let bestMatch = null;
|
||
let minDistance = Infinity;
|
||
let validCarsChecked = 0;
|
||
|
||
for (const car of cars) {
|
||
if (!car || !car.carName) {
|
||
continue;
|
||
}
|
||
const normalizedCarName = this.normalizeText(car.carName);
|
||
if (!normalizedCarName) {
|
||
continue;
|
||
}
|
||
validCarsChecked++;
|
||
const dist = stringDistance(normalizedInput, normalizedCarName);
|
||
if (dist < minDistance) {
|
||
minDistance = dist;
|
||
bestMatch = car;
|
||
}
|
||
}
|
||
|
||
if (!bestMatch) {
|
||
this.logger.debug(
|
||
`[PriceDrop] findClosestCar: No match found for "${input}" after checking ${validCarsChecked} valid cars out of ${cars.length} total`,
|
||
);
|
||
} else {
|
||
this.logger.debug(
|
||
`[PriceDrop] findClosestCar: Best match for "${input}" is "${bestMatch.carName}" with distance ${minDistance}`,
|
||
);
|
||
}
|
||
|
||
return bestMatch;
|
||
}
|
||
|
||
private priceDropFormula(
|
||
carPrice: number | string,
|
||
carModel: number,
|
||
carValue: number[],
|
||
) {
|
||
let normalizePrice: number;
|
||
|
||
if (typeof carPrice === "string") {
|
||
normalizePrice = this.parsePersianNumber(carPrice);
|
||
} else if (typeof carPrice === "number") {
|
||
normalizePrice = carPrice;
|
||
} else {
|
||
this.logger.warn(
|
||
`Invalid carPrice type received in formula: ${typeof carPrice}`,
|
||
);
|
||
normalizePrice = 0;
|
||
}
|
||
|
||
const coefficient = this.yearCoefficient[carModel] || 1;
|
||
const sumOfSeverity = carValue.reduce((a, c) => a + c, 0);
|
||
const totalDrop = (normalizePrice * coefficient * sumOfSeverity) / 400;
|
||
|
||
return {
|
||
carPrice: normalizePrice,
|
||
carModel: carModel || null,
|
||
carValue: carValue || [],
|
||
sumOfSeverity: sumOfSeverity || 0,
|
||
coefficientYear: coefficient || 1,
|
||
total: totalDrop || 0,
|
||
};
|
||
}
|
||
|
||
async calculatePriceDrop(request: any, carPartsAi: any, query?: any) {
|
||
const requestId = request?._id?.toString() || "unknown";
|
||
|
||
try {
|
||
// Step 1: Check SandHub data
|
||
if (!request?.blameFile?.sanHubId) {
|
||
this.logger.warn(
|
||
`[PriceDrop] Request ${requestId}: Missing sanHubId in blameFile. Cannot calculate price drop.`,
|
||
);
|
||
return;
|
||
}
|
||
|
||
const sandHubData = await this.sandHubService.getSandHubDataFromId(
|
||
request.blameFile.sanHubId,
|
||
);
|
||
if (!sandHubData) {
|
||
this.logger.warn(
|
||
`[PriceDrop] Request ${requestId}: Could not retrieve SandHub data for sanHubId: ${request.blameFile.sanHubId}. Cannot calculate price drop.`,
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (
|
||
sandHubData.MapTypNam === undefined ||
|
||
sandHubData.MapTypNam === null
|
||
) {
|
||
this.logger.warn(
|
||
`[PriceDrop] Request ${requestId}: SandHub data missing ModelCii. Cannot calculate price drop.`,
|
||
);
|
||
return;
|
||
}
|
||
|
||
const existingPriceDrop = request.priceDrop || {};
|
||
const manualOverride = query || {};
|
||
|
||
// Step 2: Check car parts and get severity values
|
||
if (
|
||
!carPartsAi ||
|
||
(Array.isArray(carPartsAi) && carPartsAi.length === 0)
|
||
) {
|
||
this.logger.warn(
|
||
`[PriceDrop] Request ${requestId}: No car parts data provided. Cannot calculate severity values.`,
|
||
);
|
||
}
|
||
|
||
const severityValues = this.getSeverityValues(carPartsAi);
|
||
let autoCalculatedData: any = {};
|
||
|
||
if (severityValues.length === 0) {
|
||
this.logger.warn(
|
||
`[PriceDrop] Request ${requestId}: No severity values found from car parts. Price drop calculation will use existing data or manual override only.`,
|
||
);
|
||
}
|
||
|
||
// Step 3: Check car detail and find closest car
|
||
if (!request?.carDetail) {
|
||
this.logger.warn(
|
||
`[PriceDrop] Request ${requestId}: Missing carDetail in request. Cannot find car price.`,
|
||
);
|
||
} else if (!request.carDetail.carName) {
|
||
this.logger.warn(
|
||
`[PriceDrop] Request ${requestId}: Missing carName in carDetail. Cannot find car price.`,
|
||
);
|
||
} else if (severityValues.length > 0) {
|
||
// Only fetch car prices if we have severity values to calculate
|
||
const carPrices = await this.fetchCarPrices();
|
||
|
||
if (!carPrices || carPrices.length === 0) {
|
||
this.logger.error(
|
||
`[PriceDrop] Request ${requestId}: Failed to fetch car prices or car prices list is empty. Cannot find matching car.`,
|
||
);
|
||
} else {
|
||
this.logger.debug(
|
||
`[PriceDrop] Request ${requestId}: Fetched ${carPrices.length} car prices. Searching for: "${request.carDetail.carName}"`,
|
||
);
|
||
|
||
const closestCar = this.findClosestCar(
|
||
request.carDetail.carName,
|
||
carPrices,
|
||
);
|
||
|
||
if (!closestCar) {
|
||
this.logger.warn(
|
||
`[PriceDrop] Request ${requestId}: Could not find closest matching car for "${request.carDetail.carName}". Available cars: ${carPrices.length}. Cannot determine car price.`,
|
||
);
|
||
} else if (!closestCar.marketPrice) {
|
||
this.logger.warn(
|
||
`[PriceDrop] Request ${requestId}: Found matching car "${closestCar.carName}" but marketPrice is missing. Cannot calculate price drop.`,
|
||
);
|
||
} else {
|
||
this.logger.debug(
|
||
`[PriceDrop] Request ${requestId}: Found matching car "${closestCar.carName}" with marketPrice: ${closestCar.marketPrice}`,
|
||
);
|
||
autoCalculatedData = {
|
||
carPrice: closestCar.marketPrice,
|
||
carValue: severityValues,
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
// Step 4: Merge and recalculate
|
||
const finalPriceDropData = this.mergeAndRecalculatePriceDrop(
|
||
existingPriceDrop,
|
||
autoCalculatedData,
|
||
manualOverride,
|
||
sandHubData.ModelCii,
|
||
);
|
||
|
||
if (!finalPriceDropData) {
|
||
this.logger.error(
|
||
`[PriceDrop] Request ${requestId}: Failed to generate final price drop data.`,
|
||
);
|
||
return;
|
||
}
|
||
|
||
// Step 5: Log calculation result
|
||
if (
|
||
!finalPriceDropData.carPrice ||
|
||
!finalPriceDropData.carValue ||
|
||
finalPriceDropData.carValue.length === 0
|
||
) {
|
||
this.logger.warn(
|
||
`[PriceDrop] Request ${requestId}: Price drop calculated but missing critical data. ` +
|
||
`carPrice: ${finalPriceDropData.carPrice}, ` +
|
||
`carValue length: ${finalPriceDropData.carValue?.length || 0}, ` +
|
||
`total: ${finalPriceDropData.total}`,
|
||
);
|
||
} else {
|
||
this.logger.debug(
|
||
`[PriceDrop] Request ${requestId}: Successfully calculated price drop. ` +
|
||
`carPrice: ${finalPriceDropData.carPrice}, ` +
|
||
`sumOfSeverity: ${finalPriceDropData.sumOfSeverity}, ` +
|
||
`total: ${finalPriceDropData.total}`,
|
||
);
|
||
}
|
||
|
||
// Step 6: Save to database
|
||
await this.claimRequestManagementDbService.findOneAndUpdate(
|
||
{ _id: new Types.ObjectId(request._id) },
|
||
{ $set: { priceDrop: finalPriceDropData } },
|
||
);
|
||
|
||
this.logger.log(
|
||
`[PriceDrop] Request ${requestId}: Price drop calculation completed and saved.`,
|
||
);
|
||
} catch (err) {
|
||
this.logger.error(
|
||
`[PriceDrop] Request ${requestId}: An error occurred during the price drop calculation: ${err.message}`,
|
||
err.stack,
|
||
);
|
||
}
|
||
}
|
||
|
||
private mergeAndRecalculatePriceDrop(
|
||
existingData: any,
|
||
autoCalculatedData: any,
|
||
manualOverride: any,
|
||
carModel: number,
|
||
) {
|
||
const mergedInputs = {
|
||
...existingData,
|
||
...autoCalculatedData,
|
||
...manualOverride,
|
||
};
|
||
|
||
if (!mergedInputs.carPrice || !mergedInputs.carValue) {
|
||
return {
|
||
carPrice: mergedInputs.carPrice || null,
|
||
carModel: carModel,
|
||
carValue: mergedInputs.carValue || [],
|
||
sumOfSeverity: 0,
|
||
coefficientYear: this.yearCoefficient[carModel] || 1,
|
||
total: 0,
|
||
};
|
||
}
|
||
|
||
return this.priceDropFormula(
|
||
mergedInputs.carPrice,
|
||
carModel,
|
||
mergedInputs.carValue,
|
||
);
|
||
}
|
||
|
||
private getSeverityValues(carPartsAi: any[]): number[] {
|
||
const severities: number[] = [];
|
||
|
||
const knownPartKeyMap = new Map<string, string>();
|
||
for (const originalKey of Object.keys(this.priceDropPart)) {
|
||
if (typeof originalKey === "string") {
|
||
knownPartKeyMap.set(this.normalizeKey(originalKey), originalKey);
|
||
}
|
||
}
|
||
|
||
for (const part of carPartsAi) {
|
||
const damagedPartTable = part?.aiReport?.damaged_part_table;
|
||
if (Array.isArray(damagedPartTable) && damagedPartTable.length > 0) {
|
||
for (const damagedPartInfo of damagedPartTable) {
|
||
if (damagedPartInfo && typeof damagedPartInfo.name === "string") {
|
||
const normalizedAiPartName = this.normalizeKey(
|
||
damagedPartInfo.name,
|
||
);
|
||
|
||
if (knownPartKeyMap.has(normalizedAiPartName)) {
|
||
const originalPriceDropKey =
|
||
knownPartKeyMap.get(normalizedAiPartName);
|
||
|
||
const severityValue =
|
||
this.priceDropPart[originalPriceDropKey]?.[
|
||
damagedPartInfo.severity
|
||
];
|
||
|
||
if (severityValue !== undefined) {
|
||
severities.push(severityValue);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return severities;
|
||
}
|
||
|
||
private async fetchCarPrices(): Promise<
|
||
{ carName: string; marketPrice: number }[]
|
||
> {
|
||
const carPrices: { carName: string; marketPrice: number }[] = [];
|
||
const endpoints = ["akharin", "hamrah"];
|
||
|
||
for (const item of endpoints) {
|
||
try {
|
||
const url = `${process.env.CW_URL}price?${item}`;
|
||
const response = await lastValueFrom(this.httpService.get(url));
|
||
if (Array.isArray(response.data)) {
|
||
let validEntries = 0;
|
||
for (const r of response.data) {
|
||
if (r?.carName && r?.marketPrice) {
|
||
carPrices.push({
|
||
carName: r.carName,
|
||
marketPrice: r.marketPrice,
|
||
});
|
||
validEntries++;
|
||
}
|
||
}
|
||
this.logger.debug(
|
||
`[PriceDrop] Fetched ${validEntries} valid car prices from endpoint '${item}' (total entries: ${response.data.length})`,
|
||
);
|
||
} else {
|
||
this.logger.warn(
|
||
`[PriceDrop] Endpoint '${item}' returned non-array data: ${typeof response.data}`,
|
||
);
|
||
}
|
||
} catch (err) {
|
||
this.logger.error(
|
||
`[PriceDrop] Error fetching car prices for endpoint '${item}': ${err.message}`,
|
||
err.stack,
|
||
);
|
||
}
|
||
}
|
||
|
||
if (carPrices.length === 0) {
|
||
this.logger.error(
|
||
`[PriceDrop] No car prices fetched from any endpoint. Endpoints tried: ${endpoints.join(", ")}`,
|
||
);
|
||
} else {
|
||
this.logger.debug(
|
||
`[PriceDrop] Total car prices fetched: ${carPrices.length}`,
|
||
);
|
||
}
|
||
|
||
return carPrices;
|
||
}
|
||
|
||
private calculateFinalDropPrice(
|
||
closestCar: { marketPrice: number } | null,
|
||
carModel: any,
|
||
severities: number[],
|
||
query?: any,
|
||
) {
|
||
if (query?.carPrice && query?.carValue) {
|
||
return this.priceDropFormula(query.carPrice, carModel, query.carValue);
|
||
}
|
||
if (closestCar?.marketPrice && severities.length > 0) {
|
||
return this.priceDropFormula(
|
||
closestCar.marketPrice,
|
||
carModel,
|
||
severities,
|
||
);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async requestPerId(requestId: string, currentUser: any, query?: any) {
|
||
try {
|
||
// 1. Fetch the initial request document and perform calculations
|
||
const initialRequest =
|
||
await this.claimRequestManagementDbService.findOneDocument(requestId);
|
||
|
||
if (!initialRequest) {
|
||
throw new HttpException(
|
||
{ responseCode: 1006, message: "Request not found" },
|
||
HttpStatus.NOT_FOUND,
|
||
);
|
||
}
|
||
|
||
await this.calculatePriceDrop(
|
||
initialRequest,
|
||
initialRequest.imageRequired.selectPartOfCar,
|
||
query,
|
||
);
|
||
|
||
const requestUpdated =
|
||
await this.claimRequestManagementDbService.findOneDocument(requestId);
|
||
|
||
await this.populateFactorLinks(requestUpdated.damageExpertReply);
|
||
await this.populateFactorLinks(requestUpdated.damageExpertReplyFinal);
|
||
|
||
// 2. Populate required documents with file links
|
||
if (
|
||
requestUpdated.requiredDocuments &&
|
||
Object.keys(requestUpdated.requiredDocuments).length > 0
|
||
) {
|
||
for (const documentType in requestUpdated.requiredDocuments) {
|
||
const documentId = requestUpdated.requiredDocuments[documentType];
|
||
if (documentId) {
|
||
try {
|
||
const doc = await this.claimRequiredDocumentDbService.findById(
|
||
documentId.toString(),
|
||
);
|
||
if (doc && doc.path) {
|
||
// Replace ID with file URL
|
||
requestUpdated.requiredDocuments[documentType] = buildFileLink(
|
||
doc.path,
|
||
) as any;
|
||
}
|
||
} catch (error) {
|
||
this.logger.warn(
|
||
`Failed to populate required document ${documentType}: ${error.message}`,
|
||
);
|
||
// Keep the ID if population fails
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. Populate the resent document and voice links from the nested blameFile.
|
||
if (requestUpdated.blameFile?.expertResendReply) {
|
||
// Helper function to avoid repeating code
|
||
const populatePartyLinks = async (
|
||
partyKey: "firstParty" | "secondParty",
|
||
) => {
|
||
const partyReply =
|
||
requestUpdated.blameFile.expertResendReply[partyKey];
|
||
if (!partyReply) return;
|
||
|
||
// Populate the voice link
|
||
if (partyReply.voice) {
|
||
const voiceDoc = await this.blameVoiceDbService.findOne(
|
||
partyReply.voice.toString(),
|
||
);
|
||
if (voiceDoc) {
|
||
partyReply.voice = buildFileLink(voiceDoc.path);
|
||
}
|
||
}
|
||
|
||
// Populate the document links
|
||
if (partyReply.documents) {
|
||
for (const docType in partyReply.documents) {
|
||
const docId = partyReply.documents[docType];
|
||
if (docId) {
|
||
// This line will now work correctly after you inject the service.
|
||
const doc = await this.blameDocumentDbService.findById(
|
||
docId.toString(),
|
||
);
|
||
if (doc) {
|
||
partyReply.documents[docType] = buildFileLink(doc.path); // Replace ID with URL
|
||
}
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
// Run the population for both parties
|
||
await populatePartyLinks("firstParty");
|
||
await populatePartyLinks("secondParty");
|
||
}
|
||
|
||
// 4. Perform authorization checks (as before)
|
||
if (this.isCurrentUserAllowed(requestUpdated, currentUser)) {
|
||
return new ClaimPerIdRs(requestUpdated);
|
||
}
|
||
|
||
// Check if locked by someone else (allow if locked by current user)
|
||
if (this.isRequestLocked(requestUpdated, currentUser)) {
|
||
throw new HttpException(
|
||
{ responseCode: 1007, message: "Request is locked" },
|
||
HttpStatus.FORBIDDEN,
|
||
);
|
||
}
|
||
|
||
// 5. Return the fully populated response
|
||
return new ClaimPerIdRs(requestUpdated);
|
||
} catch (error) {
|
||
this.globalErrHandler(error);
|
||
}
|
||
}
|
||
|
||
async imageRequired(requestId, currentUser) {
|
||
const request =
|
||
await this.claimRequestManagementDbService.findOneDocument(requestId);
|
||
|
||
if (!request) {
|
||
throw new HttpException(
|
||
{ responseCode: 1006, message: "Request not found" },
|
||
HttpStatus.NOT_FOUND,
|
||
);
|
||
}
|
||
|
||
if (this.isCurrentUserAllowed(request, currentUser)) {
|
||
return (await this.claimRequestManagementDbService.findOne(requestId))
|
||
.imageRequired;
|
||
} else if (this.isRequestLocked(request, currentUser)) {
|
||
throw new HttpException(
|
||
{ responseCode: 1007, message: "Request is locked" },
|
||
HttpStatus.FORBIDDEN,
|
||
);
|
||
} else {
|
||
throw new HttpException(
|
||
{ responseCode: 1008, message: "notAllowed is locked" },
|
||
HttpStatus.FORBIDDEN,
|
||
);
|
||
}
|
||
}
|
||
|
||
private async populateFactorLinks(reply: any): Promise<void> {
|
||
if (!reply || !Array.isArray(reply.parts)) {
|
||
return;
|
||
}
|
||
|
||
for (const part of reply.parts) {
|
||
if (part.factorLink && Types.ObjectId.isValid(part.factorLink)) {
|
||
const factorDoc = await this.claimFactorsImageDbService.findById(
|
||
part.factorLink.toString(),
|
||
);
|
||
|
||
if (factorDoc) {
|
||
part.factorLink = buildFileLink(factorDoc.path);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
async getRequiredDocuments(requestId: string, currentUser: any) {
|
||
try {
|
||
const request =
|
||
await this.claimRequestManagementDbService.findOneDocument(requestId);
|
||
|
||
if (!request) {
|
||
throw new HttpException(
|
||
{ responseCode: 1006, message: "Request not found" },
|
||
HttpStatus.NOT_FOUND,
|
||
);
|
||
}
|
||
|
||
// Perform authorization checks - allow if locked by current user
|
||
if (this.isRequestLocked(request, currentUser)) {
|
||
throw new HttpException(
|
||
{ responseCode: 1007, message: "Request is locked" },
|
||
HttpStatus.FORBIDDEN,
|
||
);
|
||
}
|
||
|
||
// Fetch all required documents for this claim
|
||
const documents =
|
||
await this.claimRequiredDocumentDbService.findByClaimId(requestId);
|
||
|
||
// Populate with file URLs
|
||
const populatedDocuments = documents.map((doc) => ({
|
||
_id: doc._id,
|
||
documentType: doc.documentType,
|
||
fileName: doc.fileName,
|
||
fileUrl: buildFileLink(doc.path),
|
||
uploadedAt: doc.uploadedAt,
|
||
}));
|
||
|
||
return {
|
||
claimId: requestId,
|
||
totalDocuments: populatedDocuments.length,
|
||
documents: populatedDocuments,
|
||
};
|
||
} catch (error) {
|
||
this.globalErrHandler(error);
|
||
}
|
||
}
|
||
|
||
private async processAiImages(
|
||
imageRequired: any,
|
||
): Promise<{ aroundTheCar: any[]; damagePartOfCar: any[] }> {
|
||
const aiImages = { aroundTheCar: [], damagePartOfCar: [] };
|
||
return aiImages;
|
||
}
|
||
|
||
private async processImage(imageId: string) {
|
||
return this.damageImageDbService.findOne(imageId);
|
||
}
|
||
|
||
private isCurrentUserAllowed(request: any, currentUser: any): boolean {
|
||
// Check if locked by current user and lock is still active
|
||
const isLockedByCurrentUser =
|
||
String(request?.actorLocked?.actorId) === currentUser.sub &&
|
||
request.lockFile;
|
||
|
||
if (!isLockedByCurrentUser) {
|
||
return false;
|
||
}
|
||
|
||
// Also check if lock has expired (unlockTime has passed)
|
||
if (request.unlockTime) {
|
||
const unlockTime = new Date(request.unlockTime).getTime();
|
||
const now = Date.now();
|
||
if (now >= unlockTime) {
|
||
// Lock has expired, but still allow access if they were the one who locked it
|
||
// The unlockApi will handle the cleanup
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
private isRequestLocked(request: any, currentUser?: any): boolean {
|
||
if (
|
||
!request.lockFile ||
|
||
request.claimStatus !== ReqClaimStatus.ReviewRequest
|
||
) {
|
||
return false;
|
||
}
|
||
|
||
// Check if lock has expired
|
||
if (request.unlockTime) {
|
||
const unlockTime = new Date(request.unlockTime).getTime();
|
||
const now = Date.now();
|
||
if (now >= unlockTime) {
|
||
// Lock has expired, treat as not locked
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// If currentUser is provided, allow access if they are the one who locked it
|
||
if (currentUser) {
|
||
const isLockedByCurrentUser =
|
||
String(request?.actorLocked?.actorId) === currentUser.sub;
|
||
// Return false (not locked) if locked by current user, true if locked by someone else
|
||
return !isLockedByCurrentUser;
|
||
}
|
||
|
||
// If no currentUser provided, treat as locked
|
||
return true;
|
||
}
|
||
|
||
async submitReplyRequest(
|
||
requestId: string,
|
||
reply: ClaimSubmitReplyDto,
|
||
userId: any,
|
||
) {
|
||
try {
|
||
const request =
|
||
await this.claimRequestManagementDbService.findOne(requestId);
|
||
|
||
if (!request) {
|
||
throw new NotFoundException("Request not found");
|
||
}
|
||
if (
|
||
String(request?.actorLocked?.actorId) !== userId.sub &&
|
||
!request?.objection
|
||
) {
|
||
throw new ForbiddenException("Access denied to this request");
|
||
}
|
||
|
||
// Check if lock has expired (unlockTime has passed)
|
||
if (request?.unlockTime && !request?.objection) {
|
||
const unlockTime = new Date(request.unlockTime).getTime();
|
||
const now = Date.now();
|
||
if (now >= unlockTime) {
|
||
throw new ForbiddenException("Your time has expired");
|
||
}
|
||
} else if (request?.unlockTime == null && !request?.objection) {
|
||
throw new ForbiddenException("Your time has expired");
|
||
}
|
||
|
||
if (!request.lockFile && !request?.objection) {
|
||
throw new ForbiddenException(
|
||
"For submit reply you must lock the request",
|
||
);
|
||
}
|
||
|
||
// Validate total price cap (priced lines sum), when enabled.
|
||
const priceCap = getClaimV2TotalPaymentCapToman();
|
||
if (priceCap !== null && reply.parts && reply.parts.length > 0) {
|
||
let totalPrice = 0;
|
||
|
||
for (const part of reply.parts) {
|
||
if (part.totalPayment) {
|
||
let partPrice: number;
|
||
if (typeof part.totalPayment === "string") {
|
||
// Handle Persian numbers and remove commas
|
||
partPrice = this.parsePersianNumber(part.totalPayment);
|
||
} else if (typeof part.totalPayment === "number") {
|
||
partPrice = part.totalPayment;
|
||
} else {
|
||
this.logger.warn(
|
||
`Invalid totalPayment type for part ${part.partId}: ${typeof part.totalPayment}`,
|
||
);
|
||
partPrice = 0;
|
||
}
|
||
|
||
if (isNaN(partPrice)) {
|
||
this.logger.warn(
|
||
`Could not parse totalPayment for part ${part.partId}: ${part.totalPayment}`,
|
||
);
|
||
partPrice = 0;
|
||
}
|
||
|
||
totalPrice += partPrice;
|
||
}
|
||
}
|
||
|
||
if (totalPrice > priceCap) {
|
||
throw new BadRequestException({
|
||
message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`,
|
||
error: "PRICE_CAP_ERROR",
|
||
code: "PRICE_CAP_ERROR",
|
||
totalPrice: totalPrice,
|
||
priceCap,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Validate daghi fields
|
||
if (reply.parts && reply.parts.length > 0) {
|
||
for (const part of reply.parts) {
|
||
if (!part.daghi || !part.daghi.option) {
|
||
throw new BadRequestException(
|
||
`Daghi option is required for part ${part.partId}`,
|
||
);
|
||
}
|
||
|
||
// Validate based on option
|
||
if (part.daghi.option === DaghiOption.RECYCLED_PARTS_VALUE) {
|
||
if (!part.daghi.price) {
|
||
throw new BadRequestException(
|
||
`Price is required for part ${part.partId} when option is '${DaghiOption.RECYCLED_PARTS_VALUE}'`,
|
||
);
|
||
}
|
||
} else if (part.daghi.option === DaghiOption.DELIVER_DAMAGED_PART) {
|
||
if (!part.daghi.branchId) {
|
||
throw new BadRequestException(
|
||
`Branch ID is required for part ${part.partId} when option is '${DaghiOption.DELIVER_DAMAGED_PART}'`,
|
||
);
|
||
}
|
||
// Validate branchId is a valid ObjectId
|
||
if (!Types.ObjectId.isValid(part.daghi.branchId)) {
|
||
throw new BadRequestException(
|
||
`Invalid branch ID format for part ${part.partId}`,
|
||
);
|
||
}
|
||
}
|
||
// For NO_VALUE and WITH_DAMAGED_PART_CALCULATION, no additional fields needed
|
||
}
|
||
}
|
||
|
||
const isObjection = !!request.objection;
|
||
const replyFieldKey = isObjection
|
||
? "damageExpertReplyFinal"
|
||
: "damageExpertReply";
|
||
|
||
const needsFactorUpload =
|
||
reply.parts?.some((part) => part.factorNeeded === true) ?? false;
|
||
|
||
const nextStatus = needsFactorUpload
|
||
? ReqClaimStatus.PendingFactorUpload
|
||
: ReqClaimStatus.CheckedRequest;
|
||
|
||
const nextStep = needsFactorUpload
|
||
? ClaimStepsEnum.WaitingForFactorUpload
|
||
: ClaimStepsEnum.WaitingForUserToReact;
|
||
|
||
// Process parts: convert branchId string to ObjectId if present
|
||
const processedParts = reply.parts.map((part) => ({
|
||
...part,
|
||
daghi: {
|
||
option: part.daghi.option,
|
||
...(part.daghi.price && { price: part.daghi.price }),
|
||
...(part.daghi.branchId && {
|
||
branchId: new Types.ObjectId(part.daghi.branchId),
|
||
}),
|
||
},
|
||
}));
|
||
|
||
const expertProfileSnapshot = await this.snapshotDamageExpert(userId.sub);
|
||
|
||
const replyPayload = {
|
||
description: reply.description,
|
||
parts: processedParts,
|
||
submitTime: new Date(),
|
||
actorDetail: {
|
||
actorName: userId.fullName,
|
||
actorId: userId.sub,
|
||
},
|
||
...(expertProfileSnapshot && { expertProfileSnapshot }),
|
||
};
|
||
|
||
const updatePayload = {
|
||
$set: {
|
||
lockFile: false,
|
||
claimStatus: nextStatus,
|
||
currentStep: nextStep,
|
||
[replyFieldKey]: replyPayload,
|
||
},
|
||
$push: {
|
||
actorsChecker: {
|
||
[ReqClaimStatus.CheckedRequest]: request.actorLocked,
|
||
Date: new Date(),
|
||
},
|
||
steps: nextStep,
|
||
},
|
||
};
|
||
|
||
await this.claimRequestManagementDbService.findAndUpdate(
|
||
requestId,
|
||
updatePayload,
|
||
);
|
||
|
||
if (!isObjection) {
|
||
await this.recordClaimExpertActivity({
|
||
expertId: String(userId.sub),
|
||
tenantId: String(request.userClientKey ?? userId.clientKey ?? ""),
|
||
claimId: String(requestId),
|
||
eventType: ExpertFileActivityType.HANDLED,
|
||
idempotencyKey: `claim:${requestId}:handled:${userId.sub}`,
|
||
});
|
||
}
|
||
|
||
return {
|
||
requestId: request._id,
|
||
claimStatus: nextStatus,
|
||
};
|
||
} catch (error) {
|
||
this.logger.error(
|
||
`Error in submitReplyRequest for claim ${requestId}:`,
|
||
error.stack,
|
||
);
|
||
if (error instanceof HttpException) {
|
||
throw error;
|
||
}
|
||
throw new HttpException(
|
||
"An internal server error occurred.",
|
||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||
);
|
||
}
|
||
}
|
||
|
||
async submitResend(
|
||
requestId: string,
|
||
body: ClaimSubmitResendDto,
|
||
userId: any,
|
||
) {
|
||
const request =
|
||
await this.claimRequestManagementDbService.findOne(requestId);
|
||
|
||
if (!request) throw new NotFoundException("request_not_found");
|
||
if (String(request?.actorLocked?.actorId) !== userId)
|
||
throw new ForbiddenException("access_denied_please_lock");
|
||
if (request.unlockTime == null)
|
||
throw new ForbiddenException("time_expired");
|
||
if (!request.lockFile)
|
||
throw new ForbiddenException(
|
||
"for submit reply you must lock the request",
|
||
);
|
||
if (request.damageExpertResend)
|
||
throw new ForbiddenException("request already has resend reply");
|
||
if (request.damageExpertReplyFinal)
|
||
throw new ForbiddenException("request already has final reply");
|
||
|
||
const resendSnapshot = await this.snapshotDamageExpert(userId);
|
||
|
||
await this.claimRequestManagementDbService.findAndUpdate(requestId, {
|
||
lockFile: false,
|
||
claimStatus: ReqClaimStatus.WaitingForUserToResend,
|
||
damageExpertResend: {
|
||
resendDescription: body.resendDescription,
|
||
resendDocuments: body.resendDocuments,
|
||
resendCarParts: body.resendCarParts,
|
||
...(resendSnapshot && { expertProfileSnapshot: resendSnapshot }),
|
||
},
|
||
$push: {
|
||
actorsChecker: {
|
||
[ReqClaimStatus.WaitingForUserToResend]: request.actorLocked,
|
||
},
|
||
Date: new Date(),
|
||
},
|
||
});
|
||
|
||
return request;
|
||
}
|
||
|
||
async streamServiceV2(requestId, query, res, header) {
|
||
const request = await this.claimCaseDbService.findById(requestId);
|
||
// const blameCase = await this.blameCaseDbService
|
||
return request;
|
||
// let videoPath: string = "";
|
||
// switch (query) {
|
||
// case "accident":
|
||
// videoPath = await this.getAccidentVideoPath(
|
||
// String(
|
||
// request?.blameFile?.firstPartyDetails?.firstPartyFile
|
||
// ?.firstPartyVideoId,
|
||
// ),
|
||
// );
|
||
// break;
|
||
// case "car-capture":
|
||
// videoPath = await this.getCarCapture(requestId);
|
||
// break;
|
||
// default:
|
||
// throw new NotFoundException("Invalid query type");
|
||
// }
|
||
// if (!videoPath) throw new NotFoundException("video_not_found");
|
||
|
||
// const absolutePath = join(process.cwd(), videoPath.replace(/^\/+/, ""));
|
||
|
||
// if (!existsSync(absolutePath)) {
|
||
// throw new NotFoundException(
|
||
// `Video file not found at path: ${absolutePath}`,
|
||
// );
|
||
// }
|
||
|
||
// const { size } = statSync(absolutePath);
|
||
// const range = header.range;
|
||
|
||
// if (range) {
|
||
// const [startStr, endStr] = range.replace(/bytes=/, "").split("-");
|
||
// const start = parseInt(startStr, 10);
|
||
// const end = endStr ? parseInt(endStr, 10) : size - 1;
|
||
// const chunkSize = end - start + 1;
|
||
|
||
// const file = createReadStream(absolutePath, { start, end });
|
||
|
||
// res.writeHead(206, {
|
||
// "Content-Range": `bytes ${start}-${end}/${size}`,
|
||
// "Accept-Ranges": "bytes",
|
||
// "Content-Length": chunkSize,
|
||
// "Content-Type": "video/mp4",
|
||
// });
|
||
|
||
// file.pipe(res);
|
||
// } else {
|
||
// res.writeHead(200, {
|
||
// "Content-Length": size,
|
||
// "Content-Type": "video/mp4",
|
||
// });
|
||
|
||
// createReadStream(absolutePath).pipe(res);
|
||
// }
|
||
}
|
||
|
||
async streamService(requestId, query, res, header) {
|
||
const request =
|
||
await this.claimRequestManagementDbService.findOne(requestId);
|
||
let videoPath: string = "";
|
||
switch (query) {
|
||
case "accident":
|
||
videoPath = await this.getAccidentVideoPath(
|
||
String(
|
||
request?.blameFile?.firstPartyDetails?.firstPartyFile
|
||
?.firstPartyVideoId,
|
||
),
|
||
);
|
||
break;
|
||
case "car-capture":
|
||
videoPath = await this.getCarCapture(requestId);
|
||
break;
|
||
default:
|
||
throw new NotFoundException("Invalid query type");
|
||
}
|
||
if (!videoPath) throw new NotFoundException("video_not_found");
|
||
|
||
const absolutePath = join(process.cwd(), videoPath.replace(/^\/+/, ""));
|
||
|
||
if (!existsSync(absolutePath)) {
|
||
throw new NotFoundException(
|
||
`Video file not found at path: ${absolutePath}`,
|
||
);
|
||
}
|
||
|
||
const { size } = statSync(absolutePath);
|
||
const range = header.range;
|
||
|
||
if (range) {
|
||
const [startStr, endStr] = range.replace(/bytes=/, "").split("-");
|
||
const start = parseInt(startStr, 10);
|
||
const end = endStr ? parseInt(endStr, 10) : size - 1;
|
||
const chunkSize = end - start + 1;
|
||
|
||
const file = createReadStream(absolutePath, { start, end });
|
||
|
||
res.writeHead(206, {
|
||
"Content-Range": `bytes ${start}-${end}/${size}`,
|
||
"Accept-Ranges": "bytes",
|
||
"Content-Length": chunkSize,
|
||
"Content-Type": "video/mp4",
|
||
});
|
||
|
||
file.pipe(res);
|
||
} else {
|
||
res.writeHead(200, {
|
||
"Content-Length": size,
|
||
"Content-Type": "video/mp4",
|
||
});
|
||
|
||
createReadStream(absolutePath).pipe(res);
|
||
}
|
||
}
|
||
|
||
async getVideoLink(
|
||
requestId: string,
|
||
query: "car-capture" | "accident",
|
||
): Promise<{ url: string }> {
|
||
const request =
|
||
await this.claimRequestManagementDbService.findOne(requestId);
|
||
if (!request) {
|
||
throw new NotFoundException("Claim request not found");
|
||
}
|
||
|
||
let videoPath: string = "";
|
||
switch (query) {
|
||
case "accident":
|
||
videoPath = await this.getAccidentVideoPath(
|
||
String(
|
||
request?.blameFile?.firstPartyDetails?.firstPartyFile
|
||
?.firstPartyVideoId,
|
||
),
|
||
);
|
||
break;
|
||
case "car-capture":
|
||
videoPath = await this.getCarCapture(requestId);
|
||
break;
|
||
default:
|
||
throw new BadRequestException("Invalid query type specified");
|
||
}
|
||
|
||
if (!videoPath) {
|
||
throw new NotFoundException(
|
||
"Video file not found for the specified type.",
|
||
);
|
||
}
|
||
|
||
// Use your existing helper to build the full, public URL
|
||
const fileUrl = buildFileLink(videoPath);
|
||
|
||
// Return the URL in a clean JSON object
|
||
return { url: fileUrl };
|
||
}
|
||
|
||
async voiceStream(id, res, headers) {
|
||
// audio/mpeg
|
||
const voice = await this.blameVoiceDbService.findOne(id);
|
||
if (!voice) throw new NotFoundException("video_not_found");
|
||
const { size } = statSync(voice.path);
|
||
const voicePath = voice.path;
|
||
const VoiceRange = headers.range;
|
||
if (VoiceRange) {
|
||
const parts = VoiceRange?.replace(/bytes=/, "").split("-");
|
||
const end = parts[1] ? parseInt(parts[1], 16) : size - 1;
|
||
const start = parseInt(parts[0], 10);
|
||
const chunksize = end - start + 1;
|
||
|
||
const file = createReadStream(voicePath, { start, end });
|
||
|
||
const header = {
|
||
"Content-Range": `bytes ${start}-${end}/${size}`,
|
||
"Accept-Ranges": "bytes",
|
||
"Content-Length": chunksize,
|
||
"Content-Type": "audio/mpeg",
|
||
};
|
||
|
||
res.writeHead(206, header);
|
||
file.pipe(res);
|
||
} else {
|
||
const head = {
|
||
"Content-Length": size,
|
||
"Content-Type": "audio/mpeg",
|
||
};
|
||
res.writeHead(200, head);
|
||
createReadStream(voicePath).pipe(res);
|
||
}
|
||
}
|
||
|
||
async getVoiceLink(id: string): Promise<{ url: string }> {
|
||
const voice = await this.blameVoiceDbService.findOne(id);
|
||
if (!voice) {
|
||
throw new NotFoundException("Voice file not found");
|
||
}
|
||
|
||
const fileUrl = buildFileLink(voice.path);
|
||
|
||
return { url: fileUrl };
|
||
}
|
||
|
||
async getCarCapture(requestId): Promise<string> {
|
||
const video = await this.claimVideoCaptureDbService.findOne({
|
||
claimId: new Types.ObjectId(requestId),
|
||
});
|
||
|
||
if (!video?.path) {
|
||
throw new NotFoundException("Car capture video not found");
|
||
}
|
||
|
||
return String(video.path);
|
||
}
|
||
|
||
async getAccidentVideoPath(requestId: string): Promise<string> {
|
||
const video = await this.blameVideoDbService.findOneByRequestId(requestId);
|
||
if (!video?.path) {
|
||
throw new NotFoundException("Accident video not found");
|
||
}
|
||
|
||
return video.path;
|
||
}
|
||
|
||
async validateClaimFactors(
|
||
claimId: string,
|
||
decisions: {
|
||
partId: string;
|
||
status: FactorStatus;
|
||
rejectionReason?: string;
|
||
}[],
|
||
expertId: string,
|
||
) {
|
||
const claim = await this.claimRequestManagementDbService.findOne(claimId);
|
||
if (!claim) {
|
||
throw new NotFoundException("Claim not found");
|
||
}
|
||
|
||
const finalReply = claim.damageExpertReplyFinal || claim.damageExpertReply;
|
||
const replyFieldKey = claim.damageExpertReplyFinal
|
||
? "damageExpertReplyFinal"
|
||
: "damageExpertReply";
|
||
|
||
if (!finalReply) {
|
||
throw new BadRequestException("No expert reply found in this claim.");
|
||
}
|
||
|
||
for (const decision of decisions) {
|
||
const partIndex = finalReply.parts.findIndex(
|
||
(p) => p.partId === decision.partId,
|
||
);
|
||
if (partIndex === -1) {
|
||
this.logger.warn(
|
||
`Part with ID ${decision.partId} not found in claim ${claimId}. Skipping.`,
|
||
);
|
||
continue;
|
||
}
|
||
|
||
await this.claimRequestManagementDbService.findOneAndUpdate(
|
||
{ _id: new Types.ObjectId(claimId) },
|
||
{
|
||
$set: {
|
||
[`${replyFieldKey}.parts.${partIndex}.factorStatus`]:
|
||
decision.status,
|
||
[`${replyFieldKey}.parts.${partIndex}.rejectionReason`]:
|
||
decision.rejectionReason || null,
|
||
},
|
||
},
|
||
);
|
||
}
|
||
|
||
const factorSnap = await this.snapshotDamageExpert(expertId);
|
||
if (factorSnap && decisions.length > 0) {
|
||
await this.claimRequestManagementDbService.findOneAndUpdate(
|
||
{ _id: new Types.ObjectId(claimId) },
|
||
{
|
||
$set: { factorValidationExpertProfileSnapshot: factorSnap },
|
||
},
|
||
);
|
||
}
|
||
|
||
const updatedClaim =
|
||
await this.claimRequestManagementDbService.findOne(claimId);
|
||
const updatedReply =
|
||
updatedClaim.damageExpertReplyFinal || updatedClaim.damageExpertReply;
|
||
|
||
const requiredFactors = updatedReply.parts.filter((p) => p.factorNeeded);
|
||
|
||
const anyRejected = requiredFactors.some(
|
||
(p) => p.factorStatus === FactorStatus.REJECTED,
|
||
);
|
||
const anyStillPending = requiredFactors.some(
|
||
(p) => p.factorStatus === FactorStatus.PENDING || !p.factorStatus,
|
||
);
|
||
|
||
let newStatus: ReqClaimStatus | null = null;
|
||
let responseMessage = "Factor validation complete.";
|
||
|
||
if (anyRejected) {
|
||
// At least one factor is rejected → stay in factor flow and ask user to re-upload
|
||
newStatus = ReqClaimStatus.FactorRejected;
|
||
responseMessage =
|
||
"Factors reviewed. Some factors were rejected, awaiting user resubmission.";
|
||
// TODO: Send an SMS or notification to the user here.
|
||
} else if (anyStillPending) {
|
||
// Some factors still pending (e.g. not validated in this batch)
|
||
responseMessage =
|
||
"Some factors were approved, but others are still pending validation.";
|
||
} else {
|
||
// All required factors are approved:
|
||
// Move claim into a state where the user can review the final decision and object if needed.
|
||
newStatus = ReqClaimStatus.CheckedRequest;
|
||
responseMessage =
|
||
"All required factors have been approved. The claim is now ready for user review and possible objection.";
|
||
}
|
||
|
||
if (newStatus) {
|
||
const update: any = {
|
||
$set: { claimStatus: newStatus },
|
||
};
|
||
|
||
// When factors are fully approved, also move the step to WaitingForUserToReact,
|
||
// just like the non-factor flow in submitReplyRequest.
|
||
if (newStatus === ReqClaimStatus.CheckedRequest) {
|
||
update.$set.currentStep = ClaimStepsEnum.WaitingForUserToReact;
|
||
update.$set.nextStep = ClaimStepsEnum.WaitingForUserToReact;
|
||
update.$push = {
|
||
steps: ClaimStepsEnum.WaitingForUserToReact,
|
||
};
|
||
}
|
||
|
||
await this.claimRequestManagementDbService.findByIdAndUpdate(
|
||
claimId,
|
||
update,
|
||
);
|
||
}
|
||
|
||
return {
|
||
message: responseMessage,
|
||
newStatus: newStatus || updatedClaim.claimStatus,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Close the claim after factor validation without a second insurer-review signature.
|
||
* Owner already uploaded factor files; mixed replies already have priced-line acceptance on file.
|
||
*/
|
||
private async completeClaimCaseAfterFactorValidationV2(
|
||
claimRequestId: string,
|
||
claimForTenant: any,
|
||
actor: { sub: string; fullName?: string; clientKey?: string },
|
||
historyType: string,
|
||
historyActor: {
|
||
actorId: Types.ObjectId;
|
||
actorName?: string;
|
||
actorType: string;
|
||
},
|
||
metadata: Record<string, unknown>,
|
||
): Promise<void> {
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||
$set: {
|
||
status: ClaimCaseStatus.COMPLETED,
|
||
claimStatus: ClaimStatus.APPROVED,
|
||
"workflow.currentStep": ClaimWorkflowStep.CLAIM_COMPLETED,
|
||
"workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED,
|
||
},
|
||
$push: {
|
||
"workflow.completedSteps": ClaimWorkflowStep.INSURER_REVIEW,
|
||
history: {
|
||
type: historyType,
|
||
actor: historyActor,
|
||
timestamp: new Date(),
|
||
metadata,
|
||
},
|
||
},
|
||
});
|
||
await this.recordClaimExpertActivity({
|
||
expertId: String(actor.sub),
|
||
tenantId: this.claimActivityTenantId(claimForTenant, actor),
|
||
claimId: String(claimRequestId),
|
||
eventType: ExpertFileActivityType.HANDLED,
|
||
idempotencyKey: `claim:${claimRequestId}:factor_validation:${historyType}:${actor.sub}`,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* V2 (ClaimCase): validate user-uploaded repair factors on the active expert reply.
|
||
* Preconditions: all `factorNeeded` parts have `factorLink`; case is UNDER_REVIEW at EXPERT_COST_EVALUATION.
|
||
* — All approved → COMPLETED + APPROVED (expert-entered line totals; no extra owner signature).
|
||
* — Any rejected (repriced) → COMPLETED + APPROVED (auto-close for now; owner sign may be added later).
|
||
* When enabled by env, total of all repair lines must be ≤ the claim v2 total payment cap.
|
||
* Response: `claimStatus` = `ClaimStatus`; `caseStatus` = `ClaimCaseStatus`.
|
||
*/
|
||
async validateClaimFactorsV2(
|
||
claimRequestId: string,
|
||
body: FactorValidationV2Dto,
|
||
actor: { sub: string; fullName?: string; clientKey?: string },
|
||
) {
|
||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||
if (!claim) {
|
||
throw new NotFoundException("Claim request not found");
|
||
}
|
||
|
||
await this.assertExpertActorOnClaim(claim, actor);
|
||
|
||
const factorValidationSnapshot = await this.snapshotDamageExpert(actor.sub);
|
||
|
||
if (!claimIsAwaitingExpertFactorValidationV2(claim)) {
|
||
throw new BadRequestException(
|
||
"Claim is not awaiting expert factor validation (UNDER_REVIEW at EXPERT_COST_EVALUATION).",
|
||
);
|
||
}
|
||
|
||
const replyField = claim.evaluation?.damageExpertReplyFinal
|
||
? "damageExpertReplyFinal"
|
||
: "damageExpertReply";
|
||
const finalReply =
|
||
replyField === "damageExpertReplyFinal"
|
||
? claim.evaluation?.damageExpertReplyFinal
|
||
: claim.evaluation?.damageExpertReply;
|
||
|
||
if (!finalReply?.parts?.length) {
|
||
throw new BadRequestException("No expert reply found in this claim.");
|
||
}
|
||
|
||
const requiredFactors = finalReply.parts.filter((p) => p.factorNeeded);
|
||
if (requiredFactors.length === 0) {
|
||
throw new BadRequestException("No parts require factor validation.");
|
||
}
|
||
if (!requiredFactors.every((p) => !!p.factorLink)) {
|
||
throw new BadRequestException(
|
||
"Not all required factors have been uploaded yet.",
|
||
);
|
||
}
|
||
|
||
const $set: Record<string, unknown> = {};
|
||
for (const decision of body.decisions) {
|
||
const partIndex = finalReply.parts.findIndex((p) =>
|
||
sameCatalogPartId(p.partId, decision.partId),
|
||
);
|
||
if (partIndex === -1) {
|
||
this.logger.warn(
|
||
`Part ${decision.partId} not found in claim ${claimRequestId}, skipping.`,
|
||
);
|
||
continue;
|
||
}
|
||
const part = finalReply.parts[partIndex];
|
||
if (!part.factorNeeded) {
|
||
this.logger.warn(
|
||
`Part ${decision.partId} is not factor-needed, skipping.`,
|
||
);
|
||
continue;
|
||
}
|
||
|
||
const base = `evaluation.${replyField}.parts.${partIndex}`;
|
||
$set[`${base}.factorStatus`] = decision.status;
|
||
$set[`${base}.rejectionReason`] = decision.rejectionReason ?? null;
|
||
if (decision.price !== undefined) {
|
||
$set[`${base}.price`] = decision.price;
|
||
}
|
||
if (decision.salary !== undefined) {
|
||
$set[`${base}.salary`] = decision.salary;
|
||
}
|
||
if (decision.totalPayment !== undefined) {
|
||
$set[`${base}.totalPayment`] = decision.totalPayment;
|
||
}
|
||
|
||
if (
|
||
decision.status === FactorStatus.APPROVED ||
|
||
decision.status === FactorStatus.REJECTED
|
||
) {
|
||
if (!this.expertFactorValidationDecisionHasLinePricing(decision)) {
|
||
throw new BadRequestException(
|
||
`Part ${decision.partId}: ${decision.status === FactorStatus.APPROVED ? "approved" : "rejected"} factor lines require expert totalPayment (or both price and salary).`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (Object.keys($set).length === 0) {
|
||
throw new BadRequestException("No valid factor decisions to apply.");
|
||
}
|
||
|
||
if (factorValidationSnapshot) {
|
||
$set["evaluation.factorValidationExpertProfileSnapshot"] =
|
||
factorValidationSnapshot;
|
||
}
|
||
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||
$set,
|
||
});
|
||
|
||
await this.recordClaimExpertActivity({
|
||
expertId: String(actor.sub),
|
||
tenantId: this.claimActivityTenantId(claim, actor),
|
||
claimId: String(claimRequestId),
|
||
eventType: ExpertFileActivityType.CHECKED,
|
||
idempotencyKey: `claim:${claimRequestId}:checked:${actor.sub}`,
|
||
});
|
||
|
||
const updatedClaim = await this.claimCaseDbService.findById(claimRequestId);
|
||
const updatedReply =
|
||
replyField === "damageExpertReplyFinal"
|
||
? updatedClaim!.evaluation!.damageExpertReplyFinal!
|
||
: updatedClaim!.evaluation!.damageExpertReply!;
|
||
|
||
const reqParts = updatedReply.parts.filter((p) => p.factorNeeded);
|
||
const anyRejected = reqParts.some(
|
||
(p) => p.factorStatus === FactorStatus.REJECTED,
|
||
);
|
||
const anyStillPending = reqParts.some(
|
||
(p) => !p.factorStatus || p.factorStatus === FactorStatus.PENDING,
|
||
);
|
||
|
||
const baseMessage = "Factor validation updated.";
|
||
if (anyStillPending) {
|
||
return {
|
||
message: `${baseMessage} Some factors are still pending review.`,
|
||
claimRequestId,
|
||
claimStatus: updatedClaim!.claimStatus,
|
||
caseStatus: updatedClaim!.status,
|
||
pendingFactorValidation: true,
|
||
};
|
||
}
|
||
|
||
const priceCap = getClaimV2TotalPaymentCapToman();
|
||
if (priceCap !== null) {
|
||
let totalPrice = 0;
|
||
for (const part of updatedReply.parts || []) {
|
||
const line = this.claimReplyPartLineTotalForCap(part);
|
||
if (isNaN(line)) {
|
||
throw new BadRequestException({
|
||
message: `Part ${String(part.partId ?? "")}: invalid amount for price total — use totalPayment or price and salary.`,
|
||
error: "PRICE_CAP_ERROR",
|
||
});
|
||
}
|
||
totalPrice += line;
|
||
}
|
||
if (totalPrice > priceCap) {
|
||
throw new BadRequestException({
|
||
message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`,
|
||
error: "PRICE_CAP_ERROR",
|
||
totalPrice,
|
||
priceCap,
|
||
});
|
||
}
|
||
}
|
||
|
||
const historyActor = {
|
||
actorId: new Types.ObjectId(actor.sub),
|
||
actorName: actor.fullName,
|
||
actorType: "damage_expert",
|
||
};
|
||
|
||
if (anyRejected) {
|
||
await this.completeClaimCaseAfterFactorValidationV2(
|
||
claimRequestId,
|
||
claim,
|
||
actor,
|
||
"FACTORS_REJECTED_REPRICED_AUTO_COMPLETED",
|
||
historyActor,
|
||
{ replyField },
|
||
);
|
||
const fanavaran =
|
||
await this.claimRequestManagementService.autoSubmitToFanavaranParsianV2OnClaimCompleted(
|
||
claimRequestId,
|
||
);
|
||
return {
|
||
message: this.appendFanavaranAutoSubmitToMessage(
|
||
"Factors were reviewed with expert repricing on rejected lines. The claim is completed without an owner signature (temporary policy; may require owner acceptance later).",
|
||
fanavaran,
|
||
),
|
||
claimRequestId,
|
||
claimStatus: ClaimStatus.APPROVED,
|
||
caseStatus: ClaimCaseStatus.COMPLETED,
|
||
outcome: "REJECTED_REPRICED_AUTO_COMPLETED",
|
||
fanavaran,
|
||
};
|
||
}
|
||
|
||
await this.completeClaimCaseAfterFactorValidationV2(
|
||
claimRequestId,
|
||
claim,
|
||
actor,
|
||
"FACTORS_VALIDATED_ALL_APPROVED_AUTO_COMPLETED",
|
||
historyActor,
|
||
{ replyField },
|
||
);
|
||
|
||
const fanavaran =
|
||
await this.claimRequestManagementService.autoSubmitToFanavaranParsianV2OnClaimCompleted(
|
||
claimRequestId,
|
||
);
|
||
|
||
return {
|
||
message: this.appendFanavaranAutoSubmitToMessage(
|
||
"All factors were approved by the expert. The claim is completed without an additional owner signature.",
|
||
fanavaran,
|
||
),
|
||
claimRequestId,
|
||
claimStatus: ClaimStatus.APPROVED,
|
||
caseStatus: ClaimCaseStatus.COMPLETED,
|
||
outcome: "ALL_APPROVED_AUTO_COMPLETED",
|
||
fanavaran,
|
||
};
|
||
}
|
||
|
||
async inPersonVisit(requestId: string, actorDetail: any) {
|
||
const request =
|
||
await this.claimRequestManagementDbService.findOne(requestId);
|
||
if (!request) {
|
||
throw new NotFoundException("Blame not found");
|
||
}
|
||
|
||
const visitSnapshot = await this.snapshotDamageExpert(actorDetail.sub);
|
||
|
||
const updated = await this.claimRequestManagementDbService.findAndUpdate(
|
||
requestId,
|
||
{
|
||
claimStatus: ReqClaimStatus.InPersonVisit,
|
||
...(visitSnapshot && {
|
||
damageExpertInPersonVisitProfileSnapshot: visitSnapshot,
|
||
}),
|
||
},
|
||
);
|
||
|
||
await this.recordClaimExpertActivity({
|
||
expertId: String(actorDetail.sub),
|
||
tenantId: String(request.userClientKey ?? actorDetail.clientKey ?? ""),
|
||
claimId: String(requestId),
|
||
eventType: ExpertFileActivityType.HANDLED,
|
||
idempotencyKey: `claim:${requestId}:handled:${actorDetail.sub}`,
|
||
});
|
||
|
||
return updated;
|
||
}
|
||
|
||
async retrieveInsuranceBranches(insuranceId: string) {
|
||
return await this.branchDbService.findAll(insuranceId);
|
||
}
|
||
|
||
/**
|
||
* V2: branches for the damage expert's insurer tenant (JWT `clientKey`).
|
||
* Same data as v1 `GET expert-claim/branches/:insuranceId` without passing client id.
|
||
*/
|
||
async retrieveInsuranceBranchesV2(actor: { clientKey?: string }) {
|
||
const clientKey = requireActorClientKey(actor);
|
||
return await this.branchDbService.findAll(clientKey);
|
||
}
|
||
|
||
private globalErrHandler(error) {
|
||
this.logger.error(error.response?.responseCode, error.response);
|
||
throw new HttpException(error.response, error.claimStatus);
|
||
}
|
||
|
||
/**
|
||
* Assign the current damage expert to a claim for review (V2).
|
||
* Free cases are locked atomically; already-assigned-to-self is idempotent.
|
||
*/
|
||
async assignClaimForReviewV2(
|
||
claimRequestId: string,
|
||
actor: { sub: string; fullName?: string; clientKey?: string; role?: string },
|
||
): Promise<ExpertFileAssignResultDto> {
|
||
if ((actor as any).role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor);
|
||
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
|
||
|
||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||
if (!claim) {
|
||
throw new NotFoundException("Claim request not found");
|
||
}
|
||
|
||
await this.assertExpertActorOnClaim(claim, actor);
|
||
|
||
const isFieldExpertOwner =
|
||
(actor as any).role === RoleEnum.FIELD_EXPERT &&
|
||
(await this.fieldExpertOwnsClaim(claim, actor));
|
||
const isFactorValidationLock =
|
||
claimIsAwaitingExpertFactorValidationV2(claim);
|
||
const isDamageReviewLock =
|
||
claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT;
|
||
const isExpertReviewingReentry =
|
||
claim.status === ClaimCaseStatus.EXPERT_REVIEWING;
|
||
const isResendPending =
|
||
claim.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND;
|
||
const isTerminalClaim =
|
||
claim.status === ClaimCaseStatus.COMPLETED ||
|
||
claim.status === ClaimCaseStatus.CANCELLED;
|
||
const isFieldExpertPortfolioLock =
|
||
isFieldExpertOwner &&
|
||
!isTerminalClaim &&
|
||
!isDamageReviewLock &&
|
||
!isFactorValidationLock &&
|
||
!isExpertReviewingReentry &&
|
||
!isResendPending;
|
||
|
||
if (
|
||
!isDamageReviewLock &&
|
||
!isFactorValidationLock &&
|
||
!isExpertReviewingReentry &&
|
||
!isResendPending &&
|
||
!isFieldExpertPortfolioLock
|
||
) {
|
||
throw new BadRequestException({
|
||
success: false,
|
||
status: "unavailable" satisfies ExpertFileAssignStatus,
|
||
message: "Request is not available for expert review",
|
||
});
|
||
}
|
||
|
||
const expertOid = new Types.ObjectId(actor.sub);
|
||
const reviewAssigneeId = resolvePersistentReviewAssigneeId(
|
||
claim.workflow,
|
||
claim.history,
|
||
CLAIM_REVIEW_ASSIGNED_HISTORY_TYPE,
|
||
);
|
||
if (reviewAssigneeId && reviewAssigneeId !== actor.sub) {
|
||
throw new ConflictException(
|
||
buildExpertAssignConflictForOtherReviewer(
|
||
reviewAssigneeId,
|
||
claim.workflow,
|
||
),
|
||
);
|
||
}
|
||
|
||
const lockedById = String(claim.workflow?.lockedBy?.actorId ?? "");
|
||
const lockEnforced =
|
||
claim.workflow?.locked &&
|
||
this.isClaimV2WorkflowLockCurrentlyEnforced(claim);
|
||
|
||
if (lockEnforced && lockedById && lockedById !== actor.sub) {
|
||
throw new ConflictException(
|
||
buildExpertAssignConflictForOtherReviewer(lockedById, claim.workflow),
|
||
);
|
||
}
|
||
|
||
if (lockEnforced && lockedById === actor.sub) {
|
||
return {
|
||
success: true,
|
||
status: "already_assigned_to_you",
|
||
message: "You already started processing this request",
|
||
assignedAt: claim.workflow?.lockedAt
|
||
? new Date(claim.workflow.lockedAt as Date).toISOString()
|
||
: undefined,
|
||
};
|
||
}
|
||
|
||
const now = new Date();
|
||
const lockSnapshot = await this.snapshotDamageExpert(actor.sub);
|
||
const expiredAt = new Date(now.getTime() + this.claimV2WorkflowLockTtlMs);
|
||
const actorType = isFieldExpertOwner ? "field_expert" : "damage_expert";
|
||
const actorRoleLabel = isFieldExpertOwner
|
||
? ("field_expert" as const)
|
||
: ("damage_expert" as const);
|
||
|
||
const lockedByPayload = {
|
||
actorId: expertOid,
|
||
actorName: actor.fullName,
|
||
actorRole: actorRoleLabel,
|
||
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
|
||
};
|
||
|
||
const baseLockUpdate: Record<string, unknown> = {
|
||
"workflow.locked": true,
|
||
"workflow.lockedAt": now,
|
||
"workflow.expiredAt": expiredAt,
|
||
"workflow.lockedBy": lockedByPayload,
|
||
...(isDamageReviewLock || isExpertReviewingReentry || isResendPending
|
||
? {
|
||
"workflow.preLockQueueSnapshot": {
|
||
claimStatus: claim.claimStatus,
|
||
currentStep: this.normalizeClaimWorkflowStepForSnapshot(
|
||
claim.workflow?.currentStep,
|
||
ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
|
||
),
|
||
...(claim.workflow?.nextStep != null
|
||
? {
|
||
nextStep: this.normalizeClaimWorkflowStepForSnapshot(
|
||
claim.workflow.nextStep,
|
||
ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
|
||
),
|
||
}
|
||
: {}),
|
||
},
|
||
}
|
||
: {}),
|
||
$push: {
|
||
history: {
|
||
type: "CLAIM_ASSIGNED",
|
||
actor: {
|
||
actorId: expertOid,
|
||
actorName: actor.fullName,
|
||
actorType,
|
||
},
|
||
timestamp: now,
|
||
metadata: {
|
||
note: isFactorValidationLock
|
||
? "Expert assigned for factor validation review"
|
||
: isFieldExpertPortfolioLock
|
||
? "Field expert portfolio lock (no status change)"
|
||
: "Expert assigned via review assign endpoint",
|
||
...(isFactorValidationLock ? { phase: "factorValidation" } : {}),
|
||
},
|
||
},
|
||
},
|
||
};
|
||
|
||
if (!claim.workflow?.assignedForReviewBy) {
|
||
baseLockUpdate["workflow.assignedForReviewBy"] = lockedByPayload;
|
||
}
|
||
|
||
const lockUpdate: Record<string, unknown> =
|
||
isFactorValidationLock ||
|
||
isExpertReviewingReentry ||
|
||
isResendPending ||
|
||
isFieldExpertPortfolioLock
|
||
? baseLockUpdate
|
||
: {
|
||
...baseLockUpdate,
|
||
status: ClaimCaseStatus.EXPERT_REVIEWING,
|
||
claimStatus: ClaimStatus.UNDER_REVIEW,
|
||
"workflow.currentStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
|
||
};
|
||
|
||
const assignFilter: Record<string, unknown> = {
|
||
_id: new Types.ObjectId(claimRequestId),
|
||
$and: [
|
||
{
|
||
$or: [
|
||
{ "workflow.locked": { $ne: true } },
|
||
{ "workflow.locked": false },
|
||
{ "workflow.expiredAt": { $lte: now } },
|
||
{ "workflow.lockedBy.actorId": expertOid },
|
||
],
|
||
},
|
||
{
|
||
$or: [
|
||
{ "workflow.assignedForReviewBy": { $exists: false } },
|
||
{ "workflow.assignedForReviewBy": null },
|
||
{ "workflow.assignedForReviewBy.actorId": expertOid },
|
||
],
|
||
},
|
||
],
|
||
};
|
||
|
||
if (isFactorValidationLock) {
|
||
assignFilter.claimStatus = ClaimStatus.UNDER_REVIEW;
|
||
assignFilter["workflow.currentStep"] =
|
||
ClaimWorkflowStep.EXPERT_COST_EVALUATION;
|
||
assignFilter.status = {
|
||
$in: [
|
||
ClaimCaseStatus.EXPERT_VALIDATING_REPAIR_FACTORS,
|
||
ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL,
|
||
],
|
||
};
|
||
} else if (isExpertReviewingReentry) {
|
||
assignFilter.status = ClaimCaseStatus.EXPERT_REVIEWING;
|
||
} else if (isResendPending) {
|
||
assignFilter.status = ClaimCaseStatus.WAITING_FOR_USER_RESEND;
|
||
} else if (isFieldExpertPortfolioLock) {
|
||
assignFilter.initiatedByFieldExpertId = expertOid;
|
||
assignFilter.status = claim.status;
|
||
} else {
|
||
assignFilter.status = ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT;
|
||
}
|
||
|
||
const updated = await this.claimCaseDbService.findOneAndUpdate(
|
||
assignFilter as any,
|
||
lockUpdate,
|
||
{ new: true },
|
||
);
|
||
|
||
if (!updated) {
|
||
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
|
||
const latest = await this.claimCaseDbService.findById(claimRequestId);
|
||
const otherAssigneeId = resolvePersistentReviewAssigneeId(
|
||
latest?.workflow,
|
||
latest?.history,
|
||
CLAIM_REVIEW_ASSIGNED_HISTORY_TYPE,
|
||
);
|
||
if (otherAssigneeId && otherAssigneeId !== actor.sub) {
|
||
throw new ConflictException(
|
||
buildExpertAssignConflictForOtherReviewer(
|
||
otherAssigneeId,
|
||
latest?.workflow,
|
||
),
|
||
);
|
||
}
|
||
const otherId = String(latest?.workflow?.lockedBy?.actorId ?? "");
|
||
if (
|
||
latest?.workflow?.locked &&
|
||
this.isClaimV2WorkflowLockCurrentlyEnforced(latest) &&
|
||
otherId &&
|
||
otherId !== actor.sub
|
||
) {
|
||
throw new ConflictException(
|
||
buildExpertAssignConflictForOtherReviewer(otherId, latest?.workflow),
|
||
);
|
||
}
|
||
throw new BadRequestException({
|
||
success: false,
|
||
status: "unavailable" satisfies ExpertFileAssignStatus,
|
||
message: "Request could not be assigned",
|
||
});
|
||
}
|
||
|
||
await this.recordClaimExpertActivity({
|
||
expertId: String(actor.sub),
|
||
tenantId: this.claimActivityTenantId(updated, actor),
|
||
claimId: String(claimRequestId),
|
||
eventType: ExpertFileActivityType.CHECKED,
|
||
idempotencyKey: `claim:${claimRequestId}:checked:v2:${actor.sub}`,
|
||
});
|
||
|
||
const ownerPhone = await this.resolveClaimOwnerPhone(updated);
|
||
if (
|
||
ownerPhone &&
|
||
isDamageReviewLock &&
|
||
!isFieldExpertPortfolioLock &&
|
||
!isExpertReviewingReentry &&
|
||
!isResendPending
|
||
) {
|
||
const expertLastName =
|
||
actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
|
||
await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice(
|
||
{
|
||
receptor: ownerPhone,
|
||
fileKind: "claim",
|
||
publicId: updated.publicId,
|
||
expertLastName,
|
||
},
|
||
);
|
||
}
|
||
|
||
this.logger.log(
|
||
`Claim ${claimRequestId} assigned to expert ${actor.sub} at ${now.toISOString()}`,
|
||
);
|
||
|
||
return {
|
||
success: true,
|
||
status: "assigned",
|
||
assignedAt: now.toISOString(),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* V2: Lock a claim request for expert review.
|
||
*
|
||
* Validations:
|
||
* - Claim must exist
|
||
* - Status must be either:
|
||
* • `WAITING_FOR_DAMAGE_EXPERT` (initial damage review queue), OR
|
||
* • the factor-validation queue (`EXPERT_VALIDATING_REPAIR_FACTORS`,
|
||
* or legacy `WAITING_FOR_INSURER_APPROVAL`, with
|
||
* `claimStatus=UNDER_REVIEW` + `workflow.currentStep=EXPERT_COST_EVALUATION`).
|
||
* - Must not already be locked by another expert.
|
||
*
|
||
* On success:
|
||
* - Always sets `workflow.locked=true`, `workflow.lockedBy=actor`,
|
||
* `workflow.lockedAt/expiredAt`, and snapshots pre-lock queue state.
|
||
* - For the **damage review** path: status → `EXPERT_REVIEWING`,
|
||
* `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_DAMAGE_ASSESSMENT`.
|
||
* - For the **factor-validation** path: leaves `status`, `claimStatus`, and
|
||
* `workflow.currentStep` untouched so the claim stays in the factor-validation
|
||
* queue (so `expireClaimWorkflowLockV2IfStale` and the queue endpoint keep
|
||
* treating it correctly when the lock expires).
|
||
*
|
||
* Delegates to {@link assignClaimForReviewV2}; maps conflict to BadRequest for legacy clients.
|
||
*/
|
||
async lockClaimRequestV2(claimRequestId: string, actor: any) {
|
||
try {
|
||
const result = await this.assignClaimForReviewV2(claimRequestId, actor);
|
||
const response: Record<string, unknown> = {
|
||
claimRequestId,
|
||
locked: true,
|
||
message: result.message,
|
||
};
|
||
if (result.status === "assigned") {
|
||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||
if (claim?.status === ClaimCaseStatus.EXPERT_REVIEWING) {
|
||
response.status = ClaimCaseStatus.EXPERT_REVIEWING;
|
||
response.claimStatus = ClaimStatus.UNDER_REVIEW;
|
||
response.currentStep = ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT;
|
||
}
|
||
}
|
||
return response;
|
||
} catch (error) {
|
||
if (error instanceof ConflictException) {
|
||
const body = error.getResponse();
|
||
throw new BadRequestException(body);
|
||
}
|
||
if (error instanceof HttpException) throw error;
|
||
this.logger.error(
|
||
"lockClaimRequestV2 failed",
|
||
claimRequestId,
|
||
error instanceof Error ? error.stack : String(error),
|
||
);
|
||
throw new HttpException(
|
||
error instanceof Error ? error.message : "Failed to lock claim",
|
||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||
);
|
||
}
|
||
}
|
||
|
||
async submitResendDocsV2(
|
||
claimRequestId: string,
|
||
reply: ClaimSubmitResendV2Dto,
|
||
actor: any,
|
||
) {
|
||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||
|
||
if (!claim) {
|
||
throw new NotFoundException("Claim request not found");
|
||
}
|
||
|
||
await this.assertExpertActorOnClaim(claim, actor);
|
||
|
||
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
||
throw new BadRequestException(
|
||
`Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`,
|
||
);
|
||
}
|
||
|
||
if (!claim.workflow?.locked) {
|
||
throw new ForbiddenException(
|
||
"You must lock the claim before submitting a resend request",
|
||
);
|
||
}
|
||
|
||
if (claim.workflow.lockedBy?.actorId?.toString() !== actor.sub) {
|
||
throw new ForbiddenException("This claim is locked by another expert");
|
||
}
|
||
|
||
// Block duplicate resend while a prior request is still unfinished (stale EXPERT_REVIEWING + pending resend).
|
||
// After a successful submit, status becomes WAITING_FOR_USER_RESEND so this endpoint is not callable until the owner finishes.
|
||
const existingResend = claim.evaluation?.damageExpertResend;
|
||
if (
|
||
existingResend &&
|
||
!existingResend.fulfilledAt &&
|
||
resendRequestHasPayload(existingResend)
|
||
) {
|
||
throw new ConflictException(
|
||
"A resend request is still open for this claim. Wait until the owner completes uploads or contact support if the case is stuck.",
|
||
);
|
||
}
|
||
|
||
if (existingResend?.fulfilledAt) {
|
||
throw new BadRequestException(
|
||
"The owner has already fulfilled a damage-expert resend for this claim. You cannot request another resend.",
|
||
);
|
||
}
|
||
|
||
const allowedDocTypes = new Set<string>(
|
||
Object.values(ClaimRequiredDocumentType),
|
||
);
|
||
const rawDocs = reply.resendDocuments ?? [];
|
||
const docs: ClaimRequiredDocumentType[] = [];
|
||
for (const x of rawDocs) {
|
||
const s = String(x).trim();
|
||
if (!allowedDocTypes.has(s)) {
|
||
throw new BadRequestException(
|
||
`Invalid resendDocuments value: "${String(x)}". Must be a ClaimRequiredDocumentType string (e.g. national_card, damaged_driving_license_front).`,
|
||
);
|
||
}
|
||
docs.push(s as ClaimRequiredDocumentType);
|
||
}
|
||
const uniqueDocs = [...new Set(docs)];
|
||
|
||
const carType = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
|
||
const normalizedParts = normalizeResendCarPartsForStorage(
|
||
reply.resendCarParts,
|
||
carType,
|
||
claim.damage?.selectedParts,
|
||
);
|
||
|
||
const desc = String(reply.resendDescription ?? "").trim();
|
||
if (!desc && uniqueDocs.length === 0 && normalizedParts.length === 0) {
|
||
throw new BadRequestException(
|
||
"Provide resendDescription and/or resendDocuments and/or resendCarParts.",
|
||
);
|
||
}
|
||
|
||
const resendSnapshot = await this.snapshotDamageExpert(actor.sub);
|
||
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||
$set: {
|
||
status: ClaimCaseStatus.WAITING_FOR_USER_RESEND,
|
||
claimStatus: ClaimStatus.NEEDS_REVISION,
|
||
"workflow.locked": false,
|
||
"workflow.currentStep": ClaimWorkflowStep.USER_EXPERT_RESEND,
|
||
"workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
|
||
"evaluation.damageExpertResend": {
|
||
resendDescription: desc || undefined,
|
||
resendDocuments: uniqueDocs,
|
||
resendCarParts: normalizedParts,
|
||
requestedByExpertId: new Types.ObjectId(actor.sub),
|
||
...(resendSnapshot && { expertProfileSnapshot: resendSnapshot }),
|
||
},
|
||
},
|
||
$unset: {
|
||
"workflow.lockedAt": "",
|
||
"workflow.expiredAt": "",
|
||
"workflow.lockedBy": "",
|
||
"workflow.preLockQueueSnapshot": "",
|
||
},
|
||
$push: {
|
||
history: {
|
||
type: "EXPERT_RESEND_REQUESTED",
|
||
actor: {
|
||
actorId: new Types.ObjectId(actor.sub),
|
||
actorName: actor.fullName,
|
||
actorType: "damage_expert",
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
documentCount: uniqueDocs.length,
|
||
carPartCount: normalizedParts.length,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
const ownerPhoneResend = await this.resolveClaimOwnerPhone(claim);
|
||
if (ownerPhoneResend) {
|
||
await this.smsOrchestrationService.sendResendDocumentsNotice({
|
||
receptor: ownerPhoneResend,
|
||
fileKind: "claim",
|
||
publicId: claim.publicId,
|
||
link: this.smsOrchestrationService.buildClaimLink(String(claim._id)),
|
||
});
|
||
}
|
||
|
||
await this.recordClaimExpertActivity({
|
||
expertId: String(actor.sub),
|
||
tenantId: this.claimActivityTenantId(claim, actor),
|
||
claimId: String(claimRequestId),
|
||
eventType: ExpertFileActivityType.HANDLED,
|
||
idempotencyKey: `claim:${claimRequestId}:handled:resend:${actor.sub}`,
|
||
});
|
||
|
||
return {
|
||
claimRequestId,
|
||
status: ClaimCaseStatus.WAITING_FOR_USER_RESEND,
|
||
claimStatus: ClaimStatus.NEEDS_REVISION,
|
||
currentStep: ClaimWorkflowStep.USER_EXPERT_RESEND,
|
||
nextStep: ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
|
||
message:
|
||
"Resend request recorded. The owner must upload the requested documents and/or part photos (or confirm if only instructions were given). The claim will return to the damage expert queue when complete.",
|
||
};
|
||
}
|
||
|
||
/**
|
||
* V2: Submit damage expert reply for a claim.
|
||
*
|
||
* Validations:
|
||
* - Claim must exist
|
||
* - Must be locked by this expert (workflow.lockedBy.actorId === actor.sub)
|
||
* - Must be in EXPERT_REVIEWING status
|
||
* - Total payment across all parts must not exceed 53,000,000 (same cap as factor validation totals)
|
||
* - Each part must include `daghi` (option + conditional price/branchId) like V1
|
||
*
|
||
* On success:
|
||
* - Stores reply; clears owner signature fields (`ownerInsurerApproval`, `ownerPricedPartsApproval`)
|
||
* - Unlocks the workflow
|
||
* - Pricing-only (`factorNeeded=false` everywhere): INSURER_REVIEW_AWAITING_OWNER_SIGN, APPROVED, INSURER_REVIEW → owner final sign/reject.
|
||
* - All lines `factorNeeded`: NEEDS_REVISION, OWNER_UPLOAD_FACTOR_DOCUMENTS → uploads → UNDER_REVIEW, EXPERT_COST_EVALUATION (validate factors).
|
||
* - Mixed priced + factor: NEEDS_REVISION, INSURER_REVIEW with next OWNER_UPLOAD_FACTOR_DOCUMENTS → owner signs priced lines first → upload factors → expert validates → final sign.
|
||
*/
|
||
async submitExpertReplyV2(
|
||
claimRequestId: string,
|
||
reply: import("./dto/expert-claim-v2.dto").SubmitExpertReplyV2Dto,
|
||
actor: any,
|
||
) {
|
||
if (actor.role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor);
|
||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||
|
||
if (!claim) {
|
||
throw new NotFoundException("Claim request not found");
|
||
}
|
||
|
||
await this.assertExpertActorOnClaim(claim, actor);
|
||
|
||
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
||
throw new BadRequestException(
|
||
`Claim is not in a reviewable state. Current status: ${claim.status}`,
|
||
);
|
||
}
|
||
|
||
if (!claim.workflow?.locked) {
|
||
throw new ForbiddenException(
|
||
"You must lock the claim before submitting a reply",
|
||
);
|
||
}
|
||
|
||
if (claim.workflow.lockedBy?.actorId?.toString() !== actor.sub) {
|
||
throw new ForbiddenException("This claim is locked by another expert");
|
||
}
|
||
|
||
// Price cap validation, when enabled.
|
||
const priceCap = getClaimV2TotalPaymentCapToman();
|
||
if (priceCap !== null) {
|
||
let totalPrice = 0;
|
||
for (const part of reply.parts || []) {
|
||
const parsed = this.parsePersianNumber(String(part.totalPayment ?? "0"));
|
||
if (!isNaN(parsed)) totalPrice += parsed;
|
||
}
|
||
if (totalPrice > priceCap) {
|
||
throw new BadRequestException({
|
||
message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`,
|
||
error: "PRICE_CAP_ERROR",
|
||
totalPrice,
|
||
priceCap,
|
||
});
|
||
}
|
||
}
|
||
|
||
const carTypeSubmit = claim.vehicle?.carType as
|
||
| ClaimVehicleTypeV2
|
||
| undefined;
|
||
const daghiNormalized =
|
||
reply.parts?.length > 0
|
||
? this.validateAndNormalizeDaghiForExpertReplyV2(reply.parts)
|
||
: [];
|
||
|
||
const ownerSelectedParts = normalizeDamageSelectedParts(
|
||
(claim as any)?.damage?.selectedParts,
|
||
carTypeSubmit,
|
||
(claim as any)?.damage?.selectedOuterParts,
|
||
);
|
||
const seenCatalogIds = new Set<number>();
|
||
const expertAddedParts: DamageSelectedPartV2[] = [];
|
||
|
||
const processedParts = daghiNormalized.map((p) => {
|
||
const selected = resolvePartForExpertReply(
|
||
p.partId,
|
||
ownerSelectedParts,
|
||
carTypeSubmit,
|
||
);
|
||
if (!selected) {
|
||
throw new BadRequestException(
|
||
`Unknown partId "${p.partId}". Use numeric catalog id from claim detail damagedParts.`,
|
||
);
|
||
}
|
||
|
||
let carPartDamage: Record<string, unknown>;
|
||
try {
|
||
carPartDamage = normalizeCarPartDamageForExpertReply(
|
||
{
|
||
id: selected.id,
|
||
name: selected.name,
|
||
side: selected.side,
|
||
label_fa: selected.label_fa,
|
||
...(selected.catalogKey ? { catalogKey: selected.catalogKey } : {}),
|
||
},
|
||
carTypeSubmit,
|
||
);
|
||
} catch (err) {
|
||
throw new BadRequestException(
|
||
`Invalid part ${p.partId}: ${
|
||
err instanceof Error ? err.message : String(err)
|
||
}`,
|
||
);
|
||
}
|
||
|
||
const catalogId = catalogPartIdFromCarPartDamage(carPartDamage);
|
||
if (catalogId == null) {
|
||
throw new BadRequestException(
|
||
`Invalid part ${p.partId}: a numeric catalog id is required.`,
|
||
);
|
||
}
|
||
|
||
if (seenCatalogIds.has(catalogId)) {
|
||
throw new BadRequestException(
|
||
`Duplicate part submitted in expert reply: ${catalogId}.`,
|
||
);
|
||
}
|
||
seenCatalogIds.add(catalogId);
|
||
|
||
const wasOnClaim = ownerSelectedParts.some(
|
||
(op) => partLookupKey(op) === partLookupKey(selected),
|
||
);
|
||
if (!wasOnClaim) {
|
||
expertAddedParts.push(selected);
|
||
}
|
||
|
||
return {
|
||
...p,
|
||
partId: catalogId,
|
||
carPartDamage,
|
||
};
|
||
});
|
||
|
||
const { mixedFactorAndPrice, allFactorLines } =
|
||
classifyV2ExpertPricingParts(processedParts);
|
||
const needsFactorUpload =
|
||
reply.parts?.some((p) => p.factorNeeded === true) ?? false;
|
||
|
||
const objectionSubmitted = !!claim.evaluation?.objection?.submittedAt;
|
||
const hasFinalReply = !!claim.evaluation?.damageExpertReplyFinal;
|
||
|
||
if (objectionSubmitted && hasFinalReply) {
|
||
throw new ConflictException(
|
||
"A final expert reply after objection already exists for this claim.",
|
||
);
|
||
}
|
||
|
||
const isFinalReplyAfterObjection = objectionSubmitted && !hasFinalReply;
|
||
const replyField = isFinalReplyAfterObjection
|
||
? "damageExpertReplyFinal"
|
||
: "damageExpertReply";
|
||
const completedStep = isFinalReplyAfterObjection
|
||
? ClaimWorkflowStep.EXPERT_FINAL_REPLY
|
||
: ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT;
|
||
|
||
const expertProfileSnapshot = await this.snapshotDamageExpert(actor.sub);
|
||
|
||
const replyPayload = {
|
||
description: reply.description,
|
||
parts: processedParts,
|
||
submittedAt: new Date(),
|
||
actorDetail: {
|
||
actorId: actor.sub,
|
||
actorName: actor.fullName,
|
||
},
|
||
...(expertProfileSnapshot && { expertProfileSnapshot }),
|
||
};
|
||
|
||
let currentStep = ClaimWorkflowStep.INSURER_REVIEW;
|
||
let nextWorkflowStep = ClaimWorkflowStep.CLAIM_COMPLETED;
|
||
|
||
const nextCaseStatus = claimCaseStatusAfterExpertReplyV2(processedParts);
|
||
let nextClaimStatus = ClaimStatus.APPROVED;
|
||
|
||
if (needsFactorUpload) {
|
||
nextClaimStatus = ClaimStatus.NEEDS_REVISION;
|
||
if (mixedFactorAndPrice) {
|
||
currentStep = ClaimWorkflowStep.INSURER_REVIEW;
|
||
nextWorkflowStep = ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS;
|
||
} else {
|
||
currentStep = ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS;
|
||
nextWorkflowStep = ClaimWorkflowStep.EXPERT_COST_EVALUATION;
|
||
}
|
||
}
|
||
|
||
const mergedSelectedParts = [...ownerSelectedParts];
|
||
if (expertAddedParts.length > 0) {
|
||
const mergedKeys = new Set(
|
||
mergedSelectedParts.map((sp) => partLookupKey(sp)),
|
||
);
|
||
for (const sp of expertAddedParts) {
|
||
const k = partLookupKey(sp);
|
||
if (!mergedKeys.has(k)) {
|
||
mergedKeys.add(k);
|
||
mergedSelectedParts.push(sp);
|
||
}
|
||
}
|
||
}
|
||
|
||
const updatePayload: Record<string, unknown> = {
|
||
status: nextCaseStatus,
|
||
claimStatus: nextClaimStatus,
|
||
...(expertAddedParts.length > 0
|
||
? { "damage.selectedParts": mergedSelectedParts }
|
||
: {}),
|
||
"workflow.locked": false,
|
||
$unset: {
|
||
"workflow.lockedAt": "",
|
||
"workflow.expiredAt": "",
|
||
"workflow.lockedBy": "",
|
||
"workflow.preLockQueueSnapshot": "",
|
||
"evaluation.ownerInsurerApproval": "",
|
||
"evaluation.ownerPricedPartsApproval": "",
|
||
},
|
||
"workflow.currentStep": currentStep,
|
||
"workflow.nextStep": needsFactorUpload
|
||
? nextWorkflowStep
|
||
: ClaimWorkflowStep.CLAIM_COMPLETED,
|
||
[`evaluation.${replyField}`]: replyPayload,
|
||
$push: {
|
||
"workflow.completedSteps": completedStep,
|
||
history: {
|
||
type: isFinalReplyAfterObjection
|
||
? "EXPERT_FINAL_REPLY_SUBMITTED"
|
||
: "EXPERT_REPLY_SUBMITTED",
|
||
actor: {
|
||
actorId: new Types.ObjectId(actor.sub),
|
||
actorName: actor.fullName,
|
||
actorType: "damage_expert",
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
factorNeeded: needsFactorUpload,
|
||
mixedFactorAndPrice,
|
||
allFactorLines,
|
||
partsCount: processedParts.length,
|
||
isFinalReplyAfterObjection,
|
||
replyField,
|
||
},
|
||
},
|
||
},
|
||
};
|
||
|
||
await this.claimCaseDbService.findByIdAndUpdate(
|
||
claimRequestId,
|
||
updatePayload,
|
||
);
|
||
|
||
await this.recordClaimExpertActivity({
|
||
expertId: String(actor.sub),
|
||
tenantId: this.claimActivityTenantId(claim, actor),
|
||
claimId: String(claimRequestId),
|
||
eventType: ExpertFileActivityType.HANDLED,
|
||
idempotencyKey: `claim:${claimRequestId}:handled:${actor.sub}`,
|
||
});
|
||
|
||
const ownerPhoneNotify = await this.resolveClaimOwnerPhone(claim);
|
||
if (ownerPhoneNotify && !needsFactorUpload) {
|
||
const expertLastName =
|
||
actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
|
||
await this.smsOrchestrationService.sendSignatureReviewNotice({
|
||
receptor: ownerPhoneNotify,
|
||
fileKind: "claim",
|
||
publicId: claim.publicId,
|
||
expertLastName,
|
||
link: this.smsOrchestrationService.buildClaimLink(String(claim._id)),
|
||
});
|
||
}
|
||
|
||
return {
|
||
claimRequestId,
|
||
status: nextCaseStatus,
|
||
claimStatus: nextClaimStatus,
|
||
currentStep,
|
||
workflowNextStep: needsFactorUpload
|
||
? nextWorkflowStep
|
||
: ClaimWorkflowStep.CLAIM_COMPLETED,
|
||
factorNeeded: needsFactorUpload,
|
||
mixedPricingAndFactors: mixedFactorAndPrice,
|
||
allPartsFactorNeeded: !!needsFactorUpload && !!allFactorLines,
|
||
isFinalReplyAfterObjection,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* V2: Expert marks claim for in-person visit.
|
||
*
|
||
* This is used when the expert decides the user must attend in person
|
||
* before a final assessment can be made.
|
||
*
|
||
* Validations:
|
||
* - Claim must exist
|
||
* - Must be locked by this expert
|
||
* - Status must be EXPERT_REVIEWING
|
||
*
|
||
* On success:
|
||
* - Sets status = EXPERT_REVIEWING (remains), claimStatus = NEEDS_REVISION
|
||
* - Sets evaluation.visitLocation (optional note)
|
||
* - Records history event IN_PERSON_VISIT_REQUESTED
|
||
* - Unlocks the workflow so user can act
|
||
*/
|
||
async requestInPersonVisitV2(
|
||
claimRequestId: string,
|
||
actor: any,
|
||
note?: string,
|
||
) {
|
||
requireActorClientKey(actor);
|
||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||
|
||
if (!claim) {
|
||
throw new NotFoundException("Claim request not found");
|
||
}
|
||
|
||
assertClaimCaseForTenant(claim, actor);
|
||
|
||
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
||
throw new BadRequestException(
|
||
`Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`,
|
||
);
|
||
}
|
||
|
||
if (!claim.workflow?.locked) {
|
||
throw new ForbiddenException(
|
||
"You must lock the claim before requesting an in-person visit",
|
||
);
|
||
}
|
||
|
||
if (claim.workflow.lockedBy?.actorId?.toString() !== actor.sub) {
|
||
throw new ForbiddenException("This claim is locked by another expert");
|
||
}
|
||
|
||
const visitSnapshot = await this.snapshotDamageExpert(actor.sub);
|
||
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||
claimStatus: ClaimStatus.NEEDS_REVISION,
|
||
"workflow.locked": false,
|
||
$unset: {
|
||
"workflow.lockedAt": "",
|
||
"workflow.expiredAt": "",
|
||
"workflow.lockedBy": "",
|
||
"workflow.preLockQueueSnapshot": "",
|
||
},
|
||
...(note ? { "evaluation.visitLocation": note } : {}),
|
||
...(visitSnapshot && {
|
||
"evaluation.inPersonVisitExpertProfileSnapshot": visitSnapshot,
|
||
}),
|
||
$push: {
|
||
history: {
|
||
type: "IN_PERSON_VISIT_REQUESTED",
|
||
actor: {
|
||
actorId: new Types.ObjectId(actor.sub),
|
||
actorName: actor.fullName,
|
||
actorType: "damage_expert",
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
note: note || "Expert requested in-person visit",
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
await this.recordClaimExpertActivity({
|
||
expertId: String(actor.sub),
|
||
tenantId: this.claimActivityTenantId(claim, actor),
|
||
claimId: String(claimRequestId),
|
||
eventType: ExpertFileActivityType.HANDLED,
|
||
idempotencyKey: `claim:${claimRequestId}:handled:inperson:${actor.sub}`,
|
||
});
|
||
|
||
return {
|
||
claimRequestId,
|
||
status: claim.status,
|
||
claimStatus: ClaimStatus.NEEDS_REVISION,
|
||
message: "In-person visit requested. User will be notified.",
|
||
};
|
||
}
|
||
|
||
/**
|
||
* V2: Count claim cases in this damage expert’s portfolio, grouped for dashboard.
|
||
* Includes files the expert locked, replied on, or has CHECKED/HANDLED in expertFileActivities.
|
||
* Does not include the open tenant queue (`WAITING_FOR_DAMAGE_EXPERT`) unless this expert touched the file.
|
||
*/
|
||
async getStatusReportBucketsV2(actor: any): Promise<Record<string, number>> {
|
||
if (actor.role === RoleEnum.FIELD_EXPERT) {
|
||
const rows = (await this.claimCaseDbService.find(
|
||
{ initiatedByFieldExpertId: new Types.ObjectId(actor.sub) },
|
||
{ lean: true },
|
||
)) as any[];
|
||
const buckets = initialClaimExpertReportBuckets();
|
||
for (const doc of rows) {
|
||
const key = claimCaseStatusToReportBucket(String(doc.status ?? ""));
|
||
buckets.all++;
|
||
buckets[key] = (buckets[key] ?? 0) + 1;
|
||
}
|
||
buckets["PENDING_ON_USER"] =
|
||
(buckets[ClaimCaseStatus.WAITING_FOR_USER_RESEND] ?? 0) +
|
||
(buckets[ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL] ?? 0) +
|
||
(buckets[ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN] ?? 0) +
|
||
(buckets[ClaimCaseStatus.INSURER_REVIEW_MIXED_FACTORS_PENDING] ?? 0) +
|
||
(buckets[ClaimCaseStatus.OWNER_REPAIR_FACTOR_UPLOAD_PENDING] ?? 0);
|
||
buckets["CHECK_NEEDED"] =
|
||
buckets["PENDING_ON_USER"] +
|
||
(buckets[ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT] ?? 0);
|
||
return buckets;
|
||
}
|
||
const clientKey = requireActorClientKey(actor);
|
||
const expertId = String(actor.sub);
|
||
const expertOid = new Types.ObjectId(expertId);
|
||
|
||
const activityEvents = await this.expertFileActivityDbService.findByExpert(
|
||
expertId,
|
||
ExpertFileKind.CLAIM,
|
||
);
|
||
const activityFileIds =
|
||
expertPortfolioFileIdsFromActivityEvents(activityEvents);
|
||
|
||
const orClauses: Record<string, unknown>[] = [
|
||
{ "workflow.lockedBy.actorId": expertOid },
|
||
{ "evaluation.damageExpertReply.actorDetail.actorId": expertOid },
|
||
{ "evaluation.damageExpertReplyFinal.actorDetail.actorId": expertOid },
|
||
{ "evaluation.damageExpertResend.requestedByExpertId": expertOid },
|
||
];
|
||
const activityOids = objectIdsFromStringSet(activityFileIds);
|
||
if (activityOids.length > 0) {
|
||
orClauses.push({ _id: { $in: activityOids } });
|
||
}
|
||
|
||
const rows =
|
||
orClauses.length === 0
|
||
? []
|
||
: await this.claimCaseDbService.find(
|
||
{ $or: orClauses },
|
||
{ lean: true },
|
||
);
|
||
|
||
const buckets = initialClaimExpertReportBuckets();
|
||
const seen = new Set<string>();
|
||
for (const doc of rows as Record<string, unknown>[]) {
|
||
const id = String(doc._id ?? "");
|
||
if (seen.has(id)) continue;
|
||
if (
|
||
!claimDocInExpertPortfolio(doc, expertId, activityFileIds, clientKey)
|
||
) {
|
||
continue;
|
||
}
|
||
seen.add(id);
|
||
const key = claimCaseStatusToReportBucket(String(doc.status ?? ""));
|
||
buckets.all++;
|
||
buckets[key] = (buckets[key] ?? 0) + 1;
|
||
}
|
||
|
||
// Add new calculated fields for front-end
|
||
buckets["PENDING_ON_USER"] =
|
||
buckets[ClaimCaseStatus.WAITING_FOR_USER_RESEND] +
|
||
buckets[ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL] +
|
||
buckets[ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN] +
|
||
buckets[ClaimCaseStatus.INSURER_REVIEW_MIXED_FACTORS_PENDING] +
|
||
buckets[ClaimCaseStatus.OWNER_REPAIR_FACTOR_UPLOAD_PENDING];
|
||
buckets["CHECK_NEEDED"] =
|
||
buckets["PENDING_ON_USER"] +
|
||
buckets[ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT];
|
||
|
||
return buckets;
|
||
}
|
||
|
||
/**
|
||
* V2: Get claim list for damage expert
|
||
*
|
||
* Returns claims that are (then filtered to the actor's insurer via `claimCaseTouchesClient`):
|
||
* 1. WAITING_FOR_DAMAGE_EXPERT and not locked (open queue)
|
||
* 2. Locked by this expert (in-progress)
|
||
* 3. EXPERT_VALIDATING_REPAIR_FACTORS (or legacy WAITING_FOR_INSURER_APPROVAL) + UNDER_REVIEW at EXPERT_COST_EVALUATION (factor validation queue)
|
||
*/
|
||
async getClaimListV2(
|
||
actor: any,
|
||
query: ListQueryV2Dto = {},
|
||
): Promise<GetClaimListV2ResponseDto> {
|
||
if (actor.role === RoleEnum.FIELD_EXPERT) {
|
||
return this.getFieldExpertClaimListV2(actor, query);
|
||
}
|
||
requireActorClientKey(actor);
|
||
const actorId = actor.sub;
|
||
const clientKey = actor.clientKey as string;
|
||
|
||
await this.reconcileStaleExpertReviewingClaimLocksForTenant(actor);
|
||
|
||
const claims = await this.claimCaseDbService.find({
|
||
$or: [
|
||
// Available: waiting, not locked
|
||
{
|
||
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
|
||
"workflow.locked": { $ne: true },
|
||
// No persistent assignee (or expired without decision)
|
||
$or: [
|
||
{ "workflow.assignedForReviewBy": { $exists: false } },
|
||
{ "workflow.assignedForReviewBy": null },
|
||
],
|
||
},
|
||
// This expert's locked/in-progress claims
|
||
{
|
||
"workflow.locked": true,
|
||
"workflow.lockedBy.actorId": new Types.ObjectId(actorId),
|
||
},
|
||
// This expert's persistently assigned claims (decided or resend/objection follow-up)
|
||
{
|
||
"workflow.assignedForReviewBy.actorId": new Types.ObjectId(actorId),
|
||
},
|
||
// Factor validation queue (open to all tenant experts — no lock needed)
|
||
{
|
||
$or: [
|
||
{
|
||
status: ClaimCaseStatus.EXPERT_VALIDATING_REPAIR_FACTORS,
|
||
claimStatus: ClaimStatus.UNDER_REVIEW,
|
||
"workflow.currentStep": ClaimWorkflowStep.EXPERT_COST_EVALUATION,
|
||
},
|
||
{
|
||
status: ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL,
|
||
claimStatus: ClaimStatus.UNDER_REVIEW,
|
||
"workflow.currentStep": ClaimWorkflowStep.EXPERT_COST_EVALUATION,
|
||
},
|
||
],
|
||
},
|
||
// WAITING_FOR_USER_RESEND assigned to this expert
|
||
{
|
||
status: ClaimCaseStatus.WAITING_FOR_USER_RESEND,
|
||
"workflow.assignedForReviewBy.actorId": new Types.ObjectId(actorId),
|
||
},
|
||
// EXPERT_REVIEWING with stale lock (reconciliation will fix status, show in meantime)
|
||
{
|
||
status: ClaimCaseStatus.EXPERT_REVIEWING,
|
||
"workflow.locked": { $ne: true },
|
||
},
|
||
],
|
||
});
|
||
|
||
const filtered = (claims as any[]).filter((c) =>
|
||
claimCaseTouchesClient(c, clientKey),
|
||
);
|
||
|
||
// Reconcile stale locks — if no decision was made, also clear assignedForReviewBy
|
||
const staleLockToReconcile = filtered.filter(
|
||
(c) =>
|
||
c.workflow?.locked && !this.isClaimV2WorkflowLockCurrentlyEnforced(c),
|
||
);
|
||
|
||
const touchedIds = (
|
||
await Promise.all(
|
||
staleLockToReconcile.map(async (c) => {
|
||
const updated = await this.expireClaimWorkflowLockV2IfStale(
|
||
String(c._id),
|
||
);
|
||
return updated ? String(c._id) : null;
|
||
}),
|
||
)
|
||
).filter((id): id is string => id != null);
|
||
|
||
if (touchedIds.length > 0) {
|
||
const refreshed = await this.claimCaseDbService.find({
|
||
_id: { $in: touchedIds.map((id) => new Types.ObjectId(id)) },
|
||
});
|
||
const byId = new Map(refreshed.map((r) => [String(r._id), r] as const));
|
||
for (const c of filtered) {
|
||
const r = byId.get(String(c._id));
|
||
if (r) Object.assign(c, r);
|
||
}
|
||
}
|
||
|
||
// Post-filter: remove files that belong to another expert after reconciliation
|
||
const finalFiltered = filtered.filter((c) => {
|
||
const assignedId = String(c.workflow?.assignedForReviewBy?.actorId ?? "");
|
||
const lockEnforced =
|
||
c.workflow?.locked && this.isClaimV2WorkflowLockCurrentlyEnforced(c);
|
||
const lockedById = String(c.workflow?.lockedBy?.actorId ?? "");
|
||
const isFactorValidation = claimIsAwaitingExpertFactorValidationV2(c);
|
||
|
||
// Factor validation is open to all tenant experts
|
||
if (isFactorValidation) return true;
|
||
|
||
// Available: no assignee, not locked by someone else
|
||
const isAvailable =
|
||
c.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT &&
|
||
!assignedId &&
|
||
(!lockEnforced || lockedById === actorId);
|
||
|
||
// Mine: assigned to me (covers decided, resend, objection follow-up)
|
||
const isAssignedToMe = !!assignedId && assignedId === actorId;
|
||
|
||
// Mine: currently locked by me
|
||
const isLockedByMe = lockEnforced && lockedById === actorId;
|
||
|
||
// Mine: resend pending and I'm the assigned expert
|
||
const isResendMine =
|
||
c.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND && isAssignedToMe;
|
||
|
||
return isAvailable || isAssignedToMe || isLockedByMe || isResendMine;
|
||
});
|
||
|
||
const blameIds = [
|
||
...new Set(
|
||
finalFiltered
|
||
.map((c) => c.blameRequestId?.toString())
|
||
.filter((id): id is string => !!id),
|
||
),
|
||
];
|
||
const blames =
|
||
blameIds.length > 0
|
||
? ((await this.blameRequestDbService.find(
|
||
{ _id: { $in: blameIds.map((id) => new Types.ObjectId(id)) } },
|
||
{ lean: true, select: "type parties expert.decision blameStatus" },
|
||
)) as any[])
|
||
: [];
|
||
const blameById = new Map<string, any>(
|
||
blames.map((b) => [String(b._id), b]),
|
||
);
|
||
|
||
const list = finalFiltered.map((c) => {
|
||
const awaitingFactorValidation =
|
||
claimIsAwaitingExpertFactorValidationV2(c);
|
||
const v = this.vehicleForExpertFromClaimAndBlameMap(c, blameById);
|
||
const blame = c.blameRequestId
|
||
? blameById.get(c.blameRequestId.toString())
|
||
: undefined;
|
||
const fileCtx = blame ? this.blameFileContextForExpert(blame) : {};
|
||
const lockActive = !!(
|
||
c.workflow?.locked && this.isClaimV2WorkflowLockCurrentlyEnforced(c)
|
||
);
|
||
const statusForList =
|
||
c.status === ClaimCaseStatus.EXPERT_REVIEWING
|
||
? ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT
|
||
: c.status;
|
||
|
||
return {
|
||
claimRequestId: c._id.toString(),
|
||
publicId: c.publicId,
|
||
status: statusForList,
|
||
currentStep: c.workflow?.currentStep || "",
|
||
locked: lockActive,
|
||
lockedBy:
|
||
lockActive && c.workflow?.lockedBy
|
||
? {
|
||
actorId: c.workflow.lockedBy.actorId?.toString(),
|
||
actorName: c.workflow.lockedBy.actorName,
|
||
lockedAt: (c.workflow as any).lockedAt?.toISOString?.(),
|
||
expiredAt: (c.workflow as any).expiredAt?.toISOString?.(),
|
||
}
|
||
: undefined,
|
||
vehicle: v
|
||
? { carName: v.carName, carModel: v.carModel, carType: v.carType }
|
||
: undefined,
|
||
...fileCtx,
|
||
createdAt: c.createdAt,
|
||
awaitingFactorValidation,
|
||
};
|
||
}) as ClaimListItemV2Dto[];
|
||
|
||
const paged = applyListQueryV2(
|
||
list,
|
||
{
|
||
publicId: (r) => r.publicId,
|
||
createdAt: (r) => r.createdAt,
|
||
requestNo: (r) => r.publicId,
|
||
status: (r) => r.status,
|
||
searchExtras: (r) =>
|
||
[
|
||
r.claimRequestId,
|
||
r.currentStep,
|
||
r.vehicle?.carName,
|
||
r.vehicle?.carModel,
|
||
r.blameRequestType,
|
||
].filter(Boolean) as string[],
|
||
},
|
||
query,
|
||
);
|
||
|
||
return {
|
||
list: paged.list,
|
||
total: paged.total,
|
||
page: paged.page,
|
||
limit: paged.limit,
|
||
totalPages: paged.totalPages,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Claim list for a FIELD_EXPERT — returns only the claims they personally
|
||
* initiated (expert-initiated IN_PERSON blame → createClaimFromBlameForExpertV2).
|
||
*/
|
||
private async getFieldExpertClaimListV2(
|
||
actor: any,
|
||
query: ListQueryV2Dto = {},
|
||
): Promise<GetClaimListV2ResponseDto> {
|
||
const expertOid = new Types.ObjectId(actor.sub);
|
||
const expertBlameIds = await this.blameRequestDbService
|
||
.find(
|
||
{ expertInitiated: true, initiatedByFieldExpertId: expertOid },
|
||
{ select: "_id", lean: true },
|
||
)
|
||
.then((docs) => docs.map((d) => (d as { _id: unknown })._id));
|
||
|
||
const claimOr: Record<string, unknown>[] = [
|
||
{ initiatedByFieldExpertId: expertOid },
|
||
];
|
||
if (expertBlameIds.length > 0) {
|
||
claimOr.push({ blameRequestId: { $in: expertBlameIds } });
|
||
}
|
||
|
||
const claims = (await this.claimCaseDbService.find({
|
||
$or: claimOr,
|
||
})) as any[];
|
||
|
||
const blameIds = [
|
||
...new Set(
|
||
claims
|
||
.map((c) => c.blameRequestId?.toString())
|
||
.filter((id): id is string => !!id),
|
||
),
|
||
];
|
||
const blames =
|
||
blameIds.length > 0
|
||
? ((await this.blameRequestDbService.find(
|
||
{ _id: { $in: blameIds.map((id) => new Types.ObjectId(id)) } },
|
||
{ lean: true, select: "type parties expert.decision blameStatus" },
|
||
)) as any[])
|
||
: [];
|
||
const blameById = new Map<string, any>(
|
||
blames.map((b) => [String(b._id), b]),
|
||
);
|
||
|
||
const list = claims.map((c) => {
|
||
const v = this.vehicleForExpertFromClaimAndBlameMap(c, blameById);
|
||
const blame = c.blameRequestId
|
||
? blameById.get(c.blameRequestId.toString())
|
||
: undefined;
|
||
const fileCtx = blame ? this.blameFileContextForExpert(blame) : {};
|
||
const lockActive = !!(
|
||
c.workflow?.locked && this.isClaimV2WorkflowLockCurrentlyEnforced(c)
|
||
);
|
||
return {
|
||
claimRequestId: c._id.toString(),
|
||
publicId: c.publicId,
|
||
status: c.status,
|
||
currentStep: c.workflow?.currentStep || "",
|
||
locked: lockActive,
|
||
lockedBy:
|
||
lockActive && c.workflow?.lockedBy
|
||
? {
|
||
actorId: c.workflow.lockedBy.actorId?.toString(),
|
||
actorName: c.workflow.lockedBy.actorName,
|
||
lockedAt: (c.workflow as any).lockedAt?.toISOString?.(),
|
||
expiredAt: (c.workflow as any).expiredAt?.toISOString?.(),
|
||
}
|
||
: undefined,
|
||
vehicle: v
|
||
? { carName: v.carName, carModel: v.carModel, carType: v.carType }
|
||
: undefined,
|
||
...fileCtx,
|
||
createdAt: c.createdAt,
|
||
awaitingFactorValidation: claimIsAwaitingExpertFactorValidationV2(c),
|
||
};
|
||
}) as ClaimListItemV2Dto[];
|
||
|
||
const paged = applyListQueryV2(
|
||
list,
|
||
{
|
||
publicId: (r) => r.publicId,
|
||
createdAt: (r) => r.createdAt,
|
||
requestNo: (r) => r.publicId,
|
||
status: (r) => r.status,
|
||
searchExtras: (r) =>
|
||
[
|
||
r.claimRequestId,
|
||
r.currentStep,
|
||
r.vehicle?.carName,
|
||
r.vehicle?.carModel,
|
||
].filter(Boolean) as string[],
|
||
},
|
||
query,
|
||
);
|
||
|
||
return {
|
||
list: paged.list,
|
||
total: paged.total,
|
||
page: paged.page,
|
||
limit: paged.limit,
|
||
totalPages: paged.totalPages,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Load linked blame case for damage-expert claim detail (same enrichment as expert-blame `findOneV2`:
|
||
* party evidence `videoUrl` / `voiceUrls`, Jalali date strings).
|
||
*/
|
||
private async buildBlameCaseSnapshotForClaimDetail(
|
||
blameRequestId: string,
|
||
): Promise<Record<string, unknown> | null> {
|
||
const doc =
|
||
await this.blameRequestDbService.findByIdWithoutHistory(blameRequestId);
|
||
if (!doc) {
|
||
return null;
|
||
}
|
||
|
||
const docRec = doc as Record<string, unknown>;
|
||
const built = buildMutualAgreementExpertDecision(docRec);
|
||
const existingDecision = (
|
||
docRec.expert as Record<string, unknown> | undefined
|
||
)?.decision as Record<string, unknown> | undefined;
|
||
if (built && !existingDecision?.guiltyPartyId) {
|
||
const prevExpert =
|
||
typeof docRec.expert === "object" && docRec.expert !== null
|
||
? { ...(docRec.expert as Record<string, unknown>) }
|
||
: {};
|
||
const expert = { ...prevExpert, decision: built };
|
||
await this.blameRequestDbService.findByIdAndUpdate(blameRequestId, {
|
||
$set: { expert },
|
||
});
|
||
docRec.expert = expert;
|
||
}
|
||
|
||
const parties = (doc.parties ?? []) as Array<{
|
||
evidence?: {
|
||
videoId?: string | number;
|
||
voices?: (string | number)[];
|
||
videoUrl?: string;
|
||
voiceUrls?: string[];
|
||
};
|
||
}>;
|
||
|
||
for (const party of parties) {
|
||
if (!party.evidence) continue;
|
||
const evidence = party.evidence as Record<string, unknown>;
|
||
|
||
if (evidence.videoId) {
|
||
const videoDoc = await this.blameVideoDbService.findById(
|
||
String(evidence.videoId),
|
||
);
|
||
if (videoDoc?.path) {
|
||
evidence.videoUrl = buildFileLink(videoDoc.path);
|
||
}
|
||
}
|
||
|
||
if (evidence.voices && Array.isArray(evidence.voices)) {
|
||
const voiceUrls: string[] = [];
|
||
for (const voiceId of evidence.voices) {
|
||
const voiceDoc = await this.blameVoiceDbService.findById(
|
||
String(voiceId),
|
||
);
|
||
if (voiceDoc?.path) {
|
||
voiceUrls.push(buildFileLink(voiceDoc.path));
|
||
}
|
||
}
|
||
evidence.voiceUrls = voiceUrls;
|
||
}
|
||
}
|
||
|
||
const createdAt = doc.createdAt
|
||
? new Date(doc.createdAt as string | number)
|
||
: new Date();
|
||
const updatedAt = doc.updatedAt
|
||
? new Date(doc.updatedAt as string | number)
|
||
: new Date();
|
||
const [createdDate, createdTime] = toJalaliDateAndTime(createdAt);
|
||
const [updatedDate, updatedTime] = toJalaliDateAndTime(updatedAt);
|
||
(doc as Record<string, unknown>).createdAtFormatted =
|
||
`${createdDate} ${createdTime}`;
|
||
(doc as Record<string, unknown>).updatedAtFormatted =
|
||
`${updatedDate} ${updatedTime}`;
|
||
|
||
docRec.parties = enrichBlamePartiesForAgreementView(docRec);
|
||
|
||
return docRec;
|
||
}
|
||
|
||
/** Strip bulky insurer-inquiry payloads (`raw` / `mapped`) from vehicle.inquiry. */
|
||
private sanitizeVehicleInquiryForApi(vehicle: any) {
|
||
if (!vehicle || typeof vehicle !== "object") return vehicle;
|
||
const inquiry = vehicle.inquiry;
|
||
if (!inquiry || typeof inquiry !== "object") return vehicle;
|
||
const { raw, mapped, ...safeInquiry } = inquiry as Record<string, unknown>;
|
||
return {
|
||
...vehicle,
|
||
inquiry: safeInquiry,
|
||
};
|
||
}
|
||
|
||
/** Linked blame snapshot for damage-expert detail: keep API response lean. */
|
||
private sanitizeBlameCaseForClaimDetailApi(
|
||
blame: Record<string, unknown>,
|
||
): Record<string, unknown> {
|
||
const out = { ...blame };
|
||
const parties = out.parties;
|
||
if (Array.isArray(parties)) {
|
||
out.parties = parties.map((p: any) =>
|
||
p && typeof p === "object"
|
||
? { ...p, vehicle: this.sanitizeVehicleInquiryForApi(p.vehicle) }
|
||
: p,
|
||
);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** JSON-safe `expert.decision` for API responses (ObjectIds → string). */
|
||
private serializeBlameExpertDecisionForClaimDetail(
|
||
decision: unknown,
|
||
): Record<string, unknown> | undefined {
|
||
if (!decision || typeof decision !== "object") {
|
||
return undefined;
|
||
}
|
||
const d = decision as Record<string, unknown>;
|
||
const guilty = d.guiltyPartyId;
|
||
const decidedBy = d.decidedByExpertId;
|
||
const decidedAt = d.decidedAt;
|
||
return {
|
||
...d,
|
||
guiltyPartyId:
|
||
guilty != null &&
|
||
typeof (guilty as { toString?: () => string }).toString === "function"
|
||
? String(guilty)
|
||
: guilty,
|
||
decidedByExpertId:
|
||
decidedBy != null &&
|
||
typeof (decidedBy as { toString?: () => string }).toString ===
|
||
"function"
|
||
? String(decidedBy)
|
||
: decidedBy,
|
||
decidedAt:
|
||
decidedAt instanceof Date ? decidedAt.toISOString() : decidedAt,
|
||
};
|
||
}
|
||
|
||
private claimHasDamageExpertReply(claim: {
|
||
evaluation?: {
|
||
damageExpertReply?: unknown;
|
||
damageExpertReplyFinal?: unknown;
|
||
};
|
||
}): boolean {
|
||
return !!(
|
||
claim.evaluation?.damageExpertReply ||
|
||
claim.evaluation?.damageExpertReplyFinal
|
||
);
|
||
}
|
||
|
||
/**
|
||
* True while a workflow lock is still within its window (`expiredAt` when set, else lockedAt + TTL).
|
||
* Missing both timestamps on old documents: treat lock as enforced until cleared.
|
||
*/
|
||
private isClaimV2WorkflowLockCurrentlyEnforced(claim: any): boolean {
|
||
if (!claim?.workflow?.locked) return false;
|
||
const exp = claim.workflow?.expiredAt;
|
||
if (exp) {
|
||
return Date.now() < new Date(exp as Date).getTime();
|
||
}
|
||
const la = claim.workflow?.lockedAt as Date | string | undefined;
|
||
if (!la) return true;
|
||
return Date.now() < new Date(la).getTime() + this.claimV2WorkflowLockTtlMs;
|
||
}
|
||
|
||
/**
|
||
* EXPERT_REVIEWING + expired lock is not listed until status is reverted. Run expire for this tenant
|
||
* so the next query returns those cases as WAITING_FOR_DAMAGE_EXPERT.
|
||
*/
|
||
private async reconcileStaleExpertReviewingClaimLocksForTenant(actor: {
|
||
sub: string;
|
||
clientKey?: string;
|
||
role?: string;
|
||
}): Promise<void> {
|
||
if ((actor as any).role === RoleEnum.FIELD_EXPERT) return;
|
||
const clientKey = requireActorClientKey(actor);
|
||
const lockTtlMs = this.claimV2WorkflowLockTtlMs;
|
||
const now = new Date();
|
||
const candidates = (await this.claimCaseDbService.find(
|
||
{
|
||
status: ClaimCaseStatus.EXPERT_REVIEWING,
|
||
"workflow.locked": true,
|
||
$or: [
|
||
{ "workflow.expiredAt": { $lte: now } },
|
||
{
|
||
$expr: {
|
||
$and: [
|
||
{ $eq: ["$workflow.locked", true] },
|
||
{
|
||
$lte: [{ $add: ["$workflow.lockedAt", lockTtlMs] }, now],
|
||
},
|
||
],
|
||
},
|
||
},
|
||
],
|
||
},
|
||
{ lean: true, select: "_id owner" },
|
||
)) as Array<{ _id: unknown; owner?: unknown }>;
|
||
|
||
const relevant = candidates.filter((c) =>
|
||
claimCaseTouchesClient(c, clientKey),
|
||
);
|
||
await Promise.all(
|
||
relevant.map((c) => this.expireClaimWorkflowLockV2IfStale(String(c._id))),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Persist unlock when claim V2 workflow lock TTL has passed.
|
||
* When the case was in {@link ClaimCaseStatus.EXPERT_REVIEWING}, restores queue fields from
|
||
* `workflow.preLockQueueSnapshot` (or defaults) and sets status back to WAITING_FOR_DAMAGE_EXPERT
|
||
* so the claim reappears in the open expert queue (same intent as blame list expiry + in-memory unlock).
|
||
*/
|
||
private async expireClaimWorkflowLockV2IfStale(
|
||
claimRequestId: string,
|
||
): Promise<boolean> {
|
||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||
if (!claim?.workflow?.locked) return false;
|
||
|
||
const expiredAt = claim.workflow?.expiredAt;
|
||
const lockedAt = claim.workflow?.lockedAt;
|
||
const now = Date.now();
|
||
const ttl = this.claimV2WorkflowLockTtlMs;
|
||
|
||
if (expiredAt && now < new Date(expiredAt as Date).getTime()) return false;
|
||
if (
|
||
!expiredAt &&
|
||
lockedAt &&
|
||
now < new Date(lockedAt as Date).getTime() + ttl
|
||
) {
|
||
return false;
|
||
}
|
||
|
||
const lockedById = claim.workflow.lockedBy?.actorId;
|
||
const filter: Record<string, unknown> = {
|
||
_id: new Types.ObjectId(claimRequestId),
|
||
"workflow.locked": true,
|
||
};
|
||
if (expiredAt) {
|
||
filter["workflow.expiredAt"] = expiredAt;
|
||
} else if (lockedAt) {
|
||
filter["workflow.lockedAt"] = lockedAt;
|
||
} else if (lockedById) {
|
||
filter["workflow.lockedBy.actorId"] = new Types.ObjectId(
|
||
String(lockedById),
|
||
);
|
||
}
|
||
|
||
const needsQueueRestore = claim.status === ClaimCaseStatus.EXPERT_REVIEWING;
|
||
|
||
const snap = claim.workflow?.preLockQueueSnapshot as
|
||
| {
|
||
claimStatus?: ClaimStatus;
|
||
currentStep?: ClaimWorkflowStep;
|
||
nextStep?: ClaimWorkflowStep;
|
||
}
|
||
| undefined;
|
||
|
||
const restoreClaimStatus = snap?.claimStatus ?? ClaimStatus.PENDING;
|
||
const restoreCurrent = this.normalizeClaimWorkflowStepForSnapshot(
|
||
snap?.currentStep,
|
||
ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
|
||
);
|
||
const restoreNext =
|
||
snap?.nextStep != null
|
||
? this.normalizeClaimWorkflowStepForSnapshot(
|
||
snap.nextStep,
|
||
ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
|
||
)
|
||
: ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT;
|
||
|
||
const lockFieldsUnset = {
|
||
"workflow.lockedAt": "",
|
||
"workflow.expiredAt": "",
|
||
"workflow.lockedBy": "",
|
||
"workflow.preLockQueueSnapshot": "",
|
||
};
|
||
|
||
const assigneeToPersist = claimWorkflowAssigneeToPersistOnLockExpiry(
|
||
claim.workflow,
|
||
claim,
|
||
);
|
||
const expireLockSet: Record<string, unknown> = { "workflow.locked": false };
|
||
if (assigneeToPersist) {
|
||
expireLockSet["workflow.assignedForReviewBy"] = assigneeToPersist;
|
||
}
|
||
|
||
const update = needsQueueRestore
|
||
? {
|
||
$set: {
|
||
...expireLockSet,
|
||
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
|
||
claimStatus: restoreClaimStatus,
|
||
"workflow.currentStep": restoreCurrent,
|
||
"workflow.nextStep": restoreNext,
|
||
},
|
||
$unset: lockFieldsUnset,
|
||
}
|
||
: {
|
||
$set: expireLockSet,
|
||
$unset: lockFieldsUnset,
|
||
};
|
||
|
||
const cleared = await this.claimCaseDbService.findOneAndUpdate(
|
||
filter as any,
|
||
update as any,
|
||
{ new: false },
|
||
);
|
||
if (cleared && lockedById && claim.owner?.userClientKey) {
|
||
await this.recordClaimExpertActivity({
|
||
expertId: String(lockedById),
|
||
tenantId: String(claim.owner.userClientKey),
|
||
claimId: String(claimRequestId),
|
||
eventType: ExpertFileActivityType.UNCHECKED,
|
||
idempotencyKey: `claim:${claimRequestId}:unchecked:expired:${lockedById}`,
|
||
});
|
||
}
|
||
return !!cleared;
|
||
}
|
||
|
||
/**
|
||
* V2: Get claim detail for damage expert
|
||
*
|
||
* Validations:
|
||
* - Claim must exist and belong to the actor's insurer (`assertClaimCaseForTenant`)
|
||
* - Allowed when: WAITING_FOR_DAMAGE_EXPERT (queue), or EXPERT_REVIEWING (after lock — same expert must be able to reopen detail),
|
||
* or factor-validation queue (EXPERT_VALIDATING_REPAIR_FACTORS / legacy WAITING_FOR_INSURER_APPROVAL + UNDER_REVIEW + EXPERT_COST_EVALUATION)
|
||
* - If an active workflow lock exists (15 min from lockedAt): only the locking expert; after expiry, any expert (tenant) may view
|
||
*/
|
||
async getClaimDetailV2(
|
||
claimRequestId: string,
|
||
actor: any,
|
||
): Promise<ClaimDetailV2ResponseDto> {
|
||
const actorId = actor.sub;
|
||
if (actor.role !== RoleEnum.FIELD_EXPERT) {
|
||
await this.reconcileStaleExpertReviewingClaimLocksForTenant(actor);
|
||
}
|
||
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
|
||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||
|
||
if (!claim) {
|
||
throw new NotFoundException("Claim request not found");
|
||
}
|
||
|
||
const linkedBlame = await this.loadBlameForClaim(claim);
|
||
assertClaimCaseForExpertActor(claim, actor, linkedBlame);
|
||
|
||
// Variables used both in the gate block and in the detail-build section below
|
||
const isDamageExpertPhase =
|
||
claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT ||
|
||
claim.status === ClaimCaseStatus.EXPERT_REVIEWING;
|
||
const isFactorValidationPending =
|
||
claimIsAwaitingExpertFactorValidationV2(claim);
|
||
|
||
// Field experts can always view their own initiated claims regardless of status
|
||
if (actor.role === RoleEnum.FIELD_EXPERT) {
|
||
if (!claimCaseInitiatedByFieldExpert(claim, actor, linkedBlame)) {
|
||
throw new ForbiddenException("This claim was not initiated by you.");
|
||
}
|
||
// Fall through to the detail build below (skip the damage-expert gate)
|
||
} else {
|
||
const isResendPending =
|
||
claim.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND;
|
||
|
||
const assignedForReviewById = String(
|
||
(claim.workflow as any)?.assignedForReviewBy?.actorId ?? "",
|
||
);
|
||
const lockEnforced = this.isClaimV2WorkflowLockCurrentlyEnforced(claim);
|
||
const lockedById = String(claim.workflow?.lockedBy?.actorId ?? "");
|
||
const isAssignedToMe =
|
||
!!assignedForReviewById && assignedForReviewById === actorId;
|
||
|
||
// Gate: must satisfy at least one bucket
|
||
if (
|
||
!isDamageExpertPhase &&
|
||
!isFactorValidationPending &&
|
||
!isResendPending &&
|
||
!isAssignedToMe
|
||
) {
|
||
throw new ForbiddenException(
|
||
`This claim is not available for expert review. Current status: ${claim.status}`,
|
||
);
|
||
}
|
||
|
||
const isAvailable =
|
||
isDamageExpertPhase && !assignedForReviewById && !lockEnforced;
|
||
const isLockedByMe = lockEnforced && lockedById === actorId;
|
||
const isFactorValidationOpen = isFactorValidationPending;
|
||
|
||
if (
|
||
!isAvailable &&
|
||
!isAssignedToMe &&
|
||
!isLockedByMe &&
|
||
!isFactorValidationOpen
|
||
) {
|
||
if (lockEnforced && lockedById && lockedById !== actorId) {
|
||
throw new ForbiddenException(
|
||
`This claim is locked by another expert: ${claim.workflow?.lockedBy?.actorName}`,
|
||
);
|
||
}
|
||
if (assignedForReviewById && assignedForReviewById !== actorId) {
|
||
throw new ForbiddenException(
|
||
"This claim has been assigned to another expert.",
|
||
);
|
||
}
|
||
throw new ForbiddenException(
|
||
"You do not have permission to view this claim.",
|
||
);
|
||
}
|
||
} // end damage-expert gate (else block)
|
||
|
||
// Build requiredDocuments map
|
||
const requiredDocs = claim.requiredDocuments as any;
|
||
const requiredDocumentsStatus: Record<string, any> = {};
|
||
|
||
// Resend documents requested by expert — normalize to canonical keys for comparison
|
||
const resendDocumentKeys = new Set(
|
||
normalizeResendDocumentKeys(
|
||
(claim as any).evaluation?.damageExpertResend?.resendDocuments,
|
||
),
|
||
);
|
||
const resendFulfilled = !!(claim as any).evaluation?.damageExpertResend
|
||
?.fulfilledAt;
|
||
|
||
if (requiredDocs) {
|
||
const keys =
|
||
requiredDocs instanceof Map
|
||
? Array.from(requiredDocs.keys())
|
||
: Object.keys(requiredDocs);
|
||
for (const k of keys as string[]) {
|
||
const doc =
|
||
requiredDocs instanceof Map ? requiredDocs.get(k) : requiredDocs[k];
|
||
const canonicalKey = canonicalizeResendDocumentKey(k) ?? k;
|
||
const wasResendRequested = resendDocumentKeys.has(canonicalKey);
|
||
|
||
requiredDocumentsStatus[k] = {
|
||
uploaded: !!doc?.uploaded,
|
||
fileId: doc?.fileId?.toString(),
|
||
fileName: doc?.fileName,
|
||
filePath: doc?.filePath ? buildFileLink(doc.filePath) : undefined,
|
||
resend: {
|
||
wasRequested: wasResendRequested,
|
||
isFulfilled: wasResendRequested && resendFulfilled,
|
||
},
|
||
};
|
||
}
|
||
}
|
||
|
||
// Build car angles map
|
||
const carAnglesData = claim.media?.carAngles as any;
|
||
const damagedPartsDataForAngles = claim.media?.damagedParts as any;
|
||
const carAngles: Record<string, { captured: boolean; url?: string }> = {};
|
||
for (const k of ["front", "back", "left", "right"]) {
|
||
const cap = getClaimCarAngleCaptureBlob(
|
||
carAnglesData,
|
||
damagedPartsDataForAngles,
|
||
k as ClaimCarAngleKey,
|
||
) as { url?: string; path?: string } | undefined;
|
||
carAngles[k] = {
|
||
captured: !!cap,
|
||
url: resolveStoredFileUrl(cap),
|
||
};
|
||
}
|
||
|
||
// Build enriched damaged parts
|
||
const carTypeExpert = claim.vehicle?.carType as
|
||
| ClaimVehicleTypeV2
|
||
| undefined;
|
||
const selectedNormExpert = normalizeDamageSelectedParts(
|
||
claim.damage?.selectedParts,
|
||
carTypeExpert,
|
||
(claim.damage as any)?.selectedOuterParts,
|
||
);
|
||
const damagedPartsDataExpert = claim.media?.damagedParts as any;
|
||
|
||
const damagedParts = buildEnrichedDamagedParts({
|
||
selectedParts: selectedNormExpert,
|
||
damagedPartsData: damagedPartsDataExpert,
|
||
evaluationBlock: (claim as any).evaluation,
|
||
expertAddedParts: (claim.damage as any)?.expertAddedParts ?? [],
|
||
getDamagedPartCaptureBlob,
|
||
hasDamagedPartCapture,
|
||
catalogLikeKeyFromPart,
|
||
buildFileLink,
|
||
resolveStoredFileUrl,
|
||
});
|
||
|
||
// Vehicle payload — fall back to blame inquiry if claim vehicle is sparse
|
||
let vehiclePayload = claim.vehicle as any;
|
||
const hasClaimVehicle =
|
||
vehiclePayload &&
|
||
(vehiclePayload.carName ||
|
||
vehiclePayload.carModel ||
|
||
vehiclePayload.carType ||
|
||
vehiclePayload.plate);
|
||
|
||
let blameLean: any = null;
|
||
|
||
const videoCaptureIdRaw = (claim.media as any)?.videoCaptureId;
|
||
const blameRequestIdStr = claim.blameRequestId?.toString();
|
||
|
||
const [videoCaptureRow, blameCase, blameLeanRows] = await Promise.all([
|
||
videoCaptureIdRaw
|
||
? this.claimVideoCaptureDbService.findById(String(videoCaptureIdRaw))
|
||
: Promise.resolve(null),
|
||
blameRequestIdStr
|
||
? this.buildBlameCaseSnapshotForClaimDetail(blameRequestIdStr)
|
||
: Promise.resolve(null),
|
||
claim.blameRequestId
|
||
? this.blameRequestDbService.find(
|
||
{ _id: new Types.ObjectId(claim.blameRequestId.toString()) },
|
||
{ lean: true, select: "type parties expert.decision" },
|
||
)
|
||
: Promise.resolve([]),
|
||
]);
|
||
|
||
if (Array.isArray(blameLeanRows) && blameLeanRows.length) {
|
||
blameLean = blameLeanRows[0] ?? null;
|
||
}
|
||
|
||
if (!hasClaimVehicle && blameLean) {
|
||
const fromBlame = this.damagedPartyVehicleFromBlame(blameLean, claim);
|
||
if (fromBlame) vehiclePayload = fromBlame;
|
||
}
|
||
|
||
const blameFileContext = blameLean
|
||
? this.blameFileContextForExpert(blameLean)
|
||
: {};
|
||
|
||
let videoCapture: ClaimDetailV2ResponseDto["videoCapture"] = undefined;
|
||
if (videoCaptureRow) {
|
||
const vc = videoCaptureRow as any;
|
||
videoCapture = {
|
||
id: String(vc._id ?? videoCaptureIdRaw),
|
||
fileName: vc.fileName,
|
||
path: vc.path,
|
||
url: vc.path ? buildFileLink(vc.path) : undefined,
|
||
};
|
||
}
|
||
|
||
const blameExpertDecision = blameCase
|
||
? this.serializeBlameExpertDecisionForClaimDetail(
|
||
(blameCase.expert as Record<string, unknown> | undefined)?.decision,
|
||
)
|
||
: undefined;
|
||
|
||
const money = (
|
||
claim as { money?: { sheba?: string; nationalCodeOfInsurer?: string } }
|
||
).money;
|
||
const moneyPayload =
|
||
money && (money.sheba != null || money.nationalCodeOfInsurer != null)
|
||
? {
|
||
sheba: money.sheba,
|
||
nationalCodeOfInsurer: money.nationalCodeOfInsurer,
|
||
}
|
||
: undefined;
|
||
|
||
const statusForExpertApi =
|
||
claim.status === ClaimCaseStatus.EXPERT_REVIEWING
|
||
? ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT
|
||
: claim.status;
|
||
|
||
const blameCaseForApi = blameCase
|
||
? this.sanitizeBlameCaseForClaimDetailApi(
|
||
blameCase as Record<string, unknown>,
|
||
)
|
||
: undefined;
|
||
|
||
const claimEvaluationRaw = (claim as any).evaluation as
|
||
| Record<string, unknown>
|
||
| undefined;
|
||
const enrichedEvaluation =
|
||
await this.enrichClaimEvaluationForExpert(claimEvaluationRaw);
|
||
|
||
let evaluationForApi: Record<string, unknown> | undefined;
|
||
if (enrichedEvaluation) {
|
||
evaluationForApi = {};
|
||
if (isFactorValidationPending) {
|
||
if (enrichedEvaluation.damageExpertReply !== undefined) {
|
||
evaluationForApi.damageExpertReply =
|
||
enrichedEvaluation.damageExpertReply;
|
||
}
|
||
if (enrichedEvaluation.damageExpertReplyFinal !== undefined) {
|
||
evaluationForApi.damageExpertReplyFinal =
|
||
enrichedEvaluation.damageExpertReplyFinal;
|
||
}
|
||
}
|
||
if (enrichedEvaluation.ownerInsurerApproval !== undefined) {
|
||
evaluationForApi.ownerInsurerApproval =
|
||
enrichedEvaluation.ownerInsurerApproval;
|
||
}
|
||
if (enrichedEvaluation.ownerPricedPartsApproval !== undefined) {
|
||
evaluationForApi.ownerPricedPartsApproval =
|
||
enrichedEvaluation.ownerPricedPartsApproval;
|
||
}
|
||
if (isDamageExpertPhase && enrichedEvaluation.priceDrop !== undefined) {
|
||
evaluationForApi.priceDrop = enrichedEvaluation.priceDrop;
|
||
}
|
||
if (Object.keys(evaluationForApi).length === 0) {
|
||
evaluationForApi = undefined;
|
||
}
|
||
}
|
||
|
||
return {
|
||
claimRequestId: claim._id.toString(),
|
||
publicId: claim.publicId,
|
||
status: statusForExpertApi,
|
||
claimStatus: claim.claimStatus || "PENDING",
|
||
currentStep: claim.workflow?.currentStep || "",
|
||
nextStep: claim.workflow?.nextStep,
|
||
locked: claim.workflow?.locked || false,
|
||
lockedBy: claim.workflow?.lockedBy
|
||
? {
|
||
actorId: claim.workflow.lockedBy.actorId?.toString(),
|
||
actorName: claim.workflow.lockedBy.actorName,
|
||
lockedAt: (claim.workflow as any).lockedAt?.toISOString(),
|
||
expiredAt: (claim.workflow as any).expiredAt?.toISOString(),
|
||
}
|
||
: undefined,
|
||
owner: claim.owner
|
||
? {
|
||
userId: claim.owner.userId?.toString(),
|
||
fullName: claim.owner.fullName,
|
||
}
|
||
: undefined,
|
||
vehicle: vehiclePayload
|
||
? this.sanitizeVehicleInquiryForApi(vehiclePayload)
|
||
: undefined,
|
||
...blameFileContext,
|
||
blameRequestId: claim.blameRequestId?.toString(),
|
||
blameRequestNo: claim.blameRequestNo,
|
||
money: moneyPayload,
|
||
otherParts: claim.damage?.otherParts,
|
||
requiredDocuments:
|
||
Object.keys(requiredDocumentsStatus).length > 0
|
||
? requiredDocumentsStatus
|
||
: undefined,
|
||
carAngles,
|
||
damagedParts,
|
||
awaitingFactorValidation: isFactorValidationPending,
|
||
evaluation: evaluationForApi as
|
||
| ClaimDetailV2ResponseDto["evaluation"]
|
||
| undefined,
|
||
videoCapture,
|
||
blameCase: blameCaseForApi,
|
||
blameExpertDecision,
|
||
createdAt: (claim as any).createdAt,
|
||
updatedAt: (claim as any).updatedAt,
|
||
};
|
||
}
|
||
|
||
/** V2 price-drop: claim locked by this expert during damage assessment. */
|
||
private async assertExpertCanEditClaimDuringReviewV2(
|
||
claim: any,
|
||
actor: any,
|
||
): Promise<void> {
|
||
if (actor.role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor);
|
||
await this.assertExpertActorOnClaim(claim, actor);
|
||
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
||
throw new BadRequestException(
|
||
`Claim must be EXPERT_REVIEWING to edit price drop. Current status: ${claim.status}`,
|
||
);
|
||
}
|
||
if (!claim.workflow?.locked) {
|
||
throw new ForbiddenException(
|
||
"You must lock the claim before entering price-drop data",
|
||
);
|
||
}
|
||
if (claim.workflow.lockedBy?.actorId?.toString() !== String(actor.sub)) {
|
||
throw new ForbiddenException("This claim is locked by another expert");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* V2: Context for manual price-drop (vehicle, inquiry year, catalog, damaged parts).
|
||
*/
|
||
async getPriceDropContextV2(claimRequestId: string, actor: any) {
|
||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||
if (!claim) {
|
||
throw new NotFoundException("Claim request not found");
|
||
}
|
||
await this.assertExpertCanEditClaimDuringReviewV2(claim, actor);
|
||
|
||
const carType = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
|
||
const selectedNorm = normalizeDamageSelectedParts(
|
||
claim.damage?.selectedParts,
|
||
carType,
|
||
(claim.damage as any)?.selectedOuterParts,
|
||
);
|
||
|
||
let suggestedCarModelYear: number | null = null;
|
||
if (claim.blameRequestId) {
|
||
const blameRows = await this.blameRequestDbService.find(
|
||
{ _id: new Types.ObjectId(claim.blameRequestId.toString()) },
|
||
{
|
||
lean: true,
|
||
select: "parties.role parties.person.userId parties.vehicle.inquiry",
|
||
},
|
||
);
|
||
const blame = blameRows[0] as Record<string, unknown> | undefined;
|
||
suggestedCarModelYear = resolveVehicleModelYearFromBlame(
|
||
claim,
|
||
blame as Parameters<typeof resolveVehicleModelYearFromBlame>[1],
|
||
);
|
||
}
|
||
|
||
const existing = (claim as any).evaluation?.priceDrop;
|
||
|
||
return {
|
||
claimRequestId: String(claim._id),
|
||
vehicle: {
|
||
carName: claim.vehicle?.carName,
|
||
carModel: claim.vehicle?.carModel,
|
||
carType: claim.vehicle?.carType,
|
||
},
|
||
suggestedCarModelYear,
|
||
priceDrop: existing ?? null,
|
||
severityOptions: PRICE_DROP_SEVERITY_OPTIONS,
|
||
priceDropCatalog: buildPriceDropCatalogForApi(),
|
||
damagedParts: buildDamagedPartsPriceDropLines(selectedNorm),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* V2: Expert enters vehicle + per-part severities; persists `vehicle` and `evaluation.priceDrop`.
|
||
*/
|
||
async upsertPriceDropV2(
|
||
claimRequestId: string,
|
||
body: UpsertClaimPriceDropV2Dto,
|
||
actor: any,
|
||
) {
|
||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||
if (!claim) {
|
||
throw new NotFoundException("Claim request not found");
|
||
}
|
||
await 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.",
|
||
);
|
||
}
|
||
|
||
// Manual path — change totalPriceDrop → total to match calculated path
|
||
const manualPayload = {
|
||
manualOverride: true,
|
||
total: body.manualPriceDrop, // ← was totalPriceDrop
|
||
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
|
||
// At the top of the calculated path, after the manualPriceDrop early return:
|
||
const existingPriceDrop = (claim as any).evaluation?.priceDrop;
|
||
if (
|
||
existingPriceDrop?.manualOverride === true &&
|
||
!body.partSeverities?.length
|
||
) {
|
||
throw new BadRequestException(
|
||
"This claim has a manual price-drop correction. " +
|
||
"To recalculate, provide partSeverities explicitly — this will replace the manual correction.",
|
||
);
|
||
}
|
||
|
||
const carType = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
|
||
const selectedNorm = normalizeDamageSelectedParts(
|
||
claim.damage?.selectedParts,
|
||
carType,
|
||
(claim.damage as any)?.selectedOuterParts,
|
||
);
|
||
|
||
if (!body.partSeverities?.length) {
|
||
throw new BadRequestException(
|
||
"Provide at least one partSeverities line, or use manualPriceDrop for a direct override.",
|
||
);
|
||
}
|
||
|
||
let carModelYear = body.carModelYear;
|
||
if (carModelYear == null && claim.blameRequestId) {
|
||
const blameRows = await this.blameRequestDbService.find(
|
||
{ _id: new Types.ObjectId(claim.blameRequestId.toString()) },
|
||
{
|
||
lean: true,
|
||
select: "parties.role parties.person.userId parties.vehicle.inquiry",
|
||
},
|
||
);
|
||
carModelYear =
|
||
resolveVehicleModelYearFromBlame(
|
||
claim,
|
||
blameRows[0] as Parameters<
|
||
typeof resolveVehicleModelYearFromBlame
|
||
>[1],
|
||
) ?? undefined;
|
||
}
|
||
if (carModelYear == null || !Number.isFinite(carModelYear)) {
|
||
throw new BadRequestException(
|
||
"carModelYear is required when it cannot be resolved from the linked blame plate inquiry.",
|
||
);
|
||
}
|
||
|
||
const { coefficients, lines, errors } = buildCoefficientsFromPartSeverities(
|
||
selectedNorm,
|
||
body.partSeverities.map((p) => ({
|
||
partId: p.partId,
|
||
severity: p.severity,
|
||
})),
|
||
carType,
|
||
);
|
||
if (errors.length > 0) {
|
||
throw new BadRequestException(errors.join(" "));
|
||
}
|
||
if (coefficients.length === 0) {
|
||
throw new BadRequestException(
|
||
"No valid severity coefficients could be derived from partSeverities.",
|
||
);
|
||
}
|
||
|
||
const calculated = calculateClaimPriceDrop(
|
||
body.carPrice,
|
||
carModelYear,
|
||
coefficients,
|
||
);
|
||
|
||
const priceDropPayload = {
|
||
...calculated,
|
||
partLines: lines,
|
||
};
|
||
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||
$set: {
|
||
...vehicleSet,
|
||
"evaluation.priceDrop": priceDropPayload,
|
||
},
|
||
});
|
||
|
||
const updated = await this.claimCaseDbService.findById(claimRequestId);
|
||
return {
|
||
claimRequestId,
|
||
vehicle: {
|
||
carName: updated?.vehicle?.carName,
|
||
carModel: updated?.vehicle?.carModel,
|
||
carType: updated?.vehicle?.carType,
|
||
},
|
||
priceDrop: priceDropPayload,
|
||
partLines: lines,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* V2: Damage expert can modify selected damaged parts before final submit.
|
||
* Allowed only when claim is EXPERT_REVIEWING and locked by this expert.
|
||
*/
|
||
async updateClaimDamagedPartsV2(
|
||
claimRequestId: string,
|
||
body: UpdateClaimDamagedPartsV2Dto,
|
||
actor: any,
|
||
) {
|
||
if (actor.role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor);
|
||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||
if (!claim) {
|
||
throw new NotFoundException("Claim request not found");
|
||
}
|
||
await this.assertExpertActorOnClaim(claim, actor);
|
||
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
||
throw new BadRequestException(
|
||
`Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`,
|
||
);
|
||
}
|
||
if (!claim.workflow?.locked) {
|
||
throw new ForbiddenException(
|
||
"You must lock the claim before modifying damaged parts",
|
||
);
|
||
}
|
||
if (claim.workflow.lockedBy?.actorId?.toString() !== actor.sub) {
|
||
throw new ForbiddenException("This claim is locked by another expert");
|
||
}
|
||
|
||
const damagedPartsEditSnapshot = await this.snapshotDamageExpert(actor.sub);
|
||
|
||
const carType = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
|
||
const previousNorm = normalizeDamageSelectedParts(
|
||
(claim as any).damage?.selectedParts,
|
||
carType,
|
||
(claim as any).damage?.selectedOuterParts,
|
||
);
|
||
const prevMedia = coerceDamagedPartsMediaToArray(
|
||
(claim as any).media?.damagedParts,
|
||
previousNorm,
|
||
);
|
||
|
||
const nextNorm = body.selectedParts.map((p) =>
|
||
sanitizeDamageSelectedPartV2(
|
||
{
|
||
id: p.id ?? null,
|
||
name: String(p.name || "").trim(),
|
||
side: String(p.side || ""),
|
||
label_fa:
|
||
String(p.label_fa || "").trim() || String(p.name || "").trim(),
|
||
...(p.catalogKey?.trim() ? { catalogKey: p.catalogKey.trim() } : {}),
|
||
},
|
||
carType,
|
||
),
|
||
);
|
||
|
||
const matchPreviousIndex = (
|
||
next: (typeof nextNorm)[0],
|
||
previous: typeof previousNorm,
|
||
): number => {
|
||
if (next.id != null) {
|
||
const byId = previous.findIndex((x) => x.id === next.id);
|
||
if (byId >= 0) return byId;
|
||
}
|
||
if (next.catalogKey) {
|
||
const byCk = previous.findIndex(
|
||
(x) => x.catalogKey === next.catalogKey,
|
||
);
|
||
if (byCk >= 0) return byCk;
|
||
}
|
||
return previous.findIndex(
|
||
(x) => x.name === next.name && x.side === next.side,
|
||
);
|
||
};
|
||
|
||
const nextMedia = nextNorm.map((sp) => {
|
||
const j = matchPreviousIndex(sp, previousNorm);
|
||
const row =
|
||
j >= 0 && prevMedia[j] && typeof prevMedia[j] === "object"
|
||
? (prevMedia[j] as Record<string, unknown>)
|
||
: {};
|
||
return {
|
||
...(sp.id != null ? { id: sp.id } : {}),
|
||
name: sp.name,
|
||
side: sp.side,
|
||
label_fa: sp.label_fa,
|
||
...(sp.catalogKey ? { catalogKey: sp.catalogKey } : {}),
|
||
...(row.path ? { path: row.path } : {}),
|
||
...(row.fileName ? { fileName: row.fileName } : {}),
|
||
...(row.url ? { url: row.url } : {}),
|
||
...(row.capturedAt ? { capturedAt: row.capturedAt } : {}),
|
||
};
|
||
});
|
||
|
||
const previous = Array.isArray((claim as any).damage?.selectedParts)
|
||
? [...(claim as any).damage.selectedParts]
|
||
: [];
|
||
|
||
// Diff to find parts the expert is adding that weren't in the original selection
|
||
const previousPartIds = new Set(
|
||
previousNorm.map((p) => p.id ?? `${p.name}::${p.side}`),
|
||
);
|
||
const expertAddedNow = nextNorm.filter(
|
||
(p) => !previousPartIds.has(p.id ?? `${p.name}::${p.side}`),
|
||
);
|
||
|
||
// Merge with parts the expert added in any previous edits on this claim (idempotent)
|
||
const existingExpertAdded: any[] =
|
||
(claim.damage as any)?.expertAddedParts ?? [];
|
||
const existingExpertAddedIds = new Set(
|
||
existingExpertAdded.map((p: any) => p.id ?? `${p.name}::${p.side}`),
|
||
);
|
||
const expertAddedToAppend = expertAddedNow.filter(
|
||
(p) => !existingExpertAddedIds.has(p.id ?? `${p.name}::${p.side}`),
|
||
);
|
||
const mergedExpertAdded = [...existingExpertAdded, ...expertAddedToAppend];
|
||
|
||
const $set: Record<string, unknown> = {
|
||
"damage.selectedParts": nextNorm,
|
||
"media.damagedParts": nextMedia,
|
||
"damage.expertAddedParts": mergedExpertAdded,
|
||
};
|
||
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||
$set,
|
||
$push: {
|
||
history: {
|
||
type: "EXPERT_DAMAGED_PARTS_UPDATED",
|
||
actor: {
|
||
actorId: new Types.ObjectId(actor.sub),
|
||
actorName: actor.fullName,
|
||
actorType: "damage_expert",
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
previousSelectedParts: previous,
|
||
selectedParts: nextNorm,
|
||
expertAddedParts: mergedExpertAdded,
|
||
...(damagedPartsEditSnapshot && {
|
||
expertProfileSnapshot: damagedPartsEditSnapshot,
|
||
}),
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
return {
|
||
claimRequestId: claim._id.toString(),
|
||
selectedParts: nextNorm,
|
||
previousSelectedParts: previous,
|
||
expertAddedParts: mergedExpertAdded,
|
||
message: "Damaged parts updated successfully.",
|
||
};
|
||
}
|
||
}
|