Compare commits

...

12 Commits

Author SHA1 Message Date
8173764913 Merge pull request 'main' (#322) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#322
2026-09-15 17:48:40 +03:30
c72cb265f1 merge upstream 2026-09-15 17:48:20 +03:30
271dc4df2a badane bugs continues 2026-09-15 17:47:38 +03:30
b2ff3f574b Merge pull request 'main' (#321) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#321
2026-09-15 17:07:37 +03:30
769a581a51 merge upstream 2026-09-15 17:07:08 +03:30
466773fb2b badane update is implemented now 2026-09-15 17:05:54 +03:30
37041ba19f Merge pull request 'third party update , optional body is done' (#320) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#320
2026-09-15 16:36:52 +03:30
0b0a1dfa13 third party update , optional body is done 2026-09-15 16:36:13 +03:30
073b4dec38 Merge pull request 'Fixed THIRD_PARTY VIN inquiry request' (#319) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#319
2026-09-15 16:00:44 +03:30
c1f0d041ce Merge pull request 'fanavaran stages matching data is double checked and more controlled +' (#318) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#318
2026-09-15 15:39:38 +03:30
f9c60a2854 fanavaran stages matching data is double checked and more controlled + 2026-09-15 15:36:28 +03:30
4310ce6398 Merge pull request 'Changed CAP to RIAL' (#317) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#317
2026-09-15 11:56:24 +03:30
9 changed files with 1759 additions and 283 deletions

View File

@@ -97,14 +97,26 @@ export class FanavaranSyncStage {
@Prop({ type: Number })
vehicleKindId?: number;
/** How VehicleKindId was resolved: vehicle-inquiry | car-type-lookup */
@Prop({ type: String })
vehicleKindSource?: string;
/** Cached Fanavaran PlaqueKindId from VIN inquiry / vehicle GET. */
@Prop({ type: Number })
plaqueKindId?: number;
/** How PlaqueKindId was resolved: vehicle-inquiry | config-default */
@Prop({ type: String })
plaqueKindSource?: string;
/** Cached Fanavaran PlaqueSampleId from VIN inquiry / vehicle GET. */
@Prop({ type: Number })
plaqueSampleId?: number;
/** How PlaqueSampleId was resolved: vehicle-inquiry | config-default */
@Prop({ type: String })
plaqueSampleSource?: string;
/**
* Cached Fanavaran AccidentVehicleUsedId, resolved from VIN inquiry / vehicle GET
* UsedId (lookup-filter safe). Not the Mongo tenant default.

View File

@@ -0,0 +1,157 @@
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
import {
fanavaranClaimProductFromBlameType,
fanavaranClaimsBaseUrl,
fanavaranClaimStageUrl,
fanavaranHasDamageCaseStage,
fanavaranHullImportClaimsUrl,
fanavaranPartyInquirySources,
isFanavaranSubmitSupportedBlameType,
pickCarBodyPolicyNationalCode,
pickStoredCarBodyPolicyId,
} from "./fanavaran-claim-product";
describe("fanavaran claim product", () => {
it("maps CAR_BODY blame files onto the hull claims resource", () => {
expect(fanavaranClaimProductFromBlameType(BlameRequestType.CAR_BODY)).toBe(
"car-body",
);
expect(fanavaranClaimsBaseUrl("car-body")).toBe(
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims",
);
});
it("keeps THIRD_PARTY on the financial third-party claims resource", () => {
expect(
fanavaranClaimProductFromBlameType(BlameRequestType.THIRD_PARTY),
).toBe("third-party");
expect(fanavaranClaimsBaseUrl("third-party")).toBe(
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/third-party-car-financial-claims",
);
});
it("does not POST hull damage to the missing dmg-cases resource", () => {
expect(fanavaranHasDamageCaseStage("car-body")).toBe(false);
expect(fanavaranClaimStageUrl("car-body", "damage-case", 5023617)).toBeNull();
expect(fanavaranClaimStageUrl("car-body", "attachments", 5023617)).toBe(
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims/5023617/files",
);
expect(fanavaranClaimStageUrl("car-body", "expertise", 5023617)).toBe(
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims/5023617/expertise",
);
expect(fanavaranClaimStageUrl("car-body", "culprits", 5023617)).toBe(
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims/5023617/culprits",
);
expect(fanavaranClaimStageUrl("third-party", "culprits", 1)).toBeNull();
expect(fanavaranHullImportClaimsUrl()).toBe(
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-import-claims",
);
expect(fanavaranClaimsBaseUrl("car-body")).not.toContain(
"vehicle-hull-import-claims",
);
});
it("allows Fanavaran submit for both car products", () => {
expect(isFanavaranSubmitSupportedBlameType("THIRD_PARTY")).toBe(true);
expect(isFanavaranSubmitSupportedBlameType("CAR_BODY")).toBe(true);
expect(isFanavaranSubmitSupportedBlameType("OTHER")).toBe(false);
});
it("reuses the car-body inquiry PolicyId without another policy list call", () => {
const policyId = pickStoredCarBodyPolicyId({
parties: [
{
role: PartyRole.FIRST,
insurance: { carBodyInsurance: { policyId: 15292336 } },
},
],
});
expect(policyId).toBe(15292336);
});
it("reads PolicyId from nested car-body inquiry raw when the flat field is missing", () => {
const policyId = pickStoredCarBodyPolicyId({
parties: [
{
role: PartyRole.FIRST,
vehicle: {
inquiry: {
carBody: {
mapped: {},
raw: { policyId: "15292336", policy: { PolicyId: 99 } },
},
},
},
},
],
});
expect(policyId).toBe(15292336);
});
it("uses the first-party car-body policyholder national code", () => {
const nationalCode = pickCarBodyPolicyNationalCode({
parties: [
{
role: PartyRole.FIRST,
person: { nationalCodeOfInsurer: "0012345678" },
insurance: {
carBodyInsurance: { ownerNationalCode: "0098765432" },
},
},
],
});
expect(nationalCode).toBe("0012345678");
});
it("flattens car-body inquiry fields onto the damage-case aliases", () => {
const { mapped } = fanavaranPartyInquirySources("car-body", {
insurance: {
carBodyInsurance: {
policyNumber: "70019846985",
chassisNumber: "IRNKAEK4150012345",
},
},
vehicle: {
inquiry: {
carBody: {
mapped: {
policyId: 15292336,
policyNumber: "70019846985",
chassisNumber: "IRNKAEK4150012345",
motorNumber: "M123",
vin: "IRNKAEK4150012345",
StartDate: "1405/05/18",
EndDate: "1406/05/18",
builtYear: 1402,
platePartOne: "29",
plateLetterTitle: "د",
platePartThree: "782",
plateSerialNumber: "44",
},
raw: {
policy: { CINumber: "70019846985" },
vehicle: { ChassisNo: "IRNKAEK4150012345" },
},
},
},
},
});
expect(mapped.policyId).toBe(15292336);
expect(mapped.PrntCmpDocNo).toBe("70019846985");
expect(mapped.ShsNum).toBe("IRNKAEK4150012345");
expect(mapped.MtrNum).toBe("M123");
expect(mapped.VIN).toBe("IRNKAEK4150012345");
expect(mapped.HBgnDte).toBe("1405/05/18");
expect(mapped.HEndDte).toBe("1406/05/18");
expect(mapped.PrdDte).toBe(1402);
expect(mapped.plk1).toBe("29");
expect(mapped.plk2).toBe("د");
expect(mapped.plk3).toBe("782");
expect(mapped.plksrl).toBe("44");
});
});

View File

@@ -0,0 +1,271 @@
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
import {
FANAVARAN_CAR_BODY_LINE_ID,
FANAVARAN_THIRD_PARTY_LINE_ID,
asObjectRecord,
parseFanavaranId,
type FanavaranCarPolicyProduct,
} from "src/lookups/fanavaran-last-car-policy";
export type FanavaranClaimProduct = FanavaranCarPolicyProduct;
const FANAVARAN_CLAIMS_HOST =
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car";
export function fanavaranClaimProductFromBlameType(
type?: string | null,
): FanavaranClaimProduct {
return type === BlameRequestType.CAR_BODY || type === "CAR_BODY"
? "car-body"
: "third-party";
}
export function isFanavaranSubmitSupportedBlameType(
type?: string | null,
): boolean {
return (
type === BlameRequestType.THIRD_PARTY ||
type === "THIRD_PARTY" ||
type === BlameRequestType.CAR_BODY ||
type === "CAR_BODY"
);
}
export function fanavaranClaimsResourcePath(
product: FanavaranClaimProduct,
): string {
return product === "car-body"
? "vehicle-hull-claims"
: "third-party-car-financial-claims";
}
export function fanavaranClaimsBaseUrl(
product: FanavaranClaimProduct,
): string {
return `${FANAVARAN_CLAIMS_HOST}/${fanavaranClaimsResourcePath(product)}`;
}
export type FanavaranClaimStage =
| "damage-case"
| "attachments"
| "expertise"
| "culprits";
/** Hull (بدنه) has no GEN.12 dmg-cases resource. Proven 2026-09-15: Feature:url(.../vehicle-hull-claims/{id}/dmg-cases) not found. Files and expertise do exist. */
export const FANAVARAN_HULL_NO_DAMAGE_CASE_REASON =
"Fanavaran vehicle-hull-claims has no dmg-cases resource. Hull is first-party (one damaged vehicle on the base claim). Continue with files and expertise.";
export const FANAVARAN_HULL_IMPORT_CLAIMS_PATH = "vehicle-hull-import-claims";
export function fanavaranHasDamageCaseStage(
product: FanavaranClaimProduct,
): boolean {
return product === "third-party";
}
export function fanavaranHullImportClaimsUrl(): string {
return `${FANAVARAN_CLAIMS_HOST}/${FANAVARAN_HULL_IMPORT_CLAIMS_PATH}`;
}
export function fanavaranClaimStageUrl(
product: FanavaranClaimProduct,
stage: FanavaranClaimStage,
claimId: number | string,
): string | null {
if (stage === "damage-case" && !fanavaranHasDamageCaseStage(product)) {
return null;
}
if (stage === "culprits" && product !== "car-body") {
return null;
}
const suffix =
stage === "damage-case"
? "dmg-cases"
: stage === "attachments"
? "files"
: stage === "culprits"
? "culprits"
: "expertise";
return `${fanavaranClaimsBaseUrl(product)}/${claimId}/${suffix}`;
}
export function fanavaranInsuranceLineIdForProduct(
product: FanavaranClaimProduct,
): number {
return product === "car-body"
? FANAVARAN_CAR_BODY_LINE_ID
: FANAVARAN_THIRD_PARTY_LINE_ID;
}
type BlamePartyForCarBodyPolicy = {
role?: string;
person?: { nationalCodeOfInsurer?: unknown };
insurance?: {
carBodyInsurance?: {
policyId?: unknown;
ownerNationalCode?: unknown;
insurerNationalCode?: unknown;
policyNumber?: unknown;
chassisNumber?: unknown;
motorNumber?: unknown;
vin?: unknown;
startDate?: unknown;
endDate?: unknown;
};
};
vehicle?: {
inquiry?: {
mapped?: Record<string, unknown>;
raw?: Record<string, unknown> | { data?: Record<string, unknown> };
carBody?: {
mapped?: Record<string, unknown>;
raw?: Record<string, unknown> & {
policyId?: unknown;
policy?: Record<string, unknown>;
vehicle?: Record<string, unknown>;
};
};
};
};
};
function firstCarBodyParty(
blame?: { parties?: BlamePartyForCarBodyPolicy[] } | null,
): BlamePartyForCarBodyPolicy | null {
const parties = blame?.parties ?? [];
return (
parties.find((party) => party?.role === PartyRole.FIRST) ??
parties[0] ??
null
);
}
function nonEmptyText(value: unknown): string | null {
if (value == null) return null;
const text = String(value).trim();
return text ? text : null;
}
export function pickStoredCarBodyPolicyId(
blame?: { parties?: BlamePartyForCarBodyPolicy[] } | null,
): number | null {
const party = firstCarBodyParty(blame);
const carBody = party?.vehicle?.inquiry?.carBody;
const raw = asObjectRecord(carBody?.raw) ?? {};
const mapped = asObjectRecord(carBody?.mapped) ?? {};
const rawPolicy = asObjectRecord(raw.policy);
return parseFanavaranId(
party?.insurance?.carBodyInsurance?.policyId ??
mapped.policyId ??
raw.policyId ??
rawPolicy?.PolicyId,
);
}
export function pickCarBodyPolicyNationalCode(
blame?: { parties?: BlamePartyForCarBodyPolicy[] } | null,
): string | null {
const party = firstCarBodyParty(blame);
return (
nonEmptyText(party?.person?.nationalCodeOfInsurer) ??
nonEmptyText(party?.insurance?.carBodyInsurance?.ownerNationalCode) ??
nonEmptyText(party?.insurance?.carBodyInsurance?.insurerNationalCode)
);
}
export function fanavaranPartyInquirySources(
product: FanavaranClaimProduct,
party?: BlamePartyForCarBodyPolicy | null,
): {
mapped: Record<string, any>;
raw: Record<string, any>;
} {
const inquiry = party?.vehicle?.inquiry ?? {};
if (product !== "car-body") {
const mapped = asObjectRecord(inquiry.mapped) ?? {};
const rawValue = inquiry.raw as
| Record<string, unknown>
| { data?: Record<string, unknown> }
| undefined;
const raw =
asObjectRecord(
rawValue &&
typeof rawValue === "object" &&
"data" in rawValue &&
rawValue.data != null &&
typeof rawValue.data === "object" &&
!Array.isArray(rawValue.data)
? rawValue.data
: rawValue,
) ?? {};
return { mapped, raw };
}
const carBody = inquiry.carBody ?? {};
const mapped = asObjectRecord(carBody.mapped) ?? {};
const raw = asObjectRecord(carBody.raw) ?? {};
const policy = asObjectRecord(raw.policy) ?? {};
const vehicle = asObjectRecord(raw.vehicle) ?? {};
const insurance = party?.insurance?.carBodyInsurance ?? {};
const policyNumber =
mapped.policyNumber ??
insurance.policyNumber ??
policy.CINumber ??
policy.PolicyNo;
const chassisNumber =
mapped.chassisNumber ?? insurance.chassisNumber ?? vehicle.ChassisNo;
const motorNumber =
mapped.motorNumber ?? insurance.motorNumber ?? vehicle.MotorNo;
const vin = mapped.vin ?? insurance.vin ?? vehicle.VIN ?? vehicle.ChassisNo;
const beginDate =
mapped.StartDate ?? mapped.startDate ?? insurance.startDate ?? policy.BeginDate;
const endDate =
mapped.EndDate ?? mapped.endDate ?? insurance.endDate ?? policy.EndDate;
const builtYear = mapped.builtYear ?? vehicle.BuiltYear;
const plaque = asObjectRecord(vehicle.plaque) ?? {};
const plk1 =
mapped.platePartOne ?? plaque.leftTwoDigits ?? vehicle.PlaqueLeftNo;
const plk2 =
mapped.plateLetterTitle ?? plaque.serialLetter;
const plk3 =
mapped.platePartThree ?? plaque.threeDigits ?? vehicle.PlaqueRightNo;
const plksrl =
mapped.plateSerialNumber ??
plaque.rightTwoDigits ??
vehicle.PlaqueSerial;
return {
mapped: {
...mapped,
...insurance,
policyId:
mapped.policyId ??
insurance.policyId ??
raw.policyId ??
policy.PolicyId,
PrntCmpDocNo: policyNumber,
PlcyUnqCod: policy.PolicyUnqCod ?? mapped.policyNumber,
ShsNum: chassisNumber,
MtrNum: motorNumber,
VIN: vin,
HBgnDte: beginDate,
HEndDte: endDate,
PrdDte: builtYear,
plk1,
Plk1: plk1,
plk2,
Plk2: plk2,
plk3,
Plk3: plk3,
plksrl,
PlkSrl: plksrl,
},
raw: {
...policy,
...vehicle,
...raw,
},
};
}

View File

@@ -0,0 +1,156 @@
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
import {
hullExpertiseAssertFields,
collectFanavaranExpertiseReadinessWarnings,
pickHullVehicleIdentity,
toFanavaranHullExpertiseDmgSection,
toFanavaranHullExpertisePayload,
} from "./fanavaran-hull-expertise";
describe("fanavaran hull expertise", () => {
it("maps car-body inquiry identity onto GEN.06 vehicle fields", () => {
const vehicle = pickHullVehicleIdentity({
parties: [
{
role: PartyRole.FIRST,
insurance: {
carBodyInsurance: {
chassisNumber: "NAS431100K1050805",
motorNumber: "M136251767",
vin: "VIN123",
},
},
vehicle: {
inquiry: {
carBody: {
mapped: {
platePartOne: "29",
plateLetterTitle: "د",
platePartThree: "782",
plateSerialNumber: "60",
builtYear: 1398,
},
},
},
},
},
],
});
expect(vehicle).toEqual({
motorNo: "M136251767",
chassisNo: "NAS431100K1050805",
vin: "VIN123",
plaqueNo: "29د782",
plaqueSerial: "60",
builtYear: 1398,
});
});
it("builds a GEN.06 payload without ثالث expertise fields", () => {
const payload = toFanavaranHullExpertisePayload({
claimExpertId: 29,
dmgAssessmentDate: "1405/06/24",
inspectionTime: "10:33",
wage: 1000,
componentReplacementCost: 2000,
wasteValue: 0,
dropAmount: 12,
vehicleCurrentValue: 1900000000,
vehicle: {
motorNo: "M1",
chassisNo: "C1",
vin: null,
plaqueNo: "695ط61",
plaqueSerial: "87",
builtYear: 1398,
},
dmgSections: [
toFanavaranHullExpertiseDmgSection({
partId: 9,
desc: "bumper",
wasteValue: 0,
amount: 3000,
}),
],
});
expect(payload.Wage).toBe(1000);
expect(payload.RepairWage).toBeUndefined();
expect(payload.DmgCaseId).toBeUndefined();
expect(payload.InspectionPlaceId).toBeUndefined();
expect(payload.DropAmountStatus).toBeUndefined();
expect(payload.DamagedVehicleCurrentPrice).toBeUndefined();
expect(payload.ClaimExpertId).toBe(29);
expect(payload.PlaqueNo).toBe("695ط61");
expect(payload.DmgSections).toEqual([
{
Count: 1,
Desc: "bumper",
WasteValue: 0,
AccessoryKindId: 9,
DmgKindId: null,
VehicleHullAccessoryId: null,
DmgSectionCosts: [
{ Caption: "bumper", Amount: 3000, DmgCostKindId: null },
],
},
]);
});
it("does not require ثالث expertise fields on a GEN.06 hull payload", () => {
const payload = toFanavaranHullExpertisePayload({
claimExpertId: 29,
dmgAssessmentDate: "1405/06/24",
inspectionTime: "10:41",
wage: 6000000,
componentReplacementCost: 10000000,
wasteValue: 0,
dropAmount: 10000,
vehicleCurrentValue: null,
vehicle: {
motorNo: "13389030167",
chassisNo: "NAAP41FD5BJ301065",
vin: "IRFC891V7D2301065",
plaqueNo: "56د394",
plaqueSerial: "66",
builtYear: 1389,
},
dmgSections: [
toFanavaranHullExpertiseDmgSection({
partId: 9,
desc: "سپر جلو",
wasteValue: 0,
amount: 11000000,
}),
toFanavaranHullExpertiseDmgSection({
partId: 11,
desc: "آينه سمت راننده",
wasteValue: 0,
amount: 5000000,
}),
],
});
expect(
collectFanavaranExpertiseReadinessWarnings(
payload,
hullExpertiseAssertFields("car-body"),
),
).toEqual([]);
expect(
collectFanavaranExpertiseReadinessWarnings(
payload,
hullExpertiseAssertFields("third-party"),
),
).toEqual([
"DmgCaseId is required.",
"InspectionPlaceId is required.",
"DropAmountStatus is required.",
"DmgSections[0].DmgSectionId is required.",
"DmgSections[0].AccidentLevel is required.",
"DmgSections[1].DmgSectionId is required.",
"DmgSections[1].AccidentLevel is required.",
]);
});
});

View File

@@ -0,0 +1,160 @@
import { parseFanavaranId } from "src/lookups/fanavaran-last-car-policy";
import {
fanavaranPartyInquirySources,
type FanavaranClaimProduct,
} from "./fanavaran-claim-product";
export type FanavaranHullVehicleIdentity = {
motorNo: string | null;
chassisNo: string | null;
vin: string | null;
plaqueNo: string | null;
plaqueSerial: string | null;
builtYear: number | null;
};
export function pickHullVehicleIdentity(
blame?: { parties?: unknown[] } | null,
): FanavaranHullVehicleIdentity {
const party = (blame?.parties ?? [])[0] as
| Parameters<typeof fanavaranPartyInquirySources>[1]
| undefined;
const first =
(blame?.parties ?? []).find(
(row) => (row as { role?: string })?.role === "FIRST",
) ?? party;
const { mapped } = fanavaranPartyInquirySources(
"car-body",
first as Parameters<typeof fanavaranPartyInquirySources>[1],
);
const left = text(mapped.plk1 ?? mapped.Plk1);
const letter = text(mapped.plk2 ?? mapped.Plk2);
const right = text(mapped.plk3 ?? mapped.Plk3);
const serial = text(mapped.plksrl ?? mapped.PlkSrl);
const plaqueNo = [left, letter, right].filter(Boolean).join("") || null;
return {
motorNo: text(mapped.MtrNum),
chassisNo: text(mapped.ShsNum),
vin: text(mapped.VIN),
plaqueNo,
plaqueSerial: serial,
builtYear: parseFanavaranId(mapped.PrdDte),
};
}
function text(value: unknown): string | null {
if (value == null) return null;
const next = String(value).trim();
return next ? next : null;
}
export function toFanavaranHullExpertisePayload(input: {
claimExpertId: number;
dmgAssessmentDate: string;
inspectionTime: string;
wage: number;
componentReplacementCost: number;
wasteValue: number;
dropAmount: number;
vehicleCurrentValue: number | null;
vehicle: FanavaranHullVehicleIdentity;
dmgSections: Record<string, unknown>[];
}): Record<string, unknown> {
return {
ClaimExpertId: input.claimExpertId,
DmgAssessmentDate: input.dmgAssessmentDate,
InspectionTime: input.inspectionTime,
MotorNo: input.vehicle.motorNo,
ChassisNo: input.vehicle.chassisNo,
BuiltYear: input.vehicle.builtYear,
VIN: input.vehicle.vin,
PlaqueNo: input.vehicle.plaqueNo,
PlaqueSerial: input.vehicle.plaqueSerial,
Wage: input.wage,
ComponentReplacementCost: input.componentReplacementCost,
WasteValue: input.wasteValue,
CarryAndRescueCost: null,
RepairDuration: null,
VehicleCurrentValue: input.vehicleCurrentValue,
WreckHighestValue: null,
InspectionDeduction: null,
DmgAndWasteDesc: null,
WentDistanceByExpert: null,
IsDestruction: null,
DropAmount: input.dropAmount,
ColorId: null,
PlaqueDesignId: null,
PlaqueCityId: null,
AccidentPercent: null,
TheftCases: [],
DmgSections: input.dmgSections,
};
}
export function toFanavaranHullExpertiseDmgSection(input: {
partId?: unknown;
desc: string;
wasteValue: number;
amount: number;
}): Record<string, unknown> {
return {
Count: 1,
Desc: input.desc,
WasteValue: input.wasteValue,
AccessoryKindId: parseFanavaranId(input.partId),
DmgKindId: null,
VehicleHullAccessoryId: null,
DmgSectionCosts: [
{
Caption: input.desc,
Amount: input.amount,
DmgCostKindId: null,
},
],
};
}
export function hullExpertiseAssertFields(
product: FanavaranClaimProduct,
): { requireDmgCaseId: boolean; requireThirdPartyLookups: boolean } {
if (product === "car-body") {
return { requireDmgCaseId: false, requireThirdPartyLookups: false };
}
return { requireDmgCaseId: true, requireThirdPartyLookups: true };
}
export function collectFanavaranExpertiseReadinessWarnings(
payload: Record<string, unknown>,
options?: { requireDmgCaseId?: boolean; requireThirdPartyLookups?: boolean },
): string[] {
const warnings: string[] = [];
const sections = Array.isArray(payload.DmgSections)
? payload.DmgSections
: [];
const thirdPartyLookups = options?.requireThirdPartyLookups !== false;
if (options?.requireDmgCaseId !== false && !payload.DmgCaseId) {
warnings.push("DmgCaseId is required.");
}
if (thirdPartyLookups) {
if (!payload.InspectionPlaceId) {
warnings.push("InspectionPlaceId is required.");
}
if (!payload.DropAmountStatus) {
warnings.push("DropAmountStatus is required.");
}
}
if (!sections.length) {
warnings.push("At least one DmgSections row is required.");
}
for (const [index, section] of sections.entries()) {
const row = section as Record<string, unknown>;
if (!thirdPartyLookups) continue;
if (!row.DmgSectionId) {
warnings.push(`DmgSections[${index}].DmgSectionId is required.`);
}
if (!row.AccidentLevel) {
warnings.push(`DmgSections[${index}].AccidentLevel is required.`);
}
}
return Array.from(new Set(warnings));
}

View File

@@ -10,6 +10,7 @@ import {
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiBody,
ApiOperation,
ApiParam,
ApiQuery,
@@ -57,7 +58,7 @@ export class FanavaranController {
@ApiOperation({
summary: "Preview Fanavaran base claim create payload",
description:
"Builds the GEN.03 payload from local claim/blame data. Fanavaran-sourced PolicyId is resolve-once: first call may inquire and caches on the claim; later calls reuse the cache. Auth token is shared until Asia/Tehran midnight. Pass forceRefreshPolicy=true to re-inquire.",
"GET has no request body. Always rebuilds GEN.03 from live policy + VIN/vehicle inquiries. Does not reuse fanavaranSync lastPayload / PolicyId / UsedId cache. Auth token is still shared until Asia/Tehran midnight.",
})
@ApiParam({
name: "client",
@@ -76,34 +77,30 @@ export class FanavaranController {
@ApiQuery({
name: "forceRefreshPolicy",
required: false,
description:
"When true, ignores cached PolicyId and performs a live Fanavaran policy inquiry again. Do not pass this from normal UI loads.",
deprecated: true,
description: "Ignored. Preview always re-inquires PolicyId.",
})
@ApiQuery({
name: "resolvePolicy",
required: false,
deprecated: true,
description:
"Deprecated. Ignored for cache-busting. PolicyId is resolve-once from fanavaranSync.baseClaim.policyId; use forceRefreshPolicy=true only to re-inquire.",
description: "Ignored. Preview always re-inquires PolicyId.",
})
async preview(
@Param("client") client: string,
@Param("claimCaseId") claimCaseId: string,
@Query("debug") debug?: string,
@Query("forceRefreshPolicy") forceRefreshPolicy?: string,
@Query("forceRefreshPolicy") _forceRefreshPolicy?: string,
@Query("resolvePolicy") _resolvePolicy?: string,
) {
const clientKey = this.parseClientParam(client);
// IMPORTANT: resolvePolicy must NOT force a live inquiry. Older UI clients
// send resolvePolicy=true on every preview load; that used to defeat the
// PolicyId cache and re-Login Fanavaran on every click.
return await this.claimRequestManagementService.previewFanavaranSubmitV2(
claimCaseId,
clientKey,
{
debug: debug === "1" || debug === "true",
forceRefreshPolicy:
forceRefreshPolicy === "1" || forceRefreshPolicy === "true",
forceRefreshPolicy: true,
forceRefreshUsedId: true,
requirePolicyId: false,
},
);
@@ -113,7 +110,7 @@ export class FanavaranController {
@ApiOperation({
summary: "Submit Fanavaran base claim create request",
description:
"Authenticates with the selected tenant credentials and submits the GEN.03 base claim create request. Stores returned Id as claimId and ClaimNo when present.",
"Swagger JSON body is optional. Always rebuilds GEN.03 from live inquiries, then POSTs that rebuilt payload. Pasting a previous preview JSON does not skip inquiry. Only user-entry keys are applied from the body (EstimateAmount, licence, dates, address, police report). PolicyId and AccidentVehicleUsedId in pasted JSON are ignored.",
})
@ApiParam({
name: "client",
@@ -124,6 +121,12 @@ export class FanavaranController {
name: "claimCaseId",
description: "Claim case MongoDB ObjectId",
})
@ApiBody({
required: false,
description:
"Optional. Paste previous preview JSON if you want to tweak user-entry fields. Lookup ids in that JSON are ignored.",
schema: { type: "object", additionalProperties: true, example: {} },
})
async submit(
@Param("client") client: string,
@Param("claimCaseId") claimCaseId: string,
@@ -141,7 +144,7 @@ export class FanavaranController {
@ApiOperation({
summary: "Preview Fanavaran damage-case payload",
description:
"Builds the GEN.12 damaged vehicle/person case payload without calling Fanavaran. Requires selected damaged parts; submit requires a Fanavaran claimId.",
"GET has no request body. Always rebuilds GEN.12 from live VIN/vehicle/driver inquiries. Does not reuse lastPayload / plaqueKindId / DriverId cache.",
})
@ApiParam({
name: "client",
@@ -167,7 +170,7 @@ export class FanavaranController {
@ApiOperation({
summary: "Submit Fanavaran damage-case request",
description:
"Submits the GEN.12 dmg-cases request. If base claim (claimId) is missing, soft-ensures GEN.03 base claim first, then submits damage. Skips when dmgCaseId already exists.",
"Swagger JSON body is optional. Always rebuilds GEN.12 from live VIN/vehicle/driver inquiries, then POSTs that rebuilt payload. Pasting a previous preview JSON does not skip inquiry. Only Desc, EstimateAmount, LicenceNo, LicenceIssuDate, DriverIsOwner are taken from the body. PlaqueKindId, PlaqueSampleId, VehicleKindId, DriverId, InsuranceCorpId in pasted JSON are ignored.",
})
@ApiParam({
name: "client",
@@ -178,6 +181,12 @@ export class FanavaranController {
name: "claimCaseId",
description: "Claim case MongoDB ObjectId",
})
@ApiBody({
required: false,
description:
"Optional. Paste previous preview JSON if you want to tweak Desc / licence / estimate. Lookup-filter ids in that JSON are ignored.",
schema: { type: "object", additionalProperties: true, example: {} },
})
async submitDamageCase(
@Param("client") client: string,
@Param("claimCaseId") claimCaseId: string,
@@ -247,7 +256,7 @@ export class FanavaranController {
@ApiOperation({
summary: "Preview Fanavaran expertise payload",
description:
"Builds the GEN.08 expertise payload from the active damage expert reply, price-drop data, and Fanavaran lookup mappings without calling Fanavaran.",
"GET has no request body. Rebuilds GEN.08 from the active expert reply and lookups on every call. Does not reuse lastPayload cache.",
})
@ApiParam({
name: "client",
@@ -273,7 +282,7 @@ export class FanavaranController {
@ApiOperation({
summary: "Submit Fanavaran expertise request",
description:
"Submits the GEN.08 expertise payload for a claim with existing Fanavaran claimId and dmgCaseId. Stores returned Id as local expertiseId.",
"Swagger JSON body is optional. Always rebuilds GEN.08 from the local expert reply and lookups, then POSTs that rebuilt payload. Pasting a previous preview JSON does not skip rebuild. Only money/date fields are taken from the body. DmgCaseId, ClaimExpertId, and DmgSections in pasted JSON are ignored.",
})
@ApiParam({
name: "client",
@@ -284,6 +293,12 @@ export class FanavaranController {
name: "claimCaseId",
description: "Claim case MongoDB ObjectId",
})
@ApiBody({
required: false,
description:
"Optional. Paste previous preview JSON if you want to tweak money/date fields. DmgCaseId / ClaimExpertId / DmgSections in that JSON are ignored.",
schema: { type: "object", additionalProperties: true, example: {} },
})
async submitExpertise(
@Param("client") client: string,
@Param("claimCaseId") claimCaseId: string,

View File

@@ -1,7 +1,12 @@
import {
canReuseCachedFanavaranVehicleIds,
collectPartyVinCandidates,
fanavaranIdOrConfigDefault,
fanavaranPayloadMatchesDamagedIdentity,
fanavaranVehicleMatchesDamagedIdentity,
filterPoliciesByLine,
applyFanavaranManualPayloadOverrides,
resolveDamageCasePlaqueLookupIds,
insuranceLineLabel,
parseLastCarPolicyInput,
pickFanavaranPlaqueLookupFields,
@@ -10,6 +15,7 @@ import {
pickVinFromPartyVehicle,
plaqueLetterFromMiddleCode,
plaqueMatchesVehicle,
selectFanavaranVehicleForDamageCase,
selectLastAmongCarMatches,
toAppPlaque,
vehicleMatchesCar,
@@ -311,4 +317,188 @@ describe("fanavaran last car policy", () => {
it("returns null when there are no matches", () => {
expect(selectLastAmongCarMatches([], today)).toBeNull();
});
const damagedIdentity = {
vin: "IRPC941V2BD798656",
chassis: "NAS431100E5798656",
plaque: { left: "59", letter: "ی", right: "419", serial: "78" },
policyCINumber: "16040446424",
};
const culpritVehicle = {
Id: 475449,
VIN: "IRFC891V7D2301065",
ChassisNo: "NAAP41FD5BJ301065",
VehicleKindId: 6953,
VersionNo: 3,
PlaqueKindId: 8,
PlaqueSampleId: 10,
PlaqueLeftNo: "56",
PlaqueMiddleCodeId: 5,
PlaqueRightNo: "394",
PlaqueSerial: "66",
PlaqueNo: "394د56",
};
it("does not treat the culprit vehicle as the damaged car", () => {
expect(
fanavaranVehicleMatchesDamagedIdentity(culpritVehicle, damagedIdentity),
).toBe(false);
expect(
selectFanavaranVehicleForDamageCase(
[
culpritVehicle,
{ Id: 9, VIN: "OTHER", PlaqueKindId: 15, PlaqueSampleId: 24 },
],
damagedIdentity,
),
).toBeNull();
});
it("ignores a PlaqueKindId 15 row that does not share VIN or plate", () => {
expect(
selectFanavaranVehicleForDamageCase(
[
{ Id: 1, VIN: "IRPC941V2BD798656" },
{
Id: 2,
VIN: "IRFC901V119493683",
PlaqueKindId: 15,
PlaqueSampleId: 24,
PlaqueLeftNo: "62",
PlaqueMiddleCodeId: 14,
PlaqueRightNo: "278",
PlaqueSerial: "50",
},
],
damagedIdentity,
),
).toEqual({ Id: 1, VIN: "IRPC941V2BD798656" });
});
it("requires plate agreement when the inquiry row has plaque parts", () => {
expect(
selectFanavaranVehicleForDamageCase(
[
{
Id: 1,
VIN: "IRPC941V2BD798656",
PlaqueKindId: 15,
PlaqueLeftNo: "56",
PlaqueMiddleCodeId: 5,
PlaqueRightNo: "394",
PlaqueSerial: "66",
},
],
damagedIdentity,
),
).toBeNull();
});
it("picks the newest matching version of the damaged VIN and plate", () => {
expect(
selectFanavaranVehicleForDamageCase(
[
{
Id: 10,
VIN: "IRPC941V2BD798656",
ChassisNo: "NAS431100E5798656",
VersionNo: 1,
PlaqueKindId: 15,
PlaqueSampleId: 24,
PlaqueLeftNo: "59",
PlaqueMiddleCodeId: 16,
PlaqueRightNo: "419",
PlaqueSerial: "78",
},
{
Id: 10,
VIN: "IRPC941V2BD798656",
ChassisNo: "NAS431100E5798656",
VersionNo: 3,
PlaqueKindId: 8,
PlaqueSampleId: 10,
PlaqueLeftNo: "59",
PlaqueMiddleCodeId: 16,
PlaqueRightNo: "419",
PlaqueSerial: "78",
},
],
damagedIdentity,
)?.PlaqueKindId,
).toBe(8);
});
it("rejects cached payload ids from a different VIN, plate, or CI number", () => {
expect(
fanavaranPayloadMatchesDamagedIdentity(
{
VIN: "IRFC891V7D2301065",
ChassisNo: "NAAP41FD5BJ301065",
PolicyCINumber: "16018466143",
PlaqueKindId: 8,
PlaqueLeftNo: "56",
PlaqueMiddleCodeId: 5,
PlaqueRightNo: "394",
PlaqueSerial: "66",
},
damagedIdentity,
),
).toBe(false);
expect(canReuseCachedFanavaranVehicleIds("config-default")).toBe(false);
expect(canReuseCachedFanavaranVehicleIds("vehicle-inquiry")).toBe(true);
expect(canReuseCachedFanavaranVehicleIds(undefined)).toBe(false);
});
it("accepts cache only when VIN, plate, and CI number all agree", () => {
expect(
fanavaranPayloadMatchesDamagedIdentity(
{
VIN: "IRPC941V2BD798656",
ChassisNo: "NAS431100E5798656",
PolicyCINumber: "16040446424",
PlaqueLeftNo: "59",
PlaqueMiddleCodeId: 16,
PlaqueRightNo: "419",
PlaqueSerial: "78",
PlaqueKindId: 8,
},
damagedIdentity,
),
).toBe(true);
});
it("uses national plaque 8/10 when the damaged VIN is unknown to Fanavaran", () => {
expect(resolveDamageCasePlaqueLookupIds(null)).toEqual({
kindId: 8,
sampleId: 10,
source: "national-plate-default",
});
});
it("keeps live PlaqueKindId 8 when Swagger body still has stale 15", () => {
expect(
applyFanavaranManualPayloadOverrides(
{
PlaqueKindId: 8,
PlaqueSampleId: 10,
VehicleKindId: 6704,
AccidentVehicleUsedId: 1,
Desc: "سپر جلو",
},
{
PlaqueKindId: 15,
PlaqueSampleId: 24,
Desc: "سپر جلو (edited)",
},
["Desc", "EstimateAmount", "LicenceNo"],
),
).toEqual({
PlaqueKindId: 8,
PlaqueSampleId: 10,
VehicleKindId: 6704,
AccidentVehicleUsedId: 1,
Desc: "سپر جلو (edited)",
});
});
});

View File

@@ -262,6 +262,242 @@ export function pickFanavaranVehicleFromInquiry(
return asObjectRecord(inquired);
}
export type FanavaranDamagedVehicleIdentity = {
vin?: string | null;
chassis?: string | null;
plaque?: FanavaranPlaqueParts | null;
policyCINumber?: string | null;
vehicleKindId?: number | null;
};
export function asVehicleInquiryRows(
inquired: unknown,
): Record<string, unknown>[] {
if (Array.isArray(inquired)) {
return inquired
.map((row) => asObjectRecord(row))
.filter((row): row is Record<string, unknown> => row != null);
}
const one = asObjectRecord(inquired);
return one ? [one] : [];
}
export function normalizePolicyCINumber(value: unknown): string {
return toEnglishDigits(value).replace(/\s+/g, "").trim();
}
function vehicleHasPlaqueParts(vehicle: Record<string, unknown>): boolean {
return Boolean(
asPlaqueText(vehicle.PlaqueLeftNo ?? vehicle.plaqueLeftNo) &&
(parseFanavaranId(
vehicle.PlaqueMiddleCodeId ?? vehicle.plaqueMiddleCodeId,
) != null ||
String(vehicle.PlaqueLetter ?? vehicle.plaqueLetter ?? "").trim()) &&
asPlaqueText(vehicle.PlaqueRightNo ?? vehicle.plaqueRightNo) &&
asPlaqueText(vehicle.PlaqueSerial ?? vehicle.plaqueSerial),
);
}
function vinEquals(left: unknown, right: unknown): boolean {
const a = normalizeVin(left);
const b = normalizeVin(right);
return Boolean(a && b && a === b);
}
/**
* Same physical car: VIN and plaque must not contradict the damaged-party
* inquiry. A row that only "has PlaqueKindId" is not enough.
*/
export function fanavaranVehicleMatchesDamagedIdentity(
vehicle: Record<string, unknown> | null,
identity: FanavaranDamagedVehicleIdentity,
): boolean {
if (!vehicle) return false;
const identityVin = normalizeVin(identity.vin);
const identityChassis = normalizeVin(identity.chassis);
const vehicleVin = pickVehicleVin(vehicle);
const vehicleChassis = normalizeVin(
vehicle.ChassisNo ?? vehicle.chassisNo ?? vehicle.ShsNum,
);
const vinMatched = Boolean(
identityVin &&
(vinEquals(vehicleVin, identityVin) ||
vinEquals(vehicleChassis, identityVin)),
);
const chassisMatched = Boolean(
identityChassis &&
(vinEquals(vehicleVin, identityChassis) ||
vinEquals(vehicleChassis, identityChassis)),
);
if (identityVin && vehicleVin && !vinMatched && !chassisMatched) {
return false;
}
const plaqueMatched = Boolean(
identity.plaque && plaqueMatchesVehicle(identity.plaque, vehicle),
);
if (identity.plaque && vehicleHasPlaqueParts(vehicle) && !plaqueMatched) {
return false;
}
return vinMatched || chassisMatched || plaqueMatched;
}
export function fanavaranPayloadMatchesDamagedIdentity(
payload: Record<string, unknown> | null,
identity: FanavaranDamagedVehicleIdentity,
): boolean {
if (!payload) return false;
const payloadVin = normalizeVin(payload.VIN ?? payload.vin);
const identityVin = normalizeVin(identity.vin);
if (payloadVin && identityVin && payloadVin !== identityVin) return false;
const payloadChassis = normalizeVin(
payload.ChassisNo ?? payload.chassisNo,
);
const identityChassis = normalizeVin(identity.chassis);
if (payloadChassis && identityChassis && payloadChassis !== identityChassis) {
return false;
}
const payloadCI = normalizePolicyCINumber(payload.PolicyCINumber);
const identityCI = normalizePolicyCINumber(identity.policyCINumber);
if (payloadCI && identityCI && payloadCI !== identityCI) return false;
if (identity.plaque && vehicleHasPlaqueParts(payload)) {
if (!plaqueMatchesVehicle(identity.plaque, payload)) return false;
}
const vinMatched = Boolean(payloadVin && identityVin && payloadVin === identityVin);
const chassisMatched = Boolean(
payloadChassis && identityChassis && payloadChassis === identityChassis,
);
const ciMatched = Boolean(payloadCI && identityCI && payloadCI === identityCI);
const plaqueMatched = Boolean(
identity.plaque && plaqueMatchesVehicle(identity.plaque, payload),
);
return vinMatched || chassisMatched || ciMatched || plaqueMatched;
}
export function canReuseCachedFanavaranVehicleIds(source: unknown): boolean {
return source === "vehicle-inquiry" || source === "vin-inquiry" || source === "vehicle-get";
}
/** Proven Parsian national (چهار تکه) plaque codebook ids. */
export const FANAVARAN_NATIONAL_PLAQUE_KIND_ID = 8;
export const FANAVARAN_NATIONAL_PLAQUE_SAMPLE_ID = 10;
export const FANAVARAN_DAMAGE_MANUAL_OVERRIDE_KEYS = [
"Desc",
"EstimateAmount",
"LicenceNo",
"LicenceIssuDate",
"DriverIsOwner",
] as const;
export const FANAVARAN_EXPERTISE_MANUAL_OVERRIDE_KEYS = [
"RepairWage",
"ComponentReplacementCost",
"WasteValue",
"DmgAssessmentDate",
"InspectionTime",
"DamagedVehicleCurrentPrice",
"DropAmountAdditionsDeductions",
] as const;
export const FANAVARAN_BASE_MANUAL_OVERRIDE_KEYS = [
"EstimateAmount",
"CulpritLicenceNo",
"CulpritLicenceIssuDate",
"AccidentLocationAddress",
"AccidentDate",
"AnnouncementDate",
"DocReceivedDate",
"AccidentTime",
"PoliceReportDesc",
"PoliceReportSeri",
"PoliceReportSerial",
] as const;
/**
* Swagger POST body is optional user-entry tweaks only.
* Inquiry / lookup-filter ids always come from the live rebuilt payload.
*/
export function applyFanavaranManualPayloadOverrides(
built: Record<string, unknown>,
body: Record<string, unknown> | null | undefined,
keys: readonly string[],
): Record<string, unknown> {
if (!body) return built;
const next = { ...built };
for (const key of keys) {
if (!Object.prototype.hasOwnProperty.call(body, key)) continue;
if (body[key] === undefined) continue;
next[key] = body[key];
}
return next;
}
/**
* Plaque kind/sample only from a matched Fanavaran vehicle record.
* CII/ESG inquiry, unsourced cache, and tenant defaults of 15 must not win.
* National 4-part plates fall back to 8/10 (lookup-filter safe on Parsian).
*/
export function resolveDamageCasePlaqueLookupIds(matchedVehicle: Record<
string,
unknown
> | null): {
kindId: number;
sampleId: number;
source: "vehicle-inquiry" | "national-plate-default";
} {
const plaque = pickFanavaranPlaqueLookupFields(matchedVehicle);
if (plaque.kindId != null) {
return {
kindId: plaque.kindId,
sampleId: plaque.sampleId ?? FANAVARAN_NATIONAL_PLAQUE_SAMPLE_ID,
source: "vehicle-inquiry",
};
}
return {
kindId: FANAVARAN_NATIONAL_PLAQUE_KIND_ID,
sampleId: FANAVARAN_NATIONAL_PLAQUE_SAMPLE_ID,
source: "national-plate-default",
};
}
/**
* Pick the VIN-inquiry / vehicle-GET row that is the damaged car.
* Newest VersionNo wins among matches; PlaqueKindId is a tie-break only.
*/
export function selectFanavaranVehicleForDamageCase(
inquired: unknown,
identity: FanavaranDamagedVehicleIdentity,
): Record<string, unknown> | null {
const matches = asVehicleInquiryRows(inquired).filter((row) =>
fanavaranVehicleMatchesDamagedIdentity(row, identity),
);
if (matches.length === 0) return null;
return matches.reduce((current, candidate) => {
const currentVersion = parseFanavaranId(current.VersionNo) ?? 0;
const candidateVersion = parseFanavaranId(candidate.VersionNo) ?? 0;
if (candidateVersion !== currentVersion) {
return candidateVersion > currentVersion ? candidate : current;
}
const currentHasKind =
pickFanavaranPlaqueLookupFields(current).kindId != null;
const candidateHasKind =
pickFanavaranPlaqueLookupFields(candidate).kindId != null;
if (candidateHasKind && !currentHasKind) return candidate;
return current;
});
}
export function pickVinFromPartyVehicle(party: {
vehicle?: { vin?: unknown; inquiry?: unknown };
} | null | undefined): string | null {