YARA-1258

This commit is contained in:
SepehrYahyaee
2026-09-12 14:23:23 +03:30
parent 9e51fdcdaf
commit 7fd60df1d6
5 changed files with 300 additions and 21 deletions

View File

@@ -1,6 +1,5 @@
import { buildInsurerFileReport } from "./case-expert-report.builder";
import { PR } from "./persian-report-labels";
import { toJalaliDateAndTime } from "../helpers/date-jalali";
describe("buildInsurerFileReport", () => {
const getFieldValue = (
@@ -18,9 +17,6 @@ describe("buildInsurerFileReport", () => {
it("separates insurance blocks and exposes Fanavaran/timeline/evaluation data", () => {
const fileCreatedAt = new Date("2026-08-10T09:53:23.588Z");
const evaluationSubmittedAt = new Date("2026-08-10T12:30:45.000Z");
const [fileDate, fileTime] = toJalaliDateAndTime(fileCreatedAt);
const [evaluationDate, evaluationTime] =
toJalaliDateAndTime(evaluationSubmittedAt);
const report = buildInsurerFileReport({
overview: {
@@ -152,6 +148,18 @@ describe("buildInsurerFileReport", () => {
submittedAt: evaluationSubmittedAt,
description: "نیاز به تعویض سپر جلو",
actorDetail: { actorName: "کارشناس خسارت نمونه" },
parts: [
{
partId: 12,
carPartDamage: { label_fa: "سپر جلو" },
typeOfDamage: "تعویض",
price: 2_500_000,
salary: 500_000,
totalPayment: 3_000_000,
factorNeeded: false,
daghi: { option: "تحویل داغی" },
},
],
},
},
},
@@ -214,11 +222,11 @@ describe("buildInsurerFileReport", () => {
);
expect(getFieldValue(report, PR.timelineSection, PR.fileRegisteredAt)).toBe(
`${fileDate} ${fileTime}`,
"1405/05/19 13:23",
);
expect(
getFieldValue(report, PR.timelineSection, PR.evaluationRegisteredAt),
).toBe(`${evaluationDate} ${evaluationTime}`);
).toBe("1405/05/19 16:00");
expect(getFieldValue(report, PR.evaluationSection, PR.evaluationResult)).toBe(
"تأیید شده",
@@ -229,6 +237,91 @@ describe("buildInsurerFileReport", () => {
expect(getFieldValue(report, PR.evaluationSection, PR.evaluationResponse)).toBe(
"نیاز به تعویض سپر جلو",
);
expect(getFieldValue(report, "قیمت قطعات", "قطعه ۱ / نام قطعه")).toBe(
"سپر جلو",
);
expect(getFieldValue(report, "قیمت قطعات", "قطعه ۱ / قیمت قطعه")).toBe(
"۲٬۵۰۰٬۰۰۰ تومان",
);
expect(getFieldValue(report, "قیمت قطعات", "قطعه ۱ / مبلغ کل")).toBe(
"۳٬۰۰۰٬۰۰۰ تومان",
);
});
it("localizes legacy English car-body environmental conditions", () => {
const report = buildInsurerFileReport({
overview: { publicId: "A00012" },
blame: {
type: "CAR_BODY",
parties: [
{
role: "FIRST",
person: { userId: "damaged", fullName: "بیمه‌گذار" },
statement: {
weatherCondition: "CLEAR",
roadCondition: "wet",
lightCondition: "LOW_LIGHT",
},
},
],
},
});
expect(getFieldValue(report, PR.accidentSection, PR.weather)).toBe("صاف");
expect(getFieldValue(report, PR.accidentSection, PR.road)).toBe("مرطوب");
expect(getFieldValue(report, PR.accidentSection, PR.light)).toBe("کم‌نور");
});
it("exposes the damage expert part-price result as a separate section", () => {
const report = buildInsurerFileReport({
overview: { publicId: "A00013" },
claim: {
claimStatus: "APPROVED",
evaluation: {
damageExpertReply: {
description: "تعویض قطعه ضروری است",
parts: [
{
carPartDamage: { label_fa: "چراغ جلو" },
price: 4_000_000,
totalPayment: 4_000_000,
},
],
},
},
},
});
expect(getFieldValue(report, "قیمت قطعات", "قطعه ۱ / نام قطعه")).toBe(
"چراغ جلو",
);
expect(getFieldValue(report, "قیمت قطعات", "قطعه ۱ / قیمت قطعه")).toBe(
"۴٬۰۰۰٬۰۰۰ تومان",
);
});
it("falls back to claim history for the evaluation submission time", () => {
const report = buildInsurerFileReport({
overview: { publicId: "A00014" },
claim: {
evaluation: {
damageExpertReply: { description: "ارزیابی ثبت شد" },
},
history: [
{
type: "EXPERT_REPLY_SUBMITTED",
timestamp: "2026-08-10T12:30:45.000Z",
},
],
},
});
expect(
getFieldValue(report, PR.timelineSection, PR.evaluationRegisteredAt),
).toBe("1405/05/19 16:00");
expect(
getFieldValue(report, PR.evaluationSection, PR.evaluationSubmittedAt),
).toBe("1405/05/19 16:00");
});
it("uses the stored V4 licence type for a driver who differs from the policyholder", () => {

View File

@@ -9,7 +9,12 @@ import {
InsurerFileReportSection,
InsurerFileReportViewModel,
} from "./case-expert-report.types";
import { PR, persianFieldPath, persianStatus } from "./persian-report-labels";
import {
PR,
persianAccidentCondition,
persianFieldPath,
persianStatus,
} from "./persian-report-labels";
const SKIP_FLATTEN_KEYS = new Set([
"_id",
@@ -65,6 +70,32 @@ function formatDateTime(value: unknown): string | undefined {
return `${d} ${t}`;
}
const PERSIAN_NUMBER_FORMATTER = new Intl.NumberFormat("fa-IR", {
maximumFractionDigits: 0,
});
function normalizeNumber(value: unknown): number | undefined {
if (typeof value === "number") {
return Number.isFinite(value) ? value : undefined;
}
if (typeof value !== "string" || !value.trim()) return undefined;
const normalized = value
.trim()
.replace(/[۰-۹]/g, (digit) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(digit)))
.replace(/[٠-٩]/g, (digit) => String("٠١٢٣٤٥٦٧٨٩".indexOf(digit)))
.replace(/[٬,\s]/g, "");
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : undefined;
}
function formatToman(value: unknown): string | undefined {
if (value === undefined || value === null || value === "") return undefined;
const amount = normalizeNumber(value);
if (amount === undefined) return asString(value);
return `${PERSIAN_NUMBER_FORMATTER.format(amount)} تومان`;
}
function firstDefined(...values: unknown[]): string | undefined {
for (const value of values) {
const str = asString(value);
@@ -302,6 +333,23 @@ function resolveEvaluationReply(
);
}
function resolveEvaluationSubmittedAt(
claim: Record<string, unknown> | null | undefined,
reply: Record<string, unknown> | undefined,
): unknown {
if (reply?.submittedAt != null) return reply.submittedAt;
const history = Array.isArray(claim?.history) ? claim.history : [];
const submittedEvent = [...history]
.reverse()
.find((event) =>
["EXPERT_REPLY_SUBMITTED", "EXPERT_FINAL_REPLY_SUBMITTED"].includes(
String((event as Record<string, unknown>)?.type ?? ""),
),
) as Record<string, unknown> | undefined;
return submittedEvent?.timestamp;
}
function insuranceValueFromCandidates(
...values: unknown[]
): string | undefined {
@@ -650,7 +698,7 @@ function buildCaseTimelineSection(
},
{
label: PR.evaluationRegisteredAt,
value: formatDateTime(evaluationReply?.submittedAt),
value: formatDateTime(resolveEvaluationSubmittedAt(claim, evaluationReply)),
},
]);
}
@@ -723,7 +771,7 @@ function buildEvaluationSection(
},
{
label: PR.evaluationSubmittedAt,
value: formatDateTime(reply?.submittedAt),
value: formatDateTime(resolveEvaluationSubmittedAt(claim, reply)),
},
{
label: PR.evaluationResponse,
@@ -732,6 +780,66 @@ function buildEvaluationSection(
]);
}
function evaluationPartName(part: ReportRecord): string | undefined {
const damage = part.carPartDamage;
if (damage && typeof damage === "object" && !Array.isArray(damage)) {
const record = damage as ReportRecord;
return firstDefined(
record.label_fa,
record.name,
record.part,
record.label,
part.partId != null ? `${PR.part} ${part.partId}` : undefined,
);
}
return firstDefined(
damage,
part.partName,
part.partId != null ? `${PR.part} ${part.partId}` : undefined,
);
}
function evaluationDaghiValue(value: unknown): string | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return asString(value);
}
const daghi = value as ReportRecord;
const option = asString(daghi.option);
const price = formatToman(daghi.price);
return [option, price].filter(Boolean).join(" - ") || undefined;
}
function buildEvaluationPartsSection(
claim?: Record<string, unknown> | null,
): InsurerFileReportSection | undefined {
const reply = resolveEvaluationReply(claim);
const parts = Array.isArray(reply?.parts)
? (reply.parts as ReportRecord[])
: [];
if (!parts.length) return undefined;
const fields = parts.flatMap((part, index) => {
const prefix = `${PR.part} ${PERSIAN_NUMBER_FORMATTER.format(index + 1)}`;
return [
{ label: `${prefix} / ${PR.partName}`, value: evaluationPartName(part) },
{ label: `${prefix} / ${PR.damageType}`, value: asString(part.typeOfDamage) },
{ label: `${prefix} / ${PR.partPrice}`, value: formatToman(part.price) },
{ label: `${prefix} / ${PR.repairSalary}`, value: formatToman(part.salary) },
{ label: `${prefix} / ${PR.totalPayment}`, value: formatToman(part.totalPayment) },
{
label: `${prefix} / ${PR.factorNeeded}`,
value:
typeof part.factorNeeded === "boolean"
? persianStatus(part.factorNeeded)
: undefined,
},
{ label: `${prefix} / ${PR.daghi}`, value: evaluationDaghiValue(part.daghi) },
];
});
return buildSection(PR.evaluationPartsSection, fields, false);
}
function buildAccidentReportSection(
blame?: Record<string, unknown> | null,
claim?: Record<string, unknown> | null,
@@ -773,21 +881,24 @@ function buildAccidentReportSection(
},
{
label: PR.weather,
value:
asString(statement?.weatherCondition) ??
asString(snapshotAccident?.weatherCondition),
value: persianAccidentCondition(
"weather",
statement?.weatherCondition ?? snapshotAccident?.weatherCondition,
),
},
{
label: PR.road,
value:
asString(statement?.roadCondition) ??
asString(snapshotAccident?.roadCondition),
value: persianAccidentCondition(
"road",
statement?.roadCondition ?? snapshotAccident?.roadCondition,
),
},
{
label: PR.light,
value:
asString(statement?.lightCondition) ??
asString(snapshotAccident?.lightCondition),
value: persianAccidentCondition(
"light",
statement?.lightCondition ?? snapshotAccident?.lightCondition,
),
},
{
label: PR.blameStatus,
@@ -910,6 +1021,7 @@ export function buildInsurerFileReport(file: {
: undefined,
buildFanavaranCodesSection(claim),
buildEvaluationSection(claim),
buildEvaluationPartsSection(claim),
buildAccidentReportSection(blameContext, claim, damagedParty),
]);

View File

@@ -48,6 +48,15 @@ export const PR = {
evaluationExpert: "کارشناس ارزیاب",
evaluationSubmittedAt: "تاریخ و ساعت ثبت ارزیابی",
evaluationResponse: "پاسخ / توضیحات کارشناس",
evaluationPartsSection: "قیمت قطعات",
part: "قطعه",
partName: "نام قطعه",
damageType: "نوع خسارت",
partPrice: "قیمت قطعه",
repairSalary: "اجرت تعمیر",
totalPayment: "مبلغ کل",
factorNeeded: "نیازمند فاکتور",
daghi: "داغی",
admitsGuilt: "اقرار به تقصیر",
claimsDamage: "ادعای خسارت",
acceptsExpertOpinion: "پذیرش نظر کارشناس",
@@ -194,6 +203,32 @@ const STATUS_LABELS: Record<string, string> = {
false: "خیر",
};
const WEATHER_LABELS: Record<string, string> = {
clear: "صاف",
sunny: "صاف",
rainy: "بارانی",
rain: "بارانی",
snowy: "برفی",
snow: "برفی",
foggy: "مه‌آلود",
fog: "مه‌آلود",
};
const ROAD_LABELS: Record<string, string> = {
muddy: "گلی",
icy: "یخ‌زده",
wet: "مرطوب",
dry: "خشک",
};
const LIGHT_LABELS: Record<string, string> = {
daylight: "روز",
day: "روز",
night: "شب",
low_light: "کم‌نور",
lowlight: "کم‌نور",
};
/** Turn `party.insurance.policyNumber` into a Persian label. */
export function persianFieldPath(path: string): string {
if (!path) return PR.data;
@@ -231,3 +266,19 @@ export function persianStatus(value: unknown): string | undefined {
const key = String(value);
return STATUS_LABELS[key] ?? key;
}
export function persianAccidentCondition(
kind: "weather" | "road" | "light",
value: unknown,
): string | undefined {
if (value === undefined || value === null || value === "") return undefined;
const raw = String(value).trim();
const normalized = raw.toLowerCase().replace(/[\s-]+/g, "_");
const labels =
kind === "weather"
? WEATHER_LABELS
: kind === "road"
? ROAD_LABELS
: LIGHT_LABELS;
return labels[normalized] ?? raw;
}

View File

@@ -0,0 +1,17 @@
import { toJalaliDateAndTime } from "./date-jalali";
describe("toJalaliDateAndTime", () => {
it("always formats timestamps in Asia/Tehran regardless of server timezone", () => {
expect(toJalaliDateAndTime("2026-08-10T12:30:45.000Z")).toEqual([
"1405/05/19",
"16:00",
]);
});
it("uses the Tehran calendar day when UTC and Tehran dates differ", () => {
expect(toJalaliDateAndTime("2026-08-10T21:30:00.000Z")).toEqual([
"1405/05/20",
"01:00",
]);
});
});

View File

@@ -1,3 +1,5 @@
import { IRAN_TIMEZONE } from "./iran-datetime";
/**
* Converts an ISO date string or Date to Jalali (Persian) date and time.
* @param input - ISO date string (e.g. 2026-02-08T13:51:20.747+00:00) or Date
@@ -70,6 +72,7 @@ export function jalaliToGregorianDate(
*/
export function toJalaliDate(d: Date): string {
const persianDate = d.toLocaleDateString("fa-IR", {
timeZone: IRAN_TIMEZONE,
year: "numeric",
month: "2-digit",
day: "2-digit",
@@ -81,9 +84,12 @@ export function toJalaliDate(d: Date): string {
* Converts a Date to 24h time string (e.g. 13:37).
*/
export function toJalaliTime(d: Date): string {
const hours = d.getHours().toString().padStart(2, "0");
const minutes = d.getMinutes().toString().padStart(2, "0");
return `${hours}:${minutes}`;
return new Intl.DateTimeFormat("en-GB", {
timeZone: IRAN_TIMEZONE,
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).format(d);
}
/**