fix: query policies only for current holder

This commit is contained in:
SepehrYahyaee
2026-09-19 11:26:40 +03:30
parent 51e1d495a9
commit 45e0ad883a
9 changed files with 153 additions and 375 deletions

View File

@@ -5,7 +5,6 @@ import {
VehicleRegistrationState,
} from "src/common/dto/inquiry-participants.dto";
import {
assertPreviousPlateInquiryMatchesVin,
isMappedPolicyCurrent,
normalizeInquirySubmission,
participantForRole,
@@ -13,9 +12,8 @@ import {
resolveInquiryParticipants,
resolveInquirySubjects,
resolveInquiryVehicle,
runPlateInquiryWithFallback,
runCurrentPlateInquiry,
sanitizeStoredInquiryParticipants,
vehiclePlateCandidates,
} from "./inquiry-participant-resolver";
describe("inquiry participant resolver", () => {
@@ -309,42 +307,6 @@ describe("inquiry participant resolver", () => {
).toBe("0022222222");
});
it("orders the current plate before the previous-plate fallback", () => {
const currentPlate = {
leftDigits: "44",
centerAlphabet: "ب",
centerDigits: "111",
ir: "22",
};
const previousPlate = {
leftDigits: "55",
centerAlphabet: "ج",
centerDigits: "222",
ir: "33",
};
expect(
vehiclePlateCandidates({
registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
currentPlate,
previousPlate,
vin: "NAAM01E15HK123456",
previousPolicyholderNationalCode: "0098765432",
}),
).toEqual([
{ kind: "CURRENT", plate: currentPlate },
{ kind: "PREVIOUS", plate: previousPlate },
]);
});
it("rejects a previous-plate result for another chassis", () => {
expect(() =>
assertPreviousPlateInquiryMatchesVin("NAAM01E15HK123456", {
VinNumberField: "DIFFERENTVIN00001",
}),
).toThrow(BadRequestException);
});
it("requires the driver's licence status in the new contract", () => {
expect(() =>
resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, {
@@ -357,58 +319,36 @@ describe("inquiry participant resolver", () => {
).toThrow(BadRequestException);
});
it("falls back to the previous plate and accepts only a matching VIN", async () => {
it("runs only one inquiry for the current plate", async () => {
const currentPlate = {
leftDigits: "44",
centerAlphabet: "ب",
centerDigits: "111",
ir: "22",
};
const previousPlate = {
leftDigits: "55",
centerAlphabet: "ج",
centerDigits: "222",
ir: "33",
};
const query = jest
.fn()
.mockRejectedValueOnce(new Error("not found"))
.mockResolvedValueOnce({
mapped: { VinNumberField: "NAAM01E15HK123456", CompanyName: "پارسیان" },
});
const result = await runPlateInquiryWithFallback<{
mapped: { VinNumberField?: string; CompanyName?: string };
}>({
vehicle: {
registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
currentPlate,
previousPlate,
vin: "NAAM01E15HK123456",
previousPolicyholderNationalCode: "0098765432",
},
fallbackCurrentPlate: currentPlate,
query,
isUsable: (value) => !!value.mapped.CompanyName,
mappedValue: (value) => value.mapped,
const query = jest.fn().mockResolvedValue({
mapped: { CompanyName: "پارسیان" },
});
expect(query).toHaveBeenCalledTimes(2);
expect(query).toHaveBeenNthCalledWith(1, currentPlate, "CURRENT");
expect(query).toHaveBeenNthCalledWith(2, previousPlate, "PREVIOUS");
expect(result.plateKind).toBe("PREVIOUS");
const result = await runCurrentPlateInquiry<{
mapped: { CompanyName?: string };
}>({
currentPlate,
vin: "NAAM01E15HK123456",
query,
isUsable: (value) => !!value.mapped.CompanyName,
});
expect(query).toHaveBeenCalledTimes(1);
expect(query).toHaveBeenCalledWith(currentPlate);
expect(result.plateKind).toBe("CURRENT");
expect(result.attempts).toMatchObject([
{ plateKind: "CURRENT", succeeded: false, error: "not found" },
{ plateKind: "PREVIOUS", succeeded: true, usable: true },
{ plateKind: "CURRENT", succeeded: true, usable: true },
]);
expect(result.attempts[0]).toMatchObject({
plate: currentPlate,
vin: "NAAM01E15HK123456",
});
expect(result.attempts[1]).toMatchObject({
plate: previousPlate,
vin: "NAAM01E15HK123456",
});
});
it("rejects a car-body policyholder on a third-party case", () => {
@@ -510,95 +450,52 @@ describe("inquiry participant resolver", () => {
);
});
it("falls back from a stale current policy to a current previous-plate policy", async () => {
it("rejects a stale current policy without trying another plate", async () => {
const currentPlate = {
leftDigits: "44",
centerAlphabet: "ب",
centerDigits: "111",
ir: "22",
};
const previousPlate = {
leftDigits: "55",
centerAlphabet: "ج",
centerDigits: "222",
ir: "33",
};
const query = jest
.fn()
.mockResolvedValueOnce({
mapped: { CompanyName: "پارسیان", EndDate: "1404/01/01" },
})
.mockResolvedValueOnce({
mapped: {
CompanyName: "پارسیان",
EndDate: "1406/01/01",
VinNumberField: "NAAM01E15HK123456",
},
});
const query = jest.fn().mockResolvedValue({
mapped: { CompanyName: "پارسیان", EndDate: "1404/01/01" },
});
const result = await runPlateInquiryWithFallback<{
mapped: Record<string, any>;
}>({
vehicle: resolveInquiryVehicle({
registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
await expect(
runCurrentPlateInquiry<{ mapped: Record<string, any> }>({
currentPlate,
previousPlate,
vin: "NAAM01E15HK123456",
previousPolicyholderNationalCode: "0098765432",
query,
isUsable: (value) =>
!!value.mapped.CompanyName &&
isMappedPolicyCurrent(value.mapped, "2026-09-13"),
}),
fallbackCurrentPlate: currentPlate,
query,
isUsable: (value) =>
!!value.mapped.CompanyName &&
isMappedPolicyCurrent(value.mapped, "2026-09-13"),
mappedValue: (value) => value.mapped,
});
expect(result.plateKind).toBe("PREVIOUS");
expect(result.attempts[0]).toMatchObject({
plateKind: "CURRENT",
succeeded: true,
usable: false,
).rejects.toMatchObject({
attempts: [{ plateKind: "CURRENT", succeeded: true, usable: false }],
});
expect(query).toHaveBeenCalledTimes(1);
});
it("rejects the result and retains audit attempts when every plate is unusable", async () => {
it("retains the current-plate audit attempt when the result is unusable", async () => {
const currentPlate = {
leftDigits: "44",
centerAlphabet: "ب",
centerDigits: "111",
ir: "22",
};
const previousPlate = {
leftDigits: "55",
centerAlphabet: "ج",
centerDigits: "222",
ir: "33",
};
await expect(
runPlateInquiryWithFallback({
vehicle: resolveInquiryVehicle({
registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
currentPlate,
previousPlate,
vin: "NAAM01E15HK123456",
previousPolicyholderNationalCode: "0098765432",
}),
fallbackCurrentPlate: currentPlate,
runCurrentPlateInquiry({
currentPlate,
vin: "NAAM01E15HK123456",
query: async () => ({ mapped: { CompanyName: "پارسیان" } }),
isUsable: () => false,
mappedValue: (value) => value.mapped,
}),
).rejects.toMatchObject({
attempts: [
{ plateKind: "CURRENT", succeeded: true, usable: false },
{ plateKind: "PREVIOUS", succeeded: true, usable: false },
],
attempts: [{ plateKind: "CURRENT", succeeded: true, usable: false }],
});
});
it("does not use the previous plate after a transport or provider outage", async () => {
it("retains one failed current-plate attempt after a provider outage", async () => {
const currentPlate = {
leftDigits: "44",
centerAlphabet: "ب",
@@ -612,23 +509,11 @@ describe("inquiry participant resolver", () => {
);
await expect(
runPlateInquiryWithFallback({
vehicle: resolveInquiryVehicle({
registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
currentPlate,
previousPlate: {
leftDigits: "55",
centerAlphabet: "ج",
centerDigits: "222",
ir: "33",
},
vin: "NAAM01E15HK123456",
previousPolicyholderNationalCode: "0098765432",
}),
fallbackCurrentPlate: currentPlate,
runCurrentPlateInquiry({
currentPlate,
vin: "NAAM01E15HK123456",
query,
isUsable: () => false,
mappedValue: () => ({}),
}),
).rejects.toThrow("upstream timeout");
expect(query).toHaveBeenCalledTimes(1);

View File

@@ -393,26 +393,6 @@ export function resolveInquiryVehicle(
};
}
export function vehiclePlateCandidates(input?: ResolvedInquiryVehicle): Array<{
kind: "CURRENT" | "PREVIOUS";
plate: InquiryVehicleInputDto["currentPlate"];
}> {
if (!input) return [];
return [
{ kind: "CURRENT" as const, plate: input.currentPlate },
...(input.registrationState ===
VehicleRegistrationState.RECENTLY_TRANSFERRED && input.previousPlate
? [{ kind: "PREVIOUS" as const, plate: input.previousPlate }]
: []),
];
}
function normalizeVehicleSerial(value: unknown): string {
return String(value ?? "")
.toUpperCase()
.replace(/[^A-Z0-9]/g, "");
}
/** A dated result is usable only while the returned policy has not expired. */
export function isMappedPolicyCurrent(
mapped: Record<string, any>,
@@ -429,19 +409,6 @@ export function isMappedPolicyCurrent(
return endDate != null && endDate >= todayGregorian;
}
function normalizePlateForComparison(
plate: InquiryVehicleInputDto["currentPlate"],
): string {
return [
plate?.ir,
plate?.leftDigits,
plate?.centerAlphabet,
plate?.centerDigits,
]
.map((part) => String(part ?? "").trim())
.join("|");
}
const LEGACY_INQUIRY_FIELDS = [
"nationalCodeOfDriver",
"driverBirthday",
@@ -469,63 +436,21 @@ function assertStructuredInquiryInput(input: Record<string, any>): void {
}
}
export function assertPreviousPlateInquiryMatchesVin(
expectedVin: string,
mapped: Record<string, any>,
): void {
const expected = normalizeVehicleSerial(expectedVin);
const candidates = [
mapped?.VinNumberField,
mapped?.vin,
mapped?.VIN,
mapped?.ChassisNumberField,
mapped?.chassisNumber,
mapped?.ChassisNo,
mapped?.vehicle?.VIN,
mapped?.vehicle?.ChassisNo,
]
.map(normalizeVehicleSerial)
.filter(Boolean);
if (!expected || !candidates.includes(expected)) {
throw new BadRequestException(
"نتیجه استعلام پلاک قبلی با شماره شاسی (VIN) واردشده مطابقت ندارد و پرونده نیازمند بررسی دستی است.",
);
}
}
export function isPolicyNotFoundError(error: unknown): boolean {
const candidate = error as Record<string, any> | null;
const status = candidate?.status ?? candidate?.response?.status;
if (Number(status) === 404) return true;
const code = String(
candidate?.code ?? candidate?.response?.data?.code ?? "",
).toUpperCase();
if (["NOT_FOUND", "POLICY_NOT_FOUND", "NO_POLICY"].includes(code)) {
return true;
}
const message = String(
candidate?.message ?? candidate?.response?.data?.message ?? error ?? "",
);
return /\bnot[ -]?found\b|\bno (?:relevant )?policy\b|یافت نشد|فاقد بیمه(?:نامه)?/i.test(
message,
);
}
export async function runPlateInquiryWithFallback<T>(options: {
vehicle?: ResolvedInquiryVehicle;
fallbackCurrentPlate: InquiryVehicleInputDto["currentPlate"];
query: (
plate: InquiryVehicleInputDto["currentPlate"],
plateKind: "CURRENT" | "PREVIOUS",
) => Promise<T>;
/**
* Run a policy inquiry only for the submitted current plate. Recent-transfer
* data is retained as case metadata, but must never trigger an inquiry for a
* previous plate or a previous policyholder.
*/
export async function runCurrentPlateInquiry<T>(options: {
currentPlate: InquiryVehicleInputDto["currentPlate"];
vin?: string;
query: (plate: InquiryVehicleInputDto["currentPlate"]) => Promise<T>;
isUsable: (value: T) => boolean;
mappedValue: (value: T) => Record<string, any>;
shouldFallbackOnError?: (error: unknown) => boolean;
}): Promise<{
value: T;
plateKind: "CURRENT" | "PREVIOUS";
plateKind: "CURRENT";
attempts: Array<{
plateKind: "CURRENT" | "PREVIOUS";
plateKind: "CURRENT";
plate: InquiryVehicleInputDto["currentPlate"];
vin?: string;
succeeded: boolean;
@@ -533,12 +458,8 @@ export async function runPlateInquiryWithFallback<T>(options: {
error?: string;
}>;
}> {
const candidates = options.vehicle
? vehiclePlateCandidates(options.vehicle)
: [{ kind: "CURRENT" as const, plate: options.fallbackCurrentPlate }];
let lastError: unknown;
const attempts: Array<{
plateKind: "CURRENT" | "PREVIOUS";
plateKind: "CURRENT";
plate: InquiryVehicleInputDto["currentPlate"];
vin?: string;
succeeded: boolean;
@@ -546,80 +467,50 @@ export async function runPlateInquiryWithFallback<T>(options: {
error?: string;
}> = [];
for (let index = 0; index < candidates.length; index += 1) {
const candidate = candidates[index];
const isLast = index === candidates.length - 1;
try {
const value = await options.query(candidate.plate, candidate.kind);
const usable = options.isUsable(value);
if (!usable) {
attempts.push({
plateKind: candidate.kind,
plate: candidate.plate,
...(options.vehicle?.vin ? { vin: options.vehicle.vin } : {}),
succeeded: true,
usable: false,
});
if (!isLast) continue;
const error = new BadRequestException(
"بیمه‌نامه معتبر و فعالی مطابق مشخصات خودرو و کد ملی واردشده یافت نشد.",
) as BadRequestException & { attempts?: typeof attempts };
error.attempts = attempts;
throw error;
}
if (candidate.kind === "PREVIOUS" && usable) {
assertPreviousPlateInquiryMatchesVin(
options.vehicle!.vin!,
options.mappedValue(value),
);
}
try {
const value = await options.query(options.currentPlate);
const usable = options.isUsable(value);
if (!usable) {
attempts.push({
plateKind: candidate.kind,
plate: candidate.plate,
...(options.vehicle?.vin ? { vin: options.vehicle.vin } : {}),
plateKind: "CURRENT",
plate: options.currentPlate,
...(options.vin ? { vin: options.vin } : {}),
succeeded: true,
usable: true,
usable: false,
});
return { value, plateKind: candidate.kind, attempts };
} catch (error) {
lastError = error;
const alreadyRecorded =
typeof error === "object" &&
error != null &&
Array.isArray((error as { attempts?: unknown }).attempts);
if (!alreadyRecorded) {
attempts.push({
plateKind: candidate.kind,
plate: candidate.plate,
...(options.vehicle?.vin ? { vin: options.vehicle.vin } : {}),
succeeded: false,
error: error instanceof Error ? error.message : String(error),
});
}
if (
!isLast &&
!(options.shouldFallbackOnError ?? isPolicyNotFoundError)(error)
) {
if (typeof error === "object" && error != null) {
(error as { attempts?: typeof attempts }).attempts = attempts;
}
throw error;
}
if (isLast) {
if (typeof error === "object" && error != null) {
(error as { attempts?: typeof attempts }).attempts = attempts;
}
throw error;
}
const error = new BadRequestException(
"بیمه‌نامه معتبر و فعالی مطابق مشخصات خودرو و کد ملی واردشده یافت نشد.",
) as BadRequestException & { attempts?: typeof attempts };
error.attempts = attempts;
throw error;
}
attempts.push({
plateKind: "CURRENT",
plate: options.currentPlate,
...(options.vin ? { vin: options.vin } : {}),
succeeded: true,
usable: true,
});
return { value, plateKind: "CURRENT", attempts };
} catch (error) {
const alreadyRecorded =
typeof error === "object" &&
error != null &&
Array.isArray((error as { attempts?: unknown }).attempts);
if (!alreadyRecorded) {
attempts.push({
plateKind: "CURRENT",
plate: options.currentPlate,
...(options.vin ? { vin: options.vin } : {}),
succeeded: false,
error: error instanceof Error ? error.message : String(error),
});
}
if (typeof error === "object" && error != null) {
(error as { attempts?: typeof attempts }).attempts = attempts;
}
throw error;
}
throw (
lastError ??
new BadRequestException(
"برای هیچ‌یک از پلاک‌های ثبت‌شده نتیجه معتبری یافت نشد.",
)
);
}
export function normalizeInquirySubmission<T extends Record<string, any>>(

View File

@@ -1,7 +1,7 @@
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { RequestManagementService } from "./request-management.service";
describe("RequestManagementService previous policyholder routing", () => {
describe("RequestManagementService policyholder inquiry routing", () => {
const currentPlate = {
leftDigits: "44",
centerAlphabet: "ب",
@@ -46,7 +46,7 @@ describe("RequestManagementService previous policyholder routing", () => {
};
}
it("uses the previous code only for the previous-plate third-party lookup", async () => {
it("does not fall back from the current plate for third-party insurance", async () => {
const service = Object.create(RequestManagementService.prototype) as any;
service.sandHubService = {
getTejaratBlockInquiry: jest
@@ -54,10 +54,7 @@ describe("RequestManagementService previous policyholder routing", () => {
.mockResolvedValueOnce({ raw: {}, mapped: {} })
.mockResolvedValueOnce({
raw: {},
mapped: {
CompanyName: "پارسیان",
VinNumberField: vehicle.vin,
},
mapped: { CompanyName: "پارسیان", VinNumberField: vehicle.vin },
}),
};
const submission = service.normalizeInquiryInput(
@@ -65,32 +62,23 @@ describe("RequestManagementService previous policyholder routing", () => {
participantInput(BlameRequestType.THIRD_PARTY),
);
const result = await service.getThirdPartyPlateInquiry(submission);
await expect(service.getThirdPartyPlateInquiry(submission)).rejects.toThrow(
"بیمه‌نامه معتبر و فعالی مطابق مشخصات خودرو و کد ملی واردشده یافت نشد.",
);
expect(result.plateKind).toBe("PREVIOUS");
expect(
service.sandHubService.getTejaratBlockInquiry,
).toHaveBeenNthCalledWith(
expect(service.sandHubService.getTejaratBlockInquiry).toHaveBeenCalledTimes(
1,
);
expect(service.sandHubService.getTejaratBlockInquiry).toHaveBeenCalledWith(
expect.objectContaining({
plate: currentPlate,
nationalCodeOfInsurer: "0022222222",
}),
undefined,
);
expect(
service.sandHubService.getTejaratBlockInquiry,
).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
plate: previousPlate,
nationalCodeOfInsurer: "0098765432",
}),
undefined,
);
});
it("uses the previous code only for the previous-plate car-body lookup", async () => {
it("does not fall back from the current plate for car-body insurance", async () => {
const service = Object.create(RequestManagementService.prototype) as any;
service.sandHubService = {
getCarBodyInquiry: jest
@@ -98,10 +86,7 @@ describe("RequestManagementService previous policyholder routing", () => {
.mockResolvedValueOnce({ raw: {}, mapped: {} })
.mockResolvedValueOnce({
raw: {},
mapped: {
policyNumber: "BODY-1",
VinNumberField: vehicle.vin,
},
mapped: { policyNumber: "BODY-1", VinNumberField: vehicle.vin },
}),
};
const submission = service.normalizeInquiryInput(
@@ -109,22 +94,45 @@ describe("RequestManagementService previous policyholder routing", () => {
participantInput(BlameRequestType.CAR_BODY),
);
const result = await service.getCarBodyPlateInquiry(submission);
await expect(service.getCarBodyPlateInquiry(submission)).rejects.toThrow(
"بیمه‌نامه معتبر و فعالی مطابق مشخصات خودرو و کد ملی واردشده یافت نشد.",
);
expect(result.plateKind).toBe("PREVIOUS");
expect(service.sandHubService.getCarBodyInquiry).toHaveBeenNthCalledWith(
1,
expect(service.sandHubService.getCarBodyInquiry).toHaveBeenCalledTimes(1);
expect(service.sandHubService.getCarBodyInquiry).toHaveBeenCalledWith(
expect.objectContaining({
plate: currentPlate,
nationalCodeOfInsurer: "0033333333",
}),
);
expect(service.sandHubService.getCarBodyInquiry).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
plate: previousPlate,
nationalCodeOfInsurer: "0098765432",
});
it("queries VIN with the third-party policyholder", async () => {
const service = Object.create(RequestManagementService.prototype) as any;
service.sandHubService = {
getPolicyByChassisInquiry: jest.fn().mockResolvedValue({
raw: {},
mapped: { CompanyName: "پارسیان" },
}),
};
const submission = service.normalizeInquiryInput(
BlameRequestType.THIRD_PARTY,
participantInput(BlameRequestType.THIRD_PARTY),
);
await service.getThirdPartyVinInquiry(submission);
expect(
service.sandHubService.getPolicyByChassisInquiry,
).toHaveBeenCalledTimes(1);
expect(
service.sandHubService.getPolicyByChassisInquiry,
).toHaveBeenCalledWith(
{
nationalCode: "0022222222",
chassis: vehicle.vin,
},
undefined,
);
});
});

View File

@@ -140,7 +140,7 @@ import {
NormalizedInquirySubmission,
normalizeInquirySubmission,
resolveInquirySubjects,
runPlateInquiryWithFallback,
runCurrentPlateInquiry,
sanitizeStoredInquiryParticipants,
} from "./inquiry-participant-resolver";
import {
@@ -386,17 +386,14 @@ export class RequestManagementService {
options?: Record<string, any>,
): Promise<any> {
const subjects = resolveInquirySubjects(submission);
const result = await runPlateInquiryWithFallback({
vehicle: submission.vehicle,
fallbackCurrentPlate: submission.dto.plate,
query: (plate, plateKind) =>
const result = await runCurrentPlateInquiry({
currentPlate: submission.vehicle?.currentPlate ?? submission.dto.plate,
vin: submission.vehicle?.vin,
query: (plate) =>
this.sandHubService.getTejaratBlockInquiry(
{
plate: plate as any,
nationalCodeOfInsurer:
plateKind === "PREVIOUS"
? submission.vehicle!.previousPolicyholderNationalCode!
: subjects.thirdPartyPolicyNationalCode,
nationalCodeOfInsurer: subjects.thirdPartyPolicyNationalCode,
},
options,
),
@@ -404,7 +401,6 @@ export class RequestManagementService {
!value?.mapped?.Error &&
!!value?.mapped?.CompanyName &&
isMappedPolicyCurrent(value.mapped),
mappedValue: (value) => value?.mapped ?? {},
});
return {
...result.value,
@@ -423,15 +419,12 @@ export class RequestManagementService {
"اطلاعات بیمه‌گذار برای استعلام بیمه بدنه الزامی است.",
);
}
const result = await runPlateInquiryWithFallback({
vehicle: submission.vehicle,
fallbackCurrentPlate: submission.dto.plate,
query: (plate, plateKind) =>
const result = await runCurrentPlateInquiry({
currentPlate: submission.vehicle?.currentPlate ?? submission.dto.plate,
vin: submission.vehicle?.vin,
query: (plate) =>
this.sandHubService.getCarBodyInquiry({
nationalCodeOfInsurer:
plateKind === "PREVIOUS"
? submission.vehicle!.previousPolicyholderNationalCode!
: policyholderNationalCode,
nationalCodeOfInsurer: policyholderNationalCode,
plate: plate as any,
}),
isUsable: (value) =>
@@ -442,7 +435,6 @@ export class RequestManagementService {
value.mapped.CompanyName ||
value.mapped.companyId
),
mappedValue: (value) => value?.mapped ?? {},
});
return {
...result.value,