1
0
forked from Yara724/api

Merge pull request 'PDF generation + mismatched data fixes' (#156) from s.yahyaee/yara724-api:main into main

Reviewed-on: Yara724/api#156
This commit is contained in:
2026-06-27 16:44:25 +03:30
23 changed files with 1537 additions and 63 deletions

View File

@@ -3,6 +3,13 @@
"collection": "@nestjs/schematics", "collection": "@nestjs/schematics",
"sourceRoot": "src", "sourceRoot": "src",
"compilerOptions": { "compilerOptions": {
"deleteOutDir": true "deleteOutDir": true,
"assets": [
{
"include": "../assets/fonts/**/*",
"outDir": "dist",
"watchAssets": true
}
]
} }
} }

187
package-lock.json generated
View File

@@ -25,6 +25,7 @@
"fastest-levenshtein": "^1.0.16", "fastest-levenshtein": "^1.0.16",
"joi": "^18.2.1", "joi": "^18.2.1",
"mongoose": "^8.9.2", "mongoose": "^8.9.2",
"pdfkit": "^0.19.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"svg-captcha": "^1.4.0" "svg-captcha": "^1.4.0"
@@ -2107,9 +2108,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2127,9 +2125,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2147,9 +2142,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2167,9 +2159,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2187,9 +2176,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "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": { "node_modules/@noble/hashes": {
"version": "1.8.0", "version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "^14.21.3 || >=16" "node": "^14.21.3 || >=16"
@@ -3253,9 +3250,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT", "license": "Apache-2.0 AND MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3273,9 +3267,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 AND MIT", "license": "Apache-2.0 AND MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3293,9 +3284,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT", "license": "Apache-2.0 AND MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3313,9 +3301,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT", "license": "Apache-2.0 AND MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3417,6 +3402,15 @@
"dev": true, "dev": true,
"license": "Apache-2.0" "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": { "node_modules/@swc/types": {
"version": "0.1.26", "version": "0.1.26",
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz",
@@ -5144,7 +5138,6 @@
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -5400,6 +5393,24 @@
"node": ">=8" "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": { "node_modules/browserslist": {
"version": "4.28.2", "version": "4.28.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
@@ -6238,6 +6249,12 @@
"wrappy": "1" "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": { "node_modules/diff": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
@@ -6842,7 +6859,6 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-diff": { "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": { "node_modules/fork-ts-checker-webpack-plugin": {
"version": "9.1.0", "version": "9.1.0",
"resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-9.1.0.tgz", "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": ">= 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": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -8939,6 +8987,25 @@
"integrity": "sha512-xMkdAMqcyG7iN2WZZmGIfWbYxW4orRkny+0/AXIbwL0xll2zkDX0Vzo/BXFa6+7mh2UvJl9MbcTtHk0YXkFtBA==", "integrity": "sha512-xMkdAMqcyG7iN2WZZmGIfWbYxW4orRkny+0/AXIbwL0xll2zkDX0Vzo/BXFa6+7mh2UvJl9MbcTtHk0YXkFtBA==",
"license": "MIT" "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": { "node_modules/lines-and-columns": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -9821,6 +9888,12 @@
"node": ">=6" "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": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -9959,6 +10032,20 @@
"node": ">=8" "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": { "node_modules/pend": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
@@ -10085,6 +10172,14 @@
"node": ">=4" "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": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -10438,6 +10533,12 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/router": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
@@ -11859,6 +11960,32 @@
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT" "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": { "node_modules/unicorn-magic": {
"version": "0.3.0", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",

View File

@@ -41,6 +41,7 @@
"fastest-levenshtein": "^1.0.16", "fastest-levenshtein": "^1.0.16",
"joi": "^18.2.1", "joi": "^18.2.1",
"mongoose": "^8.9.2", "mongoose": "^8.9.2",
"pdfkit": "^0.19.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"svg-captcha": "^1.4.0" "svg-captcha": "^1.4.0"

View File

@@ -13,6 +13,7 @@ import { ExpertBlameModule } from "./expert-blame/expert-blame.module";
import { FanavaranModule } from "./fanavaran/fanavaran.module"; import { FanavaranModule } from "./fanavaran/fanavaran.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";
@@ -52,6 +53,7 @@ import { AppConfigModule } from "./core/config/config.module";
ClaimRequestManagementModule, ClaimRequestManagementModule,
FanavaranModule, FanavaranModule,
ExpertClaimModule, ExpertClaimModule,
CaseExpertReportModule,
AiModule, AiModule,
ReportsModule, ReportsModule,
ExpertInsurerModule, ExpertInsurerModule,

View 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`,
};
}
}

View 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,
};
}

View 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",
);
}
}
}

View 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 {}

View 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);
}
}

View 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;
};

View 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;
}

View File

@@ -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 })

View File

@@ -46,6 +46,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 {
@@ -111,9 +115,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,
@@ -745,11 +746,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(
@@ -767,7 +768,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;
} }
@@ -1246,7 +1247,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(
@@ -1258,7 +1259,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,
@@ -1435,7 +1436,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: {
@@ -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. * Delegates to {@link assignBlameCaseForReviewV2}; maps conflict to BadRequest for legacy clients.
*/ */
async lockRequestV2( async lockRequestV2(
@@ -1651,7 +1652,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.",
@@ -1893,11 +1894,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;
} }
@@ -2059,11 +2060,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;
} }

View File

@@ -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,

View File

@@ -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 {}

View File

@@ -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;

View 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);
}

View 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();
});
}

View 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);
}

View 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, "\\)")})`;
}

View File

@@ -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 })

View File

@@ -1318,7 +1318,7 @@ export class RequestManagementService {
}; };
if (!party.insurance) party.insurance = {} as any; if (!party.insurance) party.insurance = {} as any;
party.insurance.policyNumber = inquiryMapped?.LastCompanyDocumentNumber; party.insurance.policyNumber = inquiryMapped?.insuranceNumber;
party.insurance.company = clientName; party.insurance.company = clientName;
party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl; party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl;
party.insurance.startDate = inquiryMapped?.IssueDate; party.insurance.startDate = inquiryMapped?.IssueDate;

View File

@@ -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,
"پلوس راست": 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
} }