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

@@ -159,6 +159,7 @@
<div class="decision-tree"> <div class="decision-tree">
<strong>برای هر پرس‌وجوی مبتنی بر پلاک:</strong> <strong>برای هر پرس‌وجوی مبتنی بر پلاک:</strong>
<ul> <ul>
<li>درخواست همیشه با پلاک فعلی ارسال‌شده و بیمه‌گذار نهایی همان نوع بیمه انجام می‌شود. متادیتای انتقال اخیر هیچ استعلامی برای پلاک یا بیمه‌گذار قبلی ایجاد نمی‌کند.</li>
<li>۱. بررسی داده‌های آفلاین (MongoDB) — اگر داده مطابق یافت شد، آن را برگردانده و تمام HTTP را رد کن.</li> <li>۱. بررسی داده‌های آفلاین (MongoDB) — اگر داده مطابق یافت شد، آن را برگردانده و تمام HTTP را رد کن.</li>
<li>۲. اگر <code>CLIENT_ID=8</code> (تنانت پارسیان/ESG) → مسیریابی به <strong>ESG</strong> <code>/inquiry/policyByPlate</code> یا <code>/inquiry/policyByChassis</code>.</li> <li>۲. اگر <code>CLIENT_ID=8</code> (تنانت پارسیان/ESG) → مسیریابی به <strong>ESG</strong> <code>/inquiry/policyByPlate</code> یا <code>/inquiry/policyByChassis</code>.</li>
<li>۳. در غیر این صورت → مسیریابی به <strong>پرس‌وجوی تجارت</strong> <code>/block-inquiry-tejarat</code> (THIRD_PARTY) یا <code>/block-inquiry-tejarat/badane</code> (CAR_BODY).</li> <li>۳. در غیر این صورت → مسیریابی به <strong>پرس‌وجوی تجارت</strong> <code>/block-inquiry-tejarat</code> (THIRD_PARTY) یا <code>/block-inquiry-tejarat/badane</code> (CAR_BODY).</li>

View File

@@ -154,6 +154,7 @@
<div class="decision-tree"> <div class="decision-tree">
<strong>For every plate-based block inquiry:</strong> <strong>For every plate-based block inquiry:</strong>
<ul> <ul>
<li>The request always uses the submitted current plate and the resolved policyholder for that policy type. Recent-transfer metadata never triggers a previous-plate or previous-policyholder lookup.</li>
<li>1. Check offline-inquiry seeds (MongoDB) — if a matching seed exists, return it and skip all HTTP.</li> <li>1. Check offline-inquiry seeds (MongoDB) — if a matching seed exists, return it and skip all HTTP.</li>
<li>2. If <code>CLIENT_ID=8</code> (Parsian/ESG tenant) → route to <strong>ESG</strong> <code>/inquiry/policyByPlate</code> or <code>/inquiry/policyByChassis</code>.</li> <li>2. If <code>CLIENT_ID=8</code> (Parsian/ESG tenant) → route to <strong>ESG</strong> <code>/inquiry/policyByPlate</code> or <code>/inquiry/policyByChassis</code>.</li>
<li>3. Otherwise → route to <strong>Tejarat inquiry</strong> <code>/block-inquiry-tejarat</code> (THIRD_PARTY) or <code>/block-inquiry-tejarat/badane</code> (CAR_BODY).</li> <li>3. Otherwise → route to <strong>Tejarat inquiry</strong> <code>/block-inquiry-tejarat</code> (THIRD_PARTY) or <code>/block-inquiry-tejarat/badane</code> (CAR_BODY).</li>

View File

@@ -157,7 +157,7 @@
} }
``` ```
سیستم ابتدا پلاک فعلی را با کد ملی بیمه‌گذار فعلیِ مرتبط با نوع بیمه استعلام می‌کند. اگر نتیجه ناموجود، منقضی یا فاقد بیمه‌نامه مرتبط باشد، پلاک قبلی را با `previousPolicyholderNationalCode` امتحان می‌کند. نتیجه پلاک قبلی فقط در صورت تطبیق VIN پذیرفته می‌شود؛ پلاک قبلی هرگز جایگزین پلاک فعلی نمی‌شود. اطلاعات انتقال اخیر فقط به‌عنوان متادیتای پرونده ذخیره می‌شوند. در route پلاک، سیستم فقط `currentPlate` را با کد ملی بیمه‌گذار نهاییِ مرتبط با نوع بیمه استعلام می‌کند و هیچ fallbackای به `previousPlate` یا `previousPolicyholderNationalCode` ندارد. در route شماره شاسی نیز فقط `vehicle.vin` با همان بیمه‌گذار نهایی استعلام می‌شود.
حتی در route مربوط به VIN، آبجکت `vehicle` از قرارداد مشترک استفاده می‌کند و `currentPlate` در قرارداد فعلی الزامی است. مقدار VIN در `vehicle.vin` قرار می‌گیرد، نه در فیلد سطح بالای `vin`. حتی در route مربوط به VIN، آبجکت `vehicle` از قرارداد مشترک استفاده می‌کند و `currentPlate` در قرارداد فعلی الزامی است. مقدار VIN در `vehicle.vin` قرار می‌گیرد، نه در فیلد سطح بالای `vin`.

View File

@@ -79,9 +79,9 @@
} }
``` ```
مقدار پیش‌فرض `registrationState` برابر `CURRENT` است و برای این مسیر استثنایی مقدار `RECENTLY_TRANSFERRED` استفاده می‌شود. در انتقال اخیر، `previousPlate`، `previousPolicyholderNationalCode` و `vin` الزامی‌اند؛ فیلدهای مربوط به پلاک قبلی در حالت عادی `CURRENT` نباید ارسال شوند. پلاک فعلی همچنان شناسه اصلی خودرو است. هماهنگ‌کننده استعلام ابتدا پلاک فعلی را با کد ملی بیمه‌گذار فعلی بررسی می‌کند و اگر نتیجه ناموجود، قدیمی یا فاقد بیمه‌نامه مرتبط بود، پلاک قبلی را با `previousPolicyholderNationalCode` استعلام می‌کند. مقدار پیش‌فرض `registrationState` برابر `CURRENT` است و برای این مسیر استثنایی مقدار `RECENTLY_TRANSFERRED` استفاده می‌شود. در انتقال اخیر، `previousPlate`، `previousPolicyholderNationalCode` و `vin` الزامی‌اند؛ فیلدهای مربوط به پلاک قبلی در حالت عادی `CURRENT` نباید ارسال شوند. این اطلاعات انتقال فقط به‌عنوان متادیتای پرونده نگه‌داری می‌شوند و پلاک فعلی همچنان شناسه اصلی خودرو است.
پیش از پذیرش نتیجه پلاک قبلی، بک‌اند باید یکسان بودن VIN/شماره شاسی را بررسی کند. در صورت مغایرت، انتخاب خودکار متوقف و اصلاح اطلاعات یا بررسی دستی الزامی شود. هر دو پلاک و تمام تلاش‌های استعلام برای ممیزی نگه‌داری شوند، اما پلاک قبلی هیچ‌گاه نباید روی پلاک فعلی نوشته شود. در route پلاک، هماهنگ‌کننده دقیقاً یک استعلام بیمه انجام می‌دهد: `currentPlate` همراه با بیمه‌گذار نهایی همان نوع بیمه. در route شماره شاسی نیز `vehicle.vin` با همان بیمه‌گذار نهایی ارسال می‌شود. `previousPlate` هیچ‌گاه استعلام نمی‌شود، `previousPolicyholderNationalCode` به ارائه‌دهنده استعلام ارسال نمی‌شود و VIN برای انتخاب نتیجه پلاک قبلی به کار نمی‌رود.
## ترتیب پیشنهادی فرم ## ترتیب پیشنهادی فرم
@@ -102,7 +102,7 @@
- شخص نهایی هر نقش را برگرداند؛ - شخص نهایی هر نقش را برگرداند؛
- هویت درست را به استعلام مرتبط بدهد: گواهینامه ← راننده، مالکیت و تطبیق شبا ← مالک خودرو، بیمه شخص ثالث با پلاک/VIN ← بیمه‌گذار شخص ثالث، بیمه بدنه با پلاک/VIN ← بیمه‌گذار بدنه؛ - هویت درست را به استعلام مرتبط بدهد: گواهینامه ← راننده، مالکیت و تطبیق شبا ← مالک خودرو، بیمه شخص ثالث با پلاک/VIN ← بیمه‌گذار شخص ثالث، بیمه بدنه با پلاک/VIN ← بیمه‌گذار بدنه؛
- شبا را در استعلام شخص مطالبه‌کننده خسارت (`SECOND` زیان‌دیده در `THIRD_PARTY` و طرف اول در `CAR_BODY`) الزامی کند و با کد ملی مالک خودرو اعتبارسنجی کند؛ در استعلام `FIRST` مقصر پرونده ثالث شبا دریافت نمی‌شود؛ - شبا را در استعلام شخص مطالبه‌کننده خسارت (`SECOND` زیان‌دیده در `THIRD_PARTY` و طرف اول در `CAR_BODY`) الزامی کند و با کد ملی مالک خودرو اعتبارسنجی کند؛ در استعلام `FIRST` مقصر پرونده ثالث شبا دریافت نمی‌شود؛
- بر اساس یک قاعده مشخص، پلاک فعلی یا قبلی را انتخاب و نتیجه پلاک قبلی را با VIN/شماره شاسی تطبیق دهد؛ - فقط پلاک فعلی یا VIN ارسال‌شده را با بیمه‌گذار نهایی همان نوع بیمه استعلام کند؛ متادیتای انتقال قبلی نباید مسیریابی استعلام را تغییر دهد؛
- استعلام هویت را برای هر شخص یکتا فقط یک بار اجرا کند؛ - استعلام هویت را برای هر شخص یکتا فقط یک بار اجرا کند؛
- اشخاص نرمال‌شده و نقش‌های آن‌ها را در `Party` مربوط ذخیره کند. - اشخاص نرمال‌شده و نقش‌های آن‌ها را در `Party` مربوط ذخیره کند.
- `participants` و `participantRoles` ذخیره‌شده را بدون حذف اطلاعات در جزئیات پرونده پنل‌های کارشناسی و پرونده خسارت متصل نمایش دهد تا اطلاعات راننده و سایر نقش‌ها برای بررسی در دسترس بماند. - `participants` و `participantRoles` ذخیره‌شده را بدون حذف اطلاعات در جزئیات پرونده پنل‌های کارشناسی و پرونده خسارت متصل نمایش دهد تا اطلاعات راننده و سایر نقش‌ها برای بررسی در دسترس بماند.

View File

@@ -79,9 +79,9 @@ Participant roles and vehicle identifiers are separate concerns. When a vehicle
} }
``` ```
`registrationState` is `CURRENT` by default or `RECENTLY_TRANSFERRED` for this exceptional path. `previousPlate`, `previousPolicyholderNationalCode`, and `vin` are required when `registrationState=RECENTLY_TRANSFERRED`; the previous-plate fields are forbidden for the normal `CURRENT` path. The current plate remains the vehicle's primary identifier. The inquiry orchestrator queries the current plate with the current policyholder's national code first, then automatically tries the previous plate with `previousPolicyholderNationalCode` when the current result is missing, stale, or does not find the relevant policy. `registrationState` is `CURRENT` by default or `RECENTLY_TRANSFERRED` for this exceptional path. `previousPlate`, `previousPolicyholderNationalCode`, and `vin` are required when `registrationState=RECENTLY_TRANSFERRED`; the previous-plate fields are forbidden for the normal `CURRENT` path. These transfer fields are retained only as case metadata, and the current plate remains the vehicle's primary identifier.
Before accepting a previous-plate result, the backend must correlate it to the same VIN/chassis. A mismatch must stop automatic selection and require correction or manual review. Both identifiers and every attempted inquiry should be retained for audit, but a previous plate must never overwrite the current plate. For a plate route, the inquiry orchestrator performs exactly one policy lookup: `currentPlate` with the resolved policyholder for that policy type. For a VIN route, it uses `vehicle.vin` with the same resolved policyholder. It never queries `previousPlate`, never sends `previousPolicyholderNationalCode` to an inquiry provider, and does not use VIN to select a previous-plate result.
## Suggested UI sequence ## Suggested UI sequence
@@ -102,7 +102,7 @@ Create one shared participant resolver used by every inquiry route. Its interfac
- return the resolved person for each role; - return the resolved person for each role;
- route the correct identity to each inquiry: driver licence → Driver, ownership and Sheba validation → Vehicle Owner, third-party policy by plate/VIN → Third-party Policyholder, car-body policy by plate/VIN → Car-body Policyholder; - route the correct identity to each inquiry: driver licence → Driver, ownership and Sheba validation → Vehicle Owner, third-party policy by plate/VIN → Third-party Policyholder, car-body policy by plate/VIN → Car-body Policyholder;
- require Sheba in the claimant inquiry (`THIRD_PARTY` damaged/SECOND party and `CAR_BODY` first party) and validate it against the resolved Vehicle Owner; the `THIRD_PARTY` guilty/FIRST inquiry does not collect Sheba; - require Sheba in the claimant inquiry (`THIRD_PARTY` damaged/SECOND party and `CAR_BODY` first party) and validate it against the resolved Vehicle Owner; the `THIRD_PARTY` guilty/FIRST inquiry does not collect Sheba;
- choose the current or previous plate deterministically and verify previous-plate results against VIN/chassis; - query only the submitted current plate or VIN with the resolved policyholder for that policy type; previous-transfer metadata must not affect inquiry routing;
- run personal identity inquiry once per distinct person; - run personal identity inquiry once per distinct person;
- persist normalized participants and role assignments on the relevant `Party`. - persist normalized participants and role assignments on the relevant `Party`.
- expose the persisted `participants` and `participantRoles` unchanged in expert-facing blame and linked-claim details so driver and other role data remain available for review. - expose the persisted `participants` and `participantRoles` unchanged in expert-facing blame and linked-claim details so driver and other role data remain available for review.

View File

@@ -5,7 +5,6 @@ import {
VehicleRegistrationState, VehicleRegistrationState,
} from "src/common/dto/inquiry-participants.dto"; } from "src/common/dto/inquiry-participants.dto";
import { import {
assertPreviousPlateInquiryMatchesVin,
isMappedPolicyCurrent, isMappedPolicyCurrent,
normalizeInquirySubmission, normalizeInquirySubmission,
participantForRole, participantForRole,
@@ -13,9 +12,8 @@ import {
resolveInquiryParticipants, resolveInquiryParticipants,
resolveInquirySubjects, resolveInquirySubjects,
resolveInquiryVehicle, resolveInquiryVehicle,
runPlateInquiryWithFallback, runCurrentPlateInquiry,
sanitizeStoredInquiryParticipants, sanitizeStoredInquiryParticipants,
vehiclePlateCandidates,
} from "./inquiry-participant-resolver"; } from "./inquiry-participant-resolver";
describe("inquiry participant resolver", () => { describe("inquiry participant resolver", () => {
@@ -309,42 +307,6 @@ describe("inquiry participant resolver", () => {
).toBe("0022222222"); ).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", () => { it("requires the driver's licence status in the new contract", () => {
expect(() => expect(() =>
resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, { resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, {
@@ -357,58 +319,36 @@ describe("inquiry participant resolver", () => {
).toThrow(BadRequestException); ).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 = { const currentPlate = {
leftDigits: "44", leftDigits: "44",
centerAlphabet: "ب", centerAlphabet: "ب",
centerDigits: "111", centerDigits: "111",
ir: "22", ir: "22",
}; };
const previousPlate = { const query = jest.fn().mockResolvedValue({
leftDigits: "55", mapped: { CompanyName: "پارسیان" },
centerAlphabet: "ج",
centerDigits: "222",
ir: "33",
};
const query = jest
.fn()
.mockRejectedValueOnce(new Error("not found"))
.mockResolvedValueOnce({
mapped: { VinNumberField: "NAAM01E15HK123456", CompanyName: "پارسیان" },
}); });
const result = await runPlateInquiryWithFallback<{ const result = await runCurrentPlateInquiry<{
mapped: { VinNumberField?: string; CompanyName?: string }; mapped: { CompanyName?: string };
}>({ }>({
vehicle: {
registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
currentPlate, currentPlate,
previousPlate,
vin: "NAAM01E15HK123456", vin: "NAAM01E15HK123456",
previousPolicyholderNationalCode: "0098765432",
},
fallbackCurrentPlate: currentPlate,
query, query,
isUsable: (value) => !!value.mapped.CompanyName, isUsable: (value) => !!value.mapped.CompanyName,
mappedValue: (value) => value.mapped,
}); });
expect(query).toHaveBeenCalledTimes(2); expect(query).toHaveBeenCalledTimes(1);
expect(query).toHaveBeenNthCalledWith(1, currentPlate, "CURRENT"); expect(query).toHaveBeenCalledWith(currentPlate);
expect(query).toHaveBeenNthCalledWith(2, previousPlate, "PREVIOUS"); expect(result.plateKind).toBe("CURRENT");
expect(result.plateKind).toBe("PREVIOUS");
expect(result.attempts).toMatchObject([ expect(result.attempts).toMatchObject([
{ plateKind: "CURRENT", succeeded: false, error: "not found" }, { plateKind: "CURRENT", succeeded: true, usable: true },
{ plateKind: "PREVIOUS", succeeded: true, usable: true },
]); ]);
expect(result.attempts[0]).toMatchObject({ expect(result.attempts[0]).toMatchObject({
plate: currentPlate, plate: currentPlate,
vin: "NAAM01E15HK123456", vin: "NAAM01E15HK123456",
}); });
expect(result.attempts[1]).toMatchObject({
plate: previousPlate,
vin: "NAAM01E15HK123456",
});
}); });
it("rejects a car-body policyholder on a third-party case", () => { 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 = { const currentPlate = {
leftDigits: "44", leftDigits: "44",
centerAlphabet: "ب", centerAlphabet: "ب",
centerDigits: "111", centerDigits: "111",
ir: "22", ir: "22",
}; };
const previousPlate = { const query = jest.fn().mockResolvedValue({
leftDigits: "55",
centerAlphabet: "ج",
centerDigits: "222",
ir: "33",
};
const query = jest
.fn()
.mockResolvedValueOnce({
mapped: { CompanyName: "پارسیان", EndDate: "1404/01/01" }, mapped: { CompanyName: "پارسیان", EndDate: "1404/01/01" },
})
.mockResolvedValueOnce({
mapped: {
CompanyName: "پارسیان",
EndDate: "1406/01/01",
VinNumberField: "NAAM01E15HK123456",
},
}); });
const result = await runPlateInquiryWithFallback<{ await expect(
mapped: Record<string, any>; runCurrentPlateInquiry<{ mapped: Record<string, any> }>({
}>({
vehicle: resolveInquiryVehicle({
registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
currentPlate, currentPlate,
previousPlate,
vin: "NAAM01E15HK123456",
previousPolicyholderNationalCode: "0098765432",
}),
fallbackCurrentPlate: currentPlate,
query, query,
isUsable: (value) => isUsable: (value) =>
!!value.mapped.CompanyName && !!value.mapped.CompanyName &&
isMappedPolicyCurrent(value.mapped, "2026-09-13"), isMappedPolicyCurrent(value.mapped, "2026-09-13"),
mappedValue: (value) => value.mapped, }),
).rejects.toMatchObject({
attempts: [{ plateKind: "CURRENT", succeeded: true, usable: false }],
});
expect(query).toHaveBeenCalledTimes(1);
}); });
expect(result.plateKind).toBe("PREVIOUS"); it("retains the current-plate audit attempt when the result is unusable", async () => {
expect(result.attempts[0]).toMatchObject({
plateKind: "CURRENT",
succeeded: true,
usable: false,
});
});
it("rejects the result and retains audit attempts when every plate is unusable", async () => {
const currentPlate = { const currentPlate = {
leftDigits: "44", leftDigits: "44",
centerAlphabet: "ب", centerAlphabet: "ب",
centerDigits: "111", centerDigits: "111",
ir: "22", ir: "22",
}; };
const previousPlate = {
leftDigits: "55",
centerAlphabet: "ج",
centerDigits: "222",
ir: "33",
};
await expect( await expect(
runPlateInquiryWithFallback({ runCurrentPlateInquiry({
vehicle: resolveInquiryVehicle({
registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
currentPlate, currentPlate,
previousPlate,
vin: "NAAM01E15HK123456", vin: "NAAM01E15HK123456",
previousPolicyholderNationalCode: "0098765432",
}),
fallbackCurrentPlate: currentPlate,
query: async () => ({ mapped: { CompanyName: "پارسیان" } }), query: async () => ({ mapped: { CompanyName: "پارسیان" } }),
isUsable: () => false, isUsable: () => false,
mappedValue: (value) => value.mapped,
}), }),
).rejects.toMatchObject({ ).rejects.toMatchObject({
attempts: [ attempts: [{ plateKind: "CURRENT", succeeded: true, usable: false }],
{ plateKind: "CURRENT", succeeded: true, usable: false },
{ plateKind: "PREVIOUS", 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 = { const currentPlate = {
leftDigits: "44", leftDigits: "44",
centerAlphabet: "ب", centerAlphabet: "ب",
@@ -612,23 +509,11 @@ describe("inquiry participant resolver", () => {
); );
await expect( await expect(
runPlateInquiryWithFallback({ runCurrentPlateInquiry({
vehicle: resolveInquiryVehicle({
registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
currentPlate, currentPlate,
previousPlate: {
leftDigits: "55",
centerAlphabet: "ج",
centerDigits: "222",
ir: "33",
},
vin: "NAAM01E15HK123456", vin: "NAAM01E15HK123456",
previousPolicyholderNationalCode: "0098765432",
}),
fallbackCurrentPlate: currentPlate,
query, query,
isUsable: () => false, isUsable: () => false,
mappedValue: () => ({}),
}), }),
).rejects.toThrow("upstream timeout"); ).rejects.toThrow("upstream timeout");
expect(query).toHaveBeenCalledTimes(1); 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. */ /** A dated result is usable only while the returned policy has not expired. */
export function isMappedPolicyCurrent( export function isMappedPolicyCurrent(
mapped: Record<string, any>, mapped: Record<string, any>,
@@ -429,19 +409,6 @@ export function isMappedPolicyCurrent(
return endDate != null && endDate >= todayGregorian; 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 = [ const LEGACY_INQUIRY_FIELDS = [
"nationalCodeOfDriver", "nationalCodeOfDriver",
"driverBirthday", "driverBirthday",
@@ -469,63 +436,21 @@ function assertStructuredInquiryInput(input: Record<string, any>): void {
} }
} }
export function assertPreviousPlateInquiryMatchesVin( /**
expectedVin: string, * Run a policy inquiry only for the submitted current plate. Recent-transfer
mapped: Record<string, any>, * data is retained as case metadata, but must never trigger an inquiry for a
): void { * previous plate or a previous policyholder.
const expected = normalizeVehicleSerial(expectedVin); */
const candidates = [ export async function runCurrentPlateInquiry<T>(options: {
mapped?.VinNumberField, currentPlate: InquiryVehicleInputDto["currentPlate"];
mapped?.vin, vin?: string;
mapped?.VIN, query: (plate: InquiryVehicleInputDto["currentPlate"]) => Promise<T>;
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>;
isUsable: (value: T) => boolean; isUsable: (value: T) => boolean;
mappedValue: (value: T) => Record<string, any>;
shouldFallbackOnError?: (error: unknown) => boolean;
}): Promise<{ }): Promise<{
value: T; value: T;
plateKind: "CURRENT" | "PREVIOUS"; plateKind: "CURRENT";
attempts: Array<{ attempts: Array<{
plateKind: "CURRENT" | "PREVIOUS"; plateKind: "CURRENT";
plate: InquiryVehicleInputDto["currentPlate"]; plate: InquiryVehicleInputDto["currentPlate"];
vin?: string; vin?: string;
succeeded: boolean; succeeded: boolean;
@@ -533,12 +458,8 @@ export async function runPlateInquiryWithFallback<T>(options: {
error?: string; error?: string;
}>; }>;
}> { }> {
const candidates = options.vehicle
? vehiclePlateCandidates(options.vehicle)
: [{ kind: "CURRENT" as const, plate: options.fallbackCurrentPlate }];
let lastError: unknown;
const attempts: Array<{ const attempts: Array<{
plateKind: "CURRENT" | "PREVIOUS"; plateKind: "CURRENT";
plate: InquiryVehicleInputDto["currentPlate"]; plate: InquiryVehicleInputDto["currentPlate"];
vin?: string; vin?: string;
succeeded: boolean; succeeded: boolean;
@@ -546,80 +467,50 @@ export async function runPlateInquiryWithFallback<T>(options: {
error?: string; error?: string;
}> = []; }> = [];
for (let index = 0; index < candidates.length; index += 1) {
const candidate = candidates[index];
const isLast = index === candidates.length - 1;
try { try {
const value = await options.query(candidate.plate, candidate.kind); const value = await options.query(options.currentPlate);
const usable = options.isUsable(value); const usable = options.isUsable(value);
if (!usable) { if (!usable) {
attempts.push({ attempts.push({
plateKind: candidate.kind, plateKind: "CURRENT",
plate: candidate.plate, plate: options.currentPlate,
...(options.vehicle?.vin ? { vin: options.vehicle.vin } : {}), ...(options.vin ? { vin: options.vin } : {}),
succeeded: true, succeeded: true,
usable: false, usable: false,
}); });
if (!isLast) continue;
const error = new BadRequestException( const error = new BadRequestException(
"بیمه‌نامه معتبر و فعالی مطابق مشخصات خودرو و کد ملی واردشده یافت نشد.", "بیمه‌نامه معتبر و فعالی مطابق مشخصات خودرو و کد ملی واردشده یافت نشد.",
) as BadRequestException & { attempts?: typeof attempts }; ) as BadRequestException & { attempts?: typeof attempts };
error.attempts = attempts; error.attempts = attempts;
throw error; throw error;
} }
if (candidate.kind === "PREVIOUS" && usable) {
assertPreviousPlateInquiryMatchesVin(
options.vehicle!.vin!,
options.mappedValue(value),
);
}
attempts.push({ attempts.push({
plateKind: candidate.kind, plateKind: "CURRENT",
plate: candidate.plate, plate: options.currentPlate,
...(options.vehicle?.vin ? { vin: options.vehicle.vin } : {}), ...(options.vin ? { vin: options.vin } : {}),
succeeded: true, succeeded: true,
usable: true, usable: true,
}); });
return { value, plateKind: candidate.kind, attempts }; return { value, plateKind: "CURRENT", attempts };
} catch (error) { } catch (error) {
lastError = error;
const alreadyRecorded = const alreadyRecorded =
typeof error === "object" && typeof error === "object" &&
error != null && error != null &&
Array.isArray((error as { attempts?: unknown }).attempts); Array.isArray((error as { attempts?: unknown }).attempts);
if (!alreadyRecorded) { if (!alreadyRecorded) {
attempts.push({ attempts.push({
plateKind: candidate.kind, plateKind: "CURRENT",
plate: candidate.plate, plate: options.currentPlate,
...(options.vehicle?.vin ? { vin: options.vehicle.vin } : {}), ...(options.vin ? { vin: options.vin } : {}),
succeeded: false, succeeded: false,
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
}); });
} }
if (
!isLast &&
!(options.shouldFallbackOnError ?? isPolicyNotFoundError)(error)
) {
if (typeof error === "object" && error != null) { if (typeof error === "object" && error != null) {
(error as { attempts?: typeof attempts }).attempts = attempts; (error as { attempts?: typeof attempts }).attempts = attempts;
} }
throw error; throw error;
} }
if (isLast) {
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>>( 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 { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { RequestManagementService } from "./request-management.service"; import { RequestManagementService } from "./request-management.service";
describe("RequestManagementService previous policyholder routing", () => { describe("RequestManagementService policyholder inquiry routing", () => {
const currentPlate = { const currentPlate = {
leftDigits: "44", leftDigits: "44",
centerAlphabet: "ب", 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; const service = Object.create(RequestManagementService.prototype) as any;
service.sandHubService = { service.sandHubService = {
getTejaratBlockInquiry: jest getTejaratBlockInquiry: jest
@@ -54,10 +54,7 @@ describe("RequestManagementService previous policyholder routing", () => {
.mockResolvedValueOnce({ raw: {}, mapped: {} }) .mockResolvedValueOnce({ raw: {}, mapped: {} })
.mockResolvedValueOnce({ .mockResolvedValueOnce({
raw: {}, raw: {},
mapped: { mapped: { CompanyName: "پارسیان", VinNumberField: vehicle.vin },
CompanyName: "پارسیان",
VinNumberField: vehicle.vin,
},
}), }),
}; };
const submission = service.normalizeInquiryInput( const submission = service.normalizeInquiryInput(
@@ -65,32 +62,23 @@ describe("RequestManagementService previous policyholder routing", () => {
participantInput(BlameRequestType.THIRD_PARTY), 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).toHaveBeenCalledTimes(
expect(
service.sandHubService.getTejaratBlockInquiry,
).toHaveBeenNthCalledWith(
1, 1,
);
expect(service.sandHubService.getTejaratBlockInquiry).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
plate: currentPlate, plate: currentPlate,
nationalCodeOfInsurer: "0022222222", nationalCodeOfInsurer: "0022222222",
}), }),
undefined, 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; const service = Object.create(RequestManagementService.prototype) as any;
service.sandHubService = { service.sandHubService = {
getCarBodyInquiry: jest getCarBodyInquiry: jest
@@ -98,10 +86,7 @@ describe("RequestManagementService previous policyholder routing", () => {
.mockResolvedValueOnce({ raw: {}, mapped: {} }) .mockResolvedValueOnce({ raw: {}, mapped: {} })
.mockResolvedValueOnce({ .mockResolvedValueOnce({
raw: {}, raw: {},
mapped: { mapped: { policyNumber: "BODY-1", VinNumberField: vehicle.vin },
policyNumber: "BODY-1",
VinNumberField: vehicle.vin,
},
}), }),
}; };
const submission = service.normalizeInquiryInput( const submission = service.normalizeInquiryInput(
@@ -109,22 +94,45 @@ describe("RequestManagementService previous policyholder routing", () => {
participantInput(BlameRequestType.CAR_BODY), 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).toHaveBeenCalledTimes(1);
expect(service.sandHubService.getCarBodyInquiry).toHaveBeenNthCalledWith( expect(service.sandHubService.getCarBodyInquiry).toHaveBeenCalledWith(
1,
expect.objectContaining({ expect.objectContaining({
plate: currentPlate, plate: currentPlate,
nationalCodeOfInsurer: "0033333333", nationalCodeOfInsurer: "0033333333",
}), }),
); );
expect(service.sandHubService.getCarBodyInquiry).toHaveBeenNthCalledWith( });
2,
expect.objectContaining({ it("queries VIN with the third-party policyholder", async () => {
plate: previousPlate, const service = Object.create(RequestManagementService.prototype) as any;
nationalCodeOfInsurer: "0098765432", 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, NormalizedInquirySubmission,
normalizeInquirySubmission, normalizeInquirySubmission,
resolveInquirySubjects, resolveInquirySubjects,
runPlateInquiryWithFallback, runCurrentPlateInquiry,
sanitizeStoredInquiryParticipants, sanitizeStoredInquiryParticipants,
} from "./inquiry-participant-resolver"; } from "./inquiry-participant-resolver";
import { import {
@@ -386,17 +386,14 @@ export class RequestManagementService {
options?: Record<string, any>, options?: Record<string, any>,
): Promise<any> { ): Promise<any> {
const subjects = resolveInquirySubjects(submission); const subjects = resolveInquirySubjects(submission);
const result = await runPlateInquiryWithFallback({ const result = await runCurrentPlateInquiry({
vehicle: submission.vehicle, currentPlate: submission.vehicle?.currentPlate ?? submission.dto.plate,
fallbackCurrentPlate: submission.dto.plate, vin: submission.vehicle?.vin,
query: (plate, plateKind) => query: (plate) =>
this.sandHubService.getTejaratBlockInquiry( this.sandHubService.getTejaratBlockInquiry(
{ {
plate: plate as any, plate: plate as any,
nationalCodeOfInsurer: nationalCodeOfInsurer: subjects.thirdPartyPolicyNationalCode,
plateKind === "PREVIOUS"
? submission.vehicle!.previousPolicyholderNationalCode!
: subjects.thirdPartyPolicyNationalCode,
}, },
options, options,
), ),
@@ -404,7 +401,6 @@ export class RequestManagementService {
!value?.mapped?.Error && !value?.mapped?.Error &&
!!value?.mapped?.CompanyName && !!value?.mapped?.CompanyName &&
isMappedPolicyCurrent(value.mapped), isMappedPolicyCurrent(value.mapped),
mappedValue: (value) => value?.mapped ?? {},
}); });
return { return {
...result.value, ...result.value,
@@ -423,15 +419,12 @@ export class RequestManagementService {
"اطلاعات بیمه‌گذار برای استعلام بیمه بدنه الزامی است.", "اطلاعات بیمه‌گذار برای استعلام بیمه بدنه الزامی است.",
); );
} }
const result = await runPlateInquiryWithFallback({ const result = await runCurrentPlateInquiry({
vehicle: submission.vehicle, currentPlate: submission.vehicle?.currentPlate ?? submission.dto.plate,
fallbackCurrentPlate: submission.dto.plate, vin: submission.vehicle?.vin,
query: (plate, plateKind) => query: (plate) =>
this.sandHubService.getCarBodyInquiry({ this.sandHubService.getCarBodyInquiry({
nationalCodeOfInsurer: nationalCodeOfInsurer: policyholderNationalCode,
plateKind === "PREVIOUS"
? submission.vehicle!.previousPolicyholderNationalCode!
: policyholderNationalCode,
plate: plate as any, plate: plate as any,
}), }),
isUsable: (value) => isUsable: (value) =>
@@ -442,7 +435,6 @@ export class RequestManagementService {
value.mapped.CompanyName || value.mapped.CompanyName ||
value.mapped.companyId value.mapped.companyId
), ),
mappedValue: (value) => value?.mapped ?? {},
}); });
return { return {
...result.value, ...result.value,