Merge pull request 'Route inquiries by participant role' (#310) from s.yahyaee/yara724-api:main into main

Reviewed-on: Yara724/api#310
This commit is contained in:
2026-09-14 10:51:40 +03:30
10 changed files with 446 additions and 138 deletions

View File

@@ -17,7 +17,7 @@
- فقط برای طرف زیان‌دیده (`SECOND`) مجاز است؛ برای طرف مقصر (`FIRST`) خطا برمی‌گردد.
- فقط برای `thirdPartyPolicyholder` مجاز است؛ برای راننده، مالک یا بیمه‌گذار بدنه مجاز نیست.
- وقتی `unknown: true` ارسال می‌شود، هیچ فیلد هویتی دیگری در همان آبجکت نفرستید.
- در استعلام پلاکی شخص ثالث، استعلام بیمه‌گذار عمداً skip می‌شود و سیستم نباید شخصی را حدس بزند.
- در استعلام شخص ثالث با پلاک یا VIN، استعلام بیمه‌گذار عمداً skip می‌شود و سیستم نباید کد ملی راننده یا مالک را جایگزین کند.
- استعلام‌های راننده، مالکیت خودرو و اطلاعات افراد شناخته‌شده همچنان اجرا می‌شوند.
- اگر هویت بیمه‌گذار مشخص است، اصلاً از `unknown` استفاده نکنید و اطلاعات واقعی یا `sameAs` را بفرستید.
@@ -83,6 +83,23 @@
`sheba` فقط در routeهایی که قبلاً اطلاعات بانکی را در مرحله استعلام دریافت می‌کردند ارسال می‌شود؛ در V6 مرکز تماس، شماره شبا در این body نیست و بعداً توسط کاربر دریافت می‌شود.
## هر استعلام با اطلاعات کدام نقش انجام می‌شود؟
فرانت‌اند فقط اشخاص را با ساختار نقش‌محور ارسال می‌کند؛ انتخاب کد ملی مناسب برای هر سرویس در بک‌اند انجام می‌شود:
| استعلام | کد ملی مورد استفاده | شناسه خودرو/بانکی |
| --- | --- | --- |
| بیمه شخص ثالث با پلاک یا VIN | `thirdPartyPolicyholder.nationalCode` | پلاک یا `vehicle.vin` |
| بیمه بدنه با پلاک یا VIN | `carBodyPolicyholder.nationalCode` | پلاک یا `vehicle.vin` |
| تطبیق شبا | `vehicleOwner.nationalCode` | `sheba` |
| گواهینامه | `driver.nationalCode` | `driver.licenseNumber` |
بنابراین کد ملی راننده نباید به‌جای بیمه‌گذار یا مالک ارسال یا تکرار شود. اگر چند نقش متعلق به یک نفر است، `sameAs` ارتباط را مشخص می‌کند و بک‌اند همان شخص را برای استعلام مربوط به هر نقش انتخاب می‌کند.
در مرحله بانکی بعدی جریان V6، برای پرونده‌های جدید فقط `sheba` لازم است. بک‌اند کد ملی مالک خودرو را از `participants` و `participantRoles` ذخیره‌شده در پرونده تقصیر می‌خواند. فیلدهای قدیمی `nationalCodeOfInsurer` یا `nationalCodeOfOwner` فقط برای پرونده‌های تاریخی فاقد اطلاعات نقش‌محور fallback هستند؛ اگر همراه پرونده جدید ارسال شوند باید با مالک ذخیره‌شده یکسان باشند.
پنل‌های کارشناسی اطلاعات کامل اشخاص را در `parties[].participants` و نگاشت نقش‌ها را در `parties[].participantRoles` دریافت می‌کنند. اطلاعات راننده حتی اگر در استعلام بیمه یا شبا استفاده نشود در همین ساختار ذخیره و نمایش داده می‌شود.
## نقش‌ها و فیلدهای هر شخص
هر نقش باید یکی از این دو حالت را داشته باشد، نه هر دو را:
@@ -215,5 +232,6 @@ phoneNumber // در participantها
- فرستادن `RECENTLY_TRANSFERRED` بدون `previousPlate` یا `vin`.
- قرار دادن VIN یا پلاک در سطح بالای body.
- ارسال شماره تلفن در آبجکت شخص.
- تکرار کد ملی راننده یا بیمه‌گذار در مرحله شبا؛ تطبیق شبا همیشه با مالک خودرو انجام می‌شود.
مستند مدل دامنه و جزئیات تصمیم معماری در [inquiry-participants-proposal.fa.md](./inquiry-participants-proposal.fa.md) قرار دارد.

View File

@@ -49,7 +49,7 @@
فیلد `carBodyPolicyholder` برای `THIRD_PARTY` مجاز نیست و برای `CAR_BODY` الزامی است. هر نقش باید فقط یکی از دو حالت «اطلاعات شخص» یا `sameAs` را داشته باشد. بک‌اند این ورودی را به فهرست اشخاص یکتا و اتصال نقش‌ها به آن‌ها تبدیل می‌کند.
فقط برای طرف زیان‌دیده در پرونده `THIRD_PARTY` می‌توان نامشخص بودن بیمه‌گذار را به‌صورت صریح با `"thirdPartyPolicyholder": { "unknown": true }` ارسال کرد. این حالت برای طرف مقصر پذیرفته نمی‌شود. در مسیر پلاک، استعلام بیمه شخص ثالث ــ چون به هویت بیمه‌گذار نیاز دارد ــ به‌شکل قابل‌ممیزی «عمداً اجرا نشد» ثبت می‌شود؛ استعلام مبتنی بر VIN همچنان بدون حدس‌زدن هویت قابل اجرا است. اعتبارسنجی راننده و مالک و استعلام‌های هویت، مالکیت و گواهینامه همچنان انجام می‌شوند.
فقط برای طرف زیان‌دیده در پرونده `THIRD_PARTY` می‌توان نامشخص بودن بیمه‌گذار را به‌صورت صریح با `"thirdPartyPolicyholder": { "unknown": true }` ارسال کرد. این حالت برای طرف مقصر پذیرفته نمی‌شود. استعلام بیمه شخص ثالث با پلاک یا VIN ــ چون هر دو به کد ملی بیمه‌گذار نیاز دارند ــ به‌شکل قابل‌ممیزی «عمداً اجرا نشد» ثبت می‌شود و بک‌اند نباید کد ملی راننده یا مالک را جایگزین کند. اعتبارسنجی راننده و مالک و استعلام‌های هویت، مالکیت و گواهینامه همچنان انجام می‌شوند.
برای راننده، `hasDrivingLicense` الزامی است. اگر مقدار آن `true` باشد، هر دو فیلد `licenseNumber` و `licenseType` نیز الزامی هستند؛ اگر مقدار آن `false` باشد، استعلام گواهینامه عمداً اجرا نمی‌شود.
@@ -99,10 +99,11 @@
- نقش‌های لازم را بر اساس نوع پرونده اعتبارسنجی و ارجاع‌های نامعتبر یا حلقوی `sameAs` را رد کند؛
- شخص نهایی هر نقش را برگرداند؛
- هویت درست را به استعلام مرتبط بدهد: گواهینامه ← راننده، مالکیت ← مالک خودرو، بیمه شخص ثالث ← بیمه‌گذار شخص ثالث، بیمه بدنه ← بیمه‌گذار بدنه؛
- هویت درست را به استعلام مرتبط بدهد: گواهینامه ← راننده، مالکیت و تطبیق شبا ← مالک خودرو، بیمه شخص ثالث با پلاک/VIN ← بیمه‌گذار شخص ثالث، بیمه بدنه با پلاک/VIN ← بیمه‌گذار بدنه؛
- بر اساس یک قاعده مشخص، پلاک فعلی یا قبلی را انتخاب و نتیجه پلاک قبلی را با VIN/شماره شاسی تطبیق دهد؛
- استعلام هویت را برای هر شخص یکتا فقط یک بار اجرا کند؛
- اشخاص نرمال‌شده و نقش‌های آن‌ها را در `Party` مربوط ذخیره کند.
- `participants` و `participantRoles` ذخیره‌شده را بدون حذف اطلاعات در جزئیات پرونده پنل‌های کارشناسی و پرونده خسارت متصل نمایش دهد تا اطلاعات راننده و سایر نقش‌ها برای بررسی در دسترس بماند.
جریان‌های V2 کاربر/کارشناس، V3، V4، V5 و V6 باید adapter همین قوانین مشترک باشند و منطق نسبت اشخاص را جداگانه پیاده‌سازی نکنند.

View File

@@ -49,7 +49,7 @@ Use explicit role references instead:
`carBodyPolicyholder` is forbidden for `THIRD_PARTY` and required for `CAR_BODY`. A role is either a new person's identity or a `sameAs` reference, never both. The backend should normalize this input into unique participants plus role assignments.
For the damaged party of a `THIRD_PARTY` case only, the policyholder can be explicitly unresolved with `"thirdPartyPolicyholder": { "unknown": true }`. This is not accepted for the guilty party. Plate-based third-party-policy inquiry is recorded as intentionally skipped because it requires that identity; VIN-based lookup can still run without guessing a person. The driver, owner, personal, ownership, and licence rules remain enforced.
For the damaged party of a `THIRD_PARTY` case only, the policyholder can be explicitly unresolved with `"thirdPartyPolicyholder": { "unknown": true }`. This is not accepted for the guilty party. Third-party-policy inquiry by either plate or VIN is recorded as intentionally skipped because both routes require the policyholder national code; the backend must not substitute the driver or owner. The driver, owner, personal, ownership, and licence rules remain enforced.
For Driver, `hasDrivingLicense` is required. When it is `true`, both `licenseNumber` and `licenseType` are required; when it is `false`, the licence inquiry is intentionally skipped.
@@ -99,10 +99,11 @@ Create one shared participant resolver used by every inquiry route. Its interfac
- validate required roles by case type and reject circular/invalid `sameAs` references;
- return the resolved person for each role;
- route the correct identity to each inquiry: driver licence → Driver, ownership → Vehicle Owner, third-party policy → Third-party Policyholder, car-body policy → 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;
- choose the current or previous plate deterministically and verify previous-plate results against VIN/chassis;
- run personal identity inquiry once per distinct person;
- 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.
V2 user/expert routes, V3, V4, V5, and V6 should be adapters over this shared rule set rather than implementing their own relationship logic.

View File

@@ -78,6 +78,7 @@ import {
blameDamagedPartyMatchesUser,
resolveClaimOwnerFieldsFromBlame,
resolveClaimOwnerParty,
resolveDamagedPartyRow,
resolveDamagedPartyUserId,
} from "src/helpers/blame-damaged-party";
import { claimCaseInitiatedByFieldExpert } from "src/helpers/tenant-scope";
@@ -115,6 +116,8 @@ import { BranchDbService } from "src/client/entities/db-service/branch.db.servic
import { CreationMethod } from "src/request-management/entities/schema/request-management.schema";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
import { InquiryParticipantRole } from "src/common/dto/inquiry-participants.dto";
import { participantForStoredPartyRole } from "src/request-management/inquiry-participant-resolver";
import { UserRatingDto } from "./dto/user-rating.dto";
import {
canFinalizeExpertResend,
@@ -622,6 +625,20 @@ export class ClaimRequestManagementService {
return this.parsePlateFromCompactString(compactPlateId);
}
private resolveVehicleOwnerNationalCodeForClaim(
blameRequest: any,
): string | undefined {
const damagedParty = resolveDamagedPartyRow(blameRequest as any);
const owner = damagedParty
? participantForStoredPartyRole(
damagedParty as any,
InquiryParticipantRole.VEHICLE_OWNER,
)
: undefined;
const nationalCode = String(owner?.nationalCode ?? "").trim();
return nationalCode || undefined;
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -9195,7 +9212,7 @@ export class ClaimRequestManagementService {
const shebaDigits = shebaInput.replace(/^IR/i, "");
const shebaNumber = `IR${shebaDigits}`;
const nationalCode = String(
const submittedNationalCode = String(
((body as any).nationalCodeOfOwner ??
(body as any).nationalCodeOfInsurer ??
"") as string,
@@ -9205,17 +9222,28 @@ export class ClaimRequestManagementService {
"shebaNumber is required and must be valid (IR + 24 digits or only 24 digits)",
);
}
if (!/^[0-9]{10}$/.test(nationalCode)) {
throw new BadRequestException(
"nationalCodeOfOwner is required and must be exactly 10 digits",
);
}
const blameRequest = claimCase.blameRequestId
? await this.blameRequestDbService.findById(
claimCase.blameRequestId.toString(),
)
: null;
const storedOwnerNationalCode =
this.resolveVehicleOwnerNationalCodeForClaim(blameRequest);
if (
storedOwnerNationalCode &&
submittedNationalCode &&
submittedNationalCode !== storedOwnerNationalCode
) {
throw new BadRequestException(
"Submitted national code does not match the vehicle owner saved on the blame case.",
);
}
const nationalCode = storedOwnerNationalCode ?? submittedNationalCode;
if (!/^[0-9]{10}$/.test(nationalCode)) {
throw new BadRequestException(
"Vehicle owner national code is unavailable; a 10-digit nationalCodeOfOwner is required only for legacy claims.",
);
}
const ownershipPlate = this.resolveOwnershipPlateForClaim(
claimCase,
blameRequest,

View File

@@ -56,20 +56,21 @@ export class SelectOtherPartsV2Dto {
@IsString()
shebaNumber?: string;
@ApiProperty({
description: 'National code of insurer/owner - 10 digits',
@ApiPropertyOptional({
description:
'Legacy fallback for claims created before participant roles were stored. New claims derive the vehicle owner national code from the linked blame case.',
example: '1234567890',
pattern: '^[0-9]{10}$',
minLength: 10,
maxLength: 10,
})
@IsNotEmpty({ message: 'nationalCodeOfInsurer is required' })
@IsOptional()
@IsString({ message: 'National code must be a string' })
@Length(10, 10, { message: 'National code must be exactly 10 digits' })
@Matches(/^[0-9]{10}$/, {
message: 'National code must contain exactly 10 digits',
})
nationalCodeOfInsurer: string;
nationalCodeOfInsurer?: string;
@ApiPropertyOptional({
description: 'Legacy alias for backward compatibility',

View File

@@ -9,7 +9,9 @@ import {
isMappedPolicyCurrent,
normalizeInquirySubmission,
participantForRole,
participantForStoredPartyRole,
resolveInquiryParticipants,
resolveInquirySubjects,
resolveInquiryVehicle,
runPlateInquiryWithFallback,
vehiclePlateCandidates,
@@ -160,6 +162,62 @@ describe("inquiry participant resolver", () => {
expect(normalized.vehicleOwner?.nationalCode).toBe("0012345678");
});
it("routes each inquiry to its domain participant", () => {
const normalized = normalizeInquirySubmission(BlameRequestType.CAR_BODY, {
vehicle: {
currentPlate: {
leftDigits: "44",
centerAlphabet: "ب",
centerDigits: "111",
ir: "22",
},
},
driver: {
nationalCode: "0011111111",
birthday: "1370/01/01",
hasDrivingLicense: false,
},
vehicleOwner: {
nationalCode: "0022222222",
birthday: "1365/02/02",
},
thirdPartyPolicyholder: {
nationalCode: "0033333333",
birthday: "1360/03/03",
},
carBodyPolicyholder: {
nationalCode: "0044444444",
birthday: "1355/04/04",
},
});
expect(resolveInquirySubjects(normalized)).toEqual({
thirdPartyPolicyNationalCode: "0033333333",
carBodyPolicyNationalCode: "0044444444",
shebaNationalCode: "0022222222",
driverNationalCode: "0011111111",
});
});
it("resolves a persisted vehicle owner for later Sheba validation", () => {
const party = {
participants: [
{ participantId: "DRIVER", nationalCode: "0011111111" },
{ participantId: "VEHICLE_OWNER", nationalCode: "0022222222" },
],
participantRoles: {
driver: "DRIVER",
vehicleOwner: "VEHICLE_OWNER",
thirdPartyPolicyholder: "DRIVER",
},
};
expect(
participantForStoredPartyRole(party, InquiryParticipantRole.VEHICLE_OWNER)
?.nationalCode,
).toBe("0022222222");
});
it("orders the current plate before the previous-plate fallback", () => {
const currentPlate = {
leftDigits: "44",

View File

@@ -58,6 +58,40 @@ export interface NormalizedInquirySubmission<T extends Record<string, any>> {
vehicle?: ResolvedInquiryVehicle;
}
export interface InquirySubjects {
thirdPartyPolicyNationalCode: string;
carBodyPolicyNationalCode?: string;
shebaNationalCode: string;
driverNationalCode: string;
}
/**
* The single routing seam for external inquiries. Policy checks belong to
* their policyholder, Sheba belongs to the vehicle owner, and licence data
* belongs to the driver even when those roles resolve to different people.
*/
export function resolveInquirySubjects(
submission: NormalizedInquirySubmission<Record<string, any>>,
): InquirySubjects {
if (!submission.vehicleOwner) {
throw new BadRequestException(
"Vehicle owner identity is required for Sheba validation.",
);
}
return {
thirdPartyPolicyNationalCode:
submission.thirdPartyPolicyholder.nationalCode,
...(submission.carBodyPolicyholder
? {
carBodyPolicyNationalCode:
submission.carBodyPolicyholder.nationalCode,
}
: {}),
shebaNationalCode: submission.vehicleOwner.nationalCode,
driverNationalCode: submission.driver.nationalCode,
};
}
export type ResolvedInquiryVehicle = InquiryVehicleInputDto & {
registrationState: VehicleRegistrationState;
};
@@ -75,6 +109,24 @@ export function participantForRole(
);
}
/** Resolve a participant from the role links persisted on a blame party. */
export function participantForStoredPartyRole(
party: {
participants?: Array<Record<string, any>>;
participantRoles?: Partial<InquiryParticipantRoleAssignments>;
},
role: InquiryParticipantRole,
): Record<string, any> | undefined {
const roleField = ROLE_FIELDS[
role
] as keyof InquiryParticipantRoleAssignments;
const participantId = party.participantRoles?.[roleField];
if (!participantId || !Array.isArray(party.participants)) return undefined;
return party.participants.find(
(participant) => participant?.participantId === participantId,
);
}
const ROLE_FIELDS: Record<
InquiryParticipantRole,
keyof InquiryParticipantFieldsDto

View File

@@ -125,8 +125,10 @@ import { normalizePlateText } from "src/utils/plate-normalizer/plate-normalizer.
import { fanavaranClaimReferences } from "src/claim-request-management/fanavaran-claim-references";
import {
isMappedPolicyCurrent,
InquirySubjects,
NormalizedInquirySubmission,
normalizeInquirySubmission,
resolveInquirySubjects,
runPlateInquiryWithFallback,
} from "./inquiry-participant-resolver";
@@ -361,6 +363,7 @@ export class RequestManagementService {
attempts: [],
};
}
const subjects = resolveInquirySubjects(submission);
const result = await runPlateInquiryWithFallback({
vehicle: submission.vehicle,
fallbackCurrentPlate: submission.dto.plate,
@@ -368,8 +371,7 @@ export class RequestManagementService {
this.sandHubService.getTejaratBlockInquiry(
{
plate: plate as any,
nationalCodeOfInsurer:
submission.thirdPartyPolicyholder.nationalCode,
nationalCodeOfInsurer: subjects.thirdPartyPolicyNationalCode,
},
options,
),
@@ -389,14 +391,19 @@ export class RequestManagementService {
private async getCarBodyPlateInquiry(
submission: NormalizedInquirySubmission<Record<string, any>>,
): Promise<any> {
const policyholder =
submission.carBodyPolicyholder ?? submission.thirdPartyPolicyholder;
const subjects = resolveInquirySubjects(submission);
const policyholderNationalCode = subjects.carBodyPolicyNationalCode;
if (!policyholderNationalCode) {
throw new BadRequestException(
"Car-body policyholder identity is required for a CAR_BODY inquiry.",
);
}
const result = await runPlateInquiryWithFallback({
vehicle: submission.vehicle,
fallbackCurrentPlate: submission.dto.plate,
query: (plate) =>
this.sandHubService.getCarBodyInquiry({
nationalCodeOfInsurer: policyholder.nationalCode,
nationalCodeOfInsurer: policyholderNationalCode,
plate: plate as any,
}),
isUsable: (value) =>
@@ -416,6 +423,32 @@ export class RequestManagementService {
};
}
private async getThirdPartyVinInquiry(
submission: NormalizedInquirySubmission<Record<string, any>>,
options?: Record<string, any>,
): Promise<any> {
const chassis = String(
submission.vehicle?.vin ?? submission.dto.vin ?? "",
).trim();
if (!chassis) {
throw new BadRequestException(
"vehicle.vin is required for a VIN/chassis inquiry.",
);
}
if (submission.thirdPartyPolicyholder.unknown) {
return { raw: null, mapped: {}, skipped: true };
}
const subjects = resolveInquirySubjects(submission);
const result = await this.sandHubService.getPolicyByChassisInquiry(
{
nationalCode: subjects.thirdPartyPolicyNationalCode,
chassis,
},
options,
);
return { ...result, skipped: false };
}
private async runParticipantPersonalInquiries(
req: any,
role: PartyRole,
@@ -2153,7 +2186,10 @@ export class RequestManagementService {
role,
);
body = inquirySubmission.dto as unknown as InitialFormVinDto;
const subjects = resolveInquirySubjects(inquirySubmission);
this.applyInquiryParticipantsToParty(party, inquirySubmission);
const policyholderUnknown =
inquirySubmission.thirdPartyPolicyholder.unknown === true;
// Validation: driver/insurer sameness rules (identical to plate path)
if (body.driverIsInsurer === false) {
@@ -2194,8 +2230,8 @@ export class RequestManagementService {
let inquiryRaw: any;
let inquiryMapped: any;
try {
const inquiry = await this.sandHubService.getPolicyByChassisInquiry(
body.vin,
const inquiry = await this.getThirdPartyVinInquiry(
inquirySubmission,
inquiryOptions,
);
inquiryRaw = inquiry.raw;
@@ -2206,11 +2242,23 @@ export class RequestManagementService {
this.logger.log(
`[ESG] policyByChassis mapped for request=${req._id}: ${JSON.stringify(inquiryMapped)}`,
);
this.recordPartyCaseInquiryStatus(req, "thirdParty", role, true, {
source: "ESG_VIN_INQUIRY",
raw: inquiryRaw,
mapped: inquiryMapped,
});
this.recordPartyCaseInquiryStatus(
req,
"thirdParty",
role,
!inquiry.skipped,
{
source: "ESG_VIN_INQUIRY",
raw: inquiryRaw,
mapped: inquiryMapped,
...(inquiry.skipped
? {
skipped: true,
reason: "Third-party policyholder is unknown",
}
: {}),
},
);
} catch (err: any) {
this.logger.error(
`[ESG] policyByChassis failed for request=${req._id}: ${err?.message || err}`,
@@ -2271,7 +2319,7 @@ export class RequestManagementService {
// Resolve insurer client from inquiry response
const clientName = inquiryMapped?.CompanyName;
if (!clientName) {
if (!clientName && !policyholderUnknown) {
const error = new BadRequestException(
"CompanyName missing from VIN inquiry response",
);
@@ -2291,11 +2339,13 @@ export class RequestManagementService {
throw error;
}
const companyCode = inquiryMapped?.CompanyCode;
const client = await this.clientService.findOrCreateClientByCompanyCode(
companyCode,
clientName,
);
if (!client) {
const client = clientName
? await this.clientService.findOrCreateClientByCompanyCode(
companyCode,
clientName,
)
: null;
if (clientName && !client) {
const error = new BadRequestException(
"CompanyCode missing or invalid in VIN inquiry response",
);
@@ -2380,9 +2430,7 @@ export class RequestManagementService {
if (req.type === BlameRequestType.CAR_BODY && role === PartyRole.FIRST) {
try {
const carBodyInfo = await this.sandHubService.getCarBodyInquiry({
nationalCodeOfInsurer:
inquirySubmission.carBodyPolicyholder?.nationalCode ??
body.nationalCodeOfInsurer,
nationalCodeOfInsurer: subjects.carBodyPolicyNationalCode!,
plate: body.vin,
});
this.recordPartyCaseInquiryStatus(req, "carBody", role, true, {
@@ -9743,13 +9791,14 @@ export class RequestManagementService {
partyData: RunInquiriesV3Dto,
partyRole: PartyRole,
party: any,
): Promise<{ clientId?: string }> {
): Promise<{ clientId?: string; subjects: InquirySubjects }> {
const inquirySubmission = this.normalizeInquiryInput(
req.type,
partyData,
partyRole,
);
partyData = inquirySubmission.dto as RunInquiriesV3Dto;
const subjects = resolveInquirySubjects(inquirySubmission);
this.applyInquiryParticipantsToParty(party, inquirySubmission);
const policyholderUnknown =
inquirySubmission.thirdPartyPolicyholder.unknown === true;
@@ -10095,19 +10144,18 @@ export class RequestManagementService {
}
}
return { clientId };
return { clientId, subjects };
}
private async validateShebaV3(
partyData: RunInquiriesV3Dto,
sheba: string | undefined,
vehicleOwnerNationalCode: string,
clientId?: string,
): Promise<void> {
if (!partyData.sheba) return;
const nationalCode =
partyData.nationalCodeOfInsurer || partyData.nationalCodeOfDriver;
if (!sheba) return;
await this.sandHubService.getShebaValidation(
nationalCode,
partyData.sheba,
vehicleOwnerNationalCode,
sheba,
clientId ? { clientId } : undefined,
);
}
@@ -10340,6 +10388,7 @@ export class RequestManagementService {
private async syncV3ClaimDamagedPartyFromBlame(
req: any,
partyData: RunInquiriesV3Dto,
vehicleOwnerNationalCode: string,
): Promise<void> {
const claim = await this.claimCaseDbService.findOne({
blameRequestId: (req as any)._id,
@@ -10358,9 +10407,6 @@ export class RequestManagementService {
);
}
const nationalCode =
partyData.nationalCodeOfInsurer || partyData.nationalCodeOfDriver || "";
await this.claimCaseDbService.findByIdAndUpdate(String(claim._id), {
$set: {
damagedPartyUserId: new Types.ObjectId(ownerFields.userId),
@@ -10375,7 +10421,7 @@ export class RequestManagementService {
...(partyData.sheba
? {
"money.sheba": partyData.sheba,
"money.nationalCodeOfInsurer": nationalCode,
"money.nationalCodeOfInsurer": vehicleOwnerNationalCode,
}
: {}),
},
@@ -10405,9 +10451,6 @@ export class RequestManagementService {
if (!partyRole) {
throw new ConflictException("All party inquiries are already complete.");
}
dto = this.normalizeInquiryInput(req.type, dto, partyRole)
.dto as RunInquiriesV3Dto;
if (partyRole === PartyRole.FIRST) {
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
if (firstIdx === -1)
@@ -10427,7 +10470,7 @@ export class RequestManagementService {
}
}
await this.runPartyInquiriesV3Internal(
const inquiryResult = await this.runPartyInquiriesV3Internal(
req,
dto,
PartyRole.FIRST,
@@ -10435,7 +10478,8 @@ export class RequestManagementService {
);
if (req.type === BlameRequestType.CAR_BODY) {
await this.validateShebaV3(
dto,
dto.sheba,
inquiryResult.subjects.shebaNationalCode,
firstParty.person?.clientId?.toString(),
);
}
@@ -10443,12 +10487,12 @@ export class RequestManagementService {
this.markPartyInquiriesCompleteOnBlame(req, PartyRole.FIRST, actor);
await (req as any).save();
const nationalCode =
dto.nationalCodeOfInsurer || dto.nationalCodeOfDriver || "";
const newClaim = await this.createV3ClaimFromBlame(req, actor, {
sheba: req.type === BlameRequestType.CAR_BODY ? dto.sheba : undefined,
nationalCode:
req.type === BlameRequestType.CAR_BODY ? nationalCode : undefined,
req.type === BlameRequestType.CAR_BODY
? inquiryResult.subjects.shebaNationalCode
: undefined,
interimThirdParty: req.type === BlameRequestType.THIRD_PARTY,
});
@@ -10491,13 +10535,17 @@ export class RequestManagementService {
);
}
await this.runPartyInquiriesV3Internal(
const inquiryResult = await this.runPartyInquiriesV3Internal(
req,
dto,
PartyRole.SECOND,
secondParty,
);
await this.validateShebaV3(dto, secondParty.person?.clientId?.toString());
await this.validateShebaV3(
dto.sheba,
inquiryResult.subjects.shebaNationalCode,
secondParty.person?.clientId?.toString(),
);
this.ensureV3GuiltyPartyDecision(req);
if (typeof (req as any).markModified === "function") {
@@ -10505,7 +10553,11 @@ export class RequestManagementService {
}
await (req as any).save();
await this.syncV3ClaimDamagedPartyFromBlame(req, dto);
await this.syncV3ClaimDamagedPartyFromBlame(
req,
dto,
inquiryResult.subjects.shebaNationalCode,
);
this.markPartyInquiriesCompleteOnBlame(req, PartyRole.SECOND, actor);
await (req as any).save();
@@ -10542,9 +10594,6 @@ export class RequestManagementService {
if (!partyRole) {
throw new ConflictException("All party inquiries are already complete.");
}
dto = this.normalizeInquiryInput(req.type, dto, partyRole)
.dto as RunInquiriesVinV3Dto;
if (partyRole === PartyRole.FIRST) {
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
if (firstIdx === -1)
@@ -10564,7 +10613,7 @@ export class RequestManagementService {
}
}
await this.runPartyInquiriesVinV3Internal(
const inquiryResult = await this.runPartyInquiriesVinV3Internal(
req,
dto,
PartyRole.FIRST,
@@ -10572,7 +10621,8 @@ export class RequestManagementService {
);
if (req.type === BlameRequestType.CAR_BODY) {
await this.validateShebaV3(
dto as any,
dto.sheba,
inquiryResult.subjects.shebaNationalCode,
firstParty.person?.clientId?.toString(),
);
}
@@ -10580,12 +10630,12 @@ export class RequestManagementService {
this.markPartyInquiriesCompleteOnBlame(req, PartyRole.FIRST, actor);
await (req as any).save();
const nationalCode =
dto.nationalCodeOfInsurer || dto.nationalCodeOfDriver || "";
const newClaim = await this.createV3ClaimFromBlame(req, actor, {
sheba: req.type === BlameRequestType.CAR_BODY ? dto.sheba : undefined,
nationalCode:
req.type === BlameRequestType.CAR_BODY ? nationalCode : undefined,
req.type === BlameRequestType.CAR_BODY
? inquiryResult.subjects.shebaNationalCode
: undefined,
interimThirdParty: req.type === BlameRequestType.THIRD_PARTY,
});
@@ -10628,14 +10678,15 @@ export class RequestManagementService {
);
}
await this.runPartyInquiriesVinV3Internal(
const inquiryResult = await this.runPartyInquiriesVinV3Internal(
req,
dto,
PartyRole.SECOND,
secondParty,
);
await this.validateShebaV3(
dto as any,
dto.sheba,
inquiryResult.subjects.shebaNationalCode,
secondParty.person?.clientId?.toString(),
);
@@ -10645,7 +10696,11 @@ export class RequestManagementService {
}
await (req as any).save();
await this.syncV3ClaimDamagedPartyFromBlame(req, dto as any);
await this.syncV3ClaimDamagedPartyFromBlame(
req,
dto as any,
inquiryResult.subjects.shebaNationalCode,
);
this.markPartyInquiriesCompleteOnBlame(req, PartyRole.SECOND, actor);
await (req as any).save();
@@ -10669,14 +10724,17 @@ export class RequestManagementService {
partyData: RunInquiriesVinV3Dto,
partyRole: PartyRole,
party: any,
): Promise<{ clientId?: string }> {
): Promise<{ clientId?: string; subjects: InquirySubjects }> {
const inquirySubmission = this.normalizeInquiryInput(
req.type,
partyData,
partyRole,
);
partyData = inquirySubmission.dto as RunInquiriesVinV3Dto;
const subjects = resolveInquirySubjects(inquirySubmission);
this.applyInquiryParticipantsToParty(party, inquirySubmission);
const policyholderUnknown =
inquirySubmission.thirdPartyPolicyholder.unknown === true;
const roleLabel = partyRole === PartyRole.FIRST ? "FIRST" : "SECOND";
let clientId: string | undefined;
const inquiryOptions = (cid?: string) =>
@@ -10685,17 +10743,29 @@ export class RequestManagementService {
let inquiryRaw: any;
let inquiryMapped: any;
try {
const inquiry = await this.sandHubService.getPolicyByChassisInquiry(
partyData.vin,
const inquiry = await this.getThirdPartyVinInquiry(
inquirySubmission,
inquiryOptions(party?.person?.clientId?.toString()),
);
inquiryRaw = inquiry.raw;
inquiryMapped = inquiry.mapped;
this.recordPartyCaseInquiryStatus(req, "thirdParty", partyRole, true, {
source: "ESG_VIN_INQUIRY",
raw: inquiryRaw,
mapped: inquiryMapped,
});
this.recordPartyCaseInquiryStatus(
req,
"thirdParty",
partyRole,
!inquiry.skipped,
{
source: "ESG_VIN_INQUIRY",
raw: inquiryRaw,
mapped: inquiryMapped,
...(inquiry.skipped
? {
skipped: true,
reason: "Third-party policyholder is unknown",
}
: {}),
},
);
} catch (err: any) {
this.logger.error(
`[V3-VIN] vin inquiry failed for ${roleLabel} party (request=${req._id}): ${err?.message || err}`,
@@ -10737,7 +10807,7 @@ export class RequestManagementService {
const clientName = inquiryMapped?.CompanyName;
const companyCode = inquiryMapped?.CompanyCode;
if (!clientName) {
if (!clientName && !policyholderUnknown) {
const error = new BadRequestException(
`CompanyName missing from ${roleLabel} party VIN inquiry response`,
);
@@ -10756,11 +10826,13 @@ export class RequestManagementService {
await this.persistBlameInquiryAudit(req);
throw error;
}
const client = await this.clientService.findOrCreateClientByCompanyCode(
companyCode,
clientName,
);
if (!client) {
const client = clientName
? await this.clientService.findOrCreateClientByCompanyCode(
companyCode,
clientName,
)
: null;
if (clientName && !client) {
const error = new BadRequestException(
`CompanyCode missing or invalid in ${roleLabel} party VIN inquiry response`,
);
@@ -10846,9 +10918,7 @@ export class RequestManagementService {
) {
try {
const carBodyInfo = await this.sandHubService.getCarBodyInquiry({
nationalCodeOfInsurer:
inquirySubmission.carBodyPolicyholder?.nationalCode ??
partyData.nationalCodeOfInsurer,
nationalCodeOfInsurer: subjects.carBodyPolicyNationalCode!,
plate: partyData.vin as any, // VIN used as identifier for CAR_BODY
});
this.recordPartyCaseInquiryStatus(req, "carBody", partyRole, true, {
@@ -10996,7 +11066,7 @@ export class RequestManagementService {
}
}
return { clientId };
return { clientId, subjects };
}
private assertBlameV3PartyDetailPhase(req: any, partyRole: PartyRole): void {
@@ -12185,8 +12255,6 @@ export class RequestManagementService {
if (firstIdx === -1) throw new BadRequestException("First party not found");
const firstParty = req.parties[firstIdx];
dto = this.normalizeInquiryInput(req.type, dto, PartyRole.FIRST).dto;
await this.runPartyInquiriesV3Internal(
req,
dto,
@@ -12308,8 +12376,6 @@ export class RequestManagementService {
if (firstIdx === -1) throw new BadRequestException("First party not found");
const firstParty = req.parties[firstIdx];
dto = this.normalizeInquiryInput(req.type, dto, PartyRole.FIRST).dto;
await this.runPartyInquiriesVinV3Internal(
req,
dto as RunInquiriesVinV3Dto,

View File

@@ -176,12 +176,39 @@ describe("SandHubService inquiry mocks", () => {
});
await expect(
service.getPolicyByChassisInquiry("NAAR03HFFRDE07024", {
enforceDeploymentClientMatch: true,
}),
service.getPolicyByChassisInquiry(
{
nationalCode: "1234567890",
chassis: "NAAR03HFFRDE07024",
},
{ enforceDeploymentClientMatch: true },
),
).rejects.toBeInstanceOf(ForbiddenException);
});
it("sends the policyholder national code with the VIN inquiry", async () => {
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
const esg = jest.spyOn(service as any, "makeEsgRequest").mockResolvedValue({
success: true,
data: { CmpCod: "8", CmpNam: "بیمه پارسیان" },
});
await service.getPolicyByChassisInquiry({
nationalCode: "0012345678",
chassis: "NAAR03HFFRDE07024",
});
expect(esg).toHaveBeenCalledWith(
expect.stringContaining("/inquiry/policyByChassis"),
{
nationalCode: "0012345678",
chassis: "NAAR03HFFRDE07024",
},
"vinChassis",
undefined,
);
});
it("does not accept a policy when CLIENT_ID is not configured", async () => {
delete process.env.CLIENT_ID;
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
@@ -295,6 +322,47 @@ describe("SandHubService inquiry mocks", () => {
expect(result.source).toBe("ESG_CAR_BODY_VIN_INQUIRY");
});
it("uses the tenant Fanavaran VIN lookup for non-Parsian car-body inquiries", async () => {
process.env.CLIENT_ID = "15";
process.env.FANAVARAN_CLIENT = "tejaratno";
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
lookupsService.findLastProcessedCarPolicy.mockResolvedValue({
policy: { CINumber: "BODY-15" },
customer: {},
vehicle: { VIN: "NAAR03HFFRDE07024" },
});
const tejarat = jest.spyOn(service, "getTejaratCarBodyInquiry");
const result = await service.getCarBodyInquiry({
nationalCodeOfInsurer: "0012345678",
plate: "NAAR03HFFRDE07024",
});
expect(lookupsService.findLastProcessedCarPolicy).toHaveBeenCalledWith(
"car-body",
{ nationalCode: "0012345678", vin: "NAAR03HFFRDE07024" },
);
expect(tejarat).not.toHaveBeenCalled();
expect(result.source).toBe("TEJARAT_CAR_BODY_VIN_INQUIRY");
});
it("returns a VIN-shaped car-body mock without invoking a plate adapter", async () => {
process.env.CLIENT_ID = "15";
process.env.FANAVARAN_CLIENT = "tejaratno";
const tejarat = jest.spyOn(service, "getTejaratCarBodyInquiry");
const result = await service.getCarBodyInquiry({
nationalCodeOfInsurer: "0012345678",
plate: "NAAR03HFFRDE07024",
});
expect(tejarat).not.toHaveBeenCalled();
expect(lookupsService.findLastProcessedCarPolicy).not.toHaveBeenCalled();
expect(result.source).toBe("TEJARAT_CAR_BODY_VIN_INQUIRY");
expect(result.mapped.VinNumberField).toBe("NAAR03HFFRDE07024");
expect(result.mapped.insurerNationalCode).toBe("0012345678");
});
it("uses car-body mock shape in Tejarat helper when inquiry is off", async () => {
const raw = await (service as any).makeTejaratRequest(
"http://example/block-inquiry-tejarat/badane",

View File

@@ -250,11 +250,16 @@ export class SandHubService {
}
private buildMockCarBodyInquiryRaw(
userDetail: SandHubDetailDto,
userDetail: CarBodyInquiryDetail,
ctx: MockInquiryCompanyContext,
): Record<string, unknown> {
const companyId = Number(ctx.companyId);
const plate = userDetail?.plate;
const plate =
typeof userDetail?.plate === "string" ? undefined : userDetail?.plate;
const vin =
typeof userDetail?.plate === "string"
? userDetail.plate
: "LFP8C7PC3R1K12157";
const platePartOne = Number(plate?.leftDigits ?? 16);
const platePartThree = Number(plate?.centerDigits ?? 498);
const plateSerialNumber = Number(plate?.ir ?? 60);
@@ -272,8 +277,8 @@ export class SandHubService {
issueDate: "1405/01/16",
hasEndorsement: null,
motorNumber: "TZ196XYAP223A210074",
chassisNumber: "LFP8C7PC3R1K12157",
vin: "LFP8C7PC3R1K12157",
chassisNumber: vin,
vin,
plateTypeId: 9,
plateTypeTitle: "پلاک قدیمی",
platePartOne,
@@ -933,28 +938,48 @@ export class SandHubService {
}> {
const useParsianCarBodyLookup = this.shouldUseParsianCarBodyLookup();
const live = await this.isInquiryLive("carBodyPlate", options);
const plateOrVin = userDetail.plate;
const isVinInquiry = typeof plateOrVin === "string";
if (!live) {
// Mock results are generated for the active deployment. They must not be
// treated as evidence of a real insurer and therefore do not go through
// the live-policy insurer gate.
const result = await this.getTejaratCarBodyInquiry(
userDetail as SandHubDetailDto,
options,
);
const ctx = await this.mockCompanyContext(options);
const raw = this.buildMockCarBodyInquiryRaw(userDetail, ctx);
const result = { raw, mapped: this.mapCarBodyInquiryResponse(raw) };
return {
source:
typeof userDetail.plate === "string"
? useParsianCarBodyLookup
? "ESG_CAR_BODY_VIN_INQUIRY"
: "TEJARAT_CAR_BODY_VIN_INQUIRY"
: useParsianCarBodyLookup
? "ESG_CAR_BODY_INQUIRY"
: "TEJARAT_CAR_BODY_INQUIRY",
source: isVinInquiry
? useParsianCarBodyLookup
? "ESG_CAR_BODY_VIN_INQUIRY"
: "TEJARAT_CAR_BODY_VIN_INQUIRY"
: useParsianCarBodyLookup
? "ESG_CAR_BODY_INQUIRY"
: "TEJARAT_CAR_BODY_INQUIRY",
...result,
};
}
if (isVinInquiry) {
const raw = await this.lookupsService.findLastProcessedCarPolicy(
"car-body",
{
nationalCode: String(userDetail.nationalCodeOfInsurer),
vin: plateOrVin,
},
);
if (useParsianCarBodyLookup) {
this.assertParsianCarBodyLookupMatchesDeployment();
}
return {
source: useParsianCarBodyLookup
? "ESG_CAR_BODY_VIN_INQUIRY"
: "TEJARAT_CAR_BODY_VIN_INQUIRY",
raw,
mapped: mapEsgCarBodyPolicyToInquiry(raw),
};
}
if (!useParsianCarBodyLookup) {
const result = await this.getTejaratCarBodyInquiry(
userDetail as SandHubDetailDto,
@@ -962,28 +987,18 @@ export class SandHubService {
);
this.assertInsuranceMatchesDeployment(result.mapped, "CAR_BODY");
return {
source:
typeof userDetail.plate === "string"
? "TEJARAT_CAR_BODY_VIN_INQUIRY"
: "TEJARAT_CAR_BODY_INQUIRY",
source: "TEJARAT_CAR_BODY_INQUIRY",
...result,
};
}
const plateOrVin = userDetail.plate;
const query =
typeof plateOrVin === "string"
? {
nationalCode: String(userDetail.nationalCodeOfInsurer),
vin: plateOrVin,
}
: {
nationalCode: String(userDetail.nationalCodeOfInsurer),
plaqueLeft: String(plateOrVin.leftDigits),
plaqueLetter: String(plateOrVin.centerAlphabet),
plaqueRight: String(plateOrVin.centerDigits),
plaqueSerial: String(plateOrVin.ir),
};
const query = {
nationalCode: String(userDetail.nationalCodeOfInsurer),
plaqueLeft: String(plateOrVin.leftDigits),
plaqueLetter: String(plateOrVin.centerAlphabet),
plaqueRight: String(plateOrVin.centerDigits),
plaqueSerial: String(plateOrVin.ir),
};
const raw = await this.lookupsService.findLastProcessedCarPolicy(
"car-body",
query,
@@ -991,10 +1006,7 @@ export class SandHubService {
this.assertParsianCarBodyLookupMatchesDeployment();
return {
source:
typeof plateOrVin === "string"
? "ESG_CAR_BODY_VIN_INQUIRY"
: "ESG_CAR_BODY_INQUIRY",
source: "ESG_CAR_BODY_INQUIRY",
raw,
mapped: mapEsgCarBodyPolicyToInquiry(raw),
};
@@ -1109,16 +1121,19 @@ export class SandHubService {
* `buildMockPlateInquiryRaw` so the downstream mapper (`mapEsgPolicyByPlateToOldFormat`)
* can normalise it into the same `{ raw, mapped }` contract used by the plate path.
*
* @param chassisNo - 17-character VIN / chassis number
* @param identity - policyholder national code and 17-character VIN/chassis
* @param options - optional per-tenant client scope
*/
async getPolicyByChassisInquiry(
chassisNo: string,
identity: { nationalCode: string; chassis: string },
options?: SandHubInquiryOptions,
): Promise<{ raw: any; mapped: any }> {
const baseUrl = process.env.ESG_URL ?? "http://192.168.20.22:8085";
const requestUrl = `${baseUrl}/inquiry/policyByChassis`;
const requestPayload = { chassisNo };
const requestPayload = {
nationalCode: String(identity.nationalCode),
chassis: String(identity.chassis),
};
const live = await this.isInquiryLive("vinChassis", options);
@@ -1126,7 +1141,7 @@ export class SandHubService {
const ctx = await this.mockCompanyContext(options);
const raw = this.buildMockPlateInquiryRaw(ctx);
this.logger.debug(
`[MOCK] getPolicyByChassisInquiry chassisNo=${chassisNo}`,
`[MOCK] getPolicyByChassisInquiry nationalCode=${identity.nationalCode} chassis=${identity.chassis}`,
);
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw);
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);