Merge pull request 'YARA-1258' (#307) from s.yahyaee/yara724-api:main into main

Reviewed-on: Yara724/api#307
This commit is contained in:
2026-09-12 14:24:00 +03:30
5 changed files with 300 additions and 21 deletions

View File

@@ -1,6 +1,5 @@
import { buildInsurerFileReport } from "./case-expert-report.builder"; import { buildInsurerFileReport } from "./case-expert-report.builder";
import { PR } from "./persian-report-labels"; import { PR } from "./persian-report-labels";
import { toJalaliDateAndTime } from "../helpers/date-jalali";
describe("buildInsurerFileReport", () => { describe("buildInsurerFileReport", () => {
const getFieldValue = ( const getFieldValue = (
@@ -18,9 +17,6 @@ describe("buildInsurerFileReport", () => {
it("separates insurance blocks and exposes Fanavaran/timeline/evaluation data", () => { it("separates insurance blocks and exposes Fanavaran/timeline/evaluation data", () => {
const fileCreatedAt = new Date("2026-08-10T09:53:23.588Z"); const fileCreatedAt = new Date("2026-08-10T09:53:23.588Z");
const evaluationSubmittedAt = new Date("2026-08-10T12:30:45.000Z"); const evaluationSubmittedAt = new Date("2026-08-10T12:30:45.000Z");
const [fileDate, fileTime] = toJalaliDateAndTime(fileCreatedAt);
const [evaluationDate, evaluationTime] =
toJalaliDateAndTime(evaluationSubmittedAt);
const report = buildInsurerFileReport({ const report = buildInsurerFileReport({
overview: { overview: {
@@ -152,6 +148,18 @@ describe("buildInsurerFileReport", () => {
submittedAt: evaluationSubmittedAt, submittedAt: evaluationSubmittedAt,
description: "نیاز به تعویض سپر جلو", description: "نیاز به تعویض سپر جلو",
actorDetail: { actorName: "کارشناس خسارت نمونه" }, 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( expect(getFieldValue(report, PR.timelineSection, PR.fileRegisteredAt)).toBe(
`${fileDate} ${fileTime}`, "1405/05/19 13:23",
); );
expect( expect(
getFieldValue(report, PR.timelineSection, PR.evaluationRegisteredAt), getFieldValue(report, PR.timelineSection, PR.evaluationRegisteredAt),
).toBe(`${evaluationDate} ${evaluationTime}`); ).toBe("1405/05/19 16:00");
expect(getFieldValue(report, PR.evaluationSection, PR.evaluationResult)).toBe( 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, 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", () => { it("uses the stored V4 licence type for a driver who differs from the policyholder", () => {

View File

@@ -9,7 +9,12 @@ import {
InsurerFileReportSection, InsurerFileReportSection,
InsurerFileReportViewModel, InsurerFileReportViewModel,
} from "./case-expert-report.types"; } 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([ const SKIP_FLATTEN_KEYS = new Set([
"_id", "_id",
@@ -65,6 +70,32 @@ function formatDateTime(value: unknown): string | undefined {
return `${d} ${t}`; 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 { function firstDefined(...values: unknown[]): string | undefined {
for (const value of values) { for (const value of values) {
const str = asString(value); 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( function insuranceValueFromCandidates(
...values: unknown[] ...values: unknown[]
): string | undefined { ): string | undefined {
@@ -650,7 +698,7 @@ function buildCaseTimelineSection(
}, },
{ {
label: PR.evaluationRegisteredAt, label: PR.evaluationRegisteredAt,
value: formatDateTime(evaluationReply?.submittedAt), value: formatDateTime(resolveEvaluationSubmittedAt(claim, evaluationReply)),
}, },
]); ]);
} }
@@ -723,7 +771,7 @@ function buildEvaluationSection(
}, },
{ {
label: PR.evaluationSubmittedAt, label: PR.evaluationSubmittedAt,
value: formatDateTime(reply?.submittedAt), value: formatDateTime(resolveEvaluationSubmittedAt(claim, reply)),
}, },
{ {
label: PR.evaluationResponse, 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( function buildAccidentReportSection(
blame?: Record<string, unknown> | null, blame?: Record<string, unknown> | null,
claim?: Record<string, unknown> | null, claim?: Record<string, unknown> | null,
@@ -773,21 +881,24 @@ function buildAccidentReportSection(
}, },
{ {
label: PR.weather, label: PR.weather,
value: value: persianAccidentCondition(
asString(statement?.weatherCondition) ?? "weather",
asString(snapshotAccident?.weatherCondition), statement?.weatherCondition ?? snapshotAccident?.weatherCondition,
),
}, },
{ {
label: PR.road, label: PR.road,
value: value: persianAccidentCondition(
asString(statement?.roadCondition) ?? "road",
asString(snapshotAccident?.roadCondition), statement?.roadCondition ?? snapshotAccident?.roadCondition,
),
}, },
{ {
label: PR.light, label: PR.light,
value: value: persianAccidentCondition(
asString(statement?.lightCondition) ?? "light",
asString(snapshotAccident?.lightCondition), statement?.lightCondition ?? snapshotAccident?.lightCondition,
),
}, },
{ {
label: PR.blameStatus, label: PR.blameStatus,
@@ -910,6 +1021,7 @@ export function buildInsurerFileReport(file: {
: undefined, : undefined,
buildFanavaranCodesSection(claim), buildFanavaranCodesSection(claim),
buildEvaluationSection(claim), buildEvaluationSection(claim),
buildEvaluationPartsSection(claim),
buildAccidentReportSection(blameContext, claim, damagedParty), buildAccidentReportSection(blameContext, claim, damagedParty),
]); ]);

View File

@@ -48,6 +48,15 @@ export const PR = {
evaluationExpert: "کارشناس ارزیاب", evaluationExpert: "کارشناس ارزیاب",
evaluationSubmittedAt: "تاریخ و ساعت ثبت ارزیابی", evaluationSubmittedAt: "تاریخ و ساعت ثبت ارزیابی",
evaluationResponse: "پاسخ / توضیحات کارشناس", evaluationResponse: "پاسخ / توضیحات کارشناس",
evaluationPartsSection: "قیمت قطعات",
part: "قطعه",
partName: "نام قطعه",
damageType: "نوع خسارت",
partPrice: "قیمت قطعه",
repairSalary: "اجرت تعمیر",
totalPayment: "مبلغ کل",
factorNeeded: "نیازمند فاکتور",
daghi: "داغی",
admitsGuilt: "اقرار به تقصیر", admitsGuilt: "اقرار به تقصیر",
claimsDamage: "ادعای خسارت", claimsDamage: "ادعای خسارت",
acceptsExpertOpinion: "پذیرش نظر کارشناس", acceptsExpertOpinion: "پذیرش نظر کارشناس",
@@ -194,6 +203,32 @@ const STATUS_LABELS: Record<string, string> = {
false: "خیر", 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. */ /** Turn `party.insurance.policyNumber` into a Persian label. */
export function persianFieldPath(path: string): string { export function persianFieldPath(path: string): string {
if (!path) return PR.data; if (!path) return PR.data;
@@ -231,3 +266,19 @@ export function persianStatus(value: unknown): string | undefined {
const key = String(value); const key = String(value);
return STATUS_LABELS[key] ?? key; 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. * 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 * @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 { export function toJalaliDate(d: Date): string {
const persianDate = d.toLocaleDateString("fa-IR", { const persianDate = d.toLocaleDateString("fa-IR", {
timeZone: IRAN_TIMEZONE,
year: "numeric", year: "numeric",
month: "2-digit", month: "2-digit",
day: "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). * Converts a Date to 24h time string (e.g. 13:37).
*/ */
export function toJalaliTime(d: Date): string { export function toJalaliTime(d: Date): string {
const hours = d.getHours().toString().padStart(2, "0"); return new Intl.DateTimeFormat("en-GB", {
const minutes = d.getMinutes().toString().padStart(2, "0"); timeZone: IRAN_TIMEZONE,
return `${hours}:${minutes}`; hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).format(d);
} }
/** /**