Merge pull request 'main' (#296) from s.hajizadeh/yara724api:main into main

Reviewed-on: Yara724/api#296
This commit is contained in:
2026-09-07 15:12:27 +03:30
11 changed files with 1165 additions and 72 deletions

View File

@@ -2,7 +2,10 @@ import {
filterPoliciesByLine,
insuranceLineLabel,
parseLastCarPolicyInput,
pickFanavaranPlaqueLookupFields,
pickFanavaranVehicleFromInquiry,
pickVehicleId,
pickVinFromPartyVehicle,
plaqueLetterFromMiddleCode,
plaqueMatchesVehicle,
selectLastAmongCarMatches,
@@ -84,6 +87,57 @@ describe("fanavaran last car policy", () => {
);
});
it("picks the inquiry row matching policy VehicleId and VIN from the party", () => {
const inquired = [
{ Id: 1, UsedId: 1, VehicleKindId: 10 },
{
Id: 3379978,
UsedId: 84,
VehicleKindId: 5950,
VIN: "NAS451100P4934023",
},
];
expect(pickFanavaranVehicleFromInquiry(inquired, 3379978)).toEqual(
inquired[1],
);
expect(
pickVinFromPartyVehicle({
vehicle: { vin: "nas451100p4934023" },
}),
).toBe("NAS451100P4934023");
expect(
pickVinFromPartyVehicle({
vehicle: {
inquiry: { mapped: { VIN: "NAS451100P4934023" } },
},
}),
).toBe("NAS451100P4934023");
});
it("reads plaque kind and sample from VIN inquiry for the lookup filter", () => {
expect(
pickFanavaranPlaqueLookupFields({
PlaqueKindId: 15,
PlaqueSampleId: 24,
PlaqueCityId: null,
PlaqueLeftNo: "62",
PlaqueMiddleCodeId: 14,
PlaqueRightNo: "278",
PlaqueSerial: "50",
PlaqueNo: "278و62",
}),
).toEqual({
kindId: 15,
sampleId: 24,
cityId: null,
leftNo: "62",
middleCodeId: 14,
rightNo: "278",
serial: "50",
plaqueNo: "278و62",
});
});
it("matches VIN against ChassisNo when VIN is empty", () => {
expect(
vehicleMatchesCar(

View File

@@ -233,6 +233,71 @@ export function pickVehicleId(source: unknown): number | null {
return null;
}
/** Prefer the inquiry row whose Id matches the policy VehicleId. */
export function pickFanavaranVehicleFromInquiry(
inquired: unknown,
vehicleId?: number | null,
): Record<string, unknown> | null {
if (Array.isArray(inquired)) {
if (vehicleId != null) {
const match = inquired.find((row) => pickVehicleId(row) === vehicleId);
if (match) return asObjectRecord(match);
}
return asObjectRecord(inquired[0]);
}
return asObjectRecord(inquired);
}
export function pickVinFromPartyVehicle(party: {
vehicle?: { vin?: unknown; inquiry?: unknown };
} | null | undefined): string | null {
const direct = normalizeVin(party?.vehicle?.vin);
if (direct) return direct;
const inquiry = party?.vehicle?.inquiry;
const nested = asObjectRecord(inquiry);
const mapped = pickVehicleVin(asObjectRecord(nested?.mapped));
if (mapped) return mapped;
const raw = pickVehicleVin(asObjectRecord(nested?.raw ?? inquiry));
return raw || null;
}
export type FanavaranPlaqueLookupFields = {
kindId: number | null;
sampleId: number | null;
cityId: number | null;
leftNo: string | null;
middleCodeId: number | null;
rightNo: string | null;
serial: string | null;
plaqueNo: string | null;
};
function asPlaqueText(value: unknown): string | null {
const text = normalizePlaquePart(value);
return text || null;
}
/** Plaque lookup-filter fields from VIN inquiry / vehicle GET. */
export function pickFanavaranPlaqueLookupFields(
vehicle: Record<string, unknown> | null,
): FanavaranPlaqueLookupFields {
return {
kindId: parseFanavaranId(vehicle?.PlaqueKindId ?? vehicle?.plaqueKindId),
sampleId: parseFanavaranId(
vehicle?.PlaqueSampleId ?? vehicle?.plaqueSampleId,
),
cityId: parseFanavaranId(vehicle?.PlaqueCityId ?? vehicle?.plaqueCityId),
leftNo: asPlaqueText(vehicle?.PlaqueLeftNo ?? vehicle?.plaqueLeftNo),
middleCodeId: parseFanavaranId(
vehicle?.PlaqueMiddleCodeId ?? vehicle?.plaqueMiddleCodeId,
),
rightNo: asPlaqueText(vehicle?.PlaqueRightNo ?? vehicle?.plaqueRightNo),
serial: asPlaqueText(vehicle?.PlaqueSerial ?? vehicle?.plaqueSerial),
plaqueNo: asPlaqueText(vehicle?.PlaqueNo ?? vehicle?.plaqueNo),
};
}
function plaqueNumberEquals(left: unknown, right: unknown): boolean {
const a = normalizePlaquePart(left);
const b = normalizePlaquePart(right);

View File

@@ -331,7 +331,7 @@ export class LookupsController {
@ApiOperation({
summary: "Fanavaran vehicle inquiry by VIN",
description:
"Returns vehicle details from Fanavaran for the given VIN number via car/vehicles/inquiry-by-vin.",
"GET car/vehicles/inquiry-by-vin. Response includes UsedId (AccidentVehicleUsedId), VehicleKindId, plus vehicleKind/used lookup rows. Prefer this over vehicle GET when you already have the VIN.",
})
@ApiQuery({
name: "vin",
@@ -346,6 +346,42 @@ export class LookupsController {
return await this.lookupsService.inquiryByVin(vin);
}
@Get("inquiry-by-unique-identifier")
@ApiOperation({
summary: "Fanavaran party inquiry by national code and birthday",
description:
"GET common/parties/inquiry-by-unique-identifier. Use this to resolve GEN.12 DriverId. Pass birthday as 13480313 or 1348/03/13, or birthYear+birthMonth+birthDay.",
})
@ApiQuery({ name: "nationalCode", example: "0012345678" })
@ApiQuery({
name: "birthday",
required: false,
example: "13480313",
description: "Jalali birthday compact (13480313) or delimited (1348/03/13)",
})
@ApiQuery({ name: "birthYear", required: false, example: 1348 })
@ApiQuery({ name: "birthMonth", required: false, example: 3 })
@ApiQuery({ name: "birthDay", required: false, example: 13 })
@ApiOkResponse({
description: "Fanavaran parties plus picked driverId / insurerId",
schema: { type: "object" },
})
async inquiryByUniqueIdentifier(
@Query("nationalCode") nationalCode?: string,
@Query("birthday") birthday?: string,
@Query("birthYear") birthYear?: string,
@Query("birthMonth") birthMonth?: string,
@Query("birthDay") birthDay?: string,
) {
return await this.lookupsService.inquiryByUniqueIdentifier({
nationalCode,
birthday,
birthYear,
birthMonth,
birthDay,
});
}
@Get("fanavaran")
@ApiOperation({
summary: "List configured Fanavaran remote lookups",
@@ -627,4 +663,41 @@ export class LookupsController {
async bodyPolicyById(@Param("policyId", ParseIntPipe) policyId: number) {
return await this.lookupsService.bodyPolicyById(policyId);
}
@Get("vehicle/:vehicleId")
@ApiOperation({
summary: "Fanavaran vehicle by id",
description:
"GET car/vehicles/{vehicleId} after a third-party/hull policy returns VehicleId. Pass the policy VehicleVersionNo when you need that exact version; omit it for the latest version. Response includes vehicleKind (VehicleGroupId) and used (UsedId) lookup rows.",
})
@ApiParam({
name: "vehicleId",
description: "Fanavaran vehicle id from the policy VehicleId field",
example: 3379978,
})
@ApiQuery({
name: "versionNo",
required: false,
description:
"Policy VehicleVersionNo. Omit to fetch the latest vehicle version.",
example: 2,
})
@ApiOkResponse({
description:
"Vehicle record plus vehicleKind, used, plaque, and color placeholders",
schema: { type: "object" },
})
async vehicleById(
@Param("vehicleId", ParseIntPipe) vehicleId: number,
@Query("versionNo") versionNo?: string,
) {
const parsedVersion =
versionNo == null || String(versionNo).trim() === ""
? undefined
: Number(versionNo);
return await this.lookupsService.vehicleById(
vehicleId,
Number.isFinite(parsedVersion) ? parsedVersion : undefined,
);
}
}

View File

@@ -14,6 +14,10 @@ import {
TEJARAT_STATIC_ACCIDENT_FILES,
} from "src/fanavaran/fanavaran-lookup.config";
import { FanavaranLookupService } from "src/fanavaran/fanavaran-lookup.service";
import {
parseJalaliDateParts,
selectFanavaranDriverId,
} from "src/claim-request-management/fanavaran-driver-inquiry";
import { LookupDbService } from "./entities/db-service/lookup.db.service";
import {
asObjectRecord,
@@ -178,9 +182,81 @@ export class LookupsService {
return await this.getClientRemoteLookup("dmg-business-line");
}
async inquiryByUniqueIdentifier(query: {
nationalCode?: string;
birthday?: string;
birthYear?: string | number;
birthMonth?: string | number;
birthDay?: string | number;
}): Promise<unknown> {
const nationalCode = String(query.nationalCode ?? "").trim();
if (!nationalCode) {
throw new BadRequestException("nationalCode is required");
}
const fromParts = {
year: Number(query.birthYear),
month: Number(query.birthMonth),
day: Number(query.birthDay),
};
const parsed =
Number.isFinite(fromParts.year) &&
fromParts.year > 0 &&
Number.isFinite(fromParts.month) &&
fromParts.month > 0 &&
Number.isFinite(fromParts.day) &&
fromParts.day > 0
? fromParts
: parseJalaliDateParts(query.birthday);
if (!parsed) {
throw new BadRequestException(
"birthday (e.g. 13480313) or birthYear+birthMonth+birthDay is required",
);
}
const clientKey = this.activeClientKey();
const rows = await this.fanavaranLookupService.inquiryByUniqueIdentifier(
clientKey,
{
nationalCode,
birthYear: parsed.year,
birthMonth: parsed.month,
birthDay: parsed.day,
},
);
return {
nationalCode,
birthday: parsed,
driverId: selectFanavaranDriverId(rows, undefined),
insurerId: selectFanavaranDriverId(rows, true),
parties: rows,
};
}
async inquiryByVin(vin: string): Promise<unknown> {
const clientKey = this.activeClientKey();
return this.fanavaranLookupService.inquiryByVin(clientKey, vin);
const raw = await this.fanavaranLookupService.inquiryByVin(clientKey, vin);
if (Array.isArray(raw)) {
if (raw.length === 0) {
throw new NotFoundException(
`Fanavaran vehicle for VIN ${vin} was not found.`,
);
}
return Promise.all(
raw.map(async (item) => {
const vehicle = asObjectRecord(item);
return vehicle ? this.enrichVehicle(vehicle) : item;
}),
);
}
const vehicle = asObjectRecord(raw);
if (!vehicle) {
throw new NotFoundException(
`Fanavaran vehicle for VIN ${vin} was not found.`,
);
}
return this.enrichVehicle(vehicle);
}
async myPolicies(
@@ -514,11 +590,19 @@ export class LookupsService {
async vehicleById(vehicleId: number, versionNo?: number): Promise<unknown> {
const clientKey = this.activeClientKey();
return this.fanavaranLookupService.vehicleById(
clientKey,
vehicleId,
versionNo,
const vehicle = asObjectRecord(
await this.fanavaranLookupService.vehicleById(
clientKey,
vehicleId,
versionNo,
),
);
if (!vehicle) {
throw new NotFoundException(
`Fanavaran vehicle ${vehicleId} was not found.`,
);
}
return this.enrichVehicle(vehicle);
}
async customerById(customerId: number): Promise<unknown> {

View File

@@ -0,0 +1,145 @@
import { NotFoundException } from "@nestjs/common";
import { LookupsService } from "./lookups.service";
describe("LookupsService.vehicleById", () => {
const originalClient = process.env.FANAVARAN_CLIENT;
beforeAll(() => {
process.env.FANAVARAN_CLIENT = "parsian";
});
afterAll(() => {
process.env.FANAVARAN_CLIENT = originalClient;
});
function createService(lookup: Record<string, jest.Mock>) {
return new LookupsService({} as any, lookup as any);
}
it("returns the vehicle with kind, used, and plaque lookups", async () => {
const lookup = {
vehicleById: jest.fn().mockResolvedValue({
Id: 3379978,
VehicleKindId: 5904,
UsedId: 36,
VIN: "IRNKAEK4150012345",
PlaqueLeftNo: "12",
PlaqueMiddleCodeId: 2,
PlaqueRightNo: "345",
PlaqueSerial: "67",
VersionNo: 2,
}),
getRemoteLookup: jest.fn().mockImplementation((_key, url: string) => {
if (url.includes("vehicle-kinds")) {
return Promise.resolve([
{ Id: 5904, Caption: "بنز", VehicleGroupId: 3, IsActive: 1 },
]);
}
if (url.includes("vehicle-use-types")) {
return Promise.resolve([
{ Id: 36, Caption: "حمل مواد سريع الاشتعال", VehicleGroupId: 3, IsActive: 1 },
]);
}
return Promise.resolve([]);
}),
};
const service = createService(lookup);
const result = (await service.vehicleById(3379978, 2)) as Record<
string,
unknown
>;
expect(lookup.vehicleById).toHaveBeenCalledWith("parsian", 3379978, 2);
expect(result.Id).toBe(3379978);
expect(result.vehicleKind).toEqual({
Id: 5904,
Caption: "بنز",
VehicleGroupId: 3,
IsActive: 1,
});
expect(result.used).toEqual({
Id: 36,
Caption: "حمل مواد سريع الاشتعال",
VehicleGroupId: 3,
IsActive: 1,
});
expect(result.plaque).toEqual({
leftTwoDigits: "12",
serialLetter: "ب",
threeDigits: "345",
rightTwoDigits: "67",
});
});
it("enriches inquiry-by-vin rows with used and vehicleKind", async () => {
const lookup = {
inquiryByVin: jest.fn().mockResolvedValue([
{
Id: 3379978,
UsedId: 84,
VehicleKindId: 5950,
VIN: "NAS451100P4934023",
},
]),
getRemoteLookup: jest.fn().mockImplementation((_key, url: string) => {
if (url.includes("vehicle-kinds")) {
return Promise.resolve([
{ Id: 5950, Caption: "کامیون", VehicleGroupId: 3, IsActive: 1 },
]);
}
if (url.includes("vehicle-use-types")) {
return Promise.resolve([
{ Id: 84, Caption: "حمل بار", VehicleGroupId: 3, IsActive: 1 },
]);
}
return Promise.resolve([]);
}),
};
const service = createService(lookup);
const result = (await service.inquiryByVin("NAS451100P4934023")) as Array<
Record<string, unknown>
>;
expect(lookup.inquiryByVin).toHaveBeenCalledWith(
"parsian",
"NAS451100P4934023",
);
expect(result[0].UsedId).toBe(84);
expect(result[0].used).toEqual({
Id: 84,
Caption: "حمل بار",
VehicleGroupId: 3,
IsActive: 1,
});
expect(result[0].vehicleKind).toEqual({
Id: 5950,
Caption: "کامیون",
VehicleGroupId: 3,
IsActive: 1,
});
});
it("unwraps an array payload and 404s when Fanavaran returns nothing", async () => {
const lookup = {
vehicleById: jest
.fn()
.mockResolvedValueOnce([{ Id: 3379978, VehicleKindId: 1 }])
.mockResolvedValueOnce([]),
getRemoteLookup: jest.fn().mockResolvedValue([]),
};
const service = createService(lookup);
const unwrapped = (await service.vehicleById(3379978)) as Record<
string,
unknown
>;
expect(unwrapped.Id).toBe(3379978);
expect(unwrapped.color).toBeNull();
await expect(service.vehicleById(1)).rejects.toBeInstanceOf(
NotFoundException,
);
});
});