From fc37dd78a4e52281438db71ca914da70e2a97b42 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Sat, 12 Sep 2026 11:58:07 +0330 Subject: [PATCH 1/2] Fixed FaLabels --- src/expert-insurer/expert-insurer.service.ts | 23 +- .../helper/timeline-fa-labels.spec.ts | 75 +++++- .../helper/timeline-fa-labels.ts | 218 +++++++++++++++++- 3 files changed, 291 insertions(+), 25 deletions(-) diff --git a/src/expert-insurer/expert-insurer.service.ts b/src/expert-insurer/expert-insurer.service.ts index b586240..ba50fa4 100644 --- a/src/expert-insurer/expert-insurer.service.ts +++ b/src/expert-insurer/expert-insurer.service.ts @@ -81,6 +81,7 @@ import { import { getEventFaDescription, getEventFaLabel, + localizeTimelineMetadata, } from "./helper/timeline-fa-labels"; import { buildEnrichedDamagedParts } from "src/expert-claim/dto/claim-damaged-part.enricher"; @@ -2831,7 +2832,7 @@ export class ExpertInsurerService { ExpertInsurerService.resolveTimelinePerformedByCategory(performedBy), performedByFaLabel: ExpertInsurerService.resolveTimelinePerformedByFaLabel(performedBy), - metadata: this.localizeTimelineMetadata(ev), + metadata: localizeTimelineMetadata(ev), }); } } @@ -2869,7 +2870,7 @@ export class ExpertInsurerService { ExpertInsurerService.resolveTimelinePerformedByCategory(performedBy), performedByFaLabel: ExpertInsurerService.resolveTimelinePerformedByFaLabel(performedBy), - metadata: this.localizeTimelineMetadata(ev), + metadata: localizeTimelineMetadata(ev), }); } } @@ -2883,22 +2884,4 @@ export class ExpertInsurerService { // Resolve insurer's client name for context if needed — just return raw events return { publicId, events }; } - - /** - * Keep the timeline metadata intact, but replace persisted system-generated - * descriptions with their Persian presentation value. - */ - private localizeTimelineMetadata(event: { - type: string; - metadata?: Record; - }): Record | null { - if (!event.metadata) return null; - - return { - ...event.metadata, - ...(Object.prototype.hasOwnProperty.call(event.metadata, "description") - ? { description: getEventFaDescription(event) } - : {}), - }; - } } diff --git a/src/expert-insurer/helper/timeline-fa-labels.spec.ts b/src/expert-insurer/helper/timeline-fa-labels.spec.ts index 8e04ef4..557d2fa 100644 --- a/src/expert-insurer/helper/timeline-fa-labels.spec.ts +++ b/src/expert-insurer/helper/timeline-fa-labels.spec.ts @@ -1,4 +1,8 @@ -import { getEventFaDescription, getEventFaLabel } from "./timeline-fa-labels"; +import { + getEventFaDescription, + getEventFaLabel, + localizeTimelineMetadata, +} from "./timeline-fa-labels"; describe("insurer timeline Persian labels", () => { it("localizes the user-submission detail shown to insurers", () => { @@ -22,4 +26,73 @@ describe("insurer timeline Persian labels", () => { "رویدادی در روند پرونده ثبت شد.", ); }); + + it.each([ + ["FIRST_INITIAL_FORM_SUBMITTED", "فرم اولیه طرف اول ثبت شد"], + ["FIRST_LOCATION_SUBMITTED", "موقعیت مکانی طرف اول ثبت شد"], + ["FIRST_VOICE_UPLOADED", "صدای طرف اول بارگذاری شد"], + ["FIRST_DESCRIPTION_SUBMITTED", "توضیحات طرف اول ثبت شد"], + ["EXPERT_REPLY_SUBMITTED", "پاسخ کارشناس ثبت شد"], + ])("localizes sample timeline event %s", (type, expectedLabel) => { + expect(getEventFaLabel({ type })).toBe(expectedLabel); + }); + + it("uses a Persian faLabel fallback for an unknown event type", () => { + expect(getEventFaLabel({ type: "UNRECOGNIZED_EVENT" })).toBe( + "رویداد پرونده", + ); + }); + + it("localizes human-readable assignment metadata", () => { + expect( + localizeTimelineMetadata({ + type: "CLAIM_ASSIGNED", + metadata: { note: "Expert assigned via review assign endpoint" }, + }), + ).toEqual({ + note: "پرونده از طریق فرایند بررسی به کارشناس تخصیص داده شد.", + }); + }); + + it("localizes Fanavaran errors while retaining machine-facing metadata", () => { + expect( + localizeTimelineMetadata({ + type: "FANAVARAN_EXPERTISE_AUTO_SUBMIT_FAILED", + metadata: { + clientKey: "parsian", + error: "Fanavaran expertise payload is not ready.", + }, + }), + ).toEqual({ + clientKey: "parsian", + clientFaLabel: "پارسیان", + error: "اطلاعات کارشناسی برای ارسال به فناوران آماده نیست.", + }); + }); + + it("adds Persian labels for displayable metadata enums", () => { + expect( + localizeTimelineMetadata({ + type: "STEP_COMPLETED", + metadata: { + role: "FIRST", + stepKey: "USER_SUBMISSION_COMPLETE", + carType: "sedan", + captureType: "angle", + captureKey: "front", + }, + }), + ).toEqual({ + role: "FIRST", + roleFaLabel: "طرف اول", + stepKey: "USER_SUBMISSION_COMPLETE", + stepFaLabel: "ارسال اطلاعات توسط کاربر", + carType: "sedan", + carTypeFaLabel: "سواری", + captureType: "angle", + captureTypeFaLabel: "زاویه خودرو", + captureKey: "front", + captureKeyFaLabel: "نمای جلو", + }); + }); }); diff --git a/src/expert-insurer/helper/timeline-fa-labels.ts b/src/expert-insurer/helper/timeline-fa-labels.ts index c41e110..600da67 100644 --- a/src/expert-insurer/helper/timeline-fa-labels.ts +++ b/src/expert-insurer/helper/timeline-fa-labels.ts @@ -9,7 +9,7 @@ * Usage: * const label = EVENT_TYPE_FA_LABELS[event.type] * ?? resolveStepCompletedFaLabel(event) - * ?? event.type; + * ?? "رویداد پرونده"; */ // --------------------------------------------------------------------------- @@ -40,6 +40,16 @@ export const EVENT_TYPE_FA_LABELS: Record = { // First video FIRST_VIDEO_UPLOADED: "ویدیوی اول بارگذاری شد", + // Legacy first/second-party self-service flow + FIRST_INITIAL_FORM_SUBMITTED: "فرم اولیه طرف اول ثبت شد", + SECOND_INITIAL_FORM_SUBMITTED: "فرم اولیه طرف دوم ثبت شد", + FIRST_LOCATION_SUBMITTED: "موقعیت مکانی طرف اول ثبت شد", + SECOND_LOCATION_SUBMITTED: "موقعیت مکانی طرف دوم ثبت شد", + FIRST_VOICE_UPLOADED: "صدای طرف اول بارگذاری شد", + SECOND_VOICE_UPLOADED: "صدای طرف دوم بارگذاری شد", + FIRST_DESCRIPTION_SUBMITTED: "توضیحات طرف اول ثبت شد", + SECOND_DESCRIPTION_SUBMITTED: "توضیحات طرف دوم ثبت شد", + // Second party invitation SECOND_PARTY_INVITED: "طرف دوم دعوت شد", @@ -101,6 +111,12 @@ export const EVENT_TYPE_FA_LABELS: Record = { EXPERT_RESEND_FULFILLED: "مدارک درخواستی ارسال شد", IN_PERSON_VISIT_REQUESTED: "کارشناس بازدید حضوری درخواست کرد", EXPERT_DAMAGED_PARTS_UPDATED: "کارشناس قطعات آسیب‌دیده را به‌روزرسانی کرد", + EXPERT_REPLY_SUBMITTED: "پاسخ کارشناس ثبت شد", + EXPERT_FINAL_REPLY_SUBMITTED: "پاسخ نهایی کارشناس ثبت شد", + FACTORS_REJECTED_REPRICED_AUTO_COMPLETED: + "بررسی فاکتورها با قیمت‌گذاری مجدد تکمیل شد", + FACTORS_VALIDATED_ALL_APPROVED_AUTO_COMPLETED: + "همه فاکتورها تأیید و پرونده تکمیل شد", // Workflow steps (generic) STEP_COMPLETED: "مرحله تکمیل شد", @@ -171,6 +187,82 @@ export const STEP_KEY_FA_LABELS: Record = { CLAIM_COMPLETED: "پرونده خسارت تکمیل شد", }; +const TIMELINE_STEP_FA_LABELS: Record = { + ...STEP_KEY_FA_LABELS, + FIRST_VIDEO: "ویدیوی طرف اول", + SECOND_VIDEO: "ویدیوی طرف دوم", +}; + +const ROLE_FA_LABELS: Record = { + FIRST: "طرف اول", + SECOND: "طرف دوم", + FIRST_PARTY: "طرف اول", + SECOND_PARTY: "طرف دوم", +}; + +const VEHICLE_TYPE_FA_LABELS: Record = { + sedan: "سواری", + suv: "شاسی‌بلند", + hatchback: "هاچ‌بک", + pickup: "وانت", + van: "ون", +}; + +const CAPTURE_TYPE_FA_LABELS: Record = { + angle: "زاویه خودرو", + part: "قطعه آسیب‌دیده", +}; + +const CAPTURE_KEY_FA_LABELS: Record = { + front: "نمای جلو", + back: "نمای عقب", + left: "نمای چپ", + right: "نمای راست", + top: "نمای بالا", +}; + +const DOCUMENT_KEY_FA_LABELS: Record = { + car_green_card: "کارت سبز خودرو", + car_certificate: "سند خودرو", + national_card: "کارت ملی", + damaged_driving_license_front: "روی گواهینامه طرف آسیب‌دیده", + damaged_driving_license_back: "پشت گواهینامه طرف آسیب‌دیده", + damaged_chassis_number: "شماره شاسی خودروی آسیب‌دیده", + damaged_engine_photo: "تصویر موتور خودروی آسیب‌دیده", + damaged_car_card_front: "روی کارت خودروی آسیب‌دیده", + damaged_car_card_back: "پشت کارت خودروی آسیب‌دیده", + damaged_metal_plate: "پلاک خودروی آسیب‌دیده", + guilty_driving_license_front: "روی گواهینامه طرف مقصر", + guilty_driving_license_back: "پشت گواهینامه طرف مقصر", + guilty_car_card_front: "روی کارت خودروی مقصر", + guilty_car_card_back: "پشت کارت خودروی مقصر", + guilty_metal_plate: "پلاک خودروی مقصر", + guilty_damage_area: "ناحیه آسیب‌دیده خودروی مقصر", +}; + +const CLIENT_FA_LABELS: Record = { + parsian: "پارسیان", +}; + +const REPLY_FIELD_FA_LABELS: Record = { + damageExpertReply: "پاسخ کارشناس خسارت", + damageExpertReplyFinal: "پاسخ نهایی کارشناس خسارت", +}; + +const TIMELINE_SYSTEM_TEXT_FA: Record = { + "Expert assigned via review assign endpoint": + "پرونده از طریق فرایند بررسی به کارشناس تخصیص داده شد.", + "Expert assigned for factor validation review": + "پرونده برای بررسی و تأیید فاکتورها به کارشناس تخصیص داده شد.", + "Field expert portfolio lock (no status change)": + "پرونده برای بررسی در اختیار کارشناس میدانی قرار گرفت.", + "Fanavaran expertise payload is not ready.": + "اطلاعات کارشناسی برای ارسال به فناوران آماده نیست.", +}; + +const GENERIC_EVENT_FA_LABEL = "رویداد پرونده"; +const GENERIC_EVENT_FA_DESCRIPTION = "رویدادی در روند پرونده ثبت شد."; + /** * Persian descriptions for workflow-step events. These are deliberately * complete sentences because the insurer timeline exposes them as the event @@ -222,7 +314,7 @@ export function getEventFaLabel(event: { return ( resolveStepCompletedFaLabel(event) ?? EVENT_TYPE_FA_LABELS[event.type] ?? - event.type + GENERIC_EVENT_FA_LABEL ); } @@ -242,6 +334,124 @@ export function getEventFaDescription(event: { return STEP_KEY_FA_DESCRIPTIONS[stepKey]; } - const label = getEventFaLabel(event); - return label === event.type ? "رویدادی در روند پرونده ثبت شد." : `${label}.`; + const label = + resolveStepCompletedFaLabel(event) ?? EVENT_TYPE_FA_LABELS[event.type]; + return label ? `${label}.` : GENERIC_EVENT_FA_DESCRIPTION; +} + +function containsLatinText(value: string): boolean { + return /[A-Za-z]/.test(value); +} + +function localizeHumanReadableMetadataText( + value: unknown, + event: { type: string; metadata?: Record }, + field: "note" | "error" | "message" | "warning", +): unknown { + if (typeof value !== "string" || !containsLatinText(value)) return value; + + const knownTranslation = TIMELINE_SYSTEM_TEXT_FA[value.trim()]; + if (knownTranslation) return knownTranslation; + + if (field === "error" || field === "warning") { + return event.type.endsWith("_FAILED") + ? getEventFaDescription(event) + : "در اجرای این عملیات خطایی رخ داد."; + } + + return field === "note" + ? "یادداشت سیستمی این رویداد ثبت شد." + : getEventFaDescription(event); +} + +/** + * Builds presentation-safe timeline metadata. Raw identifiers and enum values + * remain available for integrations; Persian companion labels are added for + * UI rendering, while persisted human-readable English text is replaced. + */ +export function localizeTimelineMetadata(event: { + type: string; + metadata?: Record | null; +}): Record | null { + if (!event.metadata) return null; + + const metadata: Record = { ...event.metadata }; + + if (Object.prototype.hasOwnProperty.call(metadata, "description")) { + metadata.description = getEventFaDescription(event); + } + + for (const field of ["note", "error", "message", "warning"] as const) { + if (Object.prototype.hasOwnProperty.call(metadata, field)) { + metadata[field] = localizeHumanReadableMetadataText( + metadata[field], + event, + field, + ); + } + } + + if (Array.isArray(metadata.warnings)) { + metadata.warnings = metadata.warnings.map((warning) => + localizeHumanReadableMetadataText(warning, event, "warning"), + ); + } + + const role = typeof metadata.role === "string" ? metadata.role : undefined; + if (role && ROLE_FA_LABELS[role]) { + metadata.roleFaLabel = ROLE_FA_LABELS[role]; + } + + const stepKey = + typeof metadata.stepKey === "string" ? metadata.stepKey : undefined; + if (stepKey && TIMELINE_STEP_FA_LABELS[stepKey]) { + metadata.stepFaLabel = TIMELINE_STEP_FA_LABELS[stepKey]; + } + + const carType = + typeof metadata.carType === "string" ? metadata.carType : undefined; + if (carType && VEHICLE_TYPE_FA_LABELS[carType]) { + metadata.carTypeFaLabel = VEHICLE_TYPE_FA_LABELS[carType]; + } + + const captureType = + typeof metadata.captureType === "string" ? metadata.captureType : undefined; + if (captureType && CAPTURE_TYPE_FA_LABELS[captureType]) { + metadata.captureTypeFaLabel = CAPTURE_TYPE_FA_LABELS[captureType]; + } + + const captureKey = + typeof metadata.captureKey === "string" ? metadata.captureKey : undefined; + if (captureKey && CAPTURE_KEY_FA_LABELS[captureKey]) { + metadata.captureKeyFaLabel = CAPTURE_KEY_FA_LABELS[captureKey]; + } + + const resolvedCarAngleKey = + typeof metadata.resolvedCarAngleKey === "string" + ? metadata.resolvedCarAngleKey + : undefined; + if (resolvedCarAngleKey && CAPTURE_KEY_FA_LABELS[resolvedCarAngleKey]) { + metadata.resolvedCarAngleFaLabel = + CAPTURE_KEY_FA_LABELS[resolvedCarAngleKey]; + } + + const documentKey = + typeof metadata.documentKey === "string" ? metadata.documentKey : undefined; + if (documentKey && DOCUMENT_KEY_FA_LABELS[documentKey]) { + metadata.documentFaLabel = DOCUMENT_KEY_FA_LABELS[documentKey]; + } + + const clientKey = + typeof metadata.clientKey === "string" ? metadata.clientKey : undefined; + if (clientKey && CLIENT_FA_LABELS[clientKey]) { + metadata.clientFaLabel = CLIENT_FA_LABELS[clientKey]; + } + + const replyField = + typeof metadata.replyField === "string" ? metadata.replyField : undefined; + if (replyField && REPLY_FIELD_FA_LABELS[replyField]) { + metadata.replyFieldFaLabel = REPLY_FIELD_FA_LABELS[replyField]; + } + + return metadata; } From 0bab93e98399c51efd908c095447a0c6299c62e5 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Sat, 12 Sep 2026 11:58:24 +0330 Subject: [PATCH 2/2] Fixed seed data --- scripts/seed-insurer-reports-fixtures.ts | 72 ++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 6 deletions(-) diff --git a/scripts/seed-insurer-reports-fixtures.ts b/scripts/seed-insurer-reports-fixtures.ts index 7659bef..58a2d29 100644 --- a/scripts/seed-insurer-reports-fixtures.ts +++ b/scripts/seed-insurer-reports-fixtures.ts @@ -22,6 +22,8 @@ * SEED_REPORTS_TAG=reports-load-v1 * SEED_REPORTS_PUBLIC_PREFIX=RPT1 * SEED_REPORTS_DEFAULT_PASSWORD=Reports@724 + * SEED_REPORTS_PURGE_ONLY=true # remove seeded data; do not insert it again + * SEED_REPORTS_PURGE_ALL=true # with PURGE_ONLY, remove every reports seed tag */ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; @@ -652,24 +654,31 @@ function randNationalBase(prefix: string): string { return n.padEnd(6, "7"); } -async function purgeSeedPortfolio(db: mongoose.mongo.Db, seedTag: string) { +async function purgeSeedPortfolio( + db: mongoose.mongo.Db, + seedTag?: string, +) { const blameCases = db.collection("blameCases"); const claimCases = db.collection("claimCases"); const expertFileActivities = db.collection("expertFileActivities"); + const fixtureFilter = { + "seedMeta.script": SCRIPT_NAME, + ...(seedTag ? { "seedMeta.tag": seedTag } : {}), + }; const [existingBlames, existingClaims] = await Promise.all([ - blameCases.find({ "seedMeta.script": SCRIPT_NAME, "seedMeta.tag": seedTag }, { projection: { _id: 1 } }).toArray(), - claimCases.find({ "seedMeta.script": SCRIPT_NAME, "seedMeta.tag": seedTag }, { projection: { _id: 1 } }).toArray(), + blameCases.find(fixtureFilter, { projection: { _id: 1 } }).toArray(), + claimCases.find(fixtureFilter, { projection: { _id: 1 } }).toArray(), ]); const fileIds = [...existingBlames, ...existingClaims].map((d) => asObjectId(d._id)); const [deletedBlames, deletedClaims, deletedActivities] = await Promise.all([ - blameCases.deleteMany({ "seedMeta.script": SCRIPT_NAME, "seedMeta.tag": seedTag }), - claimCases.deleteMany({ "seedMeta.script": SCRIPT_NAME, "seedMeta.tag": seedTag }), + blameCases.deleteMany(fixtureFilter), + claimCases.deleteMany(fixtureFilter), expertFileActivities.deleteMany({ $or: [ - { "seedMeta.script": SCRIPT_NAME, "seedMeta.tag": seedTag }, + fixtureFilter, ...(fileIds.length ? [{ fileId: { $in: fileIds } }] : []), ], }), @@ -682,6 +691,32 @@ async function purgeSeedPortfolio(db: mongoose.mongo.Db, seedTag: string) { }; } +/** + * The portfolio seed creates a shared synthetic roster and three report-only + * branches. Remove those too once no fixture portfolio remains, so a purge + * actually returns the insurer tenant to its pre-fixture state. + */ +async function purgeSeedSupportData(db: mongoose.mongo.Db) { + const collectionNames = [ + "branches", + "expert", + "damage-expert", + "field-expert", + "file-maker", + "file-reviewer", + "call-center-agents", + ]; + const deleted = await Promise.all( + collectionNames.map(async (collectionName) => { + const result = await db + .collection(collectionName) + .deleteMany({ "seedMeta.script": SCRIPT_NAME }); + return [collectionName, result.deletedCount ?? 0] as const; + }), + ); + return Object.fromEntries(deleted); +} + function scaleBucketCounts(total: number): Record { const keys = Object.keys(BUCKET_WEIGHTS) as UnifiedBucket[]; const raw = keys.map((key) => ({ key, exact: BUCKET_WEIGHTS[key] * total })); @@ -2307,6 +2342,8 @@ async function main() { const mongoUri = resolveMongoUri(); const count = Math.max(1, Number(process.env.SEED_REPORTS_COUNT?.trim() || "1000")); const seedTag = process.env.SEED_REPORTS_TAG?.trim() || "reports-load-v1"; + const purgeOnly = process.env.SEED_REPORTS_PURGE_ONLY?.trim() === "true"; + const purgeAll = process.env.SEED_REPORTS_PURGE_ALL?.trim() === "true"; const publicPrefix = process.env.SEED_REPORTS_PUBLIC_PREFIX?.trim() || `RPT${shortHash(seedTag, 3)}`; const defaultPassword = @@ -2325,6 +2362,29 @@ async function main() { const clientLabel = client?.clientName?.persian || client?.clientName?.english || String(client._id); + if (purgeOnly) { + const purge = await purgeSeedPortfolio(db, purgeAll ? undefined : seedTag); + const remainingPortfolio = await Promise.all([ + db.collection("blameCases").countDocuments({ "seedMeta.script": SCRIPT_NAME }), + db.collection("claimCases").countDocuments({ "seedMeta.script": SCRIPT_NAME }), + ]); + const supportData = + remainingPortfolio[0] + remainingPortfolio[1] === 0 + ? await purgeSeedSupportData(db) + : null; + + console.log("\n=== Insurer reports seed purge complete ==="); + console.log(`Client : ${clientLabel} (${clientId.toHexString()})`); + console.log(`Seed scope : ${purgeAll ? "all tags" : seedTag}`); + console.log(`Removed : ${purge.deletedBlames} blame, ${purge.deletedClaims} claim, ${purge.deletedActivities} activities`); + if (supportData) { + console.log("Support data :", supportData); + } else { + console.log("Support data : retained because other report-fixture portfolios remain"); + } + return; + } + const rng = mulberry32(seedToInt(`${seedTag}:${clientId.toHexString()}:${count}`)); const passwordHash = await hashPassword(defaultPassword); const branches = await ensureBranches(db, clientId, String(clientLabel));