forked from Yara724/api
Compare commits
2 Commits
e2b879d943
...
2fc6015213
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fc6015213 | ||
|
|
cca3ed01a4 |
@@ -12,6 +12,7 @@ import { ClientModule } from "./client/client.module";
|
|||||||
import { ExpertBlameModule } from "./expert-blame/expert-blame.module";
|
import { ExpertBlameModule } from "./expert-blame/expert-blame.module";
|
||||||
import { ExpertClaimModule } from "./expert-claim/expert-claim.module";
|
import { ExpertClaimModule } from "./expert-claim/expert-claim.module";
|
||||||
import { ExpertInsurerModule } from "./expert-insurer/expert-insurer.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 { LookupsModule } from "./lookups/lookups.module";
|
||||||
import { PlatesModule } from "./plates/plates.module";
|
import { PlatesModule } from "./plates/plates.module";
|
||||||
import { ProfileModule } from "./profile/profile.module";
|
import { ProfileModule } from "./profile/profile.module";
|
||||||
@@ -50,6 +51,7 @@ import { AppConfigModule } from "./core/config/config.module";
|
|||||||
ExpertBlameModule,
|
ExpertBlameModule,
|
||||||
ClaimRequestManagementModule,
|
ClaimRequestManagementModule,
|
||||||
ExpertClaimModule,
|
ExpertClaimModule,
|
||||||
|
CaseExpertReportModule,
|
||||||
AiModule,
|
AiModule,
|
||||||
ReportsModule,
|
ReportsModule,
|
||||||
ExpertInsurerModule,
|
ExpertInsurerModule,
|
||||||
|
|||||||
36
src/case-expert-report/case-expert-report-pdf.service.ts
Normal file
36
src/case-expert-report/case-expert-report-pdf.service.ts
Normal file
@@ -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<InsurerFileReportPdfResult> {
|
||||||
|
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`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
518
src/case-expert-report/case-expert-report.builder.ts
Normal file
518
src/case-expert-report/case-expert-report.builder.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
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<string, unknown> | null,
|
||||||
|
claim?: Record<string, unknown> | null,
|
||||||
|
): string | undefined {
|
||||||
|
const names = new Set<string>();
|
||||||
|
|
||||||
|
const blameDecision = (
|
||||||
|
blame?.expert as Record<string, unknown> | undefined
|
||||||
|
)?.decision as Record<string, unknown> | undefined;
|
||||||
|
const blameExpert = expertNameFromSnapshot(
|
||||||
|
blameDecision?.expertProfileSnapshot as
|
||||||
|
| { firstName?: string; lastName?: string }
|
||||||
|
| undefined,
|
||||||
|
);
|
||||||
|
if (blameExpert) names.add(blameExpert);
|
||||||
|
|
||||||
|
const evaluation = claim?.evaluation as Record<string, unknown> | undefined;
|
||||||
|
for (const key of ["damageExpertReplyFinal", "damageExpertReply"] as const) {
|
||||||
|
const reply = evaluation?.[key] as Record<string, unknown> | 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<string, unknown> | undefined,
|
||||||
|
key: string,
|
||||||
|
role?: string,
|
||||||
|
): Record<string, unknown> | undefined {
|
||||||
|
const block = inquiries?.[key] as Record<string, unknown> | undefined;
|
||||||
|
const data = block?.data as Record<string, unknown> | undefined;
|
||||||
|
if (!data) return undefined;
|
||||||
|
if (role && data[role] && typeof data[role] === "object") {
|
||||||
|
return data[role] as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prefer mapped Tejarat fields; avoid duplicating the entire raw blob in PDF. */
|
||||||
|
function pickInquiryReportPayload(
|
||||||
|
inquiry?: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> | undefined {
|
||||||
|
if (!inquiry) return undefined;
|
||||||
|
const mapped = inquiry.mapped;
|
||||||
|
if (mapped && typeof mapped === "object" && !Array.isArray(mapped)) {
|
||||||
|
return mapped as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
const { raw: _raw, ...rest } = inquiry;
|
||||||
|
return Object.keys(rest).length ? rest : inquiry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function licenseFieldsFromInquiry(
|
||||||
|
inquiry?: Record<string, unknown>,
|
||||||
|
): { 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<typeof resolveDamagedPartyRow>,
|
||||||
|
claim?: Record<string, unknown> | 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<typeof resolveDamagedPartyRow>,
|
||||||
|
blame?: Record<string, unknown> | 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<string, unknown> | 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<typeof resolveDamagedPartyRow>,
|
||||||
|
blame?: Record<string, unknown> | null,
|
||||||
|
claim?: Record<string, unknown> | null,
|
||||||
|
): InsurerFileReportSection {
|
||||||
|
const role = damagedParty?.role ?? PartyRole.FIRST;
|
||||||
|
const insurance = damagedParty?.insurance ?? {};
|
||||||
|
const carBodyLegacy = blame?.carBodyInsuranceDetail as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
const thirdPartyInquiry = inquiryRoleData(
|
||||||
|
blame?.inquiries as Record<string, unknown> | undefined,
|
||||||
|
"thirdParty",
|
||||||
|
role,
|
||||||
|
);
|
||||||
|
const carBodyInquiry = inquiryRoleData(
|
||||||
|
blame?.inquiries as Record<string, unknown> | undefined,
|
||||||
|
"carBody",
|
||||||
|
role,
|
||||||
|
);
|
||||||
|
const claimThirdParty = inquiryRoleData(
|
||||||
|
claim?.inquiries as Record<string, unknown> | undefined,
|
||||||
|
"thirdParty",
|
||||||
|
role,
|
||||||
|
);
|
||||||
|
const claimCarBody = inquiryRoleData(
|
||||||
|
claim?.inquiries as Record<string, unknown> | 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<typeof resolveDamagedPartyRow>,
|
||||||
|
claim?: Record<string, unknown> | null,
|
||||||
|
): InsurerFileReportSection {
|
||||||
|
const partyVehicle = damagedParty?.vehicle;
|
||||||
|
const claimVehicle = claim?.vehicle as Record<string, unknown> | 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<string, unknown> | null,
|
||||||
|
claim?: Record<string, unknown> | null,
|
||||||
|
damagedParty?: ReturnType<typeof resolveDamagedPartyRow>,
|
||||||
|
): InsurerFileReportSection {
|
||||||
|
const statement = damagedParty?.statement as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
|
const location = damagedParty?.location;
|
||||||
|
const snapshotAccident = (
|
||||||
|
claim?.snapshot as { accident?: Record<string, unknown> } | undefined
|
||||||
|
)?.accident;
|
||||||
|
const blameDecision = (
|
||||||
|
blame?.expert as Record<string, unknown> | undefined
|
||||||
|
)?.decision as Record<string, unknown> | undefined;
|
||||||
|
const decisionFields = blameDecision?.fields as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| 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<string, unknown> | undefined)
|
||||||
|
?.damageExpertReplyFinal as Record<string, unknown> | undefined
|
||||||
|
)?.submittedAt ??
|
||||||
|
(
|
||||||
|
(claim?.evaluation as Record<string, unknown> | undefined)
|
||||||
|
?.damageExpertReply as Record<string, unknown> | undefined
|
||||||
|
)?.submittedAt,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: PR.damageExpertNotes,
|
||||||
|
value: asString(
|
||||||
|
(
|
||||||
|
(claim?.evaluation as Record<string, unknown> | undefined)
|
||||||
|
?.damageExpertReplyFinal as Record<string, unknown> | undefined
|
||||||
|
)?.description ??
|
||||||
|
(
|
||||||
|
(claim?.evaluation as Record<string, unknown> | undefined)
|
||||||
|
?.damageExpertReply as Record<string, unknown> | 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<string>();
|
||||||
|
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<string, unknown>;
|
||||||
|
blame?: Record<string, unknown>;
|
||||||
|
claim?: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
): 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
68
src/case-expert-report/case-expert-report.controller.ts
Normal file
68
src/case-expert-report/case-expert-report.controller.ts
Normal file
@@ -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<StreamableFile> {
|
||||||
|
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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/case-expert-report/case-expert-report.module.ts
Normal file
13
src/case-expert-report/case-expert-report.module.ts
Normal file
@@ -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 {}
|
||||||
30
src/case-expert-report/case-expert-report.service.ts
Normal file
30
src/case-expert-report/case-expert-report.service.ts
Normal file
@@ -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<InsurerFileReportPdfResult> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/case-expert-report/case-expert-report.types.ts
Normal file
21
src/case-expert-report/case-expert-report.types.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
196
src/case-expert-report/persian-report-labels.ts
Normal file
196
src/case-expert-report/persian-report-labels.ts
Normal file
@@ -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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -57,7 +57,7 @@ export class ClaimWorkflow {
|
|||||||
@Prop({ type: Date })
|
@Prop({ type: Date })
|
||||||
lockedAt?: Date;
|
lockedAt?: Date;
|
||||||
|
|
||||||
/** Lock expiry used by UI countdown (typically lockedAt + 15m). */
|
/** Lock expiry used by UI countdown (typically lockedAt + 30m). */
|
||||||
@Prop({ type: Date })
|
@Prop({ type: Date })
|
||||||
expiredAt?: Date;
|
expiredAt?: Date;
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ export class ClaimWorkflow {
|
|||||||
preLockQueueSnapshot?: ClaimPreLockQueueSnapshot;
|
preLockQueueSnapshot?: ClaimPreLockQueueSnapshot;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* First damage expert who called review assign; kept after the 15m lock expires
|
* First damage expert who called review assign; kept after the 30m lock expires
|
||||||
* so no other expert can take the case while it remains in the expert queue.
|
* so no other expert can take the case while it remains in the expert queue.
|
||||||
*/
|
*/
|
||||||
@Prop({ type: ClaimActorLockSchema })
|
@Prop({ type: ClaimActorLockSchema })
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ import {
|
|||||||
buildExpertAssignConflictForOtherReviewer,
|
buildExpertAssignConflictForOtherReviewer,
|
||||||
resolvePersistentReviewAssigneeId,
|
resolvePersistentReviewAssigneeId,
|
||||||
} from "src/helpers/expert-workflow-review-assignee";
|
} from "src/helpers/expert-workflow-review-assignee";
|
||||||
|
import {
|
||||||
|
EXPERT_WORKFLOW_LOCK_TTL_MS,
|
||||||
|
expertWorkflowLockExpiredAt,
|
||||||
|
} from "src/helpers/expert-workflow-lock";
|
||||||
import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service";
|
import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service";
|
||||||
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
|
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
|
||||||
import {
|
import {
|
||||||
@@ -110,9 +114,6 @@ function statementToFormKey(statement?: {
|
|||||||
export class ExpertBlameService {
|
export class ExpertBlameService {
|
||||||
private readonly logger = new Logger(ExpertBlameService.name);
|
private readonly logger = new Logger(ExpertBlameService.name);
|
||||||
|
|
||||||
/** Blame V2 `workflow.locked` TTL (must match checks in lock/reply/resend). */
|
|
||||||
private readonly blameV2LockTtlMs = 15 * 60 * 1000;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly requestManagementDbService: RequestManagementDbService,
|
private readonly requestManagementDbService: RequestManagementDbService,
|
||||||
private readonly blameRequestDbService: BlameRequestDbService,
|
private readonly blameRequestDbService: BlameRequestDbService,
|
||||||
@@ -743,11 +744,11 @@ export class ExpertBlameService {
|
|||||||
}
|
}
|
||||||
const la = doc.workflow.lockedAt;
|
const la = doc.workflow.lockedAt;
|
||||||
if (!la) return true;
|
if (!la) return true;
|
||||||
return Date.now() < new Date(la as Date).getTime() + this.blameV2LockTtlMs;
|
return Date.now() < new Date(la as Date).getTime() + EXPERT_WORKFLOW_LOCK_TTL_MS;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist unlock when blame V2 workflow lock is older than {@link blameV2LockTtlMs}.
|
* Persist unlock when blame V2 workflow lock is older than {@link EXPERT_WORKFLOW_LOCK_TTL_MS}.
|
||||||
* V1 uses in-memory `scheduleUnlock`; V2 only stored `workflow.lockedAt`, so locks never cleared until now.
|
* V1 uses in-memory `scheduleUnlock`; V2 only stored `workflow.lockedAt`, so locks never cleared until now.
|
||||||
*/
|
*/
|
||||||
private async expireBlameCaseWorkflowLockV2IfStale(
|
private async expireBlameCaseWorkflowLockV2IfStale(
|
||||||
@@ -765,7 +766,7 @@ export class ExpertBlameService {
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
lockedAt &&
|
lockedAt &&
|
||||||
Date.now() < new Date(lockedAt as Date).getTime() + this.blameV2LockTtlMs
|
Date.now() < new Date(lockedAt as Date).getTime() + EXPERT_WORKFLOW_LOCK_TTL_MS
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1244,7 +1245,7 @@ export class ExpertBlameService {
|
|||||||
throw new BadRequestException("Request is locked by another expert");
|
throw new BadRequestException("Request is locked by another expert");
|
||||||
}
|
}
|
||||||
|
|
||||||
const fifteenMinutes = new Date(Date.now() + 15 * 60 * 1000);
|
const lockExpiresAt = expertWorkflowLockExpiredAt();
|
||||||
const lockSnapshot = await this.snapshotFieldExpert(actorDetail.sub);
|
const lockSnapshot = await this.snapshotFieldExpert(actorDetail.sub);
|
||||||
|
|
||||||
const updateResult = await this.requestManagementDbService.findOneAndUpdate(
|
const updateResult = await this.requestManagementDbService.findOneAndUpdate(
|
||||||
@@ -1256,7 +1257,7 @@ export class ExpertBlameService {
|
|||||||
$set: {
|
$set: {
|
||||||
lockFile: true,
|
lockFile: true,
|
||||||
blameStatus: ReqBlameStatus.ReviewRequest,
|
blameStatus: ReqBlameStatus.ReviewRequest,
|
||||||
unlockTime: fifteenMinutes,
|
unlockTime: lockExpiresAt,
|
||||||
lockTime: new Date(),
|
lockTime: new Date(),
|
||||||
actorLocked: {
|
actorLocked: {
|
||||||
fullName: actorDetail.fullName,
|
fullName: actorDetail.fullName,
|
||||||
@@ -1433,7 +1434,7 @@ export class ExpertBlameService {
|
|||||||
$set: {
|
$set: {
|
||||||
"workflow.locked": true,
|
"workflow.locked": true,
|
||||||
"workflow.lockedAt": now,
|
"workflow.lockedAt": now,
|
||||||
"workflow.expiredAt": new Date(now.getTime() + this.blameV2LockTtlMs),
|
"workflow.expiredAt": new Date(now.getTime() + EXPERT_WORKFLOW_LOCK_TTL_MS),
|
||||||
"workflow.lockedBy": lockedByPayload,
|
"workflow.lockedBy": lockedByPayload,
|
||||||
},
|
},
|
||||||
$push: {
|
$push: {
|
||||||
@@ -1564,7 +1565,7 @@ export class ExpertBlameService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V2: Lock blame case for 15 minutes (blameCases collection).
|
* V2: Lock blame case for 30 minutes (blameCases collection).
|
||||||
* Delegates to {@link assignBlameCaseForReviewV2}; maps conflict to BadRequest for legacy clients.
|
* Delegates to {@link assignBlameCaseForReviewV2}; maps conflict to BadRequest for legacy clients.
|
||||||
*/
|
*/
|
||||||
async lockRequestV2(
|
async lockRequestV2(
|
||||||
@@ -1649,7 +1650,7 @@ export class ExpertBlameService {
|
|||||||
const lockedAt = request.workflow?.lockedAt;
|
const lockedAt = request.workflow?.lockedAt;
|
||||||
if (lockedAt) {
|
if (lockedAt) {
|
||||||
const lockExpiryTime =
|
const lockExpiryTime =
|
||||||
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
|
new Date(lockedAt).getTime() + EXPERT_WORKFLOW_LOCK_TTL_MS;
|
||||||
if (Date.now() > lockExpiryTime) {
|
if (Date.now() > lockExpiryTime) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
"Your lock time has expired. Please lock the request again.",
|
"Your lock time has expired. Please lock the request again.",
|
||||||
@@ -1891,11 +1892,11 @@ export class ExpertBlameService {
|
|||||||
const lockedAt = request.workflow?.lockedAt;
|
const lockedAt = request.workflow?.lockedAt;
|
||||||
const isLockedByCurrentActor = lockedByActorId === actorId;
|
const isLockedByCurrentActor = lockedByActorId === actorId;
|
||||||
|
|
||||||
// Check if lock has expired (15 minutes)
|
// Check if lock has expired (30 minutes)
|
||||||
let isLockExpired = false;
|
let isLockExpired = false;
|
||||||
if (lockedAt) {
|
if (lockedAt) {
|
||||||
const lockExpiryTime =
|
const lockExpiryTime =
|
||||||
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
|
new Date(lockedAt).getTime() + EXPERT_WORKFLOW_LOCK_TTL_MS;
|
||||||
isLockExpired = Date.now() > lockExpiryTime;
|
isLockExpired = Date.now() > lockExpiryTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2057,11 +2058,11 @@ export class ExpertBlameService {
|
|||||||
const lockedAt = request.workflow?.lockedAt;
|
const lockedAt = request.workflow?.lockedAt;
|
||||||
const isLockedByCurrentActor = lockedByActorId === actorId;
|
const isLockedByCurrentActor = lockedByActorId === actorId;
|
||||||
|
|
||||||
// Check if lock has expired (15 minutes)
|
// Check if lock has expired (30 minutes)
|
||||||
let isLockExpired = false;
|
let isLockExpired = false;
|
||||||
if (lockedAt) {
|
if (lockedAt) {
|
||||||
const lockExpiryTime =
|
const lockExpiryTime =
|
||||||
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
|
new Date(lockedAt).getTime() + EXPERT_WORKFLOW_LOCK_TTL_MS;
|
||||||
isLockExpired = Date.now() > lockExpiryTime;
|
isLockExpired = Date.now() > lockExpiryTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ import {
|
|||||||
buildExpertAssignConflictForOtherReviewer,
|
buildExpertAssignConflictForOtherReviewer,
|
||||||
resolvePersistentReviewAssigneeId,
|
resolvePersistentReviewAssigneeId,
|
||||||
} from "src/helpers/expert-workflow-review-assignee";
|
} from "src/helpers/expert-workflow-review-assignee";
|
||||||
|
import {
|
||||||
|
EXPERT_WORKFLOW_LOCK_TTL_MS,
|
||||||
|
expertWorkflowLockExpiredAt,
|
||||||
|
} from "src/helpers/expert-workflow-lock";
|
||||||
import { ClaimPerIdRs } from "./dto/claim-list-perId-rs.dto";
|
import { ClaimPerIdRs } from "./dto/claim-list-perId-rs.dto";
|
||||||
import { ClaimListDtoRs } from "./dto/claim-list-rs.dto";
|
import { ClaimListDtoRs } from "./dto/claim-list-rs.dto";
|
||||||
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
|
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
|
||||||
@@ -162,9 +166,6 @@ import { canonicalizeResendDocumentKey } from "src/helpers/claim-resend-document
|
|||||||
export class ExpertClaimService {
|
export class ExpertClaimService {
|
||||||
private readonly logger = new Logger(ExpertClaimService.name);
|
private readonly logger = new Logger(ExpertClaimService.name);
|
||||||
|
|
||||||
/** Matches blame v2 `workflow` lock TTL: locker may re-enter until expired, then others may open. */
|
|
||||||
private readonly claimV2WorkflowLockTtlMs = 15 * 60 * 1000;
|
|
||||||
|
|
||||||
private readonly priceDropPart = {
|
private readonly priceDropPart = {
|
||||||
backFender: {
|
backFender: {
|
||||||
Minor: 2,
|
Minor: 2,
|
||||||
@@ -777,7 +778,7 @@ export class ExpertClaimService {
|
|||||||
throw new BadRequestException("Request has damage expert reply");
|
throw new BadRequestException("Request has damage expert reply");
|
||||||
}
|
}
|
||||||
|
|
||||||
const fifteenMinutes = new Date(Date.now() + 15 * 60 * 1000);
|
const lockExpiresAt = expertWorkflowLockExpiredAt();
|
||||||
const lockSnapshot = await this.snapshotDamageExpert(actorDetail.sub);
|
const lockSnapshot = await this.snapshotDamageExpert(actorDetail.sub);
|
||||||
|
|
||||||
await this.claimRequestManagementDbService.findOneAndUpdate(
|
await this.claimRequestManagementDbService.findOneAndUpdate(
|
||||||
@@ -785,7 +786,7 @@ export class ExpertClaimService {
|
|||||||
{
|
{
|
||||||
lockFile: true,
|
lockFile: true,
|
||||||
claimStatus: ReqClaimStatus.ReviewRequest,
|
claimStatus: ReqClaimStatus.ReviewRequest,
|
||||||
unlockTime: fifteenMinutes,
|
unlockTime: lockExpiresAt,
|
||||||
lockTime: Date.now(),
|
lockTime: Date.now(),
|
||||||
$set: {
|
$set: {
|
||||||
actorLocked: {
|
actorLocked: {
|
||||||
@@ -814,7 +815,7 @@ export class ExpertClaimService {
|
|||||||
idempotencyKey: `claim:${requestId}:checked:${actorDetail.sub}`,
|
idempotencyKey: `claim:${requestId}:checked:${actorDetail.sub}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.unlockApi(request, fifteenMinutes.getTime() - Date.now());
|
this.unlockApi(request, lockExpiresAt.getTime() - Date.now());
|
||||||
|
|
||||||
return { _id: requestId, lock: true };
|
return { _id: requestId, lock: true };
|
||||||
}
|
}
|
||||||
@@ -2548,7 +2549,7 @@ export class ExpertClaimService {
|
|||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const lockSnapshot = await this.snapshotDamageExpert(actor.sub);
|
const lockSnapshot = await this.snapshotDamageExpert(actor.sub);
|
||||||
const expiredAt = new Date(now.getTime() + this.claimV2WorkflowLockTtlMs);
|
const expiredAt = new Date(now.getTime() + EXPERT_WORKFLOW_LOCK_TTL_MS);
|
||||||
const actorType = isFieldExpertOwner ? "field_expert" : "damage_expert";
|
const actorType = isFieldExpertOwner ? "field_expert" : "damage_expert";
|
||||||
const actorRoleLabel = isFieldExpertOwner
|
const actorRoleLabel = isFieldExpertOwner
|
||||||
? ("field_expert" as const)
|
? ("field_expert" as const)
|
||||||
@@ -4034,7 +4035,7 @@ export class ExpertClaimService {
|
|||||||
}
|
}
|
||||||
const la = claim.workflow?.lockedAt as Date | string | undefined;
|
const la = claim.workflow?.lockedAt as Date | string | undefined;
|
||||||
if (!la) return true;
|
if (!la) return true;
|
||||||
return Date.now() < new Date(la).getTime() + this.claimV2WorkflowLockTtlMs;
|
return Date.now() < new Date(la).getTime() + EXPERT_WORKFLOW_LOCK_TTL_MS;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -4048,7 +4049,7 @@ export class ExpertClaimService {
|
|||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
if ((actor as any).role === RoleEnum.FIELD_EXPERT) return;
|
if ((actor as any).role === RoleEnum.FIELD_EXPERT) return;
|
||||||
const clientKey = requireActorClientKey(actor);
|
const clientKey = requireActorClientKey(actor);
|
||||||
const lockTtlMs = this.claimV2WorkflowLockTtlMs;
|
const lockTtlMs = EXPERT_WORKFLOW_LOCK_TTL_MS;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const candidates = (await this.claimCaseDbService.find(
|
const candidates = (await this.claimCaseDbService.find(
|
||||||
{
|
{
|
||||||
@@ -4094,7 +4095,7 @@ export class ExpertClaimService {
|
|||||||
const expiredAt = claim.workflow?.expiredAt;
|
const expiredAt = claim.workflow?.expiredAt;
|
||||||
const lockedAt = claim.workflow?.lockedAt;
|
const lockedAt = claim.workflow?.lockedAt;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const ttl = this.claimV2WorkflowLockTtlMs;
|
const ttl = EXPERT_WORKFLOW_LOCK_TTL_MS;
|
||||||
|
|
||||||
if (expiredAt && now < new Date(expiredAt as Date).getTime()) return false;
|
if (expiredAt && now < new Date(expiredAt as Date).getTime()) return false;
|
||||||
if (
|
if (
|
||||||
@@ -4199,7 +4200,7 @@ export class ExpertClaimService {
|
|||||||
* - Claim must exist and belong to the actor's insurer (`assertClaimCaseForTenant`)
|
* - Claim must exist and belong to the actor's insurer (`assertClaimCaseForTenant`)
|
||||||
* - Allowed when: WAITING_FOR_DAMAGE_EXPERT (queue), or EXPERT_REVIEWING (after lock — same expert must be able to reopen detail),
|
* - Allowed when: WAITING_FOR_DAMAGE_EXPERT (queue), or EXPERT_REVIEWING (after lock — same expert must be able to reopen detail),
|
||||||
* or factor-validation queue (EXPERT_VALIDATING_REPAIR_FACTORS / legacy WAITING_FOR_INSURER_APPROVAL + UNDER_REVIEW + EXPERT_COST_EVALUATION)
|
* or factor-validation queue (EXPERT_VALIDATING_REPAIR_FACTORS / legacy WAITING_FOR_INSURER_APPROVAL + UNDER_REVIEW + EXPERT_COST_EVALUATION)
|
||||||
* - If an active workflow lock exists (15 min from lockedAt): only the locking expert; after expiry, any expert (tenant) may view
|
* - If an active workflow lock exists (30 min from lockedAt): only the locking expert; after expiry, any expert (tenant) may view
|
||||||
*/
|
*/
|
||||||
async getClaimDetailV2(
|
async getClaimDetailV2(
|
||||||
claimRequestId: string,
|
claimRequestId: string,
|
||||||
|
|||||||
@@ -46,5 +46,6 @@ import { HashModule } from "src/utils/hash/hash.module";
|
|||||||
],
|
],
|
||||||
providers: [ExpertInsurerService],
|
providers: [ExpertInsurerService],
|
||||||
controllers: [ExpertInsurerController],
|
controllers: [ExpertInsurerController],
|
||||||
|
exports: [ExpertInsurerService],
|
||||||
})
|
})
|
||||||
export class ExpertInsurerModule {}
|
export class ExpertInsurerModule {}
|
||||||
|
|||||||
@@ -32,6 +32,63 @@ export function resolveGuiltyPartyUserId(blame: {
|
|||||||
return null;
|
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<string, unknown>;
|
||||||
|
vehicle?: Record<string, unknown>;
|
||||||
|
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). */
|
/** Person row for the damaged party on a blame case (claim beneficiary / user-panel actor). */
|
||||||
export function resolveDamagedPartyPerson(blame: {
|
export function resolveDamagedPartyPerson(blame: {
|
||||||
type?: string;
|
type?: string;
|
||||||
|
|||||||
8
src/helpers/expert-workflow-lock.ts
Normal file
8
src/helpers/expert-workflow-lock.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/** Expert blame/claim review assign + workflow lock TTL (POST assign, PUT lock). */
|
||||||
|
export const EXPERT_WORKFLOW_LOCK_TTL_MS = 30 * 60 * 1000;
|
||||||
|
|
||||||
|
export function expertWorkflowLockExpiredAt(
|
||||||
|
from: Date = new Date(),
|
||||||
|
): Date {
|
||||||
|
return new Date(from.getTime() + EXPERT_WORKFLOW_LOCK_TTL_MS);
|
||||||
|
}
|
||||||
152
src/helpers/persian-pdf-document.ts
Normal file
152
src/helpers/persian-pdf-document.ts
Normal file
@@ -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<typeof PDFDocument> & {
|
||||||
|
addTitle: (text: string) => void;
|
||||||
|
addSection: (text: string) => void;
|
||||||
|
addKeyValue: (label: string, value?: string | number | null) => void;
|
||||||
|
addBlank: (lines?: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function drawRtlLine(
|
||||||
|
doc: InstanceType<typeof PDFDocument>,
|
||||||
|
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<typeof PDFDocument>,
|
||||||
|
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<typeof PDFDocument>,
|
||||||
|
): Promise<Buffer> {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
27
src/helpers/persian-pdf-text.ts
Normal file
27
src/helpers/persian-pdf-text.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
166
src/helpers/simple-pdf-writer.ts
Normal file
166
src/helpers/simple-pdf-writer.ts
Normal file
@@ -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, "\\)")})`;
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ export class Workflow {
|
|||||||
@Prop({ type: Date })
|
@Prop({ type: Date })
|
||||||
lockedAt?: Date;
|
lockedAt?: Date;
|
||||||
|
|
||||||
/** Lock expiry used by UI countdown (typically lockedAt + 15m). */
|
/** Lock expiry used by UI countdown (typically lockedAt + 30m). */
|
||||||
@Prop({ type: Date })
|
@Prop({ type: Date })
|
||||||
expiredAt?: Date;
|
expiredAt?: Date;
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ export class Workflow {
|
|||||||
lockedBy?: ActorLock;
|
lockedBy?: ActorLock;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* First expert who called review assign; kept after the 15m workflow lock expires
|
* First expert who called review assign; kept after the 30m workflow lock expires
|
||||||
* so no other expert can take the case while it remains in the expert queue.
|
* so no other expert can take the case while it remains in the expert queue.
|
||||||
*/
|
*/
|
||||||
@Prop({ type: ActorLockSchema })
|
@Prop({ type: ActorLockSchema })
|
||||||
|
|||||||
Reference in New Issue
Block a user