fix: harden claim review and inquiry workflows

Preserve damage history and current vehicle price, restore depreciation mapping, normalize inquiry/report output, and support resumable expert review with paginated case retrieval.
This commit is contained in:
2026-09-18 16:04:33 +03:30
parent a84d83a135
commit 7d1a50db7b
38 changed files with 2345 additions and 370 deletions

View File

@@ -5,6 +5,9 @@ import { RoleEnum } from "src/Types&Enums/role.enum";
import { CreationMethod } from "./entities/schema/request-management.schema";
import { PartyRole } from "./entities/schema/partyRole.enum";
import { RequestManagementService } from "./request-management.service";
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
describe("RequestManagementService V4 FileMaker workflow", () => {
it("persists FIRST_INITIAL_FORM after the first party OTP is verified", async () => {
@@ -216,4 +219,138 @@ describe("RequestManagementService V4 FileMaker workflow", () => {
WorkflowStep.FIRST_VIDEO,
);
});
it.each([false, true])(
"resumes V4/V5 FileMaker work from the linked claim during partial document upload (approval=%s)",
async (requiresFileMakerApproval) => {
const fileMakerId = new Types.ObjectId();
const blameId = new Types.ObjectId();
const claimId = new Types.ObjectId();
const request = {
_id: blameId,
publicId: "BL-FILE-MAKER-RESUME",
requestNo: "BL-RESUME",
type: BlameRequestType.THIRD_PARTY,
status: CaseStatus.OPEN,
isMadeByFileMaker: true,
initiatedByFieldExpertId: fileMakerId,
requiresFileMakerApproval,
parties: [],
workflow: {
currentStep: WorkflowStep.SECOND_COMPLETED,
nextStep: WorkflowStep.WAITING_FOR_GUILT_DECISION,
completedSteps: [
WorkflowStep.FIRST_COMPLETED,
WorkflowStep.SECOND_COMPLETED,
],
},
};
const claim = {
_id: claimId,
blameRequestId: blameId,
status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
workflow: {
currentStep: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
nextStep: ClaimWorkflowStep.SELECT_OUTER_PARTS,
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
},
};
const service =
new (RequestManagementService as any)() as RequestManagementService;
(service as any).blameRequestDbService = {
findById: jest.fn().mockResolvedValue(request),
find: jest.fn().mockResolvedValue([request]),
};
(service as any).claimCaseDbService = {
findOne: jest.fn().mockResolvedValue(claim),
find: jest.fn().mockResolvedValue([claim]),
};
const reopened = await service.getMyFileMakerFileDetail(
{ sub: String(fileMakerId), role: RoleEnum.FILE_MAKER },
String(blameId),
);
expect(reopened.status).toBe(CaseStatus.OPEN);
expect(reopened.claimStatus).toBe(
ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
);
expect(reopened.fileMakerResume).toEqual({
action: "UPLOAD_REQUIRED_DOCUMENTS",
entity: "CLAIM",
entityId: String(claimId),
status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
currentStep: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
nextStep: ClaimWorkflowStep.SELECT_OUTER_PARTS,
});
const list = await service.getMyFileMakerFiles({
sub: String(fileMakerId),
role: RoleEnum.FILE_MAKER,
});
expect(list.list[0]).toEqual(
expect.objectContaining({
linkedClaimId: String(claimId),
claimStatus: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
fileMakerResume: expect.objectContaining({
action: "UPLOAD_REQUIRED_DOCUMENTS",
entityId: String(claimId),
}),
}),
);
},
);
it("does not leave the blame narrative before all required signatures", async () => {
const fileMakerId = new Types.ObjectId();
const blameId = new Types.ObjectId();
const claimId = new Types.ObjectId();
const request = {
_id: blameId,
publicId: "BL-FILE-MAKER-NARRATIVE",
requestNo: "BL-NARRATIVE",
type: BlameRequestType.THIRD_PARTY,
status: CaseStatus.OPEN,
isMadeByFileMaker: true,
initiatedByFieldExpertId: fileMakerId,
parties: [],
workflow: {
currentStep: WorkflowStep.FIRST_DESCRIPTION,
nextStep: WorkflowStep.FIRST_SIGN,
completedSteps: [WorkflowStep.FIRST_VOICE],
},
};
const claim = {
_id: claimId,
blameRequestId: blameId,
status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
workflow: {
currentStep: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
nextStep: ClaimWorkflowStep.SELECT_OUTER_PARTS,
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
},
};
const service =
new (RequestManagementService as any)() as RequestManagementService;
(service as any).blameRequestDbService = {
findById: jest.fn().mockResolvedValue(request),
};
(service as any).claimCaseDbService = {
findOne: jest.fn().mockResolvedValue(claim),
};
const reopened = await service.getMyFileMakerFileDetail(
{ sub: String(fileMakerId), role: RoleEnum.FILE_MAKER },
String(blameId),
);
expect(reopened.fileMakerResume).toEqual(
expect.objectContaining({
action: "CONTINUE_BLAME",
entity: "BLAME",
entityId: String(blameId),
currentStep: WorkflowStep.FIRST_DESCRIPTION,
}),
);
});
});

View File

@@ -5,6 +5,7 @@ import {
Get,
Param,
Post,
Query,
Put,
UploadedFile,
UseGuards,
@@ -44,6 +45,7 @@ import {
UploadRequiredDocumentV2ResponseDto,
} from "src/claim-request-management/dto/upload-document-v2.dto";
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
/**
* V4 FileMaker flow — first half of the split blame workflow.
@@ -96,10 +98,13 @@ export class FileMakerBlameV4Controller {
@Get("my-files")
@ApiOperation({
summary: "List all blame files created by this FileMaker",
description: "Returns all V4 FileMaker blame files initiated by the authenticated FileMaker.",
description: "Returns V4 FileMaker blame files using the shared search, sort, filter, and pagination query contract.",
})
async getMyFiles(@CurrentUser() fileMaker: any) {
return this.requestManagementService.getMyFileMakerFiles(fileMaker);
async getMyFiles(
@CurrentUser() fileMaker: any,
@Query() query: ListQueryV2Dto,
) {
return this.requestManagementService.getMyFileMakerFiles(fileMaker, query);
}
@Get("my-files/:requestId")

View File

@@ -5,6 +5,7 @@ import {
Get,
Param,
Post,
Query,
Put,
UploadedFile,
UseGuards,
@@ -44,6 +45,7 @@ import {
UploadRequiredDocumentV2ResponseDto,
} from "src/claim-request-management/dto/upload-document-v2.dto";
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
/**
* V5 FileMaker flow — identical to V4 but under the /v5/ prefix.
@@ -95,10 +97,13 @@ export class FileMakerBlameV5Controller {
@Get("my-files")
@ApiOperation({
summary: "List all blame files created by this FileMaker",
description: "Returns all V5 FileMaker blame files initiated by the authenticated FileMaker.",
description: "Returns V5 FileMaker blame files using the shared search, sort, filter, and pagination query contract.",
})
async getMyFiles(@CurrentUser() fileMaker: any) {
return this.requestManagementService.getMyFileMakerFiles(fileMaker);
async getMyFiles(
@CurrentUser() fileMaker: any,
@Query() query: ListQueryV2Dto,
) {
return this.requestManagementService.getMyFileMakerFiles(fileMaker, query);
}
@Get("my-files/:requestId")

View File

@@ -10,6 +10,7 @@ import {
Patch,
Post,
Put,
Query,
UploadedFile,
UseGuards,
UseInterceptors,
@@ -48,6 +49,7 @@ import {
CapturePartV2Dto,
CapturePartV2ResponseDto,
} from "src/claim-request-management/dto/capture-part-v2.dto";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
/**
@@ -88,10 +90,16 @@ export class FileReviewerBlameV4Controller {
@ApiOperation({
summary: "List available and assigned FileMaker blame files",
description:
"Returns V4 FileMaker blame files in this reviewer's insurer: sealed files that are still available to claim, plus files already assigned to the authenticated FileReviewer.",
"Returns V4 FileMaker blame files in this reviewer's insurer using the shared search, sort, filter, and pagination query contract: sealed files that are still available to claim, plus files already assigned to the authenticated FileReviewer.",
})
async getMyFiles(@CurrentUser() fileReviewer: any) {
return this.requestManagementService.getMyFileReviewerFiles(fileReviewer);
async getMyFiles(
@CurrentUser() fileReviewer: any,
@Query() query: ListQueryV2Dto,
) {
return this.requestManagementService.getMyFileReviewerFiles(
fileReviewer,
query,
);
}
@Get("my-files/:requestId")

View File

@@ -9,6 +9,7 @@ import {
Patch,
Post,
Put,
Query,
UploadedFile,
UseGuards,
UseInterceptors,
@@ -48,6 +49,7 @@ import {
CapturePartV2ResponseDto,
} from "src/claim-request-management/dto/capture-part-v2.dto";
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
/**
* V5 FileReviewer flow — same as V4 except after the damage expert completes
@@ -86,10 +88,16 @@ export class FileReviewerBlameV5Controller {
@ApiOperation({
summary: "List available and assigned FileMaker blame files",
description:
"Returns V5 FileMaker blame files in this reviewer's insurer: sealed files that are still available to claim, plus files already assigned to the authenticated FileReviewer.",
"Returns V5 FileMaker blame files in this reviewer's insurer using the shared search, sort, filter, and pagination query contract: sealed files that are still available to claim, plus files already assigned to the authenticated FileReviewer.",
})
async getMyFiles(@CurrentUser() fileReviewer: any) {
return this.requestManagementService.getMyFileReviewerFiles(fileReviewer);
async getMyFiles(
@CurrentUser() fileReviewer: any,
@Query() query: ListQueryV2Dto,
) {
return this.requestManagementService.getMyFileReviewerFiles(
fileReviewer,
query,
);
}
@Get("my-files/:requestId")

View File

@@ -74,7 +74,7 @@ describe("inquiry participant resolver", () => {
},
vin: "NAAM01E15HK123456",
}),
).toThrow("previousPolicyholderNationalCode");
).toThrow("کد ملی بیمه‌گذار قبلی");
});
it("defaults an omitted registration state to CURRENT", () => {
@@ -102,7 +102,7 @@ describe("inquiry participant resolver", () => {
},
vin: "TOO-SHORT",
}),
).toThrow("vehicle.vin must contain exactly 17 characters.");
).toThrow("شماره شاسی (VIN) باید دقیقاً ۱۷ کاراکتر باشد.");
});
it("rejects an incomplete current plate", () => {
@@ -115,7 +115,7 @@ describe("inquiry participant resolver", () => {
ir: "22",
},
}),
).toThrow("vehicle.currentPlate.centerDigits is required.");
).toThrow("سه رقم میانی پلاک در پلاک فعلی الزامی است.");
});
it("rejects invalid vehicle choice values", () => {
@@ -129,7 +129,7 @@ describe("inquiry participant resolver", () => {
ir: "22",
},
}),
).toThrow("vehicle.registrationState must be CURRENT or RECENTLY_TRANSFERRED.");
).toThrow("وضعیت پلاک خودرو باید «فعلی» یا «تازه تعویض‌شده» باشد.");
expect(() =>
resolveInquiryVehicle({
@@ -141,7 +141,7 @@ describe("inquiry participant resolver", () => {
},
isNewCar: "false" as any,
}),
).toThrow("vehicle.isNewCar must be a boolean.");
).toThrow("وضعیت صفر بودن خودرو نامعتبر است.");
});
it("rejects a previous policyholder national code for a current registration", () => {
@@ -647,7 +647,7 @@ describe("inquiry participant resolver", () => {
expect(() =>
resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, input as any),
).toThrow("THIRD_PARTY_POLICYHOLDER does not support the unknown option.");
).toThrow("ثبت بیمه‌گذار شخص ثالث به‌صورت نامشخص امکان‌پذیر نیست.");
});
it("strips the removed unknown field from historical participant output", () => {

View File

@@ -60,6 +60,20 @@ export interface InquirySubjects {
driverNationalCode: string;
}
const PARTICIPANT_ROLE_LABELS: Record<InquiryParticipantRole, string> = {
[InquiryParticipantRole.DRIVER]: "راننده",
[InquiryParticipantRole.VEHICLE_OWNER]: "مالک خودرو",
[InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER]: "بیمه‌گذار شخص ثالث",
[InquiryParticipantRole.CAR_BODY_POLICYHOLDER]: "بیمه‌گذار بدنه",
};
const PLATE_FIELD_LABELS = {
leftDigits: "دو رقم سمت چپ پلاک",
centerAlphabet: "حرف پلاک",
centerDigits: "سه رقم میانی پلاک",
ir: "کد ایران پلاک",
} as const;
/**
* The single routing seam for external inquiries. Policy checks belong to
* their policyholder, Sheba belongs to the vehicle owner, and licence data
@@ -70,7 +84,7 @@ export function resolveInquirySubjects(
): InquirySubjects {
if (!submission.vehicleOwner) {
throw new BadRequestException(
"Vehicle owner identity is required for Sheba validation.",
"اطلاعات هویتی مالک خودرو برای استعلام شبا الزامی است.",
);
}
return {
@@ -102,7 +116,11 @@ function assertCompleteInquiryPlate(
"ir",
] as const) {
if (plate?.[field] == null || String(plate[field]).trim() === "") {
throw new BadRequestException(`${path}.${field} is required.`);
const plateLabel =
path === "vehicle.currentPlate" ? "پلاک فعلی" : "پلاک قبلی";
throw new BadRequestException(
`${PLATE_FIELD_LABELS[field]} در ${plateLabel} الزامی است.`,
);
}
}
}
@@ -171,21 +189,21 @@ function requiredIdentity(
): ResolvedInquiryParticipant {
if (Object.prototype.hasOwnProperty.call(input, "phoneNumber")) {
throw new BadRequestException(
`${role} does not accept phoneNumber; phone numbers are collected separately from inquiry identity.`,
`شماره همراه ${PARTICIPANT_ROLE_LABELS[role]} باید جدا از اطلاعات هویتی استعلام ارسال شود.`,
);
}
const nationalCode = String(input.nationalCode ?? "").trim();
const birthday = String(input.birthday ?? "").trim();
if (!nationalCode || !birthday) {
throw new BadRequestException(
`${role} requires nationalCode and birthday.`,
`کد ملی و تاریخ تولد ${PARTICIPANT_ROLE_LABELS[role]} الزامی است.`,
);
}
if (
role === InquiryParticipantRole.DRIVER &&
typeof input.hasDrivingLicense !== "boolean"
) {
throw new BadRequestException("DRIVER requires hasDrivingLicense.");
throw new BadRequestException("وضعیت داشتن گواهینامه راننده الزامی است.");
}
if (
role === InquiryParticipantRole.DRIVER &&
@@ -194,7 +212,7 @@ function requiredIdentity(
!String(input.licenseType ?? "").trim())
) {
throw new BadRequestException(
"DRIVER requires licenseNumber and licenseType when hasDrivingLicense is true.",
"شماره و نوع گواهینامه برای راننده دارای گواهینامه الزامی است.",
);
}
return {
@@ -220,7 +238,7 @@ export function resolveInquiryParticipants(
);
if (!hasRoleCompleteInput) {
throw new BadRequestException(
"driver, vehicleOwner, and thirdPartyPolicyholder are required in the structured inquiry format.",
"اطلاعات راننده، مالک خودرو و بیمه‌گذار شخص ثالث برای استعلام الزامی است.",
);
}
if (
@@ -228,7 +246,7 @@ export function resolveInquiryParticipants(
input.carBodyPolicyholder != null
) {
throw new BadRequestException(
"CAR_BODY_POLICYHOLDER is not allowed for a THIRD_PARTY case.",
"بیمه‌گذار بدنه برای پرونده شخص ثالث قابل ثبت نیست.",
);
}
@@ -249,19 +267,23 @@ export function resolveInquiryParticipants(
if (existing) return existing;
if (resolving.has(role)) {
throw new BadRequestException(
"Participant sameAs references cannot be circular.",
"ارتباط اشخاص یکسان در اطلاعات استعلام نامعتبر است.",
);
}
const value = input[ROLE_FIELDS[role]] as
| InquiryParticipantInputDto
| undefined;
if (!value) throw new BadRequestException(`${role} is required.`);
if (!value) {
throw new BadRequestException(
`اطلاعات ${PARTICIPANT_ROLE_LABELS[role]} الزامی است.`,
);
}
resolving.add(role);
let participantId: string;
if (Object.prototype.hasOwnProperty.call(value, "unknown")) {
throw new BadRequestException(
`${role} does not support the unknown option.`,
`ثبت ${PARTICIPANT_ROLE_LABELS[role]} به‌صورت نامشخص امکان‌پذیر نیست.`,
);
} else if (value.sameAs) {
const hasPersonSpecificFields = Object.entries(value).some(
@@ -269,7 +291,7 @@ export function resolveInquiryParticipants(
);
if (hasPersonSpecificFields) {
throw new BadRequestException(
`${role} must contain either sameAs or identity fields, not both.`,
`برای ${PARTICIPANT_ROLE_LABELS[role]} باید فقط ارتباط با شخص دیگر یا اطلاعات هویتی مستقل ارسال شود.`,
);
}
participantId = resolveRole(value.sameAs);
@@ -280,7 +302,7 @@ export function resolveInquiryParticipants(
);
if (duplicate) {
throw new BadRequestException(
`${role} duplicates an existing nationalCode; use sameAs instead.`,
`کد ملی ${PARTICIPANT_ROLE_LABELS[role]} تکراری است؛ ارتباط با شخص ثبت‌شده را انتخاب کنید.`,
);
}
participants.set(participant.participantId, participant);
@@ -317,29 +339,29 @@ export function resolveInquiryVehicle(
input: InquiryVehicleInputDto,
): ResolvedInquiryVehicle {
if (!input) {
throw new BadRequestException("vehicle is required.");
throw new BadRequestException("اطلاعات خودرو برای استعلام الزامی است.");
}
const registrationState =
input.registrationState ?? VehicleRegistrationState.CURRENT;
if (!Object.values(VehicleRegistrationState).includes(registrationState)) {
throw new BadRequestException(
"vehicle.registrationState must be CURRENT or RECENTLY_TRANSFERRED.",
"وضعیت پلاک خودرو باید «فعلی» یا «تازه تعویض‌شده» باشد.",
);
}
if (input.isNewCar != null && typeof input.isNewCar !== "boolean") {
throw new BadRequestException("vehicle.isNewCar must be a boolean.");
throw new BadRequestException("وضعیت صفر بودن خودرو نامعتبر است.");
}
const previousPolicyholderNationalCode = String(
input.previousPolicyholderNationalCode ?? "",
).trim();
if (!input.currentPlate) {
throw new BadRequestException("vehicle.currentPlate is required.");
throw new BadRequestException("پلاک فعلی خودرو برای استعلام الزامی است.");
}
assertCompleteInquiryPlate(input.currentPlate, "vehicle.currentPlate");
const vin = String(input.vin ?? "").trim();
if (vin && vin.length !== 17) {
throw new BadRequestException(
"vehicle.vin must contain exactly 17 characters.",
"شماره شاسی (VIN) باید دقیقاً ۱۷ کاراکتر باشد.",
);
}
if (
@@ -347,7 +369,7 @@ export function resolveInquiryVehicle(
(!input.previousPlate || !vin || !previousPolicyholderNationalCode)
) {
throw new BadRequestException(
"RECENTLY_TRANSFERRED requires previousPlate, vin, and previousPolicyholderNationalCode.",
"برای خودروی تازه تعویض‌پلاک‌شده، پلاک قبلی، شماره شاسی (VIN) و کد ملی بیمه‌گذار قبلی الزامی است.",
);
}
if (
@@ -355,7 +377,7 @@ export function resolveInquiryVehicle(
(input.previousPlate || input.previousPolicyholderNationalCode != null)
) {
throw new BadRequestException(
"previousPlate and previousPolicyholderNationalCode are only allowed for RECENTLY_TRANSFERRED vehicles.",
"پلاک و کد ملی بیمه‌گذار قبلی فقط برای خودروی تازه تعویض‌پلاک‌شده قابل ثبت است.",
);
}
if (input.previousPlate) {
@@ -442,7 +464,7 @@ function assertStructuredInquiryInput(input: Record<string, any>): void {
);
if (legacyFields.length > 0) {
throw new BadRequestException(
`Legacy inquiry fields are not accepted: ${legacyFields.join(", ")}. Use driver, vehicleOwner, thirdPartyPolicyholder, carBodyPolicyholder, and vehicle.`,
"ساختار قدیمی اطلاعات استعلام پذیرفته نمی‌شود؛ اطلاعات اشخاص و خودرو را در بخش‌های جدید ارسال کنید.",
);
}
}
@@ -466,7 +488,7 @@ export function assertPreviousPlateInquiryMatchesVin(
.filter(Boolean);
if (!expected || !candidates.includes(expected)) {
throw new BadRequestException(
"Previous-plate inquiry does not match the submitted VIN/chassis; manual review is required.",
"نتیجه استعلام پلاک قبلی با شماره شاسی (VIN) واردشده مطابقت ندارد و پرونده نیازمند بررسی دستی است.",
);
}
}
@@ -540,7 +562,7 @@ export async function runPlateInquiryWithFallback<T>(options: {
});
if (!isLast) continue;
const error = new BadRequestException(
"No current usable policy was found for the submitted vehicle identifiers.",
"بیمه‌نامه معتبر و فعالی مطابق مشخصات خودرو و کد ملی واردشده یافت نشد.",
) as BadRequestException & { attempts?: typeof attempts };
error.attempts = attempts;
throw error;
@@ -592,7 +614,12 @@ export async function runPlateInquiryWithFallback<T>(options: {
}
}
throw lastError ?? new BadRequestException("Inquiry failed for all plates.");
throw (
lastError ??
new BadRequestException(
"برای هیچ‌یک از پلاک‌های ثبت‌شده نتیجه معتبری یافت نشد.",
)
);
}
export function normalizeInquirySubmission<T extends Record<string, any>>(
@@ -610,7 +637,7 @@ export function normalizeInquirySubmission<T extends Record<string, any>>(
);
if (!driver || !thirdPartyPolicyholder) {
throw new BadRequestException(
"Driver and third-party policyholder identities are required.",
"اطلاعات هویتی راننده و بیمه‌گذار شخص ثالث الزامی است.",
);
}
const vehicleOwner = participantForRole(

View File

@@ -17,6 +17,7 @@ import {
ReinquiryInquiriesResponseDto,
ReinquiryPartyResultDto,
} from "./dto/reinquiry-inquiries.dto";
import { getInquiryErrorMessage } from "src/common/utils/inquiry-error";
type PlateParts = {
leftDigits: number;
@@ -48,7 +49,7 @@ export class InquiryRefreshService {
if (!body.publicId && !body.blameRequestId && limit === 0) {
throw new BadRequestException(
"Provide publicId, blameRequestId, or limit for bulk refresh.",
"برای اجرای مجدد استعلام، شناسه عمومی پرونده، شناسه پرونده یا تعداد پرونده‌ها را وارد کنید.",
);
}
@@ -58,14 +59,16 @@ export class InquiryRefreshService {
if (body.publicId) filter.publicId = body.publicId;
if (body.blameRequestId) {
if (!Types.ObjectId.isValid(body.blameRequestId)) {
throw new BadRequestException("Invalid blameRequestId");
throw new BadRequestException("شناسه پرونده معتبر نیست.");
}
filter._id = new Types.ObjectId(body.blameRequestId);
}
let docs = await this.blameRequestDbService.find(filter, { lean: true });
if (!docs.length) {
throw new NotFoundException("No matching blame cases found");
throw new NotFoundException(
"پرونده تقصیر مطابق اطلاعات واردشده یافت نشد.",
);
}
docs = limit > 0 ? docs.slice(0, limit) : docs;
@@ -111,8 +114,8 @@ export class InquiryRefreshService {
if (index === -1) {
partyResults.push({
role,
thirdParty: { ok: false, message: "party not found" },
person: { ok: false, message: "party not found" },
thirdParty: { ok: false, message: "طرف پرونده یافت نشد." },
person: { ok: false, message: "طرف پرونده یافت نشد." },
});
continue;
}
@@ -150,7 +153,9 @@ export class InquiryRefreshService {
blameRequestId: new Types.ObjectId(String(doc._id)),
});
claimsUpdated = linkedClaims.length;
this.logger.log(`[dry-run] ${label} would update blame + ${claimsUpdated} claim(s)`);
this.logger.log(
`[dry-run] ${label} would update blame + ${claimsUpdated} claim(s)`,
);
}
return {
@@ -208,9 +213,7 @@ export class InquiryRefreshService {
plateId: party?.vehicle?.plateId,
...(plate ? { plate } : {}),
...(nationalCode ? { nationalCode } : {}),
...(birthDate !== null && birthDate !== undefined
? { birthDate }
: {}),
...(birthDate !== null && birthDate !== undefined ? { birthDate } : {}),
};
if (dryRun) {
@@ -223,8 +226,8 @@ export class InquiryRefreshService {
result.thirdParty = {
ok: false,
message: !plate
? "plate not found on party"
: "nationalCodeOfInsurer/nationalCodeOfDriver missing",
? "پلاک برای این طرف پرونده ثبت نشده است."
: "کد ملی برای این طرف پرونده ثبت نشده است.",
};
} else {
await this.waitForRateLimit();
@@ -249,7 +252,9 @@ export class InquiryRefreshService {
inquiriesChanged = true;
result.thirdParty = {
ok: false,
message: inquiry.mapped.Error.Message || "third-party inquiry error",
message:
inquiry.mapped.Error.Message ||
getInquiryErrorMessage(inquiry.mapped, "thirdPartyPlate"),
};
} else {
nextParty = this.applyThirdPartyToParty(
@@ -292,11 +297,18 @@ export class InquiryRefreshService {
}
}
} catch (error: any) {
this.recordPartyInquiry(inquiries, "thirdParty", role, false, {}, error);
this.recordPartyInquiry(
inquiries,
"thirdParty",
role,
false,
{},
error,
);
inquiriesChanged = true;
result.thirdParty = {
ok: false,
message: error?.message || String(error),
message: getInquiryErrorMessage(error, "thirdPartyPlate"),
};
}
}
@@ -305,8 +317,8 @@ export class InquiryRefreshService {
result.person = {
ok: false,
message: !nationalCode
? "nationalCodeOfInsurer/nationalCodeOfDriver missing"
: "insurerBirthday/driverBirthday missing",
? "کد ملی برای این طرف پرونده ثبت نشده است."
: "تاریخ تولد برای این طرف پرونده ثبت نشده است.",
};
} else {
await this.waitForRateLimit();
@@ -333,7 +345,7 @@ export class InquiryRefreshService {
inquiriesChanged = true;
result.person = {
ok: false,
message: error?.message || String(error),
message: getInquiryErrorMessage(error, "personalIdentity"),
};
}
}
@@ -477,12 +489,14 @@ export class InquiryRefreshService {
): Promise<void> {
const blameDoc = await this.blameRequestDbService.findById(blameId);
if (!blameDoc) {
throw new NotFoundException(`Blame case ${blameId} not found`);
throw new NotFoundException("پرونده تقصیر یافت نشد.");
}
for (const role of roles) {
const memParty = updatedParties.find((party) => party?.role === role);
const docIdx = blameDoc.parties.findIndex((party) => party?.role === role);
const docIdx = blameDoc.parties.findIndex(
(party) => party?.role === role,
);
if (!memParty || docIdx === -1) continue;
const party = blameDoc.parties[docIdx];
@@ -514,7 +528,9 @@ export class InquiryRefreshService {
party.insurance.company = memParty.insurance.company;
}
if (memParty.insurance.financialCeiling !== undefined) {
party.insurance.financialCeiling = String(memParty.insurance.financialCeiling);
party.insurance.financialCeiling = String(
memParty.insurance.financialCeiling,
);
}
if (memParty.insurance.startDate !== undefined) {
party.insurance.startDate = memParty.insurance.startDate;
@@ -541,7 +557,8 @@ export class InquiryRefreshService {
});
const inquiryPatch: Record<string, unknown> = {};
if (inquiries.thirdParty) inquiryPatch["inquiries.thirdParty"] = inquiries.thirdParty;
if (inquiries.thirdParty)
inquiryPatch["inquiries.thirdParty"] = inquiries.thirdParty;
if (inquiries.person) inquiryPatch["inquiries.person"] = inquiries.person;
if (!Object.keys(inquiryPatch).length) return 0;
@@ -590,14 +607,16 @@ export class InquiryRefreshService {
private normalizeInquiryError(error: any): Record<string, unknown> {
return {
message: error?.message || String(error),
message: getInquiryErrorMessage(error, "generic"),
status: error?.status ?? error?.response?.status,
data: error?.data ?? error?.response?.data,
};
}
private resolvePartyPlate(party: Record<string, any>): PlateParts | null {
const fromPlateId = this.parsePlateFromCompactString(party?.vehicle?.plateId);
const fromPlateId = this.parsePlateFromCompactString(
party?.vehicle?.plateId,
);
if (fromPlateId) return fromPlateId;
const candidates = [
@@ -608,14 +627,24 @@ export class InquiryRefreshService {
].filter(Boolean);
for (const candidate of candidates) {
const leftDigits = this.firstPresent(candidate.Plk1, candidate.platePartOne);
const leftDigits = this.firstPresent(
candidate.Plk1,
candidate.platePartOne,
);
const centerAlphabet = this.firstPresent(
candidate.plateLetterid,
candidate.plateLetterId,
candidate.plateLetterTitle,
);
const centerDigits = this.firstPresent(candidate.Plk3, candidate.platePartThree);
const ir = this.firstPresent(candidate.PlkSrl, candidate.plkSrl, candidate.plateSerialNumber);
const centerDigits = this.firstPresent(
candidate.Plk3,
candidate.platePartThree,
);
const ir = this.firstPresent(
candidate.PlkSrl,
candidate.plkSrl,
candidate.plateSerialNumber,
);
if (
leftDigits !== undefined &&
@@ -623,7 +652,9 @@ export class InquiryRefreshService {
centerDigits !== undefined &&
ir !== undefined
) {
const plateLetter = this.plateNormalizer.normalizePlateText(String(centerAlphabet));
const plateLetter = this.plateNormalizer.normalizePlateText(
String(centerAlphabet),
);
const parsed: PlateParts = {
leftDigits: Number(leftDigits),
centerAlphabet: plateLetter,
@@ -653,7 +684,9 @@ export class InquiryRefreshService {
const ir = Number(irRaw);
const leftDigits = Number(leftRaw);
const centerDigits = Number(centerRaw);
const centerAlphabet = this.plateNormalizer.normalizePlateText(String(alphaRaw || ""));
const centerAlphabet = this.plateNormalizer.normalizePlateText(
String(alphaRaw || ""),
);
if (
!Number.isFinite(ir) ||
!Number.isFinite(leftDigits) ||
@@ -685,6 +718,8 @@ export class InquiryRefreshService {
}
private firstPresent(...values: unknown[]): unknown {
return values.find((value) => value !== undefined && value !== null && value !== "");
return values.find(
(value) => value !== undefined && value !== null && value !== "",
);
}
}

View File

@@ -15,12 +15,8 @@ describe("damaged-party inquiry requirements", () => {
const service = getService();
await expect(
(service as any).validateShebaV3(
undefined,
"0012345678",
"client-id",
),
).rejects.toThrow("sheba is required for the damaged party.");
(service as any).validateShebaV3(undefined, "0012345678", "client-id"),
).rejects.toThrow("شماره شبا برای طرف زیان‌دیده الزامی است.");
expect(
(service as any).sandHubService.getShebaValidation,
).not.toHaveBeenCalled();

View File

@@ -14,6 +14,7 @@ describe("RequestManagementService FileReviewer inbox", () => {
publicId: "BLM-OPEN",
type: "THIRD_PARTY",
status: "WAITING_FOR_FILE_REVIEWER",
createdAt: new Date("2026-01-02T00:00:00.000Z"),
isMadeByFileMaker: true,
expertInitiated: true,
creationMethod: "IN_PERSON",
@@ -34,6 +35,9 @@ describe("RequestManagementService FileReviewer inbox", () => {
undefined,
blameRequestDbService,
) as RequestManagementService;
(service as any).claimCaseDbService = {
find: jest.fn().mockResolvedValue([]),
};
return { service, blameRequestDbService };
}
@@ -46,7 +50,7 @@ describe("RequestManagementService FileReviewer inbox", () => {
clientKey: String(clientId),
});
expect(result).toEqual([
expect(result.list).toEqual([
expect.objectContaining({ _id: sealedFile._id, publicId: "BLM-OPEN" }),
]);
expect(blameRequestDbService.find).toHaveBeenCalledWith(
@@ -82,7 +86,34 @@ describe("RequestManagementService FileReviewer inbox", () => {
clientKey: String(clientId),
});
expect(result).toEqual([]);
expect(result.list).toEqual([]);
});
it("sorts and paginates the reviewer inbox with the shared list contract", async () => {
const olderFile = {
...sealedFile,
_id: new Types.ObjectId(),
publicId: "BLM-OLDER",
createdAt: new Date("2026-01-01T00:00:00.000Z"),
};
const { service } = createService([olderFile, sealedFile]);
const result = await service.getMyFileReviewerFiles(
{
sub: String(reviewerId),
role: RoleEnum.FILE_REVIEWER,
clientKey: String(clientId),
},
{ page: 1, limit: 1, sortBy: "createdAt", sortOrder: "desc" },
);
expect(result).toEqual(
expect.objectContaining({ total: 2, page: 1, limit: 1, totalPages: 2 }),
);
expect(result.list).toHaveLength(1);
expect(result.list[0]).toEqual(
expect.objectContaining({ publicId: "BLM-OPEN" }),
);
});
it("does not expose an open file's details to a reviewer from another tenant", async () => {

View File

@@ -60,9 +60,14 @@ import { FileMakerDbService } from "src/users/entities/db-service/file-maker.db.
import { FileReviewerDbService } from "src/users/entities/db-service/file-reviewer.db.service";
import { isOtpExpiryActive } from "src/helpers/user-otp-expiry";
import { parseIranLocalDateTime } from "src/helpers/iran-datetime";
import { applyListQueryV2 } from "src/helpers/list-query-v2";
import {
applyListQueryV2,
isInListDateRange,
parseListDateRange,
} from "src/helpers/list-query-v2";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
import { GetUserBlameListV2ResponseDto } from "src/request-management/dto/blame-list-user-v2.dto";
import { resolveUnifiedFileStatus } from "src/helpers/unified-file-status";
import { AutoCloseRequestService } from "src/utils/cron/cron.service";
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
import {
@@ -138,6 +143,9 @@ import {
runPlateInquiryWithFallback,
sanitizeStoredInquiryParticipants,
} from "./inquiry-participant-resolver";
import {
getInquiryErrorMessage,
} from "src/common/utils/inquiry-error";
/**
* Formats a compact Jalali date (number or string like 13780624) as YYYY/MM/DD.
@@ -154,6 +162,18 @@ function formatJalaliCompact(
return String(raw);
}
type FileMakerResumeProjection = {
action:
| "CONTINUE_BLAME"
| "UPLOAD_REQUIRED_DOCUMENTS"
| "WAIT_FOR_FILE_REVIEWER";
entity: "BLAME" | "CLAIM";
entityId: string;
status: string;
currentStep: string;
nextStep?: string;
};
@Injectable()
export class RequestManagementService {
private readonly logger = new Logger(RequestManagementService.name);
@@ -183,10 +203,16 @@ export class RequestManagementService {
}
/** Keep eligibility rejections distinct from an unavailable CAR_BODY provider. */
private throwCarBodyInquiryFailure(err: unknown): never {
if (err instanceof ForbiddenException) throw err;
private throwCarBodyInquiryFailure(
err: unknown,
context: "carBodyPlate" | "carBodyVin" = "carBodyPlate",
): never {
const message = getInquiryErrorMessage(err, context);
if (err instanceof ForbiddenException) {
throw new ForbiddenException(message);
}
throw new HttpException("Car body inquiry failed", HttpStatus.BAD_REQUEST);
throw new HttpException(message, HttpStatus.BAD_REQUEST);
}
/**
@@ -200,7 +226,7 @@ export class RequestManagementService {
const configuredCode = Number(process.env.CLIENT_ID);
if (!Number.isFinite(configuredCode)) {
throw new InternalServerErrorException(
"CLIENT_ID must be configured to save CAR_BODY policy ownership.",
"تنظیمات شرکت بیمه برای ذخیره بیمه‌نامه بدنه کامل نیست.",
);
}
@@ -220,7 +246,7 @@ export class RequestManagementService {
const clientId = (client as any)?._id ?? (client as any)?._doc?._id;
if (!clientId) {
throw new InternalServerErrorException(
"Configured CAR_BODY insurer client could not be resolved.",
"شرکت بیمه تنظیم‌شده برای بیمه‌نامه بدنه قابل شناسایی نیست.",
);
}
return clientId;
@@ -394,7 +420,7 @@ export class RequestManagementService {
const policyholderNationalCode = subjects.carBodyPolicyNationalCode;
if (!policyholderNationalCode) {
throw new BadRequestException(
"Car-body policyholder identity is required for a CAR_BODY inquiry.",
"اطلاعات بیمه‌گذار برای استعلام بیمه بدنه الزامی است.",
);
}
const result = await runPlateInquiryWithFallback({
@@ -434,7 +460,7 @@ export class RequestManagementService {
).trim();
if (!chassis) {
throw new BadRequestException(
"vehicle.vin is required for a VIN/chassis inquiry.",
"شماره شاسی (VIN) برای استعلام خودرو الزامی است.",
);
}
const subjects = resolveInquirySubjects(submission);
@@ -462,7 +488,7 @@ export class RequestManagementService {
for (const participant of participants) {
if (!participant.nationalCode || !participant.birthday) {
throw new BadRequestException(
`${participant.participantId} requires nationalCode and birthday for personal inquiry.`,
"کد ملی و تاریخ تولد برای استعلام هویت الزامی است.",
);
}
try {
@@ -486,7 +512,7 @@ export class RequestManagementService {
);
await this.persistBlameInquiryAudit(req);
throw new BadRequestException(
`${participant.participantId} personal identity inquiry failed: ${error?.message || "استعلام در دسترس نیست"}`,
getInquiryErrorMessage(error, "personalIdentity"),
);
}
}
@@ -512,7 +538,7 @@ export class RequestManagementService {
const plate = submission.vehicle?.currentPlate ?? submission.dto.plate;
if (!plate) {
throw new BadRequestException(
"Current plate is required for vehicle ownership inquiry.",
"پلاک فعلی برای استعلام مالکیت خودرو الزامی است.",
);
}
try {
@@ -536,7 +562,7 @@ export class RequestManagementService {
);
await this.persistBlameInquiryAudit(req);
throw new BadRequestException(
`Vehicle ownership inquiry failed: ${error?.message || "استعلام در دسترس نیست"}`,
getInquiryErrorMessage(error, "carOwnership"),
);
}
}
@@ -558,7 +584,7 @@ export class RequestManagementService {
}
if (!submission.driver.licenseNumber) {
throw new BadRequestException(
"Driver licence number is required when the driver has a licence.",
"شماره گواهینامه راننده برای استعلام گواهینامه الزامی است.",
);
}
try {
@@ -582,7 +608,7 @@ export class RequestManagementService {
);
await this.persistBlameInquiryAudit(req);
throw new BadRequestException(
`Driver licence inquiry failed: ${error?.message || "استعلام در دسترس نیست"}`,
getInquiryErrorMessage(error, "drivingLicense"),
);
}
}
@@ -683,7 +709,7 @@ export class RequestManagementService {
private normalizeInquiryError(err: any): any {
if (!err) return undefined;
return {
message: err?.message || String(err),
message: getInquiryErrorMessage(err, "generic"),
status: err?.response?.status,
data: err?.response?.data,
...(Array.isArray(err?.attempts) ? { attempts: err.attempts } : {}),
@@ -1745,7 +1771,7 @@ export class RequestManagementService {
body.insurerLicense === body.driverLicense)
) {
throw new BadRequestException(
"Insurer and Driver should be two different persons in this mode.",
"در این حالت، بیمه‌گذار و راننده باید دو شخص متفاوت باشند.",
);
}
} else if (body.driverIsInsurer === true) {
@@ -1757,7 +1783,7 @@ export class RequestManagementService {
String(body.driverBirthday) === String(body.insurerBirthday);
if (!sameNat || !sameLic || !sameBirthday) {
throw new BadRequestException(
"When driverIsInsurer is true, insurer and driver data must be the same.",
"وقتی راننده همان بیمه‌گذار است، اطلاعات راننده و بیمه‌گذار باید یکسان باشد.",
);
}
}
@@ -1831,7 +1857,10 @@ export class RequestManagementService {
err,
);
await this.persistBlameInquiryAudit(req);
throw new HttpException("Inquiry failed", HttpStatus.BAD_REQUEST);
throw new HttpException(
getInquiryErrorMessage(err, "thirdPartyPlate"),
HttpStatus.BAD_REQUEST,
);
}
if (inquiryMapped?.Error) {
@@ -1847,7 +1876,8 @@ export class RequestManagementService {
});
await this.persistBlameInquiryAudit(req);
throw new HttpException(
inquiryMapped.Error.Message || "Inquiry returned error",
inquiryMapped.Error.Message ||
getInquiryErrorMessage(inquiryMapped, "thirdPartyPlate"),
HttpStatus.BAD_REQUEST,
);
}
@@ -1875,7 +1905,7 @@ export class RequestManagementService {
const clientName = inquiryMapped?.CompanyName;
if (!clientName) {
const error = new BadRequestException(
`CompanyName missing from inquiry response`,
"پاسخ استعلام بیمه‌نامه ناقص است و نام شرکت بیمه در آن وجود ندارد.",
);
this.recordPartyCaseInquiryStatus(
req,
@@ -1903,7 +1933,7 @@ export class RequestManagementService {
: null;
if (clientName && !client) {
const error = new BadRequestException(
`CompanyCode missing or invalid in inquiry response`,
"پاسخ استعلام بیمه‌نامه ناقص است و شرکت بیمه قابل شناسایی نیست.",
);
this.recordPartyCaseInquiryStatus(
req,
@@ -2188,7 +2218,7 @@ export class RequestManagementService {
body.insurerLicense === body.driverLicense)
) {
throw new BadRequestException(
"Insurer and Driver should be two different persons in this mode.",
"در این حالت، بیمه‌گذار و راننده باید دو شخص متفاوت باشند.",
);
}
} else if (body.driverIsInsurer === true) {
@@ -2200,7 +2230,7 @@ export class RequestManagementService {
String(body.driverBirthday) === String(body.insurerBirthday);
if (!sameNat || !sameLic || !sameBirthday) {
throw new BadRequestException(
"When driverIsInsurer is true, insurer and driver data must be the same.",
"وقتی راننده همان بیمه‌گذار است، اطلاعات راننده و بیمه‌گذار باید یکسان باشد.",
);
}
}
@@ -2261,7 +2291,10 @@ export class RequestManagementService {
err,
);
await this.persistBlameInquiryAudit(req);
throw new HttpException("VIN inquiry failed", HttpStatus.BAD_REQUEST);
throw new HttpException(
getInquiryErrorMessage(err, "thirdPartyVin"),
HttpStatus.BAD_REQUEST,
);
}
if (inquiryMapped?.Error) {
@@ -2275,7 +2308,8 @@ export class RequestManagementService {
});
await this.persistBlameInquiryAudit(req);
throw new HttpException(
inquiryMapped.Error.Message || "VIN inquiry returned error",
inquiryMapped.Error.Message ||
getInquiryErrorMessage(inquiryMapped, "thirdPartyVin"),
HttpStatus.BAD_REQUEST,
);
}
@@ -2303,7 +2337,7 @@ export class RequestManagementService {
const clientName = inquiryMapped?.CompanyName;
if (!clientName) {
const error = new BadRequestException(
"CompanyName missing from VIN inquiry response",
"پاسخ استعلام VIN ناقص است و نام شرکت بیمه در آن وجود ندارد.",
);
this.recordPartyCaseInquiryStatus(
req,
@@ -2329,7 +2363,7 @@ export class RequestManagementService {
: null;
if (clientName && !client) {
const error = new BadRequestException(
"CompanyCode missing or invalid in VIN inquiry response",
"پاسخ استعلام VIN ناقص است و شرکت بیمه قابل شناسایی نیست.",
);
this.recordPartyCaseInquiryStatus(
req,
@@ -2459,7 +2493,7 @@ export class RequestManagementService {
err,
);
await this.persistBlameInquiryAudit(req);
this.throwCarBodyInquiryFailure(err);
this.throwCarBodyInquiryFailure(err, "carBodyVin");
}
}
@@ -3472,7 +3506,10 @@ export class RequestManagementService {
: null;
if (!client) {
throw new HttpException("Client not found", HttpStatus.CONFLICT);
throw new HttpException(
"شرکت بیمه موجود در پاسخ استعلام قابل شناسایی نیست.",
HttpStatus.CONFLICT,
);
}
const partyDetails =
@@ -3642,7 +3679,7 @@ export class RequestManagementService {
this.logger.error(er);
if (er instanceof HttpException) throw er;
throw new InternalServerErrorException(
"Failed to update request with plate details.",
"ذخیره اطلاعات پلاک و بیمه‌نامه انجام نشد.",
);
}
@@ -3670,7 +3707,7 @@ export class RequestManagementService {
body.driverIsInsurer === false
) {
throw new BadRequestException(
"Insurer and Driver should be two different persons in this mode.",
"در این حالت، بیمه‌گذار و راننده باید دو شخص متفاوت باشند.",
);
}
@@ -3703,7 +3740,7 @@ export class RequestManagementService {
if (isSameNationalCode || isSamePlate) {
throw new ConflictException(
"The plate and national code for the second party cannot be the same as the first party.",
"پلاک و کد ملی طرف دوم نمی‌تواند با طرف اول یکسان باشد.",
);
}
}
@@ -6466,6 +6503,20 @@ export class RequestManagementService {
},
);
const requestIds = requests.map((request: any) => request._id);
const claimsForStatus =
requestIds.length > 0
? ((await this.claimCaseDbService.find(
{ blameRequestId: { $in: requestIds } },
{ lean: true, select: "blameRequestId status" },
)) as any[])
: [];
const claimStatusByBlameId = new Map<string, string>(
claimsForStatus
.filter((claim) => claim?.blameRequestId && claim?.status)
.map((claim) => [String(claim.blameRequestId), claim.status]),
);
const enriched = requests.map((req: any) => {
const isInitiator =
(user?.role === RoleEnum.FIELD_EXPERT &&
@@ -6487,17 +6538,44 @@ export class RequestManagementService {
...obj,
userSide: party?.role ?? null,
initiatedByMe: isInitiator,
unifiedFileStatus: resolveUnifiedFileStatus({
blameStatus: req.status,
claimStatus: claimStatusByBlameId.get(String(req._id)),
}),
};
});
let filtered = enriched;
if (query.unifiedStatus) {
filtered = filtered.filter(
(row) => row.unifiedFileStatus === query.unifiedStatus,
);
}
const { fromDate, toDate } = parseListDateRange(
query.startDate,
query.endDate,
);
if (fromDate || toDate) {
filtered = filtered.filter((row) =>
isInListDateRange(row.createdAt, fromDate, toDate),
);
}
const paged = applyListQueryV2(
enriched,
filtered,
{
publicId: (r) => String((r as { publicId?: string }).publicId ?? ""),
createdAt: (r) => (r as { createdAt?: Date }).createdAt,
requestNo: (r) =>
String((r as { requestNo?: string }).requestNo ?? ""),
status: (r) => String((r as { status?: string }).status ?? ""),
status: (r) =>
String(
(r as { unifiedFileStatus?: string; status?: string })
.unifiedFileStatus ??
(r as { status?: string }).status ??
"",
),
fileType: (r) => (r as { type?: string }).type,
searchExtras: (r) => {
const row = r as {
blameStatus?: string;
@@ -7004,8 +7082,9 @@ export class RequestManagementService {
e,
);
await (req as any).save();
throw new InternalServerErrorException(
"Failed to process plate information.",
if (e instanceof HttpException) throw e;
throw new BadRequestException(
getInquiryErrorMessage(e, "thirdPartyPlate"),
);
}
@@ -7020,7 +7099,7 @@ export class RequestManagementService {
: await this.clientService.findOne({ clientName });
if (!client) {
const error = new NotFoundException(
`Client not found for company: ${clientName}`,
"شرکت بیمه موجود در پاسخ استعلام قابل شناسایی نیست.",
);
this.recordPartyCaseInquiryStatus(
req,
@@ -7345,7 +7424,7 @@ export class RequestManagementService {
: null;
if (!client) {
const error = new NotFoundException(
`Client not found for company: ${clientName}`,
"شرکت بیمه موجود در پاسخ استعلام قابل شناسایی نیست.",
);
this.recordPartyCaseInquiryStatus(
req,
@@ -8104,7 +8183,7 @@ export class RequestManagementService {
if (!client) {
const error = new NotFoundException(
`Client not found for company: ${clientName}`,
"شرکت بیمه موجود در پاسخ استعلام قابل شناسایی نیست.",
);
await this.persistLegacyInquiryAudit(
requestId,
@@ -8188,8 +8267,8 @@ export class RequestManagementService {
);
this.logger.error("Error processing first party plate:", plateError);
if (plateError instanceof HttpException) throw plateError;
throw new InternalServerErrorException(
"Failed to process first party plate information",
throw new BadRequestException(
getInquiryErrorMessage(plateError, "thirdPartyPlate"),
);
}
@@ -8241,7 +8320,7 @@ export class RequestManagementService {
if (!client) {
const error = new NotFoundException(
`Client not found for company: ${clientName}${companyCode ? ` (code: ${companyCode})` : ""}`,
"شرکت بیمه موجود در پاسخ استعلام قابل شناسایی نیست.",
);
await this.persistLegacyInquiryAudit(
requestId,
@@ -8326,8 +8405,9 @@ export class RequestManagementService {
},
);
this.logger.error("Error processing second party plate:", plateError);
throw new InternalServerErrorException(
"Failed to process second party plate information",
if (plateError instanceof HttpException) throw plateError;
throw new BadRequestException(
getInquiryErrorMessage(plateError, "thirdPartyPlate"),
);
}
@@ -8584,7 +8664,7 @@ export class RequestManagementService {
if (!client) {
const error = new NotFoundException(
`Client not found for company: ${clientName}`,
"شرکت بیمه موجود در پاسخ استعلام قابل شناسایی نیست.",
);
await this.persistLegacyInquiryAudit(
requestId,
@@ -8710,9 +8790,8 @@ export class RequestManagementService {
},
);
this.logger.error("Error processing first party plate:", plateError);
throw new InternalServerErrorException(
"Failed to process first party plate information",
);
if (plateError instanceof HttpException) throw plateError;
throw new BadRequestException(getInquiryErrorMessage(plateError));
}
// For CAR_BODY: Create expertSubmitReply
@@ -9850,14 +9929,14 @@ export class RequestManagementService {
);
await this.persistBlameInquiryAudit(req);
throw new BadRequestException(
`${roleLabel} party plate inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`,
getInquiryErrorMessage(err, "thirdPartyPlate"),
);
}
if (inquiryMapped?.Error) {
const error = new BadRequestException(
inquiryMapped.Error.Message ||
`${roleLabel} party plate inquiry returned an error`,
getInquiryErrorMessage(inquiryMapped, "thirdPartyPlate"),
);
this.recordPartyCaseInquiryStatus(
req,
@@ -9881,7 +9960,7 @@ export class RequestManagementService {
const companyCode = inquiryMapped?.CompanyCode;
if (!clientName) {
const error = new BadRequestException(
`CompanyName missing from ${roleLabel} party inquiry response`,
"پاسخ استعلام بیمه‌نامه ناقص است و نام شرکت بیمه در آن وجود ندارد.",
);
this.recordPartyCaseInquiryStatus(
req,
@@ -9908,7 +9987,7 @@ export class RequestManagementService {
: null;
if (clientName && !client) {
const error = new BadRequestException(
`CompanyCode missing or invalid in ${roleLabel} party inquiry response`,
"پاسخ استعلام بیمه‌نامه ناقص است و شرکت بیمه قابل شناسایی نیست.",
);
this.recordPartyCaseInquiryStatus(
req,
@@ -10128,7 +10207,7 @@ export class RequestManagementService {
);
await this.persistBlameInquiryAudit(req);
throw new BadRequestException(
`${roleLabel} party driving license inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`,
getInquiryErrorMessage(err, "drivingLicense"),
);
}
}
@@ -10143,7 +10222,7 @@ export class RequestManagementService {
): Promise<void> {
if (!String(sheba ?? "").trim()) {
throw new BadRequestException(
"sheba is required for the damaged party.",
"شماره شبا برای طرف زیان‌دیده الزامی است.",
);
}
await this.sandHubService.getShebaValidation(
@@ -10764,14 +10843,14 @@ export class RequestManagementService {
);
await this.persistBlameInquiryAudit(req);
throw new BadRequestException(
`${roleLabel} party VIN inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`,
getInquiryErrorMessage(err, "thirdPartyVin"),
);
}
if (inquiryMapped?.Error) {
const error = new BadRequestException(
inquiryMapped.Error.Message ||
`${roleLabel} party VIN inquiry returned an error`,
getInquiryErrorMessage(inquiryMapped, "thirdPartyVin"),
);
this.recordPartyCaseInquiryStatus(
req,
@@ -10793,7 +10872,7 @@ export class RequestManagementService {
const companyCode = inquiryMapped?.CompanyCode;
if (!clientName) {
const error = new BadRequestException(
`CompanyName missing from ${roleLabel} party VIN inquiry response`,
"پاسخ استعلام VIN ناقص است و نام شرکت بیمه در آن وجود ندارد.",
);
this.recordPartyCaseInquiryStatus(
req,
@@ -10818,7 +10897,7 @@ export class RequestManagementService {
: null;
if (clientName && !client) {
const error = new BadRequestException(
`CompanyCode missing or invalid in ${roleLabel} party VIN inquiry response`,
"پاسخ استعلام VIN ناقص است و شرکت بیمه قابل شناسایی نیست.",
);
this.recordPartyCaseInquiryStatus(
req,
@@ -10970,7 +11049,7 @@ export class RequestManagementService {
err,
);
await this.persistBlameInquiryAudit(req);
this.throwCarBodyInquiryFailure(err);
this.throwCarBodyInquiryFailure(err, "carBodyVin");
}
}
@@ -11045,7 +11124,7 @@ export class RequestManagementService {
);
await this.persistBlameInquiryAudit(req);
throw new BadRequestException(
`${roleLabel} party driving license inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`,
getInquiryErrorMessage(err, "drivingLicense"),
);
}
}
@@ -12756,27 +12835,124 @@ export class RequestManagementService {
return { ...workflow, completedSteps };
}
// /**
// * V4/V5 dirty bridge: FileMaker FE resumes from blame `status`, but pre-capture
// * document upload lives on the claim (`UPLOADING_REQUIRED_DOCUMENTS`) while blame
// * is still at FIRST/SECOND_COMPLETED. Mirror claim status into `status` only for
// * that phase so leave/re-enter can continue; keep real blame status as
// * `blameCaseStatus`. Remove once FE keys off `claimStatus` / a unified resume pointer.
// */
// private fileMakerStatusForResume(
// blameStatus: unknown,
// claimStatus: unknown,
// ): { status: unknown; blameCaseStatus?: unknown } {
// if (claimStatus === ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS) {
// return {
// status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
// blameCaseStatus: blameStatus,
// };
// }
// return { status: blameStatus };
// }
/**
* FileMaker owns a cross-aggregate workflow: the party narrative is stored on
* the blame case, while required-document progress is stored on its linked
* claim. Expose the authoritative aggregate to resume instead of overloading
* either record's status with the other record's state.
*/
private fileMakerResumeProjection(
file: any,
claim?: any,
): FileMakerResumeProjection {
const blameWorkflow = this.fileMakerWorkflowProjection(file);
const narrativeTerminalStep =
file?.type === BlameRequestType.CAR_BODY
? WorkflowStep.FIRST_COMPLETED
: WorkflowStep.SECOND_COMPLETED;
const narrativeComplete =
blameWorkflow.currentStep === narrativeTerminalStep ||
(blameWorkflow.completedSteps ?? []).includes(narrativeTerminalStep);
const claimWorkflow = claim?.workflow ?? {};
async getMyFileMakerFiles(fileMaker: any): Promise<any[]> {
if (
narrativeComplete &&
claim &&
claim.status === ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS &&
claimWorkflow.currentStep === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS
) {
return {
action: "UPLOAD_REQUIRED_DOCUMENTS",
entity: "CLAIM",
entityId: String(claim._id),
status: claim.status,
currentStep: claimWorkflow.currentStep,
nextStep: claimWorkflow.nextStep,
};
}
if (
narrativeComplete &&
claim &&
(claim.status === ClaimCaseStatus.WAITING_FOR_FILE_REVIEWER ||
file?.status === CaseStatus.WAITING_FOR_FILE_REVIEWER)
) {
return {
action: "WAIT_FOR_FILE_REVIEWER",
entity: "CLAIM",
entityId: String(claim._id),
status: claim.status,
currentStep: claimWorkflow.currentStep,
nextStep: claimWorkflow.nextStep,
};
}
return {
action: "CONTINUE_BLAME",
entity: "BLAME",
entityId: String(file._id),
status: file.status,
currentStep: blameWorkflow.currentStep,
nextStep: blameWorkflow.nextStep,
};
}
private paginateUserFacingFiles(
rows: any[],
query: ListQueryV2Dto,
): GetUserBlameListV2ResponseDto {
let filtered = rows;
if (query.unifiedStatus) {
filtered = filtered.filter(
(row) => row.unifiedFileStatus === query.unifiedStatus,
);
}
const { fromDate, toDate } = parseListDateRange(
query.startDate,
query.endDate,
);
if (fromDate || toDate) {
filtered = filtered.filter((row) =>
isInListDateRange(row.createdAt, fromDate, toDate),
);
}
const paged = applyListQueryV2(
filtered,
{
publicId: (row) => String(row.publicId ?? ""),
createdAt: (row) => row.createdAt,
requestNo: (row) => String(row.requestNo ?? ""),
status: (row) => String(row.unifiedFileStatus ?? row.status ?? ""),
fileType: (row) => row.type,
searchExtras: (row) =>
[
row._id,
row.blameStatus,
row.claimStatus,
row.workflow?.currentStep,
row.claimWorkflow?.currentStep,
]
.filter(Boolean)
.map(String),
},
query,
);
return {
list: paged.list,
total: paged.total,
page: paged.page,
limit: paged.limit,
totalPages: paged.totalPages,
};
}
async getMyFileMakerFiles(
fileMaker: any,
query: ListQueryV2Dto = {},
): Promise<GetUserBlameListV2ResponseDto> {
if (fileMaker?.role !== RoleEnum.FILE_MAKER) {
throw new ForbiddenException("Only FileMakers can use this endpoint.");
}
@@ -12785,26 +12961,27 @@ export class RequestManagementService {
isMadeByFileMaker: true,
initiatedByFieldExpertId: makerId,
});
// const blameIds = (files || []).map((f: any) => f._id).filter(Boolean);
// const claims =
// blameIds.length > 0
// ? await this.claimCaseDbService.find(
// { blameRequestId: { $in: blameIds } },
// { lean: true, select: "blameRequestId status" },
// )
// : [];
// const claimStatusByBlameId = new Map<string, unknown>();
// for (const c of claims as any[]) {
// const blameId = c?.blameRequestId != null ? String(c.blameRequestId) : "";
// if (blameId) claimStatusByBlameId.set(blameId, c.status);
// }
const blameIds = (files || []).map((f: any) => f._id).filter(Boolean);
const claims =
blameIds.length > 0
? await this.claimCaseDbService.find(
{ blameRequestId: { $in: blameIds } },
{
lean: true,
select: "blameRequestId status workflow",
},
)
: [];
const claimByBlameId = new Map<string, any>();
for (const claim of claims as any[]) {
const blameId =
claim?.blameRequestId != null ? String(claim.blameRequestId) : "";
if (blameId) claimByBlameId.set(blameId, claim);
}
return (files || []).map((f: any) => {
const list = (files || []).map((f: any) => {
const workflow = this.fileMakerWorkflowProjection(f);
// const resume = this.fileMakerStatusForResume(
// f.status,
// claimStatusByBlameId.get(String(f._id)),
// );
const claim = claimByBlameId.get(String(f._id));
return {
_id: f._id,
publicId: f.publicId,
@@ -12817,11 +12994,21 @@ export class RequestManagementService {
nextStep: workflow.nextStep,
completedSteps: workflow.completedSteps,
},
linkedClaimId: claim?._id ? String(claim._id) : null,
claimStatus: claim?.status,
unifiedFileStatus: resolveUnifiedFileStatus({
blameStatus: f.status,
claimStatus: claim?.status,
}),
claimWorkflow: claim?.workflow,
fileMakerResume: this.fileMakerResumeProjection(f, claim),
requiresFileMakerApproval: f.requiresFileMakerApproval,
createdAt: f.createdAt,
updatedAt: f.updatedAt,
};
});
return this.paginateUserFacingFiles(list, query);
}
async getMyFileMakerFileDetail(
@@ -12855,6 +13042,7 @@ export class RequestManagementService {
: claim
? { ...(claim as any) }
: null;
const fileMakerResume = this.fileMakerResumeProjection(plain, claimPlain);
return {
_id: plain._id,
publicId: plain.publicId,
@@ -12907,6 +13095,7 @@ export class RequestManagementService {
hasSigned: p.confirmation != null,
})),
linkedClaimId: claimPlain ? String(claimPlain._id) : null,
fileMakerResume,
...(claimPlain
? {
claimStatus: claimPlain.status,
@@ -12933,7 +13122,10 @@ export class RequestManagementService {
// ─── FileReviewer file list / detail (V4 + V5) ─────────────────────────────
async getMyFileReviewerFiles(fileReviewer: any): Promise<any[]> {
async getMyFileReviewerFiles(
fileReviewer: any,
query: ListQueryV2Dto = {},
): Promise<GetUserBlameListV2ResponseDto> {
if (fileReviewer?.role !== RoleEnum.FILE_REVIEWER) {
throw new ForbiddenException("Only FileReviewers can use this endpoint.");
}
@@ -12971,13 +13163,35 @@ export class RequestManagementService {
);
});
return visibleFiles.map((f: any) => ({
const visibleBlameIds = visibleFiles
.map((f: any) => f._id)
.filter(Boolean);
const claims =
visibleBlameIds.length > 0
? await this.claimCaseDbService.find(
{ blameRequestId: { $in: visibleBlameIds } },
{ lean: true, select: "blameRequestId status" },
)
: [];
const claimByBlameId = new Map<string, any>();
for (const claim of claims as any[]) {
if (claim?.blameRequestId) {
claimByBlameId.set(String(claim.blameRequestId), claim);
}
}
const list = visibleFiles.map((f: any) => ({
_id: f._id,
publicId: f.publicId,
requestNo: f.requestNo,
type: f.type,
status: f.status,
blameStatus: f.blameStatus,
claimStatus: claimByBlameId.get(String(f._id))?.status,
unifiedFileStatus: resolveUnifiedFileStatus({
blameStatus: f.status,
claimStatus: claimByBlameId.get(String(f._id))?.status,
}),
workflow: {
currentStep: f.workflow?.currentStep,
nextStep: f.workflow?.nextStep,
@@ -12987,6 +13201,8 @@ export class RequestManagementService {
createdAt: f.createdAt,
updatedAt: f.updatedAt,
}));
return this.paginateUserFacingFiles(list, query);
}
async getMyFileReviewerFileDetail(

View File

@@ -82,7 +82,7 @@ export class RequestManagementV2Controller {
@ApiOperation({
summary: "List my blame requests (V2)",
description:
"Party-owned blame files, or files initiated by the current FIELD_EXPERT / REGISTRAR. Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`.",
"Party-owned blame files, or files initiated by the current FIELD_EXPERT / REGISTRAR. Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`, `unifiedStatus`, `fileType`, `startDate`, `endDate`.",
})
async getAllBlameRequestsV2(
@CurrentUser() user: any,