Route inquiries by participant role

This commit is contained in:
SepehrYahyaee
2026-09-14 10:35:11 +03:30
parent 461afbb6b9
commit 98c7ebb83a
10 changed files with 446 additions and 138 deletions

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,