1
0
forked from Yara724/api

blame and claim refactored

This commit is contained in:
2026-02-24 12:19:25 +03:30
parent 0d8858f458
commit 35732dd70a
81 changed files with 11603 additions and 77 deletions

View File

@@ -29,6 +29,12 @@ import { UserType } from "src/Types&Enums/userType.enum";
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
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 { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.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 { 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";
@@ -146,6 +152,7 @@ export class ExpertClaimService {
private readonly blameVideoDbService: BlameVideoDbService,
private readonly claimVideoCaptureDbService: VideoCaptureDbService,
private readonly httpService: HttpService,
private readonly claimCaseDbService: ClaimCaseDbService,
private readonly sandHubService: SandHubService,
private readonly damageExpertDbService: DamageExpertDbService,
private readonly clientDbService: ClientDbService,
@@ -183,20 +190,45 @@ export class ExpertClaimService {
}
async getClaimRequestsListForExpert(actor): Promise<ClaimListDtoRs> {
console.log(actor);
// 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(
{
claimStatus: {
$in: [
ReqClaimStatus.UnChecked,
ReqClaimStatus.ReviewRequest,
ReqClaimStatus.CheckAgain,
ReqClaimStatus.CloseRequest,
ReqClaimStatus.InPersonVisit,
ReqClaimStatus.CheckedRequest,
ReqClaimStatus.PendingFactorValidation,
],
},
},
$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 = [];
@@ -1526,4 +1558,415 @@ export class ExpertClaimService {
this.logger.error(error.response?.responseCode, error.response);
throw new HttpException(error.response, error.claimStatus);
}
/**
* V2: Lock a claim request for expert review.
*
* Validations:
* - Claim must exist
* - Status must be WAITING_FOR_DAMAGE_EXPERT
* - Must not already be locked by another expert
*
* On success:
* - Sets workflow.locked = true, workflow.lockedBy = actor
* - Sets status = EXPERT_REVIEWING
* - Sets claimStatus = UNDER_REVIEW
* - Sets currentStep = EXPERT_DAMAGE_ASSESSMENT
*/
async lockClaimRequestV2(claimRequestId: string, actor: any) {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException('Claim request not found');
}
if (claim.status !== ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT) {
throw new BadRequestException(
`Claim is not available for locking. Current status: ${claim.status}`,
);
}
if (
claim.workflow?.locked &&
claim.workflow.lockedBy?.actorId?.toString() !== actor.sub
) {
throw new BadRequestException(
`Claim is already locked by another expert: ${claim.workflow.lockedBy?.actorName}`,
);
}
// Already locked by this same expert — idempotent
if (
claim.workflow?.locked &&
claim.workflow.lockedBy?.actorId?.toString() === actor.sub
) {
return { claimRequestId, locked: true, message: 'Already locked by you' };
}
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
status: ClaimCaseStatus.EXPERT_REVIEWING,
claimStatus: ClaimStatus.UNDER_REVIEW,
'workflow.locked': true,
'workflow.lockedAt': new Date(),
'workflow.lockedBy': {
actorId: new Types.ObjectId(actor.sub),
actorName: actor.fullName,
actorRole: 'damage_expert',
},
'workflow.currentStep': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
$push: {
history: {
event: 'CLAIM_LOCKED',
performedBy: actor.sub,
performedByName: actor.fullName,
performedAt: new Date(),
note: `Claim locked by damage expert ${actor.fullName}`,
},
},
});
return {
claimRequestId,
locked: true,
status: ClaimCaseStatus.EXPERT_REVIEWING,
claimStatus: ClaimStatus.UNDER_REVIEW,
currentStep: ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
};
}
/**
* 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 30,000,000
*
* On success:
* - Stores reply in evaluation.damageExpertReply
* - Unlocks the workflow
* - If any part has factorNeeded=true → status = WAITING_FOR_INSURER_APPROVAL, step = EXPERT_COST_EVALUATION
* - Otherwise → status = WAITING_FOR_INSURER_APPROVAL, step = INSURER_REVIEW, claimStatus = APPROVED
*/
async submitExpertReplyV2(
claimRequestId: string,
reply: import('./dto/expert-claim-v2.dto').SubmitExpertReplyV2Dto,
actor: any,
) {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException('Claim request not found');
}
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
const PRICE_CAP = 30_000_000;
let totalPrice = 0;
for (const part of reply.parts || []) {
const parsed = this.parsePersianNumber(String(part.totalPayment ?? '0'));
if (!isNaN(parsed)) totalPrice += parsed;
}
if (totalPrice > PRICE_CAP) {
throw new BadRequestException({
message: `Total price (${totalPrice.toLocaleString()}) exceeds the cap of ${PRICE_CAP.toLocaleString()}`,
error: 'PRICE_CAP_ERROR',
totalPrice,
priceCap: PRICE_CAP,
});
}
const needsFactorUpload = reply.parts?.some((p) => p.factorNeeded === true) ?? false;
const replyPayload = {
description: reply.description,
parts: reply.parts,
submittedAt: new Date(),
actorDetail: {
actorId: actor.sub,
actorName: actor.fullName,
},
};
const nextStep = needsFactorUpload
? ClaimWorkflowStep.EXPERT_COST_EVALUATION
: ClaimWorkflowStep.INSURER_REVIEW;
const nextCaseStatus = ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL;
const nextClaimStatus = needsFactorUpload
? ClaimStatus.NEEDS_REVISION
: ClaimStatus.APPROVED;
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
status: nextCaseStatus,
claimStatus: nextClaimStatus,
'workflow.locked': false,
'workflow.currentStep': nextStep,
'workflow.nextStep': needsFactorUpload
? ClaimWorkflowStep.INSURER_REVIEW
: ClaimWorkflowStep.CLAIM_COMPLETED,
'evaluation.damageExpertReply': replyPayload,
$push: {
'workflow.completedSteps': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
history: {
event: 'EXPERT_REPLY_SUBMITTED',
performedBy: actor.sub,
performedByName: actor.fullName,
performedAt: new Date(),
note: `Expert reply submitted. factorNeeded=${needsFactorUpload}`,
},
},
});
return {
claimRequestId,
status: nextCaseStatus,
claimStatus: nextClaimStatus,
currentStep: nextStep,
factorNeeded: needsFactorUpload,
};
}
/**
* 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) {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException('Claim request not found');
}
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');
}
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
claimStatus: ClaimStatus.NEEDS_REVISION,
'workflow.locked': false,
...(note ? { 'evaluation.visitLocation': note } : {}),
$push: {
history: {
event: 'IN_PERSON_VISIT_REQUESTED',
performedBy: actor.sub,
performedByName: actor.fullName,
performedAt: new Date(),
note: note || 'Expert requested in-person visit',
},
},
});
return {
claimRequestId,
status: claim.status,
claimStatus: ClaimStatus.NEEDS_REVISION,
message: 'In-person visit requested. User will be notified.',
};
}
/**
* V2: Get claim list for damage expert
*
* Returns claims that are:
* 1. WAITING_FOR_DAMAGE_EXPERT status AND not locked (available for anyone to pick)
* 2. WAITING_FOR_DAMAGE_EXPERT status AND not locked (claimStatus PENDING)
* 3. Any status locked by THIS expert (their own in-progress work)
*/
async getClaimListV2(actorId: string): Promise<GetClaimListV2ResponseDto> {
const claims = await this.claimCaseDbService.find({
$or: [
// Available claims: waiting for expert, not locked
{
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
'workflow.locked': { $ne: true },
},
// This expert's own locked/in-progress claims
{
'workflow.locked': true,
'workflow.lockedBy.actorId': new Types.ObjectId(actorId),
},
],
});
const list = (claims as any[]).map((c) => ({
claimRequestId: c._id.toString(),
publicId: c.publicId,
status: c.status,
currentStep: c.workflow?.currentStep || '',
locked: c.workflow?.locked || false,
lockedBy: c.workflow?.lockedBy
? {
actorId: c.workflow.lockedBy.actorId?.toString(),
actorName: c.workflow.lockedBy.actorName,
}
: undefined,
vehicle: c.vehicle
? {
carName: c.vehicle.carName,
carModel: c.vehicle.carModel,
carType: c.vehicle.carType,
}
: undefined,
createdAt: c.createdAt,
})) as ClaimListItemV2Dto[];
return { list, total: list.length };
}
/**
* V2: Get claim detail for damage expert
*
* Validations:
* - Claim must exist
* - Claim status must be WAITING_FOR_DAMAGE_EXPERT
* - If claim is locked, only the locking expert can access it
*/
async getClaimDetailV2(
claimRequestId: string,
actorId: string,
): Promise<ClaimDetailV2ResponseDto> {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException('Claim request not found');
}
if (claim.status !== ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT) {
throw new ForbiddenException(
`This claim is not available for expert review. Current status: ${claim.status}`,
);
}
// If locked by someone else, deny access
if (
claim.workflow?.locked &&
claim.workflow.lockedBy?.actorId?.toString() !== actorId
) {
throw new ForbiddenException(
`This claim is locked by another expert: ${claim.workflow.lockedBy?.actorName}`,
);
}
const hasCapture = (data: any, key: string) =>
data && (data instanceof Map ? data.get(key) : data[key]);
// Build requiredDocuments map
const requiredDocs = claim.requiredDocuments as any;
const requiredDocumentsStatus: Record<string, any> = {};
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];
requiredDocumentsStatus[k] = {
uploaded: !!doc?.uploaded,
fileId: doc?.fileId?.toString(),
fileName: doc?.fileName,
filePath: doc?.filePath ? buildFileLink(doc.filePath) : undefined,
};
}
}
// Build car angles map
const carAnglesData = claim.media?.carAngles as any;
const carAngles: Record<string, { captured: boolean; url?: string }> = {};
for (const k of ['front', 'back', 'left', 'right']) {
const cap = hasCapture(carAnglesData, k);
carAngles[k] = {
captured: !!cap,
url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined),
};
}
// Build damaged parts map
const damagedPartsData = claim.media?.damagedParts as any;
const damagedParts: Record<string, { captured: boolean; url?: string }> = {};
for (const p of claim.damage?.selectedParts || []) {
const cap = hasCapture(damagedPartsData, p);
damagedParts[p] = {
captured: !!cap,
url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined),
};
}
return {
claimRequestId: claim._id.toString(),
publicId: claim.publicId,
status: claim.status,
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(),
}
: undefined,
owner: claim.owner
? {
userId: claim.owner.userId?.toString(),
fullName: claim.owner.fullName,
}
: undefined,
vehicle: claim.vehicle,
blameRequestId: claim.blameRequestId?.toString(),
blameRequestNo: claim.blameRequestNo,
selectedParts: claim.damage?.selectedParts,
otherParts: claim.damage?.otherParts,
requiredDocuments:
Object.keys(requiredDocumentsStatus).length > 0
? requiredDocumentsStatus
: undefined,
carAngles,
damagedParts,
createdAt: (claim as any).createdAt,
updatedAt: (claim as any).updatedAt,
};
}
}