From 7fd60df1d646b336a5f739b87592d28f39174f49 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Sat, 12 Sep 2026 14:23:23 +0330 Subject: [PATCH] YARA-1258 --- .../case-expert-report.builder.spec.ts | 105 +++++++++++++- .../case-expert-report.builder.ts | 136 ++++++++++++++++-- .../persian-report-labels.ts | 51 +++++++ src/helpers/date-jalali.spec.ts | 17 +++ src/helpers/date-jalali.ts | 12 +- 5 files changed, 300 insertions(+), 21 deletions(-) create mode 100644 src/helpers/date-jalali.spec.ts diff --git a/src/case-expert-report/case-expert-report.builder.spec.ts b/src/case-expert-report/case-expert-report.builder.spec.ts index 420af14..02c063a 100644 --- a/src/case-expert-report/case-expert-report.builder.spec.ts +++ b/src/case-expert-report/case-expert-report.builder.spec.ts @@ -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", () => { diff --git a/src/case-expert-report/case-expert-report.builder.ts b/src/case-expert-report/case-expert-report.builder.ts index fdb1445..1eecc91 100644 --- a/src/case-expert-report/case-expert-report.builder.ts +++ b/src/case-expert-report/case-expert-report.builder.ts @@ -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 | null | undefined, + reply: Record | 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)?.type ?? ""), + ), + ) as Record | 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 | 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 | null, claim?: Record | 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), ]); diff --git a/src/case-expert-report/persian-report-labels.ts b/src/case-expert-report/persian-report-labels.ts index e13bf25..56ef6de 100644 --- a/src/case-expert-report/persian-report-labels.ts +++ b/src/case-expert-report/persian-report-labels.ts @@ -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 = { false: "خیر", }; +const WEATHER_LABELS: Record = { + clear: "صاف", + sunny: "صاف", + rainy: "بارانی", + rain: "بارانی", + snowy: "برفی", + snow: "برفی", + foggy: "مه‌آلود", + fog: "مه‌آلود", +}; + +const ROAD_LABELS: Record = { + muddy: "گلی", + icy: "یخ‌زده", + wet: "مرطوب", + dry: "خشک", +}; + +const LIGHT_LABELS: Record = { + 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; +} diff --git a/src/helpers/date-jalali.spec.ts b/src/helpers/date-jalali.spec.ts new file mode 100644 index 0000000..1fadaee --- /dev/null +++ b/src/helpers/date-jalali.spec.ts @@ -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", + ]); + }); +}); diff --git a/src/helpers/date-jalali.ts b/src/helpers/date-jalali.ts index a618255..165a055 100644 --- a/src/helpers/date-jalali.ts +++ b/src/helpers/date-jalali.ts @@ -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); } /**