Compare commits

...

10 Commits

Author SHA1 Message Date
3635dc2c10 Merge pull request 'Fixed car body inquiry client id fixing' (#299) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#299
2026-09-07 16:44:49 +03:30
503894bf9c Merge pull request 'updated the fucking mf fanavaran' (#298) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#298
2026-09-07 16:35:02 +03:30
b9bffdccc1 updated the fucking mf fanavaran 2026-09-07 16:33:15 +03:30
395e3dbe63 Merge pull request 'Better error handling for different insurers' (#297) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#297
2026-09-07 15:16:31 +03:30
d860e94dee Merge pull request 'main' (#296) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#296
2026-09-07 15:12:27 +03:30
b7857a40f5 bug fixed 2026-09-07 15:11:29 +03:30
c7cd9e8ce5 update the lookups list 2026-09-07 15:08:08 +03:30
6100ac80f3 Merge pull request 'enforced matching client id for moving on with the case' (#295) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#295
2026-09-07 15:06:45 +03:30
0724d66727 Merge pull request 'Fixed car body inquiry' (#294) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#294
2026-09-07 13:09:13 +03:30
4423b7fa25 Merge pull request 'FIXED mock problem' (#293) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#293
2026-09-07 13:01:29 +03:30
11 changed files with 1514 additions and 112 deletions

View File

@@ -191,6 +191,29 @@ import {
} from "src/fanavaran/schema/fanavaran-audit-log.schema"; } from "src/fanavaran/schema/fanavaran-audit-log.schema";
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service"; import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
import { selectLatestActiveFanavaranPolicy } from "./fanavaran-policy-selection"; import { selectLatestActiveFanavaranPolicy } from "./fanavaran-policy-selection";
import {
selectAccidentVehicleUsedId,
vehicleGroupIdForKind,
} from "./fanavaran-accident-vehicle-used";
import {
asPartyInquiryRows,
collectPersonBirthdays,
collectPersonNationalCodes,
parseJalaliDateParts,
pickPersonBirthday,
pickPersonNationalCode,
selectFanavaranDriverId,
} from "./fanavaran-driver-inquiry";
import {
asObjectRecord,
collectPartyVinCandidates,
normalizeVin,
parseFanavaranId,
pickFanavaranPlaqueLookupFields,
pickFanavaranVehicleFromInquiry,
pickVehicleId,
pickVinFromPartyVehicle,
} from "src/lookups/fanavaran-last-car-policy";
export interface FanavaranAutoSubmitResult { export interface FanavaranAutoSubmitResult {
attempted: boolean; attempted: boolean;
@@ -3826,50 +3849,222 @@ export class ClaimRequestManagementService {
} }
} }
private parseJalaliBirthday( private async resolveAccidentVehicleUsedFromVehicleKind(
birthday: string | number | null | undefined, clientKey: FanavaranClientKey,
): { year: number; month: number; day: number } | null { vehicleKindId: number | null,
if (birthday == null) return null; preferredId: number | null,
const str = String(birthday).trim(); ): Promise<number | null> {
if (vehicleKindId == null) return preferredId;
try {
const [kinds, useTypes] = await Promise.all([
this.getFanavaranLookupRows(clientKey, "vehicle-kinds"),
this.getFanavaranLookupRows(clientKey, "vehicle-use-types"),
]);
const groupId = vehicleGroupIdForKind(kinds, vehicleKindId);
const usedId = selectAccidentVehicleUsedId(
useTypes,
groupId,
preferredId,
);
this.logger.log(
`Resolved AccidentVehicleUsedId=${usedId} from VehicleKindId=${vehicleKindId} VehicleGroupId=${groupId ?? "none"} (UsedId=${preferredId ?? "none"})`,
);
return usedId;
} catch (error) {
this.logger.warn(
`Failed to resolve AccidentVehicleUsedId from VehicleKindId=${vehicleKindId}: ${
error instanceof Error ? error.message : error
}`,
);
return preferredId;
}
}
// Compact format: "13640610" → year=1364, month=06, day=10 /**
if (/^\d{8}$/.test(str)) { * GEN.03 lookup-filter: AccidentVehicleUsedId comes from Fanavaran vehicle
const year = parseInt(str.slice(0, 4), 10); * UsedId (VIN inquiry or vehicle GET), not Mongo tenant defaults.
const month = parseInt(str.slice(4, 6), 10); */
const day = parseInt(str.slice(6, 8), 10); private async resolveAccidentVehicleUsedFromVehicleRecord(
if (isNaN(year) || isNaN(month) || isNaN(day)) { clientKey: FanavaranClientKey,
this.logger.warn( vehicle: Record<string, unknown> | null,
`parseJalaliBirthday: failed to parse compact format "${str}" - year=${year}, month=${month}, day=${day}`, ): Promise<{ usedId: number | null; vehicleKindId: number | null }> {
); const vehicleKindId = parseFanavaranId(vehicle?.VehicleKindId);
return null; const vehicleUsedId = parseFanavaranId(vehicle?.UsedId);
const usedId = await this.resolveAccidentVehicleUsedFromVehicleKind(
clientKey,
vehicleKindId,
vehicleUsedId,
);
return { usedId, vehicleKindId };
}
private async resolveAccidentVehicleUsedFromVin(
clientKey: FanavaranClientKey,
vin: string,
vehicleId?: number | null,
): Promise<{ usedId: number | null; vehicleKindId: number | null }> {
try {
const vehicle = await this.fetchFanavaranVehicleByVin(
clientKey,
vin,
vehicleId,
);
if (!vehicle) {
this.logger.warn(`VIN inquiry for ${vin} returned no vehicle`);
return { usedId: null, vehicleKindId: null };
}
this.logger.log(
`VIN inquiry ${vin} VehicleId=${pickVehicleId(vehicle) ?? "none"} VehicleKindId=${parseFanavaranId(vehicle.VehicleKindId) ?? "none"} UsedId=${parseFanavaranId(vehicle.UsedId) ?? "none"} PlaqueKindId=${parseFanavaranId(vehicle.PlaqueKindId) ?? "none"}`,
);
return this.resolveAccidentVehicleUsedFromVehicleRecord(clientKey, vehicle);
} catch (error) {
this.logger.warn(
`Failed to resolve AccidentVehicleUsedId from VIN=${vin}: ${
error instanceof Error ? error.message : error
}`,
);
return { usedId: null, vehicleKindId: null };
}
}
private async fetchFanavaranVehicleByVin(
clientKey: FanavaranClientKey,
vin: string,
vehicleId?: number | null,
): Promise<Record<string, unknown> | null> {
const inquired = await this.fanavaranLookupService.inquiryByVin(
clientKey,
vin,
);
return pickFanavaranVehicleFromInquiry(inquired, vehicleId);
}
/**
* Damaged cars often store chassis on vehicle.vin. Try every VIN-like id
* until Fanavaran returns PlaqueKindId (required on GEN.12).
*/
private async fetchFanavaranVehicleByVinCandidates(
clientKey: FanavaranClientKey,
vins: string[],
): Promise<Record<string, unknown> | null> {
let fallback: Record<string, unknown> | null = null;
for (const vin of vins) {
try {
const vehicle = await this.fetchFanavaranVehicleByVin(clientKey, vin);
if (!vehicle) {
this.logger.warn(
`[buildFanavaranDamageCasePayload] VIN inquiry for ${vin} returned no vehicle`,
);
continue;
}
const plaqueKindId = pickFanavaranPlaqueLookupFields(vehicle).kindId;
this.logger.log(
`[buildFanavaranDamageCasePayload] VIN inquiry ${vin} VehicleId=${pickVehicleId(vehicle) ?? "none"} VehicleKindId=${parseFanavaranId(vehicle.VehicleKindId) ?? "none"} UsedId=${parseFanavaranId(vehicle.UsedId) ?? "none"} PlaqueKindId=${plaqueKindId ?? "none"}`,
);
if (plaqueKindId != null) return vehicle;
if (!fallback) fallback = vehicle;
} catch (error) {
this.logger.warn(
`[buildFanavaranDamageCasePayload] VIN inquiry failed for ${vin}: ${
error instanceof Error ? error.message : error
}`,
);
}
}
return fallback;
}
/**
* Fallback when VIN is missing: policy GET → vehicle GET → UsedId/VehicleKindId.
*/
private async resolveAccidentVehicleUsedFromPolicy(
clientKey: FanavaranClientKey,
policyId: number,
): Promise<{ usedId: number | null; vehicleKindId: number | null }> {
try {
const policy = asObjectRecord(
await this.fanavaranLookupService.thirdPartyPolicyById(
clientKey,
policyId,
),
);
const vehicleId = pickVehicleId(policy?.VehicleId ?? policy?.vehicleId);
if (vehicleId === null) {
this.logger.warn(`Policy ${policyId} has no VehicleId`);
return { usedId: null, vehicleKindId: null };
}
const versionNo = parseFanavaranId(policy?.VehicleVersionNo);
const vehicle = asObjectRecord(
await this.fanavaranLookupService.vehicleById(
clientKey,
vehicleId,
versionNo ?? undefined,
),
);
this.logger.log(
`Policy ${policyId} VehicleId=${vehicleId} version=${versionNo ?? "latest"} VehicleKindId=${parseFanavaranId(vehicle?.VehicleKindId) ?? "none"} UsedId=${parseFanavaranId(vehicle?.UsedId) ?? "none"}`,
);
return this.resolveAccidentVehicleUsedFromVehicleRecord(clientKey, vehicle);
} catch (error) {
this.logger.warn(
`Failed to resolve AccidentVehicleUsedId from PolicyId=${policyId}: ${
error instanceof Error ? error.message : error
}`,
);
return { usedId: null, vehicleKindId: null };
}
}
private async resolveAccidentVehicleUsedFromApis(input: {
clientKey: FanavaranClientKey;
parties: unknown[];
guiltyPartyId?: unknown;
policyId?: number | null;
}): Promise<{
usedId: number | null;
vehicleKindId: number | null;
source: "vin-inquiry" | "policy-vehicle" | null;
}> {
const guiltyParty = (input.parties ?? []).find(
(party: any) =>
String(party?.person?.userId ?? "") === String(input.guiltyPartyId ?? ""),
);
const vin = pickVinFromPartyVehicle(guiltyParty);
if (vin) {
const fromVin = await this.resolveAccidentVehicleUsedFromVin(
input.clientKey,
vin,
);
if (fromVin.usedId != null) {
return { ...fromVin, source: "vin-inquiry" };
} }
return { year, month, day };
} }
// Delimited formats: "1364/06/10" or "1364-06-10" if (typeof input.policyId === "number") {
const parts = str.split(/[/\-]/); const fromPolicy = await this.resolveAccidentVehicleUsedFromPolicy(
if (parts.length < 3) { input.clientKey,
this.logger.warn( input.policyId,
`parseJalaliBirthday: insufficient parts in "${str}" - got ${parts.length} parts`,
); );
return null; if (fromPolicy.usedId != null) {
return { ...fromPolicy, source: "policy-vehicle" };
}
} }
const year = parseInt(parts[0], 10);
const month = parseInt(parts[1], 10); return { usedId: null, vehicleKindId: null, source: null };
const day = parseInt(parts[2], 10); }
if (isNaN(year) || isNaN(month) || isNaN(day)) {
this.logger.warn( private parseJalaliBirthday(
`parseJalaliBirthday: failed to parse delimited format "${str}" - year=${year}, month=${month}, day=${day}`, birthday: string | number | Record<string, unknown> | null | undefined,
); ): { year: number; month: number; day: number } | null {
return null; return parseJalaliDateParts(birthday);
}
return { year, month, day };
} }
private async resolveDriverFanavaranId( private async resolveDriverFanavaranId(
clientKey: FanavaranClientKey, clientKey: FanavaranClientKey,
nationalCode: string | null | undefined, nationalCode: string | null | undefined,
driverBirthday: string | null | undefined, driverBirthday: string | number | null | undefined,
driverIsInsurer: boolean | undefined, driverIsInsurer: boolean | undefined,
): Promise<number | null> { ): Promise<number | null> {
if (!nationalCode) { if (!nationalCode) {
@@ -3893,7 +4088,7 @@ export class ClaimRequestManagementService {
); );
try { try {
const response = (await this.fanavaranLookupService.inquiryByUniqueIdentifier( const response = await this.fanavaranLookupService.inquiryByUniqueIdentifier(
clientKey, clientKey,
{ {
nationalCode, nationalCode,
@@ -3901,36 +4096,27 @@ export class ClaimRequestManagementService {
birthMonth: parsed.month, birthMonth: parsed.month,
birthDay: parsed.day, birthDay: parsed.day,
}, },
)) as Array<{ );
Id: number;
RoleId: number;
NationalCode: string;
Name?: string;
LastName?: string;
}>;
this.logger.log( this.logger.log(
`[resolveDriverFanavaranId] RESPONSE: ${JSON.stringify(response)}`, `[resolveDriverFanavaranId] RESPONSE: ${JSON.stringify(response)}`,
); );
if (!Array.isArray(response) || response.length === 0) { const driverId = selectFanavaranDriverId(response, driverIsInsurer);
this.logger.warn( if (driverId != null) {
`[resolveDriverFanavaranId] EMPTY response for nationalCode=${nationalCode}`, const rows = asPartyInquiryRows(response);
); const match = rows.find((row) => Number(row.Id) === driverId);
return null;
}
const match = response.find((entry) => entry.RoleId === targetRoleId);
if (match) {
this.logger.log( this.logger.log(
`[resolveDriverFanavaranId] MATCH: Id=${match.Id} RoleId=${match.RoleId} name=${match.Name} ${match.LastName} for nationalCode=${nationalCode}`, `[resolveDriverFanavaranId] MATCH: Id=${driverId} RoleId=${match?.RoleId ?? "none"} name=${match?.Name ?? ""} ${match?.LastName ?? ""} for nationalCode=${nationalCode}`,
); );
return match.Id; return driverId;
} }
this.logger.warn( this.logger.warn(
`[resolveDriverFanavaranId] NO MATCH for targetRoleId=${targetRoleId} (driverIsInsurer=${driverIsInsurer}) nationalCode=${nationalCode}. ` + `[resolveDriverFanavaranId] NO MATCH for nationalCode=${nationalCode} driverIsInsurer=${driverIsInsurer}. ` +
`Available entries: ${response.map((e) => `{Id=${e.Id}, RoleId=${e.RoleId}, Name=${e.Name} ${e.LastName}}`).join("; ")}`, `Available entries: ${asPartyInquiryRows(response)
.map((e) => `{Id=${e.Id}, RoleId=${e.RoleId}, Name=${e.Name} ${e.LastName}}`)
.join("; ")}`,
); );
return null; return null;
} catch (error) { } catch (error) {
@@ -3957,13 +4143,10 @@ export class ClaimRequestManagementService {
FaultPercent: number; FaultPercent: number;
}; };
}): Promise<Record<string, unknown>> { }): Promise<Record<string, unknown>> {
const damagedParty = Array.isArray(input.blameCase?.parties) const damagedParty = this.pickDamagedPartyForFanavaran(
? input.blameCase.parties.find( input.blameCase,
(party: any) => input.claimCase,
String(party?.person?.userId ?? "") === );
String(input.claimCase?.owner?.userId ?? ""),
)
: null;
const person = damagedParty?.person ?? {}; const person = damagedParty?.person ?? {};
const vehicle = damagedParty?.vehicle ?? {}; const vehicle = damagedParty?.vehicle ?? {};
const insurance = damagedParty?.insurance ?? {}; const insurance = damagedParty?.insurance ?? {};
@@ -3983,8 +4166,10 @@ export class ClaimRequestManagementService {
this.logger.log( this.logger.log(
`[buildFanavaranDamageCasePayload] damagedParty resolved: userId=${damagedParty?.person?.userId ?? "NONE"}, ` + `[buildFanavaranDamageCasePayload] damagedParty resolved: userId=${damagedParty?.person?.userId ?? "NONE"}, ` +
`nationalCode=${pickPersonNationalCode(person) ?? "MISSING"}, ` +
`nationalCodeOfDriver=${person.nationalCodeOfDriver ?? "MISSING"}, ` + `nationalCodeOfDriver=${person.nationalCodeOfDriver ?? "MISSING"}, ` +
`driverBirthday=${person.driverBirthday ?? "MISSING"}, ` + `nationalCodeOfInsurer=${person.nationalCodeOfInsurer ?? "MISSING"}, ` +
`driverBirthday=${pickPersonBirthday(person) ?? "MISSING"}, ` +
`driverIsInsurer=${person.driverIsInsurer ?? "MISSING"}, ` + `driverIsInsurer=${person.driverIsInsurer ?? "MISSING"}, ` +
`driverLicense=${person.driverLicense ?? "MISSING"}`, `driverLicense=${person.driverLicense ?? "MISSING"}`,
); );
@@ -4032,14 +4217,74 @@ export class ClaimRequestManagementService {
"ModelField", "ModelField",
"ModelCii", "ModelCii",
]); ]);
const builtYear = builtYearRaw ? Number(builtYearRaw) || null : null; const builtYearFromInquiry = builtYearRaw ? Number(builtYearRaw) || null : null;
const damagedVinCandidates: string[] = [];
const seenVins = new Set<string>();
for (const value of [
...collectPartyVinCandidates(damagedParty),
this.pickPartyInquiryField(inquiryMapped, inquiryRaw, [
"VIN",
"vin",
"VinNumberField",
]),
this.pickPartyInquiryField(inquiryMapped, inquiryRaw, [
"ShsNum",
"ChassisNumberField",
"shsNam",
]),
]) {
const vin = normalizeVin(value);
if (!vin || seenVins.has(vin)) continue;
seenVins.add(vin);
damagedVinCandidates.push(vin);
}
const cachedDamage =
(input.claimCase as any)?.fanavaranSync?.damageCase ?? {};
const lastDamagePayload = asObjectRecord(cachedDamage.lastPayload);
this.logger.log(
`[buildFanavaranDamageCasePayload] VIN candidates=${damagedVinCandidates.join(",") || "none"}`,
);
const apiVehicle = await this.fetchFanavaranVehicleByVinCandidates(
input.clientKey,
damagedVinCandidates,
);
const apiPlaque = pickFanavaranPlaqueLookupFields(apiVehicle);
const inquiryPlaque = pickFanavaranPlaqueLookupFields(
asObjectRecord(inquiryMapped) ?? asObjectRecord(inquiryRaw),
);
const plaqueKindId = this.firstPositiveFanavaranId(
apiPlaque.kindId,
inquiryPlaque.kindId,
cachedDamage.plaqueKindId,
lastDamagePayload?.PlaqueKindId,
);
const plaqueSampleId = this.firstPositiveFanavaranId(
apiPlaque.sampleId,
inquiryPlaque.sampleId,
cachedDamage.plaqueSampleId,
lastDamagePayload?.PlaqueSampleId,
);
if (apiPlaque.kindId == null && plaqueKindId != null) {
this.logger.log(
`[buildFanavaranDamageCasePayload] PlaqueKindId API miss; using DB value ${plaqueKindId}`,
);
}
if (apiPlaque.sampleId == null && plaqueSampleId != null) {
this.logger.log(
`[buildFanavaranDamageCasePayload] PlaqueSampleId API miss; using DB value ${plaqueSampleId}`,
);
}
const builtYear =
parseFanavaranId(apiVehicle?.BuiltYear) ?? builtYearFromInquiry;
const carType = input.claimCase?.vehicle?.carType as string | undefined; const carType = input.claimCase?.vehicle?.carType as string | undefined;
const cachedDamage = (input.claimCase as any)?.fanavaranSync?.damageCase ?? {}; let vehicleKindId = this.firstPositiveFanavaranId(
let vehicleKindId = apiVehicle?.VehicleKindId,
cachedDamage.vehicleKindId != null cachedDamage.vehicleKindId,
? Number(cachedDamage.vehicleKindId) lastDamagePayload?.VehicleKindId,
: null; );
if (vehicleKindId == null) { if (vehicleKindId == null) {
vehicleKindId = await this.resolveVehicleKindId( vehicleKindId = await this.resolveVehicleKindId(
input.clientKey, input.clientKey,
@@ -4047,28 +4292,31 @@ export class ClaimRequestManagementService {
); );
} }
// Prefer claim-level cache, then party.person.fanavaranDriverId, then live inquiry // Prefer claim-level cache, then party.person.fanavaranDriverId, then live inquiry, then last payload
let driverFanavaranId = let driverFanavaranId = this.firstPositiveFanavaranId(
cachedDamage.driverId ?? person.fanavaranDriverId ?? null; cachedDamage.driverId,
person.fanavaranDriverId,
lastDamagePayload?.DriverId,
);
if (driverFanavaranId) { if (driverFanavaranId) {
this.logger.log( this.logger.log(
`[buildFanavaranDamageCasePayload] Using CACHED DriverId=${driverFanavaranId}`, `[buildFanavaranDamageCasePayload] Using CACHED DriverId=${driverFanavaranId}`,
); );
} else { } else {
driverFanavaranId = await this.resolveDriverFanavaranId( driverFanavaranId = await this.resolveDriverFanavaranIdFromBlameCase(
input.clientKey, input.clientKey,
person.nationalCodeOfDriver, input.blameCase?.parties ?? [],
person.driverBirthday, person,
person.driverIsInsurer, input.claimCase,
); );
// Persist the resolved ID back to the blame case party for future use // Persist the resolved ID back to the blame case party for future use
if (driverFanavaranId && input.blameCase?._id && damagedParty) { if (driverFanavaranId && input.blameCase?._id && damagedParty) {
const partyIndex = input.blameCase.parties.findIndex( const partyIndex = (input.blameCase.parties ?? []).findIndex(
(p: any) => (p: any) => p === damagedParty ||
String(p?.person?.userId ?? "") === String(p?.person?.userId ?? "") ===
String(input.claimCase?.owner?.userId ?? ""), String(damagedParty?.person?.userId ?? ""),
); );
if (partyIndex >= 0) { if (partyIndex >= 0) {
await this.blameRequestDbService.findByIdAndUpdate( await this.blameRequestDbService.findByIdAndUpdate(
@@ -4092,19 +4340,67 @@ export class ClaimRequestManagementService {
); );
} }
const cachedUsedId =
cachedDamage.accidentVehicleUsedId != null
? Number(cachedDamage.accidentVehicleUsedId)
: (input.claimCase as any)?.fanavaranSync?.baseClaim
?.accidentVehicleUsedId != null
? Number(
(input.claimCase as any).fanavaranSync.baseClaim
.accidentVehicleUsedId,
)
: null;
const cachedUsedSource =
cachedDamage.accidentVehicleUsedSource ??
(input.claimCase as any)?.fanavaranSync?.baseClaim
?.accidentVehicleUsedSource;
const canReuseApiUsedId =
cachedUsedId != null &&
cachedUsedId > 0 &&
(cachedUsedSource === "vin-inquiry" ||
cachedUsedSource === "policy-vehicle");
let accidentVehicleUsedId: number | null = canReuseApiUsedId
? cachedUsedId
: null;
let usedSource: "vin-inquiry" | "policy-vehicle" | null = canReuseApiUsedId
? cachedUsedSource
: null;
if (accidentVehicleUsedId == null) {
const resolved = await this.resolveAccidentVehicleUsedFromApis({
clientKey: input.clientKey,
parties: input.blameCase?.parties ?? [],
guiltyPartyId: this.resolveGuiltyPartyIdV2(
input.blameCase?.parties ?? [],
input.blameCase?.expert?.decision?.guiltyPartyId,
),
policyId: (input.claimCase as any)?.fanavaranSync?.baseClaim?.policyId,
});
accidentVehicleUsedId = resolved.usedId;
usedSource = resolved.source;
}
if (accidentVehicleUsedId == null) {
accidentVehicleUsedId = await this.resolveAccidentVehicleUsedFromVehicleKind(
input.clientKey,
vehicleKindId,
null,
);
}
this.logger.log( this.logger.log(
`[buildFanavaranDamageCasePayload] Final DriverId=${driverFanavaranId ?? "NULL"} for nationalCodeOfDriver=${person.nationalCodeOfDriver ?? "MISSING"}, ` + `[buildFanavaranDamageCasePayload] Final DriverId=${driverFanavaranId ?? "NULL"} for nationalCode=${pickPersonNationalCode(person) ?? "MISSING"}, ` +
`PolicyNo=${policyNo ?? "NULL"}, PolicyCINumber=${policyCINumber ?? "NULL"}`, `PolicyNo=${policyNo ?? "NULL"}, PolicyCINumber=${policyCINumber ?? "NULL"}`,
); );
const payload = { const payload = {
BeginDate: beginDate, BeginDate: beginDate,
BuiltYear: builtYear, BuiltYear: builtYear,
ChassisNo: this.pickPartyInquiryField(inquiryMapped, inquiryRaw, [ ChassisNo:
"ShsNum", this.pickPartyInquiryField(inquiryMapped, inquiryRaw, [
"ChassisNumberField", "ShsNum",
"shsNam", "ChassisNumberField",
]), "shsNam",
]) ??
(typeof apiVehicle?.ChassisNo === "string" ? apiVehicle.ChassisNo : null),
DmgHistoryStatus: input.defaults.DmgHistoryStatus, DmgHistoryStatus: input.defaults.DmgHistoryStatus,
Desc: this.formatFanavaranSelectedPartsDesc(input.selectedParts), Desc: this.formatFanavaranSelectedPartsDesc(input.selectedParts),
DmgCaseTypeId: input.defaults.DmgCaseTypeId, DmgCaseTypeId: input.defaults.DmgCaseTypeId,
@@ -4125,41 +4421,55 @@ export class ClaimRequestManagementService {
insurerLicense: person.insurerLicense, insurerLicense: person.insurerLicense,
}), }),
LicenceTypeId: input.defaults.CulpritLicenceTypeId, LicenceTypeId: input.defaults.CulpritLicenceTypeId,
MotorNo: this.pickPartyInquiryField(inquiryMapped, inquiryRaw, [ MotorNo:
"MtrNum", this.pickPartyInquiryField(inquiryMapped, inquiryRaw, [
"EngineNumberField", "MtrNum",
"mtrnum", "EngineNumberField",
]), "mtrnum",
]) ?? (typeof apiVehicle?.MotorNo === "string" ? apiVehicle.MotorNo : null),
OwnerId: null, OwnerId: null,
PlaqueCityId: null, PlaqueCityId: apiPlaque.cityId,
PlaqueKindId: plate ? input.defaults.PlaqueKindId : null, PlaqueKindId: plaqueKindId,
PlaqueLeftNo: plate ? String(plate.leftDigits) : null, PlaqueLeftNo:
PlaqueMiddleCodeId: this.getFanavaranPlateMiddleCode( apiPlaque.leftNo ?? (plate ? String(plate.leftDigits) : null),
plate?.centerAlphabet, PlaqueMiddleCodeId:
), apiPlaque.middleCodeId ??
this.getFanavaranPlateMiddleCode(plate?.centerAlphabet),
PlaqueNo: PlaqueNo:
apiPlaque.plaqueNo ??
this.pickPartyInquiryField(inquiryMapped, inquiryRaw, ["Plk"]) ?? this.pickPartyInquiryField(inquiryMapped, inquiryRaw, ["Plk"]) ??
this.formatFanavaranPlateNo(plate), this.formatFanavaranPlateNo(plate),
PlaqueRightNo: plate ? String(plate.centerDigits) : null, PlaqueRightNo:
PlaqueSampleId: plate ? input.defaults.PlaqueSampleId : null, apiPlaque.rightNo ?? (plate ? String(plate.centerDigits) : null),
PlaqueSerial: plate ? String(plate.ir) : null, PlaqueSampleId: plaqueSampleId,
PlaqueSerial: apiPlaque.serial ?? (plate ? String(plate.ir) : null),
PolicyNo: policyNo, PolicyNo: policyNo,
PreviousPolicyEndDate: endDate ?? "", PreviousPolicyEndDate: endDate ?? "",
VehicleKindId: vehicleKindId, VehicleKindId: vehicleKindId,
VIN: this.pickPartyInquiryField(inquiryMapped, inquiryRaw, [ VIN:
"VIN", this.pickPartyInquiryField(inquiryMapped, inquiryRaw, [
"vin", "VIN",
"VinNumberField", "vin",
]), "VinNumberField",
AccidentVehicleUsedId: input.defaults.AccidentVehicleUsedId, ]) ?? (typeof apiVehicle?.VIN === "string" ? apiVehicle.VIN : null),
AccidentVehicleUsedId: accidentVehicleUsedId,
PolicyCINumber: policyCINumber, PolicyCINumber: policyCINumber,
}; };
const persistedPayload = this.keepPreviousPositiveIds(lastDamagePayload, payload, [
"DriverId",
"PlaqueKindId",
"PlaqueSampleId",
"VehicleKindId",
"AccidentVehicleUsedId",
"InsuranceCorpId",
]);
// Persist Fanavaran-sourced IDs + last payload for reuse on later preview/submit // Persist Fanavaran-sourced IDs + last payload for reuse on later preview/submit
if (input.claimCase?._id) { if (input.claimCase?._id) {
await this.claimCaseDbService.findByIdAndUpdate(String(input.claimCase._id), { await this.claimCaseDbService.findByIdAndUpdate(String(input.claimCase._id), {
$set: { $set: {
"fanavaranSync.damageCase.lastPayload": payload, "fanavaranSync.damageCase.lastPayload": persistedPayload,
"fanavaranSync.damageCase.lastPayloadBuiltAt": new Date(), "fanavaranSync.damageCase.lastPayloadBuiltAt": new Date(),
...(driverFanavaranId != null ...(driverFanavaranId != null
? { "fanavaranSync.damageCase.driverId": driverFanavaranId } ? { "fanavaranSync.damageCase.driverId": driverFanavaranId }
@@ -4167,6 +4477,24 @@ export class ClaimRequestManagementService {
...(vehicleKindId != null ...(vehicleKindId != null
? { "fanavaranSync.damageCase.vehicleKindId": vehicleKindId } ? { "fanavaranSync.damageCase.vehicleKindId": vehicleKindId }
: {}), : {}),
...(plaqueKindId != null
? { "fanavaranSync.damageCase.plaqueKindId": plaqueKindId }
: {}),
...(plaqueSampleId != null
? { "fanavaranSync.damageCase.plaqueSampleId": plaqueSampleId }
: {}),
...(accidentVehicleUsedId != null
? {
"fanavaranSync.damageCase.accidentVehicleUsedId":
accidentVehicleUsedId,
...(usedSource
? {
"fanavaranSync.damageCase.accidentVehicleUsedSource":
usedSource,
}
: {}),
}
: {}),
...(insuranceCorpId != null ...(insuranceCorpId != null
? { "fanavaranSync.damageCase.insuranceCorpId": insuranceCorpId } ? { "fanavaranSync.damageCase.insuranceCorpId": insuranceCorpId }
: {}), : {}),
@@ -4174,7 +4502,7 @@ export class ClaimRequestManagementService {
}); });
} }
return payload; return persistedPayload;
} }
/** /**
@@ -4633,6 +4961,160 @@ export class ClaimRequestManagementService {
} }
} }
private firstPositiveFanavaranId(...values: unknown[]): number | null {
for (const value of values) {
const id = parseFanavaranId(value);
if (id != null) return id;
}
return null;
}
private keepPreviousPositiveIds(
previous: Record<string, unknown> | null,
next: Record<string, unknown>,
keys: string[],
): Record<string, unknown> {
const merged = { ...previous, ...next };
for (const key of keys) {
if (this.firstPositiveFanavaranId(next[key]) != null) continue;
const previousId = this.firstPositiveFanavaranId(previous?.[key]);
if (previousId != null) {
merged[key] = previousId;
}
}
return merged;
}
private async resolveDriverFanavaranIdFromPerson(
clientKey: FanavaranClientKey,
person: {
driverIsInsurer?: boolean;
nationalCodeOfDriver?: unknown;
nationalCodeOfInsurer?: unknown;
nationalCode?: unknown;
driverBirthday?: unknown;
birthday?: unknown;
insurerBirthday?: unknown;
} | null | undefined,
): Promise<number | null> {
const codes = collectPersonNationalCodes(person);
const birthdays = collectPersonBirthdays(person);
for (const nationalCode of codes) {
for (const birthday of birthdays) {
const driverId = await this.resolveDriverFanavaranId(
clientKey,
nationalCode,
birthday,
person?.driverIsInsurer,
);
if (driverId != null) return driverId;
}
}
return null;
}
private async resolveDriverFanavaranIdFromBlameCase(
clientKey: FanavaranClientKey,
parties: any[],
preferredPerson: any,
claimCase?: any,
): Promise<number | null> {
const people = [
preferredPerson,
...parties.map((party) => party?.person),
].filter(Boolean);
this.logger.log(
`[resolveDriverFanavaranIdFromBlameCase] trying ${people.length} people then leftover codes`,
);
for (const person of people) {
const driverId = await this.resolveDriverFanavaranIdFromPerson(
clientKey,
person,
);
if (driverId != null) return driverId;
}
const leftoverCodes = [
...(claimCase?.money?.nationalCodeOfInsurer
? collectPersonNationalCodes({
nationalCodeOfInsurer: claimCase.money.nationalCodeOfInsurer,
})
: []),
];
const leftoverBirthdays: Array<string | number> = [];
const seenBirthdays = new Set<string>();
for (const person of people) {
for (const birthday of collectPersonBirthdays(person)) {
const parsed = parseJalaliDateParts(birthday);
const key = parsed
? `${parsed.year}-${parsed.month}-${parsed.day}`
: String(birthday);
if (seenBirthdays.has(key)) continue;
seenBirthdays.add(key);
leftoverBirthdays.push(birthday);
}
}
this.logger.log(
`[resolveDriverFanavaranIdFromBlameCase] leftover codes=${leftoverCodes.join(",") || "none"} birthdays=${leftoverBirthdays.join(",") || "none"}`,
);
for (const nationalCode of leftoverCodes) {
for (const birthday of leftoverBirthdays) {
const driverId = await this.resolveDriverFanavaranId(
clientKey,
nationalCode,
birthday,
preferredPerson?.driverIsInsurer,
);
if (driverId != null) return driverId;
}
}
return null;
}
/**
* زیان‌دیده for GEN.12: claim owner when linked to a blame party,
* else the party that is not the guilty one.
*/
private pickDamagedPartyForFanavaran(
blameCase: any,
claimCase: any,
): any | null {
const parties: any[] = Array.isArray(blameCase?.parties)
? blameCase.parties
: [];
if (parties.length === 0) return null;
const ownerId = claimCase?.owner?.userId;
if (ownerId) {
const byOwner = parties.find(
(party) =>
String(party?.person?.userId ?? "") === String(ownerId),
);
if (byOwner) return byOwner;
}
const guiltyPartyId = this.resolveGuiltyPartyIdV2(
parties,
blameCase?.expert?.decision?.guiltyPartyId,
);
if (guiltyPartyId) {
const injured = parties.find(
(party) =>
String(party?.person?.userId ?? "") !== String(guiltyPartyId),
);
if (injured) return injured;
}
return (
parties.find((party) => party?.role === PartyRole.FIRST) ?? parties[0]
);
}
private getNationalCodeOfInsurerForGuiltyPartyV2( private getNationalCodeOfInsurerForGuiltyPartyV2(
parties: Array<{ parties: Array<{
role?: PartyRole; role?: PartyRole;
@@ -4703,11 +5185,17 @@ export class ClaimRequestManagementService {
auditSession?: FanavaranAuditSession; auditSession?: FanavaranAuditSession;
/** Persist built payload onto fanavaranSync.baseClaim.lastPayload (default true). */ /** Persist built payload onto fanavaranSync.baseClaim.lastPayload (default true). */
persistPayload?: boolean; persistPayload?: boolean;
/**
* Re-run VIN inquiry / vehicle GET for AccidentVehicleUsedId.
* Submit always sets this so Mongo tenant default 1 cannot be reused.
*/
forceRefreshUsedId?: boolean;
}, },
): Promise<any> { ): Promise<any> {
const profile = getFanavaranClientProfile(clientKey); const profile = getFanavaranClientProfile(clientKey);
const logPrefix = `[Fanavaran ${clientKey} V2] claimCaseId=${claimCaseId}`; const logPrefix = `[Fanavaran ${clientKey} V2] claimCaseId=${claimCaseId}`;
const forceRefreshPolicy = options?.forceRefreshPolicy === true; const forceRefreshPolicy = options?.forceRefreshPolicy === true;
const forceRefreshUsedId = options?.forceRefreshUsedId === true;
const requirePolicyId = options?.requirePolicyId === true; const requirePolicyId = options?.requirePolicyId === true;
const persistPayload = options?.persistPayload !== false; const persistPayload = options?.persistPayload !== false;
const auditSession = const auditSession =
@@ -4877,6 +5365,43 @@ export class ClaimRequestManagementService {
}, },
}); });
const cachedUsedId =
forceRefreshUsedId || forceRefreshPolicy
? null
: (claimCase as any)?.fanavaranSync?.baseClaim?.accidentVehicleUsedId;
const cachedUsedSource = (claimCase as any)?.fanavaranSync?.baseClaim
?.accidentVehicleUsedSource;
const canReuseApiUsedId =
!forceRefreshUsedId &&
!forceRefreshPolicy &&
cachedUsedId != null &&
Number(cachedUsedId) > 0 &&
(cachedUsedSource === "vin-inquiry" ||
cachedUsedSource === "policy-vehicle");
delete payload.AccidentVehicleUsedId;
let usedSource: "vin-inquiry" | "policy-vehicle" | null = null;
if (canReuseApiUsedId) {
payload.AccidentVehicleUsedId = Number(cachedUsedId);
usedSource = cachedUsedSource;
} else {
const guiltyPartyIdForVin = this.resolveGuiltyPartyIdV2(
blameCase.parties ?? [],
expertDecision?.guiltyPartyId,
);
const resolved = await this.resolveAccidentVehicleUsedFromApis({
clientKey,
parties: blameCase.parties ?? [],
guiltyPartyId: guiltyPartyIdForVin,
policyId:
typeof payload.PolicyId === "number" ? payload.PolicyId : null,
});
if (resolved.usedId != null) {
payload.AccidentVehicleUsedId = resolved.usedId;
usedSource = resolved.source;
}
}
if (persistPayload) { if (persistPayload) {
await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, {
$set: { $set: {
@@ -4885,6 +5410,18 @@ export class ClaimRequestManagementService {
...(payload.PolicyId != null ...(payload.PolicyId != null
? { "fanavaranSync.baseClaim.policyId": payload.PolicyId } ? { "fanavaranSync.baseClaim.policyId": payload.PolicyId }
: {}), : {}),
...(typeof payload.AccidentVehicleUsedId === "number"
? {
"fanavaranSync.baseClaim.accidentVehicleUsedId":
payload.AccidentVehicleUsedId,
...(usedSource
? {
"fanavaranSync.baseClaim.accidentVehicleUsedSource":
usedSource,
}
: {}),
}
: {}),
}, },
}); });
} }
@@ -7280,6 +7817,7 @@ export class ClaimRequestManagementService {
requirePolicyId: true, requirePolicyId: true,
auditSession, auditSession,
persistPayload: true, persistPayload: true,
forceRefreshUsedId: true,
})); }));
// Guardrail: never send empty/null licence fields even if manual overrides arrive. // Guardrail: never send empty/null licence fields even if manual overrides arrive.

View File

@@ -93,6 +93,25 @@ export class FanavaranSyncStage {
@Prop({ type: Number }) @Prop({ type: Number })
vehicleKindId?: number; vehicleKindId?: number;
/** Cached Fanavaran PlaqueKindId from VIN inquiry / vehicle GET. */
@Prop({ type: Number })
plaqueKindId?: number;
/** Cached Fanavaran PlaqueSampleId from VIN inquiry / vehicle GET. */
@Prop({ type: Number })
plaqueSampleId?: number;
/**
* Cached Fanavaran AccidentVehicleUsedId, resolved from VIN inquiry / vehicle GET
* UsedId (lookup-filter safe). Not the Mongo tenant default.
*/
@Prop({ type: Number })
accidentVehicleUsedId?: number;
/** How AccidentVehicleUsedId was resolved: vin-inquiry | policy-vehicle */
@Prop({ type: String })
accidentVehicleUsedSource?: string;
/** Cached Fanavaran InsuranceCorpId. */ /** Cached Fanavaran InsuranceCorpId. */
@Prop({ type: Number }) @Prop({ type: Number })
insuranceCorpId?: number; insuranceCorpId?: number;

View File

@@ -0,0 +1,36 @@
import {
selectAccidentVehicleUsedId,
vehicleGroupIdForKind,
} from "./fanavaran-accident-vehicle-used";
describe("selectAccidentVehicleUsedId", () => {
const useTypes = [
{ Id: 1, Caption: "شخصي", IsActive: 1, VehicleGroupId: 1 },
{ Id: 3, Caption: "تاکسي درون شهري", IsActive: 1, VehicleGroupId: 1 },
{ Id: 35, Caption: "آمبولانس", IsActive: 1, VehicleGroupId: 2 },
{ Id: 36, Caption: "حمل مواد سريع الاشتعال", IsActive: 1, VehicleGroupId: 3 },
];
const kinds = [
{ Id: 8671, VehicleGroupId: 1, IsActive: 1 },
{ Id: 5904, VehicleGroupId: 3, IsActive: 1 },
];
it("keeps preferred id 1 when the car is passenger group 1", () => {
expect(vehicleGroupIdForKind(kinds, 8671)).toBe(1);
expect(selectAccidentVehicleUsedId(useTypes, 1, 1)).toBe(1);
});
it("keeps the vehicle UsedId when it is valid for that group", () => {
expect(selectAccidentVehicleUsedId(useTypes, 3, 36)).toBe(36);
});
it("picks an active use type for a non-passenger group instead of 1", () => {
expect(vehicleGroupIdForKind(kinds, 5904)).toBe(3);
expect(selectAccidentVehicleUsedId(useTypes, 3, 1)).toBe(36);
});
it("falls back to the vehicle UsedId when group is unknown", () => {
expect(selectAccidentVehicleUsedId(useTypes, null, 84)).toBe(84);
});
});

View File

@@ -0,0 +1,71 @@
export type FanavaranVehicleKindRow = {
Id?: unknown;
VehicleGroupId?: unknown;
IsActive?: unknown;
Caption?: unknown;
};
export type FanavaranVehicleUseTypeRow = {
Id?: unknown;
VehicleGroupId?: unknown;
IsActive?: unknown;
Caption?: unknown;
};
function asPositiveId(value: unknown): number | null {
if (value === null || value === undefined) return null;
const id = Number(value);
return Number.isFinite(id) && id > 0 ? id : null;
}
export function vehicleGroupIdForKind(
kinds: unknown,
vehicleKindId: number,
): number | null {
if (!Array.isArray(kinds)) return null;
const match = kinds.find(
(row) => asPositiveId((row as FanavaranVehicleKindRow)?.Id) === vehicleKindId,
) as FanavaranVehicleKindRow | undefined;
return asPositiveId(match?.VehicleGroupId);
}
/**
* Fanavaran filters AccidentVehicleUsedId by the policy car's VehicleGroupId.
* `preferredId` is the vehicle UsedId from VIN inquiry / vehicle GET — never a Mongo tenant default.
*/
export function selectAccidentVehicleUsedId(
useTypes: unknown,
vehicleGroupId: number | null,
preferredId: number | null,
): number | null {
if (!Array.isArray(useTypes) || vehicleGroupId == null) {
return preferredId;
}
const active = useTypes.filter((row) => {
const item = row as FanavaranVehicleUseTypeRow;
return (
item?.IsActive === 1 &&
asPositiveId(item.VehicleGroupId) === vehicleGroupId &&
asPositiveId(item.Id) != null
);
}) as FanavaranVehicleUseTypeRow[];
if (active.length === 0) {
return preferredId;
}
const preferred = active.find((row) => asPositiveId(row.Id) === preferredId);
if (preferred && preferredId != null) {
return preferredId;
}
const personal = active.find((row) =>
String(row.Caption ?? "").includes("شخص"),
);
if (personal) {
return asPositiveId(personal.Id) ?? preferredId;
}
return asPositiveId(active[0].Id) ?? preferredId;
}

View File

@@ -0,0 +1,76 @@
import {
asPartyInquiryRows,
collectPersonNationalCodes,
parseJalaliDateParts,
pickPersonBirthday,
pickPersonNationalCode,
selectFanavaranDriverId,
} from "./fanavaran-driver-inquiry";
describe("selectFanavaranDriverId", () => {
const rows = [
{ Id: 10, RoleId: 161, Name: "insurer" },
{ Id: 20, RoleId: 166, Name: "driver" },
];
it("prefers insurer role 161 when the driver is the owner", () => {
expect(selectFanavaranDriverId(rows, true)).toBe(10);
});
it("prefers driver role 166 when the driver is not the owner", () => {
expect(selectFanavaranDriverId(rows, false)).toBe(20);
});
it("uses the other role when the preferred role is missing", () => {
expect(selectFanavaranDriverId([{ Id: 20, RoleId: 166 }], true)).toBe(20);
});
it("picks 4773521 from a real Fanavaran unique-identifier response", () => {
expect(
selectFanavaranDriverId(
[
{ Id: 4773521, RoleId: 161 },
{ Id: 4773521, RoleId: 163 },
{ Id: 4773521, RoleId: 166 },
],
true,
),
).toBe(4773521);
});
});
describe("pickPersonNationalCode / birthday", () => {
it("uses insurer national code when driverIsInsurer", () => {
expect(
pickPersonNationalCode({
driverIsInsurer: true,
nationalCodeOfInsurer: "0012345678",
nationalCodeOfDriver: "",
}),
).toBe("0012345678");
});
it("falls back across birthday fields", () => {
expect(
pickPersonBirthday({
driverBirthday: null,
birthday: "13480313",
}),
).toBe("13480313");
});
});
describe("asPartyInquiryRows", () => {
it("normalizes Persian digits and compact insurer birthday 13640628", () => {
expect(
collectPersonNationalCodes({
nationalCodeOfInsurer: "۰۰۸۰۰۸۶۵۱۹",
}),
).toEqual(["0080086519"]);
expect(parseJalaliDateParts(13640628)).toEqual({
year: 1364,
month: 6,
day: 28,
});
});
});

View File

@@ -0,0 +1,165 @@
import { toEnglishDigits } from "src/lookups/fanavaran-last-car-policy";
export const FANAVARAN_INSURER_ROLE_ID = 161;
export const FANAVARAN_PLAQUE_OWNER_ROLE_ID = 163;
export const FANAVARAN_DRIVER_ROLE_ID = 166;
export type FanavaranPartyInquiryRow = {
Id?: unknown;
RoleId?: unknown;
NationalCode?: unknown;
Name?: unknown;
LastName?: unknown;
};
function asPositiveId(value: unknown): number | null {
if (value === null || value === undefined) return null;
const id = Number(value);
return Number.isFinite(id) && id > 0 ? id : null;
}
export function asPartyInquiryRows(value: unknown): FanavaranPartyInquiryRow[] {
if (Array.isArray(value)) {
return value.filter(
(row): row is FanavaranPartyInquiryRow =>
!!row && typeof row === "object" && !Array.isArray(row),
);
}
if (!value || typeof value !== "object") return [];
const record = value as Record<string, unknown>;
for (const key of ["value", "Value", "items", "Items", "data", "Data"]) {
const nested = record[key];
if (Array.isArray(nested)) return asPartyInquiryRows(nested);
}
return [];
}
/**
* DriverId for GEN.12 زیان‌دیده. Prefer insurer (161) or driver (166)
* based on driverIsInsurer, then any returned party id.
*/
export function selectFanavaranDriverId(
rows: unknown,
driverIsInsurer: boolean | undefined,
): number | null {
const parties = asPartyInquiryRows(rows);
if (parties.length === 0) return null;
const preferredRoles = driverIsInsurer
? [FANAVARAN_INSURER_ROLE_ID, FANAVARAN_DRIVER_ROLE_ID, FANAVARAN_PLAQUE_OWNER_ROLE_ID]
: [FANAVARAN_DRIVER_ROLE_ID, FANAVARAN_INSURER_ROLE_ID, FANAVARAN_PLAQUE_OWNER_ROLE_ID];
for (const roleId of preferredRoles) {
const match = parties.find((row) => asPositiveId(row.RoleId) === roleId);
if (match) return asPositiveId(match.Id);
}
return asPositiveId(parties[0].Id);
}
export function normalizeNationalCode(value: unknown): string | null {
const digits = toEnglishDigits(value).replace(/\D/g, "");
if (digits.length === 10) return digits;
if (digits.length === 8 || digits.length === 9) return digits.padStart(10, "0");
return digits.length > 0 ? digits : null;
}
export function pickPersonNationalCode(person: {
driverIsInsurer?: boolean;
nationalCodeOfDriver?: unknown;
nationalCodeOfInsurer?: unknown;
nationalCode?: unknown;
} | null | undefined): string | null {
if (!person) return null;
const driver = normalizeNationalCode(person.nationalCodeOfDriver);
const insurer = normalizeNationalCode(person.nationalCodeOfInsurer);
const generic = normalizeNationalCode(person.nationalCode);
if (person.driverIsInsurer) {
return insurer || driver || generic;
}
return driver || insurer || generic;
}
export function pickPersonBirthday(person: {
driverIsInsurer?: boolean;
driverBirthday?: unknown;
birthday?: unknown;
insurerBirthday?: unknown;
} | null | undefined): string | number | null {
if (!person) return null;
const ordered = person.driverIsInsurer
? [person.insurerBirthday, person.birthday, person.driverBirthday]
: [person.driverBirthday, person.birthday, person.insurerBirthday];
for (const value of ordered) {
if (value == null || String(value).trim() === "") continue;
if (parseJalaliDateParts(value)) return value as string | number;
}
return null;
}
export function collectPersonNationalCodes(
person: {
nationalCodeOfDriver?: unknown;
nationalCodeOfInsurer?: unknown;
nationalCode?: unknown;
} | null | undefined,
): string[] {
const codes = [
normalizeNationalCode(person?.nationalCodeOfDriver),
normalizeNationalCode(person?.nationalCodeOfInsurer),
normalizeNationalCode(person?.nationalCode),
].filter((value): value is string => !!value);
return [...new Set(codes)];
}
export function collectPersonBirthdays(person: {
driverBirthday?: unknown;
birthday?: unknown;
insurerBirthday?: unknown;
} | null | undefined): Array<string | number> {
const values = [
person?.insurerBirthday,
person?.birthday,
person?.driverBirthday,
].filter((value) => value != null && String(value).trim() !== "");
const unique: Array<string | number> = [];
const seen = new Set<string>();
for (const value of values) {
const parsed = parseJalaliDateParts(value);
const key = parsed
? `${parsed.year}-${parsed.month}-${parsed.day}`
: String(value);
if (seen.has(key)) continue;
seen.add(key);
unique.push(value as string | number);
}
return unique;
}
export function parseJalaliDateParts(
birthday: unknown,
): { year: number; month: number; day: number } | null {
if (birthday == null) return null;
if (typeof birthday === "object") {
const year = Number(toEnglishDigits((birthday as any).year ?? (birthday as any).BirthYear));
const month = Number(toEnglishDigits((birthday as any).month ?? (birthday as any).BirthMonth));
const day = Number(toEnglishDigits((birthday as any).day ?? (birthday as any).BirthDay));
if (year > 0 && month > 0 && day > 0) return { year, month, day };
return null;
}
const str = toEnglishDigits(birthday).trim();
if (/^\d{8}$/.test(str)) {
const year = parseInt(str.slice(0, 4), 10);
const month = parseInt(str.slice(4, 6), 10);
const day = parseInt(str.slice(6, 8), 10);
if (!year || !month || !day) return null;
return { year, month, day };
}
const parts = str.split(/[/\-.]/);
if (parts.length < 3) return null;
const year = parseInt(parts[0], 10);
const month = parseInt(parts[1], 10);
const day = parseInt(parts[2], 10);
if (!year || !month || !day) return null;
return { year, month, day };
}

View File

@@ -1,8 +1,12 @@
import { import {
collectPartyVinCandidates,
filterPoliciesByLine, filterPoliciesByLine,
insuranceLineLabel, insuranceLineLabel,
parseLastCarPolicyInput, parseLastCarPolicyInput,
pickFanavaranPlaqueLookupFields,
pickFanavaranVehicleFromInquiry,
pickVehicleId, pickVehicleId,
pickVinFromPartyVehicle,
plaqueLetterFromMiddleCode, plaqueLetterFromMiddleCode,
plaqueMatchesVehicle, plaqueMatchesVehicle,
selectLastAmongCarMatches, selectLastAmongCarMatches,
@@ -84,6 +88,90 @@ 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("does not let chassis stored on vehicle.vin beat the inquiry VIN", () => {
const party = {
vehicle: {
vin: "NAAN01CA9BK493683",
inquiry: {
mapped: {
VIN: "IRFC901V119493683",
ChassisNo: "NAAN01CA9BK493683",
},
},
},
};
expect(pickVinFromPartyVehicle(party)).toBe("IRFC901V119493683");
expect(collectPartyVinCandidates(party)).toEqual([
"IRFC901V119493683",
"NAAN01CA9BK493683",
]);
});
it("prefers the VIN-inquiry row that already has PlaqueKindId", () => {
expect(
pickFanavaranVehicleFromInquiry([
{ Id: 1, VIN: "IRFC901V119493683" },
{ Id: 2, VIN: "IRFC901V119493683", PlaqueKindId: 15, PlaqueSampleId: 24 },
]),
).toEqual({
Id: 2,
VIN: "IRFC901V119493683",
PlaqueKindId: 15,
PlaqueSampleId: 24,
});
});
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", () => { it("matches VIN against ChassisNo when VIN is empty", () => {
expect( expect(
vehicleMatchesCar( vehicleMatchesCar(

View File

@@ -233,6 +233,113 @@ export function pickVehicleId(source: unknown): number | null {
return 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);
}
const withPlaqueKind = inquired.find(
(row) =>
parseFanavaranId(asObjectRecord(row)?.PlaqueKindId) != null ||
parseFanavaranId(asObjectRecord(row)?.plaqueKindId) != null,
);
if (withPlaqueKind) return asObjectRecord(withPlaqueKind);
return asObjectRecord(inquired[0]);
}
return asObjectRecord(inquired);
}
export function pickVinFromPartyVehicle(party: {
vehicle?: { vin?: unknown; inquiry?: unknown };
} | null | undefined): string | null {
return collectPartyVinCandidates(party)[0] ?? null;
}
/**
* VIN first (mapped/raw VIN fields), then party.vehicle.vin, then chassis.
* Damaged cars often store chassis on `vehicle.vin` which must not win over the real VIN.
*/
export function collectPartyVinCandidates(party: {
vehicle?: { vin?: unknown; inquiry?: unknown };
} | null | undefined): string[] {
const inquiry = asObjectRecord(party?.vehicle?.inquiry);
const mapped = asObjectRecord(inquiry?.mapped);
const rawData = asObjectRecord(
asObjectRecord(inquiry?.raw)?.data ?? inquiry?.raw ?? inquiry,
);
const values = [
mapped?.VIN,
mapped?.Vin,
mapped?.vin,
mapped?.VinNumberField,
rawData?.VIN,
rawData?.Vin,
rawData?.vin,
rawData?.VinNumberField,
party?.vehicle?.vin,
mapped?.ChassisNo,
mapped?.chassisNo,
mapped?.ShsNum,
mapped?.shsNam,
mapped?.ChassisNumberField,
rawData?.ChassisNo,
rawData?.chassisNo,
rawData?.ShsNum,
rawData?.shsNam,
rawData?.ChassisNumberField,
];
const unique: string[] = [];
const seen = new Set<string>();
for (const value of values) {
const vin = normalizeVin(value);
if (!vin || seen.has(vin)) continue;
seen.add(vin);
unique.push(vin);
}
return unique;
}
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 { function plaqueNumberEquals(left: unknown, right: unknown): boolean {
const a = normalizePlaquePart(left); const a = normalizePlaquePart(left);
const b = normalizePlaquePart(right); const b = normalizePlaquePart(right);

View File

@@ -331,7 +331,7 @@ export class LookupsController {
@ApiOperation({ @ApiOperation({
summary: "Fanavaran vehicle inquiry by VIN", summary: "Fanavaran vehicle inquiry by VIN",
description: 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({ @ApiQuery({
name: "vin", name: "vin",
@@ -346,6 +346,42 @@ export class LookupsController {
return await this.lookupsService.inquiryByVin(vin); 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") @Get("fanavaran")
@ApiOperation({ @ApiOperation({
summary: "List configured Fanavaran remote lookups", summary: "List configured Fanavaran remote lookups",
@@ -627,4 +663,41 @@ export class LookupsController {
async bodyPolicyById(@Param("policyId", ParseIntPipe) policyId: number) { async bodyPolicyById(@Param("policyId", ParseIntPipe) policyId: number) {
return await this.lookupsService.bodyPolicyById(policyId); 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, TEJARAT_STATIC_ACCIDENT_FILES,
} from "src/fanavaran/fanavaran-lookup.config"; } from "src/fanavaran/fanavaran-lookup.config";
import { FanavaranLookupService } from "src/fanavaran/fanavaran-lookup.service"; 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 { LookupDbService } from "./entities/db-service/lookup.db.service";
import { import {
asObjectRecord, asObjectRecord,
@@ -178,9 +182,81 @@ export class LookupsService {
return await this.getClientRemoteLookup("dmg-business-line"); 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> { async inquiryByVin(vin: string): Promise<unknown> {
const clientKey = this.activeClientKey(); 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( async myPolicies(
@@ -514,11 +590,19 @@ export class LookupsService {
async vehicleById(vehicleId: number, versionNo?: number): Promise<unknown> { async vehicleById(vehicleId: number, versionNo?: number): Promise<unknown> {
const clientKey = this.activeClientKey(); const clientKey = this.activeClientKey();
return this.fanavaranLookupService.vehicleById( const vehicle = asObjectRecord(
clientKey, await this.fanavaranLookupService.vehicleById(
vehicleId, clientKey,
versionNo, vehicleId,
versionNo,
),
); );
if (!vehicle) {
throw new NotFoundException(
`Fanavaran vehicle ${vehicleId} was not found.`,
);
}
return this.enrichVehicle(vehicle);
} }
async customerById(customerId: number): Promise<unknown> { 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,
);
});
});