forked from Yara724/api
blame and claim refactored
This commit is contained in:
77
src/expert-claim/dto/claim-detail-v2.dto.ts
Normal file
77
src/expert-claim/dto/claim-detail-v2.dto.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class ClaimDetailV2ResponseDto {
|
||||
@ApiProperty()
|
||||
claimRequestId: string;
|
||||
|
||||
@ApiProperty()
|
||||
publicId: string;
|
||||
|
||||
@ApiProperty()
|
||||
status: string;
|
||||
|
||||
@ApiProperty()
|
||||
claimStatus: string;
|
||||
|
||||
@ApiProperty()
|
||||
currentStep: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
nextStep?: string;
|
||||
|
||||
@ApiProperty({ description: 'Whether claim is locked' })
|
||||
locked: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
lockedBy?: {
|
||||
actorId: string;
|
||||
actorName: string;
|
||||
lockedAt: string;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional()
|
||||
owner?: {
|
||||
userId: string;
|
||||
fullName?: string;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional()
|
||||
vehicle?: {
|
||||
carName?: string;
|
||||
carModel?: string;
|
||||
carType?: string;
|
||||
plate?: any;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({ description: 'Blame request ID', example: '507f...' })
|
||||
blameRequestId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Blame request number', example: 'BL12345' })
|
||||
blameRequestNo?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Selected outer damaged parts' })
|
||||
selectedParts?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Selected other damaged parts' })
|
||||
otherParts?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Required documents status' })
|
||||
requiredDocuments?: Record<string, {
|
||||
uploaded: boolean;
|
||||
fileId?: string;
|
||||
fileName?: string;
|
||||
filePath?: string;
|
||||
}>;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Car angles captured' })
|
||||
carAngles?: Record<string, { captured: boolean; url?: string }>;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Damaged parts captured' })
|
||||
damagedParts?: Record<string, { captured: boolean; url?: string }>;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt: string;
|
||||
|
||||
@ApiProperty()
|
||||
updatedAt: string;
|
||||
}
|
||||
42
src/expert-claim/dto/claim-list-v2.dto.ts
Normal file
42
src/expert-claim/dto/claim-list-v2.dto.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class ClaimListItemV2Dto {
|
||||
@ApiProperty({ description: 'Claim case ID' })
|
||||
claimRequestId: string;
|
||||
|
||||
@ApiProperty({ description: 'Public ID shared across blame+claim', example: 'A14235' })
|
||||
publicId: string;
|
||||
|
||||
@ApiProperty({ description: 'Overall case status', example: 'WAITING_FOR_DAMAGE_EXPERT' })
|
||||
status: string;
|
||||
|
||||
@ApiProperty({ description: 'Workflow step', example: 'USER_SUBMISSION_COMPLETE' })
|
||||
currentStep: string;
|
||||
|
||||
@ApiProperty({ description: 'Whether the claim is locked by another expert', example: false })
|
||||
locked: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Actor who locked this claim' })
|
||||
lockedBy?: {
|
||||
actorId: string;
|
||||
actorName: string;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({ description: 'Vehicle info from snapshot' })
|
||||
vehicle?: {
|
||||
carName?: string;
|
||||
carModel?: string;
|
||||
carType?: string;
|
||||
};
|
||||
|
||||
@ApiProperty({ description: 'Submission date', example: '2026-02-22T10:00:00.000Z' })
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export class GetClaimListV2ResponseDto {
|
||||
@ApiProperty({ type: [ClaimListItemV2Dto] })
|
||||
list: ClaimListItemV2Dto[];
|
||||
|
||||
@ApiProperty({ description: 'Total count' })
|
||||
total: number;
|
||||
}
|
||||
70
src/expert-claim/dto/expert-claim-v2.dto.ts
Normal file
70
src/expert-claim/dto/expert-claim-v2.dto.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class CarPartDamageV2Dto {
|
||||
@ApiProperty({ example: 'left' })
|
||||
@IsString()
|
||||
side: string;
|
||||
|
||||
@ApiProperty({ example: 'front_door' })
|
||||
@IsString()
|
||||
part: string;
|
||||
}
|
||||
|
||||
export class PartPricingV2Dto {
|
||||
@ApiProperty({ example: 'part-001' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
partId: string;
|
||||
|
||||
@ApiProperty({ type: CarPartDamageV2Dto })
|
||||
@ValidateNested()
|
||||
@Type(() => CarPartDamageV2Dto)
|
||||
carPartDamage: CarPartDamageV2Dto;
|
||||
|
||||
@ApiProperty({ example: 'Minor' })
|
||||
@IsString()
|
||||
typeOfDamage: string;
|
||||
|
||||
@ApiProperty({ example: '5000000' })
|
||||
@IsString()
|
||||
price: string;
|
||||
|
||||
@ApiProperty({ example: '2000000' })
|
||||
@IsString()
|
||||
salary: string;
|
||||
|
||||
@ApiProperty({ example: '7000000' })
|
||||
@IsString()
|
||||
totalPayment: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '500000' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
daghi?: string;
|
||||
|
||||
@ApiProperty({ example: false })
|
||||
@IsBoolean()
|
||||
factorNeeded: boolean;
|
||||
}
|
||||
|
||||
export class SubmitExpertReplyV2Dto {
|
||||
@ApiProperty({ example: 'Front door and hood have severe paint damage' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
description: string;
|
||||
|
||||
@ApiProperty({ type: [PartPricingV2Dto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PartPricingV2Dto)
|
||||
parts: PartPricingV2Dto[];
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { RequestManagementModule } from "src/request-management/request-manageme
|
||||
import { SandHubModule } from "src/sand-hub/sand-hub.module";
|
||||
import { UsersModule } from "src/users/users.module";
|
||||
import { ExpertClaimController } from "./expert-claim.controller";
|
||||
import { ExpertClaimV2Controller } from "./expert-claim.v2.controller";
|
||||
import { ExpertClaimService } from "./expert-claim.service";
|
||||
|
||||
@Module({
|
||||
@@ -33,7 +34,7 @@ import { ExpertClaimService } from "./expert-claim.service";
|
||||
UsersModule,
|
||||
ClientModule,
|
||||
],
|
||||
controllers: [ExpertClaimController],
|
||||
controllers: [ExpertClaimController, ExpertClaimV2Controller],
|
||||
providers: [ExpertClaimService, ClaimFactorsImageDbService],
|
||||
exports: [ClaimFactorsImageDbService, ExpertClaimService],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
110
src/expert-claim/expert-claim.v2.controller.ts
Normal file
110
src/expert-claim/expert-claim.v2.controller.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Body, Controller, Get, Param, Patch, Put, UseGuards } from "@nestjs/common";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiPropertyOptional,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import { IsOptional, IsString } from "class-validator";
|
||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
import { Roles } from "src/decorators/roles.decorator";
|
||||
import { CurrentUser } from "src/decorators/user.decorator";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { ExpertClaimService } from "./expert-claim.service";
|
||||
import { SubmitExpertReplyV2Dto } from "./dto/expert-claim-v2.dto";
|
||||
|
||||
class InPersonVisitV2Dto {
|
||||
@ApiPropertyOptional({ example: 'Paint damage requires physical inspection' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
}
|
||||
|
||||
@ApiTags("expert-claim-panel (v2)")
|
||||
@Controller("v2/expert-claim")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||
@Roles(RoleEnum.DAMAGE_EXPERT)
|
||||
export class ExpertClaimV2Controller {
|
||||
constructor(private readonly expertClaimService: ExpertClaimService) {}
|
||||
|
||||
@Get("requests")
|
||||
@ApiOperation({
|
||||
summary: "List available claim requests for damage expert",
|
||||
description:
|
||||
"Returns claims that are WAITING_FOR_DAMAGE_EXPERT and not locked by another expert, plus this expert's own locked/in-progress claims.",
|
||||
})
|
||||
async getClaimListV2(@CurrentUser() actor) {
|
||||
return await this.expertClaimService.getClaimListV2(actor.sub);
|
||||
}
|
||||
|
||||
@Get("request/:claimRequestId")
|
||||
@ApiOperation({
|
||||
summary: "Get claim request detail for damage expert",
|
||||
description:
|
||||
"Returns full claim details including captured images, required documents status, and damage selections. Claim must have status WAITING_FOR_DAMAGE_EXPERT. If locked, only the locking expert can access it.",
|
||||
})
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
async getClaimDetailV2(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@CurrentUser() actor,
|
||||
) {
|
||||
return await this.expertClaimService.getClaimDetailV2(
|
||||
claimRequestId,
|
||||
actor.sub,
|
||||
);
|
||||
}
|
||||
|
||||
@Put("lock/:claimRequestId")
|
||||
@ApiOperation({
|
||||
summary: "Lock a claim request for review",
|
||||
description:
|
||||
"Claim must have status WAITING_FOR_DAMAGE_EXPERT. Locking sets status to EXPERT_REVIEWING and claimStatus to UNDER_REVIEW. Only one expert can lock a claim at a time.",
|
||||
})
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
async lockClaimRequestV2(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@CurrentUser() actor,
|
||||
) {
|
||||
return await this.expertClaimService.lockClaimRequestV2(claimRequestId, actor);
|
||||
}
|
||||
|
||||
@Put("reply/submit/:claimRequestId")
|
||||
@ApiOperation({
|
||||
summary: "Submit expert damage assessment reply",
|
||||
description:
|
||||
"Claim must be locked by this expert (status EXPERT_REVIEWING). Submitting unlocks the claim. If any part has factorNeeded=true the claim moves to EXPERT_COST_EVALUATION; otherwise moves to INSURER_REVIEW. Total payment cap is 30,000,000.",
|
||||
})
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiBody({ type: SubmitExpertReplyV2Dto })
|
||||
async submitExpertReplyV2(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: SubmitExpertReplyV2Dto,
|
||||
@CurrentUser() actor,
|
||||
) {
|
||||
return await this.expertClaimService.submitExpertReplyV2(claimRequestId, body, actor);
|
||||
}
|
||||
|
||||
@Patch(":claimRequestId/visit")
|
||||
@ApiOperation({
|
||||
summary: "Request in-person visit for claim",
|
||||
description:
|
||||
"Expert decides the user must come in person. Claim must be locked by this expert. Sets claimStatus to NEEDS_REVISION and unlocks the claim so the user is informed.",
|
||||
})
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiBody({ type: InPersonVisitV2Dto, required: false })
|
||||
async requestInPersonVisitV2(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: InPersonVisitV2Dto,
|
||||
@CurrentUser() actor,
|
||||
) {
|
||||
return await this.expertClaimService.requestInPersonVisitV2(
|
||||
claimRequestId,
|
||||
actor,
|
||||
body?.note,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user