From 2fc6015213b5d69ea7621d03aca81e9032846944 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Tue, 23 Jun 2026 15:56:19 +0330 Subject: [PATCH] PDF generation --- src/app.module.ts | 2 + .../case-expert-report-pdf.service.ts | 36 ++ .../case-expert-report.builder.ts | 518 ++++++++++++++++++ .../case-expert-report.controller.ts | 68 +++ .../case-expert-report.module.ts | 13 + .../case-expert-report.service.ts | 30 + .../case-expert-report.types.ts | 21 + .../persian-report-labels.ts | 196 +++++++ src/expert-insurer/expert-insurer.module.ts | 1 + src/helpers/blame-damaged-party.ts | 57 ++ src/helpers/persian-pdf-document.ts | 152 +++++ src/helpers/persian-pdf-text.ts | 27 + src/helpers/simple-pdf-writer.ts | 166 ++++++ 13 files changed, 1287 insertions(+) create mode 100644 src/case-expert-report/case-expert-report-pdf.service.ts create mode 100644 src/case-expert-report/case-expert-report.builder.ts create mode 100644 src/case-expert-report/case-expert-report.controller.ts create mode 100644 src/case-expert-report/case-expert-report.module.ts create mode 100644 src/case-expert-report/case-expert-report.service.ts create mode 100644 src/case-expert-report/case-expert-report.types.ts create mode 100644 src/case-expert-report/persian-report-labels.ts create mode 100644 src/helpers/persian-pdf-document.ts create mode 100644 src/helpers/persian-pdf-text.ts create mode 100644 src/helpers/simple-pdf-writer.ts diff --git a/src/app.module.ts b/src/app.module.ts index 722909f..bb62079 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -12,6 +12,7 @@ import { ClientModule } from "./client/client.module"; import { ExpertBlameModule } from "./expert-blame/expert-blame.module"; import { ExpertClaimModule } from "./expert-claim/expert-claim.module"; import { ExpertInsurerModule } from "./expert-insurer/expert-insurer.module"; +import { CaseExpertReportModule } from "./case-expert-report/case-expert-report.module"; import { LookupsModule } from "./lookups/lookups.module"; import { PlatesModule } from "./plates/plates.module"; import { ProfileModule } from "./profile/profile.module"; @@ -50,6 +51,7 @@ import { AppConfigModule } from "./core/config/config.module"; ExpertBlameModule, ClaimRequestManagementModule, ExpertClaimModule, + CaseExpertReportModule, AiModule, ReportsModule, ExpertInsurerModule, diff --git a/src/case-expert-report/case-expert-report-pdf.service.ts b/src/case-expert-report/case-expert-report-pdf.service.ts new file mode 100644 index 0000000..ab270ad --- /dev/null +++ b/src/case-expert-report/case-expert-report-pdf.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from "@nestjs/common"; +import { + createPersianPdfDocument, + persianPdfToBuffer, +} from "src/helpers/persian-pdf-document"; +import { + InsurerFileReportPdfResult, + InsurerFileReportViewModel, +} from "./case-expert-report.types"; +import { PR } from "./persian-report-labels"; + +@Injectable() +export class CaseExpertReportPdfService { + async render(model: InsurerFileReportViewModel): Promise { + const pdf = createPersianPdfDocument(); + pdf.addTitle(model.title); + pdf.addKeyValue(PR.publicId, model.publicId); + pdf.addKeyValue(PR.requestNo, model.requestNo); + pdf.addBlank(); + + for (const section of model.sections) { + pdf.addSection(section.title); + for (const field of section.fields) { + pdf.addKeyValue(field.label, field.value); + } + pdf.addBlank(); + } + + const buffer = await persianPdfToBuffer(pdf); + const safeId = (model.publicId || "file").replace(/[^\w.-]+/g, "_"); + return { + buffer, + filename: `insurer-file-report-${safeId}.pdf`, + }; + } +} diff --git a/src/case-expert-report/case-expert-report.builder.ts b/src/case-expert-report/case-expert-report.builder.ts new file mode 100644 index 0000000..6d585f4 --- /dev/null +++ b/src/case-expert-report/case-expert-report.builder.ts @@ -0,0 +1,518 @@ +import { resolveDamagedPartyRow } from "src/helpers/blame-damaged-party"; +import { toJalaliDateAndTime } from "src/helpers/date-jalali"; +import { PartyRole } from "src/request-management/entities/schema/partyRole.enum"; +import { + InsurerFileReportField, + InsurerFileReportSection, + InsurerFileReportViewModel, +} from "./case-expert-report.types"; +import { PR, persianFieldPath, persianStatus } from "./persian-report-labels"; + +const SKIP_FLATTEN_KEYS = new Set([ + "_id", + "__v", + "history", + "workflow", + "evidence", + "confirmation", +]); + +function asString(value: unknown): string | undefined { + if (value === undefined || value === null || value === "") return undefined; + if (value instanceof Date) { + const [d, t] = toJalaliDateAndTime(value); + return `${d} ${t}`; + } + if (typeof value === "object") { + if (typeof (value as { toString?: () => string }).toString === "function") { + const s = String(value); + if (s !== "[object Object]") return s; + } + return undefined; + } + return String(value); +} + +function formatBirthDate(value: unknown): string | undefined { + if (value === undefined || value === null || value === "") return undefined; + const raw = String(value); + if (/^\d{8}$/.test(raw)) { + return `${raw.slice(0, 4)}/${raw.slice(4, 6)}/${raw.slice(6, 8)}`; + } + return raw; +} + +function flattenObject( + obj: unknown, + prefix = "", + depth = 0, +): InsurerFileReportField[] { + if (obj == null) return []; + if (depth > 4) { + return [{ label: persianFieldPath(prefix), value: asString(obj) }]; + } + + if (Array.isArray(obj)) { + if (!obj.length) return []; + return [ + { + label: persianFieldPath(prefix || "items"), + value: obj.map((item) => asString(item) ?? JSON.stringify(item)).join("، "), + }, + ]; + } + + if (typeof obj !== "object") { + return [{ label: persianFieldPath(prefix || "value"), value: asString(obj) }]; + } + + const rows: InsurerFileReportField[] = []; + const objRecord = obj as Record; + const hasMapped = + objRecord.mapped != null && typeof objRecord.mapped === "object"; + for (const [key, value] of Object.entries(objRecord)) { + if (SKIP_FLATTEN_KEYS.has(key)) continue; + if (key === "raw" && hasMapped) continue; + if (value === undefined || value === null || value === "") continue; + + const path = prefix ? `${prefix}.${key}` : key; + if ( + typeof value === "object" && + !Array.isArray(value) && + !(value instanceof Date) + ) { + rows.push(...flattenObject(value, path, depth + 1)); + } else { + rows.push({ label: persianFieldPath(path), value: asString(value) }); + } + } + return rows; +} + +function expertNameFromSnapshot(snapshot?: { + firstName?: string; + lastName?: string; +}): string | undefined { + if (!snapshot) return undefined; + const name = [snapshot.firstName, snapshot.lastName].filter(Boolean).join(" "); + return name || undefined; +} + +function collectExpertNames( + blame?: Record | null, + claim?: Record | null, +): string | undefined { + const names = new Set(); + + const blameDecision = ( + blame?.expert as Record | undefined + )?.decision as Record | undefined; + const blameExpert = expertNameFromSnapshot( + blameDecision?.expertProfileSnapshot as + | { firstName?: string; lastName?: string } + | undefined, + ); + if (blameExpert) names.add(blameExpert); + + const evaluation = claim?.evaluation as Record | undefined; + for (const key of ["damageExpertReplyFinal", "damageExpertReply"] as const) { + const reply = evaluation?.[key] as Record | undefined; + if (!reply) continue; + const actor = (reply.actorDetail as { actorName?: string } | undefined) + ?.actorName; + if (actor) names.add(actor); + const snap = expertNameFromSnapshot( + reply.expertProfileSnapshot as + | { firstName?: string; lastName?: string } + | undefined, + ); + if (snap) names.add(snap); + } + + return names.size ? [...names].join(", ") : undefined; +} + +function inquiryRoleData( + inquiries: Record | undefined, + key: string, + role?: string, +): Record | undefined { + const block = inquiries?.[key] as Record | undefined; + const data = block?.data as Record | undefined; + if (!data) return undefined; + if (role && data[role] && typeof data[role] === "object") { + return data[role] as Record; + } + return data; +} + +/** Prefer mapped Tejarat fields; avoid duplicating the entire raw blob in PDF. */ +function pickInquiryReportPayload( + inquiry?: Record, +): Record | undefined { + if (!inquiry) return undefined; + const mapped = inquiry.mapped; + if (mapped && typeof mapped === "object" && !Array.isArray(mapped)) { + return mapped as Record; + } + const { raw: _raw, ...rest } = inquiry; + return Object.keys(rest).length ? rest : inquiry; +} + +function licenseFieldsFromInquiry( + inquiry?: Record, +): { licenseType?: string; licenseDate?: string } { + if (!inquiry) return {}; + const candidates = [ + inquiry.LicenseType, + inquiry.licenseType, + inquiry.Type, + inquiry.type, + inquiry.LicenseCategory, + inquiry.licenseCategory, + ]; + const licenseType = candidates.find((v) => v != null && v !== ""); + const dateCandidates = [ + inquiry.IssueDate, + inquiry.issueDate, + inquiry.LicenseIssueDate, + inquiry.licenseIssueDate, + inquiry.ExpireDate, + inquiry.expireDate, + ]; + const licenseDate = dateCandidates.find((v) => v != null && v !== ""); + return { + licenseType: asString(licenseType), + licenseDate: asString(licenseDate), + }; +} + +function buildOwnerSection( + damagedParty: ReturnType, + claim?: Record | null, +): InsurerFileReportSection { + const person = damagedParty?.person; + const money = claim?.money as + | { sheba?: string; nationalCodeOfInsurer?: string } + | undefined; + const claimOwner = claim?.owner as { fullName?: string } | undefined; + + return { + title: PR.ownerSection, + fields: [ + { label: PR.name, value: person?.fullName ?? claimOwner?.fullName }, + { label: PR.phone, value: person?.phoneNumber }, + { + label: PR.nationalCode, + value: person?.nationalCodeOfInsurer ?? money?.nationalCodeOfInsurer, + }, + { + label: PR.birthDate, + value: formatBirthDate(person?.insurerBirthday ?? person?.birthday), + }, + { label: PR.sheba, value: money?.sheba }, + ], + }; +} + +function buildDriverSection( + damagedParty: ReturnType, + blame?: Record | null, +): InsurerFileReportSection | undefined { + const person = damagedParty?.person; + if (!person || person.driverIsInsurer !== false) return undefined; + + const role = damagedParty?.role ?? PartyRole.FIRST; + const licenseInquiry = inquiryRoleData( + blame?.inquiries as Record | undefined, + "drivingLicence", + role, + ); + const { licenseType, licenseDate } = licenseFieldsFromInquiry(licenseInquiry); + + return { + title: PR.driverSection, + fields: [ + { label: PR.name, value: person.fullName }, + { + label: PR.licenseType, + value: licenseType ?? (person.driverLicense ? PR.driverLicense : undefined), + }, + { + label: PR.licenseDate, + value: licenseDate ?? person.driverLicense, + }, + { label: PR.phone, value: person.phoneNumber }, + { label: PR.nationalCode, value: person.nationalCodeOfDriver }, + { label: PR.birthDate, value: formatBirthDate(person.driverBirthday) }, + { label: PR.licenseNumber, value: person.driverLicense }, + ], + }; +} + +function buildInsuranceSection( + damagedParty: ReturnType, + blame?: Record | null, + claim?: Record | null, +): InsurerFileReportSection { + const role = damagedParty?.role ?? PartyRole.FIRST; + const insurance = damagedParty?.insurance ?? {}; + const carBodyLegacy = blame?.carBodyInsuranceDetail as + | Record + | undefined; + + const thirdPartyInquiry = inquiryRoleData( + blame?.inquiries as Record | undefined, + "thirdParty", + role, + ); + const carBodyInquiry = inquiryRoleData( + blame?.inquiries as Record | undefined, + "carBody", + role, + ); + const claimThirdParty = inquiryRoleData( + claim?.inquiries as Record | undefined, + "thirdParty", + role, + ); + const claimCarBody = inquiryRoleData( + claim?.inquiries as Record | undefined, + "carBody", + role, + ); + + const fields: InsurerFileReportField[] = [ + ...flattenObject(insurance, "party.insurance"), + ...flattenObject( + (insurance as { carBodyInsurance?: unknown }).carBodyInsurance, + "party.insurance.carBodyInsurance", + ), + ...flattenObject( + pickInquiryReportPayload(thirdPartyInquiry), + "inquiry.thirdParty", + ), + ...flattenObject(pickInquiryReportPayload(carBodyInquiry), "inquiry.carBody"), + ...flattenObject( + pickInquiryReportPayload(claimThirdParty), + "claim.inquiry.thirdParty", + ), + ...flattenObject( + pickInquiryReportPayload(claimCarBody), + "claim.inquiry.carBody", + ), + ...flattenObject(carBodyLegacy, "blame.carBodyInsuranceDetail"), + ]; + + const deduped = dedupeFields(fields); + return { + title: PR.insuranceSection, + fields: deduped.length ? deduped : [{ label: PR.data, value: PR.empty }], + }; +} + +function buildVehicleSection( + damagedParty: ReturnType, + claim?: Record | null, +): InsurerFileReportSection { + const partyVehicle = damagedParty?.vehicle; + const claimVehicle = claim?.vehicle as Record | undefined; + + const fields = dedupeFields([ + ...flattenObject(claimVehicle, "claim.vehicle"), + ...flattenObject(partyVehicle, "party.vehicle"), + ]); + + return { + title: PR.vehicleSection, + fields: fields.length ? fields : [{ label: PR.data, value: PR.empty }], + }; +} + +function buildAccidentReportSection( + blame?: Record | null, + claim?: Record | null, + damagedParty?: ReturnType, +): InsurerFileReportSection { + const statement = damagedParty?.statement as + | Record + | undefined; + const location = damagedParty?.location; + const snapshotAccident = ( + claim?.snapshot as { accident?: Record } | undefined + )?.accident; + const blameDecision = ( + blame?.expert as Record | undefined + )?.decision as Record | undefined; + const decisionFields = blameDecision?.fields as + | Record + | undefined; + + const accidentDate = + asString(statement?.accidentDate) ?? + asString(snapshotAccident?.date) ?? + asString(blame?.createdAtFormatted) ?? + asString(blame?.createdAt); + + const accidentTime = + asString(statement?.accidentTime) ?? asString(snapshotAccident?.time); + + const fields: InsurerFileReportField[] = [ + { label: PR.date, value: accidentDate }, + { label: PR.time, value: accidentTime }, + { + label: PR.experts, + value: collectExpertNames(blame, claim), + }, + { + label: PR.location, + value: + location?.lat != null && location?.lon != null + ? `${location.lat}، ${location.lon}` + : undefined, + }, + { + label: PR.weather, + value: + asString(statement?.weatherCondition) ?? + asString(snapshotAccident?.weatherCondition), + }, + { + label: PR.road, + value: + asString(statement?.roadCondition) ?? + asString(snapshotAccident?.roadCondition), + }, + { + label: PR.light, + value: + asString(statement?.lightCondition) ?? + asString(snapshotAccident?.lightCondition), + }, + { + label: PR.blameStatus, + value: persianStatus(blame?.blameStatus), + }, + { + label: PR.claimStatus, + value: persianStatus(claim?.claimStatus), + }, + { label: PR.expertDecision, value: asString(blameDecision?.description) }, + { + label: PR.accidentWay, + value: asString( + (decisionFields?.accidentWay as { label?: string } | undefined)?.label ?? + ( + snapshotAccident?.classification as { + accidentWay?: { label?: string }; + } + )?.accidentWay?.label, + ), + }, + { + label: PR.accidentReason, + value: asString( + (decisionFields?.accidentReason as { label?: string } | undefined) + ?.label ?? + ( + snapshotAccident?.classification as { + accidentReason?: { label?: string }; + } + )?.accidentReason?.label, + ), + }, + { + label: PR.accidentType, + value: asString( + (decisionFields?.accidentType as { label?: string } | undefined)?.label ?? + ( + snapshotAccident?.classification as { + accidentType?: { label?: string }; + } + )?.accidentType?.label, + ), + }, + { + label: PR.damageExpertDate, + value: asString( + ( + (claim?.evaluation as Record | undefined) + ?.damageExpertReplyFinal as Record | undefined + )?.submittedAt ?? + ( + (claim?.evaluation as Record | undefined) + ?.damageExpertReply as Record | undefined + )?.submittedAt, + ), + }, + { + label: PR.damageExpertNotes, + value: asString( + ( + (claim?.evaluation as Record | undefined) + ?.damageExpertReplyFinal as Record | undefined + )?.description ?? + ( + (claim?.evaluation as Record | undefined) + ?.damageExpertReply as Record | undefined + )?.description, + ), + }, + { + label: PR.partyDescription, + value: asString(statement?.description), + }, + ]; + + return { + title: PR.accidentSection, + fields: dedupeFields(fields), + }; +} + +function dedupeFields(fields: InsurerFileReportField[]): InsurerFileReportField[] { + const seen = new Set(); + const out: InsurerFileReportField[] = []; + for (const field of fields) { + const value = asString(field.value); + if (!value) continue; + const key = `${field.label}::${value}`; + if (seen.has(key)) continue; + seen.add(key); + out.push({ label: field.label, value }); + } + return out; +} + +export function buildInsurerFileReport( + file: { + overview?: Record; + blame?: Record; + claim?: Record; + }, +): InsurerFileReportViewModel { + const overview = file.overview ?? {}; + const blame = file.blame ?? null; + const claim = file.claim ?? null; + const damagedParty = blame ? resolveDamagedPartyRow(blame) : null; + + const sections: InsurerFileReportSection[] = [ + buildOwnerSection(damagedParty, claim), + ]; + + const driverSection = buildDriverSection(damagedParty, blame); + if (driverSection) sections.push(driverSection); + + sections.push( + buildInsuranceSection(damagedParty, blame, claim), + buildVehicleSection(damagedParty, claim), + buildAccidentReportSection(blame, claim, damagedParty), + ); + + return { + title: PR.reportTitle, + publicId: asString(overview.publicId) ?? PR.empty, + requestNo: asString(overview.requestNo), + sections, + }; +} diff --git a/src/case-expert-report/case-expert-report.controller.ts b/src/case-expert-report/case-expert-report.controller.ts new file mode 100644 index 0000000..362a8dd --- /dev/null +++ b/src/case-expert-report/case-expert-report.controller.ts @@ -0,0 +1,68 @@ +import { + Controller, + Get, + HttpException, + InternalServerErrorException, + Param, + StreamableFile, + UseGuards, +} from "@nestjs/common"; +import { + ApiBearerAuth, + ApiOperation, + ApiParam, + ApiProduces, + ApiResponse, + ApiTags, +} from "@nestjs/swagger"; +import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard"; +import { RolesGuard } from "src/auth/guards/role.guard"; +import { Roles } from "src/decorators/roles.decorator"; +import { CurrentUser } from "src/decorators/user.decorator"; +import { RoleEnum } from "src/Types&Enums/role.enum"; +import { CaseExpertReportService } from "./case-expert-report.service"; + +@ApiTags("expert-insurer-panel") +@Controller("expert-insurer") +@ApiBearerAuth() +@UseGuards(LocalActorAuthGuard, RolesGuard) +@Roles(RoleEnum.COMPANY) +export class CaseExpertReportInsurerController { + constructor( + private readonly caseExpertReportService: CaseExpertReportService, + ) {} + + @Get("files/:publicId/report.pdf") + @ApiOperation({ + summary: "Download insurer file report PDF", + description: + "Generates a PDF for the shared publicId (blame + claim combined): damaged owner, driver when different, insurance, vehicle, and accident report sections.", + }) + @ApiParam({ name: "publicId" }) + @ApiProduces("application/pdf") + @ApiResponse({ status: 200, description: "PDF file" }) + @ApiResponse({ status: 404, description: "File not found for this publicId" }) + async downloadInsurerReport( + @CurrentUser() insurer: { clientKey?: string }, + @Param("publicId") publicId: string, + ): Promise { + try { + const { buffer, filename } = + await this.caseExpertReportService.generateForInsurer( + publicId, + insurer, + ); + return new StreamableFile(buffer, { + type: "application/pdf", + disposition: `attachment; filename="${filename}"`, + }); + } catch (error) { + if (error instanceof HttpException) throw error; + throw new InternalServerErrorException( + error instanceof Error + ? error.message + : "Failed to generate insurer file report PDF", + ); + } + } +} diff --git a/src/case-expert-report/case-expert-report.module.ts b/src/case-expert-report/case-expert-report.module.ts new file mode 100644 index 0000000..5d8c302 --- /dev/null +++ b/src/case-expert-report/case-expert-report.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { ExpertInsurerModule } from "src/expert-insurer/expert-insurer.module"; +import { CaseExpertReportInsurerController } from "./case-expert-report.controller"; +import { CaseExpertReportPdfService } from "./case-expert-report-pdf.service"; +import { CaseExpertReportService } from "./case-expert-report.service"; + +@Module({ + imports: [ExpertInsurerModule], + controllers: [CaseExpertReportInsurerController], + providers: [CaseExpertReportService, CaseExpertReportPdfService], + exports: [CaseExpertReportService], +}) +export class CaseExpertReportModule {} diff --git a/src/case-expert-report/case-expert-report.service.ts b/src/case-expert-report/case-expert-report.service.ts new file mode 100644 index 0000000..8952c81 --- /dev/null +++ b/src/case-expert-report/case-expert-report.service.ts @@ -0,0 +1,30 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { ExpertInsurerService } from "src/expert-insurer/expert-insurer.service"; +import { buildInsurerFileReport } from "./case-expert-report.builder"; +import { CaseExpertReportPdfService } from "./case-expert-report-pdf.service"; +import { InsurerFileReportPdfResult } from "./case-expert-report.types"; + +@Injectable() +export class CaseExpertReportService { + constructor( + private readonly expertInsurerService: ExpertInsurerService, + private readonly pdfService: CaseExpertReportPdfService, + ) {} + + async generateForInsurer( + publicId: string, + actor: { clientKey?: string }, + ): Promise { + const clientKey = actor?.clientKey; + if (!clientKey) { + throw new NotFoundException("Insurer context not found"); + } + + const file = await this.expertInsurerService.retrieveFileDetailsByPublicId( + clientKey, + publicId, + ); + const model = buildInsurerFileReport(file); + return this.pdfService.render(model); + } +} diff --git a/src/case-expert-report/case-expert-report.types.ts b/src/case-expert-report/case-expert-report.types.ts new file mode 100644 index 0000000..b98cbb1 --- /dev/null +++ b/src/case-expert-report/case-expert-report.types.ts @@ -0,0 +1,21 @@ +export type InsurerFileReportField = { + label: string; + value?: string | number | null; +}; + +export type InsurerFileReportSection = { + title: string; + fields: InsurerFileReportField[]; +}; + +export type InsurerFileReportViewModel = { + title: string; + publicId: string; + requestNo?: string; + sections: InsurerFileReportSection[]; +}; + +export type InsurerFileReportPdfResult = { + buffer: Buffer; + filename: string; +}; diff --git a/src/case-expert-report/persian-report-labels.ts b/src/case-expert-report/persian-report-labels.ts new file mode 100644 index 0000000..ba238bd --- /dev/null +++ b/src/case-expert-report/persian-report-labels.ts @@ -0,0 +1,196 @@ +export const PR = { + reportTitle: "گزارش پرونده بیمه گر", + publicId: "شناسه عمومی", + requestNo: "شماره درخواست", + empty: "-", + ownerSection: "مالک خودروی زیان دیده", + driverSection: "راننده خودروی زیان دیده", + insuranceSection: "اطلاعات بیمه (بدنه و شخص ثالث)", + vehicleSection: "اطلاعات خودرو", + accidentSection: "گزارش حادثه", + name: "نام", + phone: "شماره تلفن", + nationalCode: "کد ملی", + birthDate: "تاریخ تولد", + sheba: "شماره شبا", + licenseType: "نوع گواهینامه", + licenseDate: "تاریخ گواهینامه", + licenseNumber: "شماره گواهینامه", + driverLicense: "گواهینامه راننده", + data: "اطلاعات", + date: "تاریخ", + time: "زمان", + experts: "کارشناس(ان)", + location: "موقعیت (عرض و طول جغرافیایی)", + weather: "وضعیت آب و هوا", + road: "وضعیت جاده", + light: "وضعیت نور", + blameStatus: "وضعیت مقصر", + claimStatus: "وضعیت خسارت", + expertDecision: "نظر کارشناس مقصر", + accidentWay: "نحوه برخورد", + accidentReason: "علت حادثه", + accidentType: "نوع حادثه", + damageExpertDate: "تاریخ ارزیابی کارشناس خسارت", + damageExpertNotes: "توضیحات کارشناس خسارت", + partyDescription: "توضیحات طرف", +} as const; + +const KEY_LABELS: Record = { + policyNumber: "شماره بیمه‌نامه", + company: "شرکت بیمه", + insurerCompany: "شرکت بیمه‌گر", + startDate: "تاریخ شروع", + endDate: "تاریخ پایان", + financialCeiling: "سقف مالی", + coverages: "پوشش‌ها", + plateId: "پلاک", + name: "نام", + model: "مدل", + type: "نوع", + carName: "نام خودرو", + carModel: "مدل خودرو", + carType: "نوع خودرو", + isNew: "خودرو نو", + isNewCar: "خودرو نو", + leftDigits: "دو رقم چپ پلاک", + centerAlphabet: "حرف پلاک", + centerDigits: "سه رقم وسط پلاک", + ir: "کد ایران", + plate: "پلاک", + CompanyName: "نام شرکت", + PolicyNumber: "شماره بیمه‌نامه", + PolicyStartDate: "تاریخ شروع بیمه", + PolicyEndDate: "تاریخ پایان بیمه", + LicenseType: "نوع گواهینامه", + licenseType: "نوع گواهینامه", + IssueDate: "تاریخ صدور", + issueDate: "تاریخ صدور", + ExpireDate: "تاریخ انقضا", + expireDate: "تاریخ انقضا", + party: "طرف", + insurance: "بیمه", + carBodyInsurance: "بیمه بدنه", + inquiry: "استعلام", + thirdParty: "شخص ثالث", + carBody: "بدنه", + claim: "خسارت", + vehicle: "خودرو", + blame: "مقصر", + items: "موارد", + value: "مقدار", + mapped: "نتیجه استعلام", + has: "موجود", + updatedAt: "به‌روزرسانی", + source: "منبع", + PrntPlcyCmpDocNo: "شماره سند شرکت", + MapTypNam: "نام نوع خودرو", + MtrNum: "شماره موتور", + ShsNum: "شماره شاسی", + vin: "VIN", + VinNumberField: "VIN", + DisFnYrPrcnt: "درصد تخفیف مالی", + DisLfYrPrcnt: "درصد تخفیف جانی", + DisPrsnYrPrcnt: "درصد تخفیف شخص ثالث", + MapVehicleSystemName: "سیستم خودرو", + LfCvrCptl: "سرمایه پوشش جانی", + FnCvrCptl: "سرمایه پوشش مالی", + PrsnCvrCptl: "سرمایه پوشش شخص ثالث", + PersonCvrCptl: "سرمایه پوشش شخص", + LifeCvrCptl: "سرمایه پوشش حیات", + FinancialCvrCptl: "سرمایه پوشش مالی", + VehicleSystemCode: "کد سیستم خودرو", + CarGroupCode: "کد گروه خودرو", + CylCnt: "تعداد سیلندر", + LastCompanyDocumentNumber: "شماره سند آخرین شرکت", + UsageCode: "کد کاربری", + MapUsageCode: "کد کاربری نگاشت‌شده", + MapUsageName: "نام کاربری", + Plk1: "دو رقم چپ پلاک", + Plk2: "حرف پلاک", + Plk3: "سه رقم وسط پلاک", + PlkSrl: "کد ایران پلاک", + SystemField: "سیستم", + TypeField: "تیپ", + UsageField: "کاربری", + MainColorField: "رنگ اصلی", + SecondColorField: "رنگ فرعی", + ModelField: "مدل", + CapacityField: "ظرفیت", + CacityField: "ظرفیت", + CylinderNumberField: "تعداد سیلندر", + EngineNumberField: "شماره موتور", + ChassisNumberField: "شماره شاسی", + InstallDateField: "تاریخ نصب", + AxelNumberField: "تعداد محور", + WheelNumberField: "تعداد چرخ", + CompanyCode: "کد شرکت", + SatrtDate: "تاریخ شروع", + EndDate: "تاریخ پایان", + PolicyHealthLossCount: "تعداد خسارت جانی", + PolicyFinancialLossCount: "تعداد خسارت مالی", + PolicyPersonLossCount: "تعداد خسارت شخص ثالث", + Tonage: "تناژ", + ThirdPolicyCode: "کد بیمه‌نامه ثالث", + SystemCodeCii: "کد سیستم", + SystemNameCii: "نام سیستم", + TypeCodeCii: "کد نوع", + TypeNameCii: "نام نوع", + UsageNameCii: "نام کاربری", + UsageCodeCii: "کد کاربری", + ModelCii: "مدل", + StatusTypeCode: "کد وضعیت", + label_fa: "برچسب فارسی", + catalogKey: "کلید کاتالوگ", +}; + +const STATUS_LABELS: Record = { + AGREED: "توافق", + DISAGREEMENT: "اختلاف نظر", + UNKNOWN: "نامشخص", + APPROVED: "تأیید شده", + REJECTED: "رد شده", + NEEDS_REVISION: "نیاز به بازبینی", + UNDER_REVIEW: "در حال بررسی", + PENDING: "در انتظار", + true: "بله", + false: "خیر", +}; + +/** Turn `party.insurance.policyNumber` into a Persian label. */ +export function persianFieldPath(path: string): string { + if (!path) return PR.data; + const parts = path.split(".").filter(Boolean); + const last = parts[parts.length - 1] ?? path; + const translatedLast = KEY_LABELS[last] ?? last; + + const inquiryRoot = parts.find( + (p) => p === "thirdParty" || p === "carBody" || p === "mapped", + ); + if (inquiryRoot === "thirdParty") { + return `بیمه شخص ثالث / ${translatedLast}`; + } + if (inquiryRoot === "carBody") { + return `بیمه بدنه / ${translatedLast}`; + } + if (parts[0] === "party" && parts[1] === "insurance") { + return `بیمه / ${translatedLast}`; + } + if (parts[0] === "claim" && parts[1] === "vehicle") { + return `خودرو / ${translatedLast}`; + } + if (parts[0] === "party" && parts[1] === "vehicle") { + return `خودرو / ${translatedLast}`; + } + + if (parts.length === 1) return translatedLast; + + const translated = parts.map((part) => KEY_LABELS[part] ?? part); + return translated.join(" / "); +} + +export function persianStatus(value: unknown): string | undefined { + if (value === undefined || value === null || value === "") return undefined; + const key = String(value); + return STATUS_LABELS[key] ?? key; +} diff --git a/src/expert-insurer/expert-insurer.module.ts b/src/expert-insurer/expert-insurer.module.ts index 34a1ee0..5d9226b 100644 --- a/src/expert-insurer/expert-insurer.module.ts +++ b/src/expert-insurer/expert-insurer.module.ts @@ -46,5 +46,6 @@ import { HashModule } from "src/utils/hash/hash.module"; ], providers: [ExpertInsurerService], controllers: [ExpertInsurerController], + exports: [ExpertInsurerService], }) export class ExpertInsurerModule {} diff --git a/src/helpers/blame-damaged-party.ts b/src/helpers/blame-damaged-party.ts index d762983..15192ff 100644 --- a/src/helpers/blame-damaged-party.ts +++ b/src/helpers/blame-damaged-party.ts @@ -32,6 +32,63 @@ export function resolveGuiltyPartyUserId(blame: { return null; } +type BlamePartyRow = { + role?: string; + person?: { + userId?: unknown; + phoneNumber?: string; + clientId?: unknown; + fullName?: string; + nationalCodeOfInsurer?: string; + nationalCodeOfDriver?: string; + insurerLicense?: string; + driverLicense?: string; + driverIsInsurer?: boolean; + birthday?: string; + insurerBirthday?: number; + driverBirthday?: string | null; + }; + statement?: { admitsGuilt?: boolean; claimsDamage?: boolean }; + insurance?: Record; + vehicle?: Record; + location?: { lat?: number; lon?: number }; +}; + +/** Full party row for the damaged vehicle (owner / beneficiary side). */ +export function resolveDamagedPartyRow(blame: { + type?: string; + parties?: BlamePartyRow[]; + expert?: { decision?: { guiltyPartyId?: unknown } }; + blameStatus?: string; +}): BlamePartyRow | null { + const parties = blame?.parties ?? []; + if (!parties.length) return null; + + const isCarBody = + blame?.type === BlameRequestType.CAR_BODY || blame?.type === "CAR_BODY"; + + if (isCarBody) { + return parties.find((p) => p.role === "FIRST") ?? parties[0] ?? null; + } + + const guiltyId = resolveGuiltyPartyUserId(blame); + if (!guiltyId) { + return parties.find((p) => p.statement?.claimsDamage === true) ?? null; + } + + const damagedParty = parties.find( + (p) => + p.person?.userId != null && String(p.person.userId) !== String(guiltyId), + ); + if (damagedParty) return damagedParty; + + return ( + parties.find( + (p) => p.person && String(p.person.userId ?? "") !== String(guiltyId), + ) ?? null + ); +} + /** Person row for the damaged party on a blame case (claim beneficiary / user-panel actor). */ export function resolveDamagedPartyPerson(blame: { type?: string; diff --git a/src/helpers/persian-pdf-document.ts b/src/helpers/persian-pdf-document.ts new file mode 100644 index 0000000..ebd5f85 --- /dev/null +++ b/src/helpers/persian-pdf-document.ts @@ -0,0 +1,152 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import type PDFDocumentType from "pdfkit"; +import { + isMostlyAscii, + pdfKitRtlKeyValue, + pdfKitRtlParagraph, +} from "./persian-pdf-text"; + +// pdfkit publishes CJS (`module.exports = PDFDocument`) without a `.default` export. +// eslint-disable-next-line @typescript-eslint/no-require-imports +const PDFDocument = require("pdfkit") as typeof PDFDocumentType; + +const PAGE_WIDTH = 595.28; +const MARGIN = 50; +const CONTENT_WIDTH = PAGE_WIDTH - MARGIN * 2; +const LABEL_COLUMN_WIDTH = CONTENT_WIDTH * 0.44; +const VALUE_COLUMN_WIDTH = CONTENT_WIDTH * 0.5; +const VALUE_COLUMN_X = MARGIN; +const COLON_X = MARGIN + VALUE_COLUMN_WIDTH + 4; +const LABEL_COLUMN_X = COLON_X + 10; + +function resolveFontFile(variant: "regular" | "bold"): string { + const file = + variant === "bold" + ? "NotoKufiArabic-Bold.ttf" + : "NotoKufiArabic-Regular.ttf"; + const candidates = [ + join(process.cwd(), "assets", "fonts", file), + join(process.cwd(), "dist", "fonts", file), + join(process.cwd(), "dist", "assets", "fonts", file), + join("/usr/share/fonts/truetype/noto", file), + ]; + for (const path of candidates) { + if (existsSync(path)) return path; + } + throw new Error( + `Persian PDF font not found (${file}). Place it under assets/fonts/ or install noto fonts on the host.`, + ); +} + +export type PersianPdfDocument = InstanceType & { + addTitle: (text: string) => void; + addSection: (text: string) => void; + addKeyValue: (label: string, value?: string | number | null) => void; + addBlank: (lines?: number) => void; +}; + +function drawRtlLine( + doc: InstanceType, + text: string, + fontSize: number, + bold: boolean, +): void { + const y = doc.y; + doc + .font(bold ? "FaBold" : "FaRegular") + .fontSize(fontSize) + .text(pdfKitRtlParagraph(text), MARGIN, y, { + width: CONTENT_WIDTH, + align: "right", + lineBreak: false, + }); + doc.moveDown(fontSize > 12 ? 0.55 : 0.45); +} + +function drawKeyValueRow( + doc: InstanceType, + label: string, + value: string, +): void { + const y = doc.y; + const { label: faLabel, value: faValue } = pdfKitRtlKeyValue(label, value); + + if (isMostlyAscii(faValue)) { + doc.font("Helvetica").fontSize(10).text(faValue, VALUE_COLUMN_X, y, { + width: VALUE_COLUMN_WIDTH, + align: "left", + lineBreak: false, + }); + } else { + doc.font("FaRegular").fontSize(10).text(faValue, VALUE_COLUMN_X, y, { + width: VALUE_COLUMN_WIDTH, + align: "left", + lineBreak: false, + }); + } + + doc.font("Helvetica").fontSize(10).text(":", COLON_X, y, { + lineBreak: false, + }); + + doc.font("FaRegular").fontSize(10).text(faLabel, LABEL_COLUMN_X, y, { + width: LABEL_COLUMN_WIDTH, + align: "right", + lineBreak: false, + }); + + doc.moveDown(0.5); +} + +export function createPersianPdfDocument(): PersianPdfDocument { + const doc = new PDFDocument({ + size: "A4", + margin: MARGIN, + autoFirstPage: true, + }) as PersianPdfDocument; + + doc.registerFont("FaRegular", resolveFontFile("regular")); + doc.registerFont("FaBold", resolveFontFile("bold")); + doc.font("FaRegular"); + + doc.addTitle = (text: string) => { + doc.moveDown(0.2); + drawRtlLine(doc, text, 16, true); + doc.font("FaRegular").fontSize(10); + doc.moveDown(0.15); + }; + + doc.addSection = (text: string) => { + doc.moveDown(0.5); + drawRtlLine(doc, text, 13, true); + doc.font("FaRegular").fontSize(10); + doc.moveDown(0.1); + }; + + doc.addKeyValue = (label: string, value?: string | number | null) => { + const v = + value === undefined || value === null || value === "" + ? "-" + : String(value); + drawKeyValueRow(doc, label, v); + }; + + doc.addBlank = (lines = 1) => { + doc.moveDown(lines * 0.35); + }; + + return doc; +} + +export function persianPdfToBuffer( + doc: InstanceType, +): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + doc.on("data", (chunk: Buffer) => chunks.push(chunk)); + doc.on("end", () => resolve(Buffer.concat(chunks))); + doc.on("error", reject); + doc.end(); + }); +} diff --git a/src/helpers/persian-pdf-text.ts b/src/helpers/persian-pdf-text.ts new file mode 100644 index 0000000..8b59083 --- /dev/null +++ b/src/helpers/persian-pdf-text.ts @@ -0,0 +1,27 @@ +/** Strip invisible bidi / ZWNJ chars that render as tofu in embedded Arabic fonts. */ +export function normalizePersianPdfText(text: string): string { + return text + .replace(/\u200c/g, " ") + .replace(/[\u200e\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g, "") + .replace(/\u2014/g, "-") + .replace(/\s+/g, " ") + .trim(); +} + +export function pdfKitRtlKeyValue( + label: string, + value: string, +): { label: string; value: string } { + return { + label: normalizePersianPdfText(label), + value: normalizePersianPdfText(value), + }; +} + +export function pdfKitRtlParagraph(text: string): string { + return normalizePersianPdfText(text); +} + +export function isMostlyAscii(text: string): boolean { + return text.length > 0 && !/[\u0600-\u06FF]/.test(text); +} diff --git a/src/helpers/simple-pdf-writer.ts b/src/helpers/simple-pdf-writer.ts new file mode 100644 index 0000000..8c24ca9 --- /dev/null +++ b/src/helpers/simple-pdf-writer.ts @@ -0,0 +1,166 @@ +type PdfTextRun = { + pageIndex: number; + x: number; + y: number; + fontSize: number; + bold: boolean; + text: string; +}; + +/** Minimal PDF 1.4 writer (Helvetica) — no external dependencies. */ +export class SimplePdfWriter { + private readonly pageWidth = 595.28; + private readonly pageHeight = 841.89; + private readonly marginX = 50; + private readonly marginTop = 50; + private readonly marginBottom = 50; + private runs: PdfTextRun[] = []; + private pageCount = 1; + private cursorY = this.pageHeight - this.marginTop; + private pageIndex = 0; + + private ensureSpace(lineHeight: number): void { + if (this.cursorY - lineHeight < this.marginBottom) { + this.pageIndex += 1; + this.pageCount += 1; + this.cursorY = this.pageHeight - this.marginTop; + } + } + + private addRun(text: string, fontSize: number, bold = false): void { + const lineHeight = fontSize + 4; + this.ensureSpace(lineHeight); + this.runs.push({ + pageIndex: this.pageIndex, + x: this.marginX, + y: this.cursorY, + fontSize, + bold, + text, + }); + this.cursorY -= lineHeight; + } + + addTitle(text: string): void { + this.addRun(text, 16, true); + this.cursorY -= 4; + } + + addSection(text: string): void { + this.cursorY -= 6; + this.addRun(text, 13, true); + this.cursorY -= 2; + } + + addLine(text: string): void { + this.addRun(text, 10, false); + } + + addKeyValue(label: string, value?: string | number | null): void { + const v = + value === undefined || value === null || value === "" + ? "-" + : String(value); + this.addLine(`${label}: ${v}`); + } + + addBlank(): void { + this.cursorY -= 8; + } + + toBuffer(): Buffer { + const chunkById: string[] = []; + const offsets: number[] = [0]; + let size = Buffer.byteLength("%PDF-1.4\n", "utf8"); + + const addObj = (content: string): number => { + const id = chunkById.length; + offsets.push(size); + const block = `${id} 0 obj\n${content}\nendobj\n`; + chunkById.push(block); + size += Buffer.byteLength(block, "utf8"); + return id; + }; + + chunkById.push("%PDF-1.4\n"); + + const fontRegularId = addObj( + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + ); + const fontBoldId = addObj( + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>", + ); + + const contentIds: number[] = []; + for (let p = 0; p < this.pageCount; p++) { + const pageRuns = this.runs.filter((r) => r.pageIndex === p); + const cmds: string[] = []; + for (const run of pageRuns) { + const font = run.bold ? "F2" : "F1"; + cmds.push("BT"); + cmds.push(`/${font} ${run.fontSize} Tf`); + cmds.push(`1 0 0 1 ${run.x.toFixed(2)} ${run.y.toFixed(2)} Tm`); + cmds.push(`${encodePdfText(run.text)} Tj`); + cmds.push("ET"); + } + const stream = cmds.join("\n"); + contentIds.push( + addObj( + `<< /Length ${Buffer.byteLength(stream, "utf8")} >>\nstream\n${stream}\nendstream`, + ), + ); + } + + const pagesKidsToken = "__PAGES_KIDS__"; + const pagesId = addObj( + `<< /Type /Pages /Kids [${pagesKidsToken}] /Count ${this.pageCount} >>`, + ); + const pageIds: number[] = []; + for (let p = 0; p < this.pageCount; p++) { + pageIds.push( + addObj( + `<< /Type /Page /Parent ${pagesId} 0 R /Resources << /Font << /F1 ${fontRegularId} 0 R /F2 ${fontBoldId} 0 R >> >> /MediaBox [0 0 ${this.pageWidth} ${this.pageHeight}] /Contents ${contentIds[p]} 0 R >>`, + ), + ); + } + + const catalogId = addObj(`<< /Type /Catalog /Pages ${pagesId} 0 R >>`); + + chunkById[pagesId] = chunkById[pagesId].replace( + pagesKidsToken, + pageIds.map((id) => `${id} 0 R`).join(" "), + ); + + const body = chunkById.join(""); + const bodyBuf = Buffer.from(body, "utf8"); + const xrefAt = bodyBuf.length; + const xref: string[] = [ + `xref\n0 ${offsets.length}\n0000000000 65535 f \n`, + ]; + for (let i = 1; i < offsets.length; i++) { + xref.push(`${String(offsets[i]).padStart(10, "0")} 00000 n \n`); + } + const trailer = `trailer\n<< /Size ${offsets.length} /Root ${catalogId} 0 R >>\nstartxref\n${xrefAt}\n%%EOF\n`; + return Buffer.concat([bodyBuf, Buffer.from(xref.join("") + trailer, "utf8")]); + } +} + +/** PDF literal string for Helvetica (WinAnsi) — Latin text only. */ +function encodePdfText(input: string): string { + const text = input.normalize("NFC"); + const safe = [...text] + .map((ch) => { + const code = ch.charCodeAt(0); + if (code >= 0x20 && code <= 0x7e) return ch; + if (ch === "—" || ch === "–") return "-"; + if (/\s/.test(ch)) return " "; + return ""; + }) + .join("") + .replace(/\s+/g, " ") + .trim(); + return `(${safe + .replace(/\\/g, "\\\\") + .replace(/\(/g, "\\(") + .replace(/\)/g, "\\)")})`; +}