Added v3 of field-expert

This commit is contained in:
SepehrYahyaee
2026-06-22 13:05:11 +03:30
parent 89e715b0c9
commit 8f29bb564c
15 changed files with 2988 additions and 622 deletions

View File

@@ -43,6 +43,7 @@ import {
SelectOtherPartsV2Dto,
SelectOtherPartsV2ResponseDto,
} from "./dto/select-other-parts-v2.dto";
import { SelectOtherPartsV3Dto } from "./dto/select-other-parts-v3.dto";
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
import {
UploadRequiredDocumentV2Dto,
@@ -82,6 +83,7 @@ import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-managem
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 { CaseStatus as BlameCaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
import { WorkflowStep } from "src/Types&Enums/blame-request-management/blameWorkflow-steps.enum";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { ClaimSignDbService } from "./entites/db-service/claim-sign.db.service";
import { DamageImageDbService } from "./entites/db-service/damage-image.db.service";
@@ -5060,6 +5062,101 @@ export class ClaimRequestManagementService {
};
}
/**
* V2: Registrar creates a claim from a registrar-initiated IN_PERSON completed blame.
* Mirrors createClaimFromBlameForRegistrarV1 but uses V2 naming conventions.
*/
async createClaimFromBlameForRegistrarV2(
blameRequestId: string,
registrar: { sub: string; firstName?: string; lastName?: string },
): Promise<CreateClaimFromBlameResponseDto> {
const blameRequest =
await this.blameRequestDbService.findById(blameRequestId);
if (!blameRequest) throw new NotFoundException("Blame request not found");
if (blameRequest.status !== BlameCaseStatus.COMPLETED) {
throw new BadRequestException(
`Blame request must be COMPLETED. Current status: ${blameRequest.status}`,
);
}
if (
!blameRequest.registrarInitiated ||
blameRequest.creationMethod !== "IN_PERSON" ||
!blameRequest.initiatedByRegistrarId
) {
throw new BadRequestException(
"This endpoint is only for registrar-initiated IN_PERSON blame files.",
);
}
if (String(blameRequest.initiatedByRegistrarId) !== String(registrar.sub)) {
throw new ForbiddenException(
"Only the registrar who created this blame file can create the claim.",
);
}
const ownerFields = resolveClaimOwnerFieldsFromBlame(blameRequest);
if (!ownerFields) {
throw new BadRequestException(
"Could not resolve claim owner (damaged party) from blame",
);
}
const existingClaim = await this.claimCaseDbService.findOne({
blameRequestId: new Types.ObjectId(blameRequestId),
});
if (existingClaim)
throw new ConflictException("A claim for this blame case already exists");
const claimNo = await this.generateUniqueClaimNumber();
const newClaim = await this.claimCaseDbService.create({
requestNo: claimNo,
publicId: blameRequest.publicId,
blameRequestId: new Types.ObjectId(blameRequestId),
blameRequestNo: blameRequest.requestNo,
status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
claimStatus: ClaimStatus.PENDING,
inquiries: (blameRequest as any).inquiries ?? {},
workflow: {
currentStep: ClaimWorkflowStep.SELECT_OUTER_PARTS,
nextStep: ClaimWorkflowStep.SELECT_OTHER_PARTS,
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
locked: false,
},
damagedPartyUserId: new Types.ObjectId(ownerFields.userId),
owner: {
userId: new Types.ObjectId(ownerFields.userId),
userRole: ownerFields.userRole as any,
...(ownerFields.clientId
? {
clientId: new Types.ObjectId(ownerFields.clientId),
userClientKey: ownerFields.clientId,
}
: {}),
},
createdByRegistrarId: new Types.ObjectId(registrar.sub),
history: [
{
type: "CLAIM_CREATED",
actor: {
actorId: new Types.ObjectId(registrar.sub),
actorName:
`${registrar.firstName || ""} ${registrar.lastName || ""}`.trim(),
actorType: "registrar",
},
timestamp: new Date(),
metadata: {
blameRequestId,
blamePublicId: blameRequest.publicId,
createdByRegistrar: true,
},
},
],
} as any);
return {
claimRequestId: String(newClaim._id),
publicId: blameRequest.publicId,
status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
message:
"Claim created successfully. Registrar can now fill claim data on behalf of the damaged party.",
};
}
/**
* V2 API: Select damaged outer car parts for claim (Step 2 of claim workflow)
*
@@ -7660,4 +7757,405 @@ export class ClaimRequestManagementService {
const randomPart = Math.floor(10000 + Math.random() * 90000); // 5 digits
return `${prefix}${randomPart}`;
}
private async loadBlameForV3Claim(claimCase: any) {
if (!claimCase?.blameRequestId) return null;
return this.blameRequestDbService.findById(
claimCase.blameRequestId.toString(),
);
}
private hasPartyInquiriesOnBlame(blame: any, role: "FIRST" | "SECOND"): boolean {
const step =
role === "FIRST"
? WorkflowStep.FIRST_INITIAL_FORM
: WorkflowStep.SECOND_INITIAL_FORM;
return (blame.workflow?.completedSteps ?? []).includes(step);
}
private partyHasSignedOnBlame(blame: any, role: "FIRST" | "SECOND"): boolean {
const party = (blame.parties ?? []).find((p: any) => p?.role === role);
return !!party?.confirmation;
}
private assertV3ClaimDocumentsPhase(blame: any): void {
if (!(blame.expert?.decision as any)?.fields?.accidentWay) {
throw new BadRequestException(
"Submit accident fields before uploading required documents.",
);
}
const isCarBody =
blame?.type === BlameRequestType.CAR_BODY || blame?.type === "CAR_BODY";
if (isCarBody) {
if (!this.partyHasSignedOnBlame(blame, "FIRST")) {
throw new BadRequestException(
"Upload documents after the party has signed.",
);
}
return;
}
if (
!this.partyHasSignedOnBlame(blame, "FIRST") ||
!this.partyHasSignedOnBlame(blame, "SECOND")
) {
throw new BadRequestException(
"Both parties must sign before uploading required documents.",
);
}
}
private assertV3ClaimPartsPhase(blame: any): void {
if (!(blame.expert?.decision as any)?.fields?.accidentWay) {
throw new BadRequestException(
"Submit accident fields before part selection.",
);
}
const isCarBody =
blame?.type === BlameRequestType.CAR_BODY || blame?.type === "CAR_BODY";
if (isCarBody) {
if (!this.partyHasSignedOnBlame(blame, "FIRST")) {
throw new BadRequestException(
"Part selection requires the party signature.",
);
}
return;
}
if (
!this.partyHasSignedOnBlame(blame, "FIRST") ||
!this.partyHasSignedOnBlame(blame, "SECOND")
) {
throw new BadRequestException(
"Both parties must sign before part selection.",
);
}
}
private claimV3StepCompleted(
claimCase: any,
step: ClaimWorkflowStep,
): boolean {
return (claimCase.workflow?.completedSteps ?? []).includes(step);
}
private assertV3ClaimWorkflowStep(
claimCase: any,
expected: ClaimWorkflowStep | ClaimWorkflowStep[],
hint?: string,
): void {
const allowed = Array.isArray(expected) ? expected : [expected];
const current = claimCase.workflow?.currentStep;
if (!allowed.includes(current)) {
throw new BadRequestException(
hint ??
`Invalid workflow step. Expected ${allowed.join(" or ")}, but current step is ${current}`,
);
}
}
private async advanceV3ClaimToOuterPartsIfReady(
claimCase: any,
blame: any,
): Promise<void> {
if (claimCase.workflow?.currentStep === ClaimWorkflowStep.SELECT_OUTER_PARTS) {
return;
}
if (claimCase.workflow?.currentStep !== ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS) {
throw new BadRequestException(
`Complete required documents before selecting outer parts. Current step: ${claimCase.workflow?.currentStep}`,
);
}
if (!(blame.expert?.decision as any)?.fields?.accidentWay) {
throw new BadRequestException(
"Submit accident fields before selecting outer parts.",
);
}
await this.claimCaseDbService.findByIdAndUpdate(String(claimCase._id), {
$set: {
status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
"workflow.currentStep": ClaimWorkflowStep.SELECT_OUTER_PARTS,
"workflow.nextStep": ClaimWorkflowStep.SELECT_OTHER_PARTS,
},
});
}
private async assertV3InPersonClaim(claimCase: any) {
const blame = await this.loadBlameForV3Claim(claimCase);
if (!blame) {
throw new BadRequestException("V3 claim flow requires a linked blame file.");
}
if (blame.creationMethod !== CreationMethod.IN_PERSON) {
throw new BadRequestException("V3 claim flow is only for IN_PERSON blame files.");
}
if (!blame.expertInitiated) {
throw new BadRequestException("V3 claim flow requires an expert-initiated blame file.");
}
return blame;
}
async uploadRequiredDocumentV3(
claimRequestId: string,
body: UploadRequiredDocumentV2Dto,
file: Express.Multer.File,
currentUserId: string,
actor?: { sub: string; role?: string },
): Promise<UploadRequiredDocumentV2ResponseDto> {
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
if (!claimCase) {
throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`);
}
const blame = await this.assertV3InPersonClaim(claimCase);
this.assertV3ClaimDocumentsPhase(blame);
const step = claimCase.workflow?.currentStep;
if (step !== ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS) {
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: {
status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
"workflow.currentStep": ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
},
});
}
return this.uploadRequiredDocumentV2(
claimRequestId,
body,
file,
currentUserId,
actor,
);
}
async getCaptureRequirementsV3(
claimRequestId: string,
currentUserId: string,
actor?: { sub: string; role?: string },
): Promise<GetCaptureRequirementsV2ResponseDto> {
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
if (!claimCase) {
throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`);
}
const blame = await this.assertV3InPersonClaim(claimCase);
this.assertV3ClaimDocumentsPhase(blame);
return this.getCaptureRequirementsV2(claimRequestId, currentUserId, actor);
}
async selectOuterPartsV3(
claimRequestId: string,
body: SelectOuterPartsV2Dto,
currentUserId: string,
actor?: { sub: string; role?: string },
): Promise<SelectOuterPartsV2ResponseDto> {
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
if (!claimCase) {
throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`);
}
const blame = await this.assertV3InPersonClaim(claimCase);
this.assertV3ClaimPartsPhase(blame);
await this.advanceV3ClaimToOuterPartsIfReady(claimCase, blame);
const refreshed = await this.claimCaseDbService.findById(claimRequestId);
this.assertV3ClaimWorkflowStep(
refreshed,
ClaimWorkflowStep.SELECT_OUTER_PARTS,
"Select outer parts after accident fields and required documents.",
);
return this.selectOuterPartsV2(claimRequestId, body, currentUserId, actor);
}
async selectOtherPartsV3(
claimRequestId: string,
body: SelectOtherPartsV3Dto,
currentUserId: string,
actor?: { sub: string; role?: string },
file?: Express.Multer.File,
): Promise<SelectOtherPartsV2ResponseDto> {
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
if (!claimCase) {
throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`);
}
const blame = await this.assertV3InPersonClaim(claimCase);
this.assertV3ClaimPartsPhase(blame);
this.assertV3ClaimWorkflowStep(
claimCase,
ClaimWorkflowStep.SELECT_OTHER_PARTS,
"Select outer parts before other parts.",
);
if (!this.claimV3StepCompleted(claimCase, ClaimWorkflowStep.SELECT_OUTER_PARTS)) {
throw new BadRequestException(
"Complete outer part selection before selecting other parts.",
);
}
let otherParts = Array.isArray((body as any)?.otherParts)
? (body as any).otherParts
: [];
if (
!Array.isArray((body as any)?.otherParts) &&
typeof (body as any)?.otherParts === "string"
) {
try {
const parsed = JSON.parse((body as any).otherParts);
otherParts = Array.isArray(parsed) ? parsed : [];
} catch {
otherParts = [];
}
}
const shebaNumber = claimCase.money?.sheba;
const nationalCode = claimCase.money?.nationalCodeOfInsurer;
if (!shebaNumber || !nationalCode) {
throw new BadRequestException(
"Bank information is missing on the claim. Complete run-inquiries for both parties first.",
);
}
const updatePayload: any = {
"damage.otherParts": otherParts.length > 0 ? otherParts : undefined,
status: ClaimCaseStatus.CAPTURING_PART_DAMAGES,
"workflow.currentStep": ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
"workflow.nextStep": ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
$push: {
"workflow.completedSteps": ClaimWorkflowStep.SELECT_OTHER_PARTS,
history: {
type: "V3_STEP_COMPLETED",
actor: {
actorId: new Types.ObjectId(currentUserId),
actorName: claimCase.owner?.fullName || "Actor",
actorType: actor?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert",
},
timestamp: new Date(),
metadata: {
stepKey: ClaimWorkflowStep.SELECT_OTHER_PARTS,
otherParts,
v3SelectionOnly: true,
},
},
},
};
if (file) {
const docRef = await this.claimRequiredDocumentDbService.create({
path: file.path,
fileName: file.filename,
claimId: new Types.ObjectId(claimRequestId),
documentType: ClaimRequiredDocumentType.CAR_GREEN_CARD,
uploadedAt: new Date(),
});
updatePayload[`requiredDocuments.${ClaimRequiredDocumentType.CAR_GREEN_CARD}`] = {
fileId: docRef._id,
filePath: file.path,
fileName: file.filename,
uploaded: true,
uploadedAt: new Date(),
};
}
const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate(
claimRequestId,
updatePayload,
);
if (!updatedClaim) {
throw new InternalServerErrorException("Failed to update claim case");
}
return {
claimRequestId: updatedClaim._id.toString(),
publicId: updatedClaim.publicId,
otherParts,
shebaNumber: String(shebaNumber).replace(/^(.{4})(.*)(.{4})$/, "IR$1************$3"),
nationalCodeOfOwner: String(nationalCode).replace(/^(.{2})(.*)(.{2})$/, "$1******$3"),
currentStep: ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
nextStep: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
message:
"Other parts saved. Proceed to damaged-part photos and car angles (capture-part).",
};
}
async setVideoCaptureV3(
claimRequestId: string,
fileDetail: Express.Multer.File,
currentUserId: string,
actor?: { sub: string; role?: string },
): Promise<VideoCaptureV2ResponseDto> {
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
if (!claimCase) {
throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`);
}
await this.assertV3InPersonClaim(claimCase);
if (!fileDetail) {
throw new BadRequestException("Video file is required.");
}
if (claimCase.media?.videoCaptureId) {
throw new ConflictException(
"A walk-around video has already been uploaded for this claim.",
);
}
if (
claimCase.workflow?.currentStep !== ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS
) {
throw new BadRequestException(
`Complete all part/angle captures first. Expected ${ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS}, but current step is ${claimCase.workflow?.currentStep}`,
);
}
const captureProgress = getClaimCaptureProgress(claimCase);
if (!captureProgress.partsComplete) {
throw new BadRequestException(
"Upload photos for all selected damaged parts before the walk-around video.",
);
}
if (!captureProgress.anglesComplete) {
throw new BadRequestException(
"Capture all four car angles before the walk-around video.",
);
}
if (!isClaimCaptureStepComplete(claimCase)) {
throw new BadRequestException(
"Upload capture-phase vehicle documents (chassis, engine, metal plate) before the walk-around video.",
);
}
return this.setVideoCaptureV2(
claimRequestId,
fileDetail,
currentUserId,
actor,
);
}
async capturePartV3(
claimRequestId: string,
body: CapturePartV2Dto,
file: Express.Multer.File,
currentUserId: string,
actor?: { sub: string; role?: string },
): Promise<CapturePartV2ResponseDto> {
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
if (!claimCase) {
throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`);
}
const blame = await this.assertV3InPersonClaim(claimCase);
this.assertV3ClaimPartsPhase(blame);
this.assertV3ClaimWorkflowStep(
claimCase,
ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
"Select other parts before capturing damaged parts and angles.",
);
if (!this.claimV3StepCompleted(claimCase, ClaimWorkflowStep.SELECT_OTHER_PARTS)) {
throw new BadRequestException(
"Complete other part selection before capture.",
);
}
return this.capturePartV2(
claimRequestId,
body,
file,
currentUserId,
actor,
);
}
}