forked from Yara724/api
YARA-1258
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
17
src/helpers/date-jalali.spec.ts
Normal file
17
src/helpers/date-jalali.spec.ts
Normal 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user