forked from Yara724/api
Compare commits
8 Commits
2fbf40ef19
...
5022178569
| Author | SHA1 | Date | |
|---|---|---|---|
| 5022178569 | |||
| dca9e1f8d4 | |||
|
|
8f8ed8a94e | ||
|
|
3c7a656cd7 | ||
| 094d9048ba | |||
|
|
523172da67 | ||
|
|
2fc6015213 | ||
|
|
cca3ed01a4 |
@@ -3,6 +3,13 @@
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
"deleteOutDir": true,
|
||||
"assets": [
|
||||
{
|
||||
"include": "../assets/fonts/**/*",
|
||||
"outDir": "dist",
|
||||
"watchAssets": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
187
package-lock.json
generated
187
package-lock.json
generated
@@ -25,6 +25,7 @@
|
||||
"fastest-levenshtein": "^1.0.16",
|
||||
"joi": "^18.2.1",
|
||||
"mongoose": "^8.9.2",
|
||||
"pdfkit": "^0.19.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"svg-captcha": "^1.4.0"
|
||||
@@ -2107,9 +2108,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2127,9 +2125,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2147,9 +2142,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2167,9 +2159,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2187,9 +2176,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2957,11 +2943,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/ciphers": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
||||
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
@@ -3253,9 +3250,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3273,9 +3267,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3293,9 +3284,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3313,9 +3301,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3417,6 +3402,15 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.23",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
||||
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/types": {
|
||||
"version": "0.1.26",
|
||||
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz",
|
||||
@@ -5144,7 +5138,6 @@
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -5400,6 +5393,24 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/brotli": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
||||
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/browserify-zlib": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
|
||||
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "~1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.2",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
||||
@@ -6238,6 +6249,12 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/dfa": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
|
||||
@@ -6842,7 +6859,6 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-diff": {
|
||||
@@ -7102,6 +7118,32 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fontkit": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
||||
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.12",
|
||||
"brotli": "^1.3.2",
|
||||
"clone": "^2.1.2",
|
||||
"dfa": "^1.2.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"restructure": "^3.0.0",
|
||||
"tiny-inflate": "^1.0.3",
|
||||
"unicode-properties": "^1.4.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fontkit/node_modules/clone": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fork-ts-checker-webpack-plugin": {
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-9.1.0.tgz",
|
||||
@@ -8734,6 +8776,12 @@
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/js-md5": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
|
||||
"integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -8939,6 +8987,25 @@
|
||||
"integrity": "sha512-xMkdAMqcyG7iN2WZZmGIfWbYxW4orRkny+0/AXIbwL0xll2zkDX0Vzo/BXFa6+7mh2UvJl9MbcTtHk0YXkFtBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/linebreak": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
||||
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "0.0.8",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/linebreak/node_modules/base64-js": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
|
||||
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
@@ -9821,6 +9888,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -9959,6 +10032,20 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfkit": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz",
|
||||
"integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^1.0.0",
|
||||
"@noble/hashes": "^1.6.0",
|
||||
"fontkit": "^2.0.4",
|
||||
"js-md5": "^0.8.3",
|
||||
"linebreak": "^1.1.0",
|
||||
"png-js": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pend": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||
@@ -10085,6 +10172,14 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/png-js": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz",
|
||||
"integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==",
|
||||
"dependencies": {
|
||||
"browserify-zlib": "^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -10438,6 +10533,12 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/restructure": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
|
||||
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/router": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||
@@ -11859,6 +11960,32 @@
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicode-properties": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
||||
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-trie": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^0.2.5",
|
||||
"tiny-inflate": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-trie/node_modules/pako": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicorn-magic": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"fastest-levenshtein": "^1.0.16",
|
||||
"joi": "^18.2.1",
|
||||
"mongoose": "^8.9.2",
|
||||
"pdfkit": "^0.19.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"svg-captcha": "^1.4.0"
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ExpertBlameModule } from "./expert-blame/expert-blame.module";
|
||||
import { FanavaranModule } from "./fanavaran/fanavaran.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";
|
||||
@@ -52,6 +53,7 @@ import { AppConfigModule } from "./core/config/config.module";
|
||||
ClaimRequestManagementModule,
|
||||
FanavaranModule,
|
||||
ExpertClaimModule,
|
||||
CaseExpertReportModule,
|
||||
AiModule,
|
||||
ReportsModule,
|
||||
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 })
|
||||
lockedAt?: Date;
|
||||
|
||||
/** Lock expiry used by UI countdown (typically lockedAt + 15m). */
|
||||
/** Lock expiry used by UI countdown (typically lockedAt + 30m). */
|
||||
@Prop({ type: Date })
|
||||
expiredAt?: Date;
|
||||
|
||||
@@ -68,7 +68,7 @@ export class ClaimWorkflow {
|
||||
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.
|
||||
*/
|
||||
@Prop({ type: ClaimActorLockSchema })
|
||||
|
||||
@@ -46,6 +46,10 @@ import {
|
||||
buildExpertAssignConflictForOtherReviewer,
|
||||
resolvePersistentReviewAssigneeId,
|
||||
} 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 { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
|
||||
import {
|
||||
@@ -111,9 +115,6 @@ function statementToFormKey(statement?: {
|
||||
export class ExpertBlameService {
|
||||
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(
|
||||
private readonly requestManagementDbService: RequestManagementDbService,
|
||||
private readonly blameRequestDbService: BlameRequestDbService,
|
||||
@@ -745,11 +746,11 @@ export class ExpertBlameService {
|
||||
}
|
||||
const la = doc.workflow.lockedAt;
|
||||
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.
|
||||
*/
|
||||
private async expireBlameCaseWorkflowLockV2IfStale(
|
||||
@@ -767,7 +768,7 @@ export class ExpertBlameService {
|
||||
}
|
||||
if (
|
||||
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;
|
||||
}
|
||||
@@ -1246,7 +1247,7 @@ export class ExpertBlameService {
|
||||
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 updateResult = await this.requestManagementDbService.findOneAndUpdate(
|
||||
@@ -1258,7 +1259,7 @@ export class ExpertBlameService {
|
||||
$set: {
|
||||
lockFile: true,
|
||||
blameStatus: ReqBlameStatus.ReviewRequest,
|
||||
unlockTime: fifteenMinutes,
|
||||
unlockTime: lockExpiresAt,
|
||||
lockTime: new Date(),
|
||||
actorLocked: {
|
||||
fullName: actorDetail.fullName,
|
||||
@@ -1435,7 +1436,7 @@ export class ExpertBlameService {
|
||||
$set: {
|
||||
"workflow.locked": true,
|
||||
"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,
|
||||
},
|
||||
$push: {
|
||||
@@ -1566,7 +1567,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.
|
||||
*/
|
||||
async lockRequestV2(
|
||||
@@ -1651,7 +1652,7 @@ export class ExpertBlameService {
|
||||
const lockedAt = request.workflow?.lockedAt;
|
||||
if (lockedAt) {
|
||||
const lockExpiryTime =
|
||||
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
|
||||
new Date(lockedAt).getTime() + EXPERT_WORKFLOW_LOCK_TTL_MS;
|
||||
if (Date.now() > lockExpiryTime) {
|
||||
throw new ForbiddenException(
|
||||
"Your lock time has expired. Please lock the request again.",
|
||||
@@ -1893,11 +1894,11 @@ export class ExpertBlameService {
|
||||
const lockedAt = request.workflow?.lockedAt;
|
||||
const isLockedByCurrentActor = lockedByActorId === actorId;
|
||||
|
||||
// Check if lock has expired (15 minutes)
|
||||
// Check if lock has expired (30 minutes)
|
||||
let isLockExpired = false;
|
||||
if (lockedAt) {
|
||||
const lockExpiryTime =
|
||||
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
|
||||
new Date(lockedAt).getTime() + EXPERT_WORKFLOW_LOCK_TTL_MS;
|
||||
isLockExpired = Date.now() > lockExpiryTime;
|
||||
}
|
||||
|
||||
@@ -2059,11 +2060,11 @@ export class ExpertBlameService {
|
||||
const lockedAt = request.workflow?.lockedAt;
|
||||
const isLockedByCurrentActor = lockedByActorId === actorId;
|
||||
|
||||
// Check if lock has expired (15 minutes)
|
||||
// Check if lock has expired (30 minutes)
|
||||
let isLockExpired = false;
|
||||
if (lockedAt) {
|
||||
const lockExpiryTime =
|
||||
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
|
||||
new Date(lockedAt).getTime() + EXPERT_WORKFLOW_LOCK_TTL_MS;
|
||||
isLockExpired = Date.now() > lockExpiryTime;
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,10 @@ import {
|
||||
buildExpertAssignConflictForOtherReviewer,
|
||||
resolvePersistentReviewAssigneeId,
|
||||
} 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 { ClaimListDtoRs } from "./dto/claim-list-rs.dto";
|
||||
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 {
|
||||
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 = {
|
||||
backFender: {
|
||||
Minor: 2,
|
||||
@@ -777,7 +778,7 @@ export class ExpertClaimService {
|
||||
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);
|
||||
|
||||
await this.claimRequestManagementDbService.findOneAndUpdate(
|
||||
@@ -785,7 +786,7 @@ export class ExpertClaimService {
|
||||
{
|
||||
lockFile: true,
|
||||
claimStatus: ReqClaimStatus.ReviewRequest,
|
||||
unlockTime: fifteenMinutes,
|
||||
unlockTime: lockExpiresAt,
|
||||
lockTime: Date.now(),
|
||||
$set: {
|
||||
actorLocked: {
|
||||
@@ -814,7 +815,7 @@ export class ExpertClaimService {
|
||||
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 };
|
||||
}
|
||||
@@ -2548,7 +2549,7 @@ export class ExpertClaimService {
|
||||
|
||||
const now = new Date();
|
||||
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 actorRoleLabel = isFieldExpertOwner
|
||||
? ("field_expert" as const)
|
||||
@@ -4034,7 +4035,7 @@ export class ExpertClaimService {
|
||||
}
|
||||
const la = claim.workflow?.lockedAt as Date | string | undefined;
|
||||
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> {
|
||||
if ((actor as any).role === RoleEnum.FIELD_EXPERT) return;
|
||||
const clientKey = requireActorClientKey(actor);
|
||||
const lockTtlMs = this.claimV2WorkflowLockTtlMs;
|
||||
const lockTtlMs = EXPERT_WORKFLOW_LOCK_TTL_MS;
|
||||
const now = new Date();
|
||||
const candidates = (await this.claimCaseDbService.find(
|
||||
{
|
||||
@@ -4094,7 +4095,7 @@ export class ExpertClaimService {
|
||||
const expiredAt = claim.workflow?.expiredAt;
|
||||
const lockedAt = claim.workflow?.lockedAt;
|
||||
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 (
|
||||
@@ -4199,7 +4200,7 @@ export class ExpertClaimService {
|
||||
* - 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),
|
||||
* 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(
|
||||
claimRequestId: string,
|
||||
|
||||
@@ -46,5 +46,6 @@ import { HashModule } from "src/utils/hash/hash.module";
|
||||
],
|
||||
providers: [ExpertInsurerService],
|
||||
controllers: [ExpertInsurerController],
|
||||
exports: [ExpertInsurerService],
|
||||
})
|
||||
export class ExpertInsurerModule {}
|
||||
|
||||
@@ -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<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). */
|
||||
export function resolveDamagedPartyPerson(blame: {
|
||||
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 })
|
||||
lockedAt?: Date;
|
||||
|
||||
/** Lock expiry used by UI countdown (typically lockedAt + 15m). */
|
||||
/** Lock expiry used by UI countdown (typically lockedAt + 30m). */
|
||||
@Prop({ type: Date })
|
||||
expiredAt?: Date;
|
||||
|
||||
@@ -50,7 +50,7 @@ export class Workflow {
|
||||
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.
|
||||
*/
|
||||
@Prop({ type: ActorLockSchema })
|
||||
|
||||
@@ -1318,7 +1318,7 @@ export class RequestManagementService {
|
||||
};
|
||||
|
||||
if (!party.insurance) party.insurance = {} as any;
|
||||
party.insurance.policyNumber = inquiryMapped?.LastCompanyDocumentNumber;
|
||||
party.insurance.policyNumber = inquiryMapped?.insuranceNumber;
|
||||
party.insurance.company = clientName;
|
||||
party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl;
|
||||
party.insurance.startDate = inquiryMapped?.IssueDate;
|
||||
|
||||
@@ -176,5 +176,47 @@
|
||||
"پروانه یا فن": false,
|
||||
"لولههای ورودی و خروجی آب": false,
|
||||
"زانویی آب": false,
|
||||
"شلنگ مایع خنککننده": false
|
||||
"شلنگ مایع خنککننده": false,
|
||||
"گژنپین": false,
|
||||
"رادیاتور": false,
|
||||
"فن رادیاتور": false,
|
||||
"موتور فن": false,
|
||||
"پروانه فن": false,
|
||||
"سینی فن": false,
|
||||
"منبع انبساط": false,
|
||||
"شلنگ بالای رادیاتور": false,
|
||||
"شلنگ پایین رادیاتور": false,
|
||||
"گیربکس": false,
|
||||
"کیت کلاچ": false,
|
||||
"فلایویل": false,
|
||||
"پلوس چپ": false,
|
||||
"پلوس راست": false,
|
||||
"بوستر ترمز": false,
|
||||
"سیلندر اصلی ترمز": false,
|
||||
"واحد ABS": false,
|
||||
"کالیپر جلو چپ": false,
|
||||
"کالیپر جلو راست": false,
|
||||
"دیسک ترمز جلو چپ": false,
|
||||
"دیسک ترمز جلو راست": false,
|
||||
"لنت ترمز جلو": false,
|
||||
"میل فرمان": false,
|
||||
"کمک فنر جلو چپ": false,
|
||||
"کمک فنر جلو راست": false,
|
||||
"کمک فنر عقب چپ": false,
|
||||
"کمک فنر عقب راست": false,
|
||||
"فنر جلو چپ": false,
|
||||
"فنر جلو راست": false,
|
||||
"باتری": false,
|
||||
"استارت": false,
|
||||
"ECU": false,
|
||||
"سنسور اکسیژن": false,
|
||||
"سنسور MAF": false,
|
||||
"سنسور میللنگ": false,
|
||||
"سنسور میل بادامک": false,
|
||||
"منبع اگزوز": false,
|
||||
"رادیاتور بخاری": false,
|
||||
"دمنده بخاری": false,
|
||||
"کمپرسور کولر": false,
|
||||
"کندانسور": false,
|
||||
"اواپراتور": false
|
||||
}
|
||||
Reference in New Issue
Block a user