Compare commits

...

190 Commits

Author SHA1 Message Date
985c28ed6b docs 2026-09-21 10:05:49 +03:30
9dd0ec0c00 Merge pull request 'fix claim validation and expert branch scoping' (#335) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#335
2026-09-20 15:03:22 +03:30
SepehrYahyaee
e06178f804 fix claim validation and expert branch scoping 2026-09-20 15:00:46 +03:30
a9adad3c1b Merge pull request 'VehicleHullAccessoryId' (#334) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#334
2026-09-20 12:32:48 +03:30
bbe5b7b77b VehicleHullAccessoryId 2026-09-20 12:31:39 +03:30
1e13bb3a3c Merge pull request 'Repair Duration' (#333) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#333
2026-09-20 12:07:11 +03:30
a8d8df574b Repair Duration 2026-09-20 12:06:20 +03:30
cf07a33951 Merge pull request 'hat PUT is now best-effort. If it 502s, YARA logs it and continues with POST …/vehicle-hull-claims/5032516/expertise.' (#332) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#332
2026-09-20 11:07:02 +03:30
6f8a6fed69 hat PUT is now best-effort. If it 502s, YARA logs it and continues with POST …/vehicle-hull-claims/5032516/expertise. 2026-09-20 11:05:07 +03:30
dbf46cfdda Merge pull request 'fix ESG chassis inquiry payload' (#331) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#331
2026-09-19 16:51:31 +03:30
SepehrYahyaee
4ef53f2cc9 fix ESG chassis inquiry payload 2026-09-19 16:50:34 +03:30
b4c1d7bd5f Merge pull request 'main' (#330) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#330
2026-09-19 16:12:46 +03:30
SepehrYahyaee
4cd6e0ecb3 test: align hull culprit default 2026-09-19 16:09:50 +03:30
SepehrYahyaee
8071215803 fix inquiry integration and insurer case details 2026-09-19 16:08:28 +03:30
cd36a0f2d4 Merge pull request 'main' (#329) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#329
2026-09-19 15:38:51 +03:30
ffaeb50c61 merge upstream 2026-09-19 15:38:13 +03:30
8afc1fabc4 fuck 2026-09-19 15:38:00 +03:30
1d9ef5e3d2 Merge pull request 'update the culprit licenses chnged and ActualPremium' (#328) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#328
2026-09-19 13:10:09 +03:30
be06bfe1b5 update the culprit licenses chnged and ActualPremium 2026-09-19 13:09:28 +03:30
85576e7c5e Merge pull request 'fix: query policies only for current holder' (#327) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#327
2026-09-19 12:13:54 +03:30
SepehrYahyaee
406b139b3d fix: align inquiry errors and expert review rules 2026-09-19 12:11:43 +03:30
SepehrYahyaee
45e0ad883a fix: query policies only for current holder 2026-09-19 11:26:40 +03:30
51e1d495a9 Merge pull request 'main' (#326) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#326
2026-09-18 16:11:29 +03:30
7d1a50db7b fix: harden claim review and inquiry workflows
Preserve damage history and current vehicle price, restore depreciation mapping, normalize inquiry/report output, and support resumable expert review with paginated case retrieval.
2026-09-18 16:04:33 +03:30
a84d83a135 Fixed dependencies 2026-09-17 12:26:58 +03:30
631c9bce16 Docs 2026-09-17 12:26:42 +03:30
a5f534817b Merge pull request 'main' (#325) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#325
2026-09-17 08:59:07 +03:30
ebfb4385de Use FileMaker and FileReviewer Fanavaran expert ids on V4/V5 flows.
Block file create and review when the matching third-party or car-body code is missing, and override GEN.03/GEN.06 ClaimExpertId from those profiles instead of tenant defaults.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 16:26:58 +03:30
acb5d2a682 car body implemented 2026-09-16 16:05:54 +03:30
a174332d81 Merge pull request 'Rollback uploadDocument resume, added carPrice' (#324) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#324
2026-09-16 15:41:17 +03:30
SepehrYahyaee
c9e273fc4e Rollback uploadDocument step resume 2026-09-16 15:37:27 +03:30
SepehrYahyaee
ecf69cc332 Change Car_BODY fanavaran default client code and id to parsian 2026-09-16 14:16:44 +03:30
31f8cabf80 Merge pull request 'Fixed uploadDocuments status bug, Fixed image deletion bug' (#323) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#323
2026-09-16 12:52:29 +03:30
SepehrYahyaee
fee0bcb5be Fixed images being removed, fixed v4/v5 wrong status on uploadDocument 2026-09-16 12:50:12 +03:30
SepehrYahyaee
dc30518a7f Completed DOCS for participants 2026-09-16 10:24:22 +03:30
8173764913 Merge pull request 'main' (#322) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#322
2026-09-15 17:48:40 +03:30
c72cb265f1 merge upstream 2026-09-15 17:48:20 +03:30
271dc4df2a badane bugs continues 2026-09-15 17:47:38 +03:30
b2ff3f574b Merge pull request 'main' (#321) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#321
2026-09-15 17:07:37 +03:30
769a581a51 merge upstream 2026-09-15 17:07:08 +03:30
466773fb2b badane update is implemented now 2026-09-15 17:05:54 +03:30
37041ba19f Merge pull request 'third party update , optional body is done' (#320) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#320
2026-09-15 16:36:52 +03:30
0b0a1dfa13 third party update , optional body is done 2026-09-15 16:36:13 +03:30
073b4dec38 Merge pull request 'Fixed THIRD_PARTY VIN inquiry request' (#319) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#319
2026-09-15 16:00:44 +03:30
SepehrYahyaee
cb4f02918f Fixed THIRD_PARTY VIN inquiry request 2026-09-15 15:50:29 +03:30
c1f0d041ce Merge pull request 'fanavaran stages matching data is double checked and more controlled +' (#318) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#318
2026-09-15 15:39:38 +03:30
f9c60a2854 fanavaran stages matching data is double checked and more controlled + 2026-09-15 15:36:28 +03:30
4310ce6398 Merge pull request 'Changed CAP to RIAL' (#317) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#317
2026-09-15 11:56:24 +03:30
SepehrYahyaee
96b56233bf Changed CAP to RIAL 2026-09-15 11:54:14 +03:30
d6b5f8f4d4 Merge pull request 'Changed Price units to Rial' (#316) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#316
2026-09-14 16:49:02 +03:30
SepehrYahyaee
152250a387 Changed Price units to Rial 2026-09-14 16:48:34 +03:30
ada85f9e0c Merge pull request 'Fixed mock data' (#315) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#315
2026-09-14 16:07:02 +03:30
SepehrYahyaee
e7103a9408 Fixed mock data 2026-09-14 16:06:39 +03:30
af8bec193b Merge pull request 'Fixed Participants' (#314) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#314
2026-09-14 14:27:49 +03:30
SepehrYahyaee
9930e9ff5b Fixed Participants 2026-09-14 14:27:23 +03:30
b3a298e254 Merge pull request 'update the fanavaran which accepts unknown person too' (#313) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#313
2026-09-14 13:43:16 +03:30
b13b0211ad update the fanavaran which accepts unknown person too 2026-09-14 13:42:53 +03:30
6e5b7cb1fd Merge pull request 'Fixed type fixes and errors' (#312) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#312
2026-09-14 12:52:02 +03:30
SepehrYahyaee
51166a8d0d Fixed type fixes and errors 2026-09-14 12:49:46 +03:30
4f301d1d03 Merge pull request 'Fix V4 FileMaker workflow re-entry' (#311) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#311
2026-09-14 11:43:41 +03:30
SepehrYahyaee
1b69da38ff Fix V4 FileMaker workflow re-entry 2026-09-14 11:42:48 +03:30
1941013b00 Merge pull request 'Route inquiries by participant role' (#310) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#310
2026-09-14 10:51:40 +03:30
SepehrYahyaee
98c7ebb83a Route inquiries by participant role 2026-09-14 10:43:57 +03:30
461afbb6b9 Merge pull request 'Implement role-complete inquiry participants + Bug fixes' (#309) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#309
2026-09-13 16:23:42 +03:30
SepehrYahyaee
6a26811555 Calculate insurer report review duration 2026-09-13 16:15:12 +03:30
SepehrYahyaee
f9d4b5df9a Localize timeline system reasons 2026-09-13 16:04:51 +03:30
SepehrYahyaee
0eaf55e73c Document structured inquiry payload for frontend 2026-09-13 14:35:45 +03:30
SepehrYahyaee
5b77241f28 Require structured inquiry participant inputs 2026-09-13 14:13:05 +03:30
SepehrYahyaee
cc8a96c875 Persist inquiry audit on client resolution failures 2026-09-13 12:59:39 +03:30
SepehrYahyaee
5812637a1e Mark rejected inquiry results as failed 2026-09-13 12:55:55 +03:30
SepehrYahyaee
287978a555 Retain inquiry audit context on failure 2026-09-13 11:42:20 +03:30
SepehrYahyaee
fe22e69351 Persist failed inquiry audit trails 2026-09-13 11:36:03 +03:30
SepehrYahyaee
b5b18114d8 Complete inquiry participant flow coverage 2026-09-13 11:29:43 +03:30
SepehrYahyaee
d060b6d9e2 Harden inquiry participant edge cases 2026-09-13 11:18:37 +03:30
SepehrYahyaee
c64f23091a Implement role-complete inquiry participants 2026-09-13 10:59:00 +03:30
SepehrYahyaee
401ad6a143 Added Docs 2026-09-12 17:02:12 +03:30
ed9ef3bac3 Merge pull request 'Removed unncessary data from PDF' (#308) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#308
2026-09-12 14:35:33 +03:30
SepehrYahyaee
bc9c217d8a Removed unncessary data from PDF 2026-09-12 14:35:01 +03:30
48c8290f9c Merge pull request 'YARA-1258' (#307) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#307
2026-09-12 14:24:00 +03:30
SepehrYahyaee
7fd60df1d6 YARA-1258 2026-09-12 14:23:23 +03:30
7f50b75c64 Merge pull request 'Preserved FE contact' (#306) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#306
2026-09-12 12:20:26 +03:30
SepehrYahyaee
9e51fdcdaf Preserved FE contact 2026-09-12 12:20:03 +03:30
58e374d502 Merge pull request 'Fixed FaLabels in Timeline' (#305) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#305
2026-09-12 11:59:03 +03:30
SepehrYahyaee
0bab93e983 Fixed seed data 2026-09-12 11:58:24 +03:30
SepehrYahyaee
fc37dd78a4 Fixed FaLabels 2026-09-12 11:58:07 +03:30
795ecbae96 Merge pull request 'YARA-1257, YARA-1272, YARA-985' (#304) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#304
2026-09-12 11:21:01 +03:30
SepehrYahyaee
15342e16d8 YARA-1257, YARA-1272, YARA-985 2026-09-12 11:19:57 +03:30
2dc25100ea Merge pull request 'fixed branch being mandatory' (#303) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#303
2026-09-12 10:19:37 +03:30
SepehrYahyaee
ebe45646c1 fixed branch being mandatory 2026-09-12 10:18:17 +03:30
bf515f3011 Merge pull request 'YARA-1267, YARA-1268' (#302) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#302
2026-09-08 14:53:15 +03:30
SepehrYahyaee
55da8188a9 YARA-1267, YARA-1268 2026-09-08 14:51:51 +03:30
20aa6957e9 Merge pull request 'Better error handling and validation and min/max prices added for expert submit' (#301) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#301
2026-09-08 13:12:20 +03:30
SepehrYahyaee
0e861ff6d1 Better error handling and validation and min/max prices added for expert submit 2026-09-08 13:11:24 +03:30
eb6cef1127 Merge pull request 'fixed default values' (#300) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#300
2026-09-07 17:16:16 +03:30
f98712ba83 fixed default values 2026-09-07 17:15:41 +03:30
3635dc2c10 Merge pull request 'Fixed car body inquiry client id fixing' (#299) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#299
2026-09-07 16:44:49 +03:30
SepehrYahyaee
f037450958 Fixed car body inquiry client id fixing 2026-09-07 16:44:08 +03:30
503894bf9c Merge pull request 'updated the fucking mf fanavaran' (#298) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#298
2026-09-07 16:35:02 +03:30
b9bffdccc1 updated the fucking mf fanavaran 2026-09-07 16:33:15 +03:30
395e3dbe63 Merge pull request 'Better error handling for different insurers' (#297) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#297
2026-09-07 15:16:31 +03:30
SepehrYahyaee
9fc7198f40 Better error handling for different insurers 2026-09-07 15:16:06 +03:30
d860e94dee Merge pull request 'main' (#296) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#296
2026-09-07 15:12:27 +03:30
b7857a40f5 bug fixed 2026-09-07 15:11:29 +03:30
c7cd9e8ce5 update the lookups list 2026-09-07 15:08:08 +03:30
6100ac80f3 Merge pull request 'enforced matching client id for moving on with the case' (#295) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#295
2026-09-07 15:06:45 +03:30
SepehrYahyaee
229957e283 enforced matching client id for moving on with the case 2026-09-07 15:06:06 +03:30
0724d66727 Merge pull request 'Fixed car body inquiry' (#294) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#294
2026-09-07 13:09:13 +03:30
SepehrYahyaee
7e3b308572 Fixed car body inquiry 2026-09-07 13:08:23 +03:30
4423b7fa25 Merge pull request 'FIXED mock problem' (#293) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#293
2026-09-07 13:01:29 +03:30
SepehrYahyaee
519967855e Fixed mock TP Inquiry 2026-09-07 13:00:18 +03:30
01346f950c merge upstream 2026-09-07 12:51:57 +03:30
80e112f09b Merge pull request 'main' (#292) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#292
2026-09-07 11:06:32 +03:30
80b71107a1 merge upstream 2026-09-07 11:05:56 +03:30
470f6e0920 update yara 2026-09-07 10:47:32 +03:30
584a550ce2 Merge pull request 'Merge pull request 'Implemented CAR_BODY fanavaran inquiry' (#291) from s.yahyaee/yara724-api:main into main' (#2) from Yara724/api:main into main
Reviewed-on: s.yahyaee/yara724-api#2
2026-09-06 15:13:02 +03:30
SepehrYahyaee
8b34e94de3 Fixed Car_Body inquiry for Parsian 2026-09-06 15:08:12 +03:30
706bffd1e7 Merge pull request 'Implemented CAR_BODY fanavaran inquiry' (#291) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#291
2026-09-06 14:15:46 +03:30
SepehrYahyaee
ef9768795d Implemented CAR_BODY fanavaran inquiry 2026-09-06 14:11:32 +03:30
4d2501d90c Merge pull request 'Removed some validations that were unneccesary for expert submit' (#290) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#290
2026-09-06 12:10:39 +03:30
SepehrYahyaee
c9b5b6f765 Removed some validations that were unneccesary for expert submit 2026-09-06 12:09:34 +03:30
d52d18a97c Merge pull request 'fanavaran car body and third_party lookup codings applied' (#289) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#289
2026-09-06 10:48:17 +03:30
02edd0b2ca fanavaran car body and third_party lookup codings applied 2026-09-06 10:45:40 +03:30
5f714e1456 Merge pull request 'Fixed daqi being enforced on REPAIR' (#288) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#288
2026-09-05 18:42:37 +03:30
SepehrYahyaee
233a16d96c Fixed daqi being enforced on REPAIR 2026-09-05 18:42:07 +03:30
6ecbe91ede Merge pull request 'car body policy and sales extended' (#287) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#287
2026-09-05 18:38:31 +03:30
eb636bdbdd car body policy and sales extended 2026-09-05 18:37:42 +03:30
eb072ef080 Merge pull request 'Added complete validation for expert claim submit state' (#286) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#286
2026-09-05 17:33:52 +03:30
SepehrYahyaee
e48dffef89 Added complete validation for expert claim submit state 2026-09-05 17:33:23 +03:30
4f7ee9a0de Merge pull request 'HOTFIX: not allowing empty price when expert tries to submit' (#285) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#285
2026-09-05 16:45:16 +03:30
SepehrYahyaee
feacad58ca HOTFIX: not allowing empty price when expert tries to submit 2026-09-05 16:44:40 +03:30
70fa49398d Merge pull request 'locations dynamically in fanavaran' (#284) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#284
2026-09-02 15:32:25 +03:30
5c2b660600 locations dynamically in fanavaran 2026-09-02 15:31:55 +03:30
ebfeef9e01 Merge pull request 'Fixed FileMaker and FileReviewer access to COMPLETED files' (#283) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#283
2026-09-01 21:58:43 +03:30
cf74d75146 Fixed FileMaker and FileReviewer access to COMPLETED files 2026-09-01 21:55:38 +03:30
f5a60eafb1 Merge pull request 'feat: complete in-person claims without final sign' (#282) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#282
2026-09-01 15:04:51 +03:30
SepehrYahyaee
ae49031c55 feat: complete in-person claims without final sign 2026-09-01 15:03:08 +03:30
23d636416d Merge pull request 'YARA-1259' (#281) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#281
2026-09-01 14:18:10 +03:30
SepehrYahyaee
6880de5960 YARA-1259 2026-09-01 14:13:52 +03:30
85bd892720 Merge pull request 'Fix PDF return data' (#280) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#280
2026-08-31 12:32:00 +03:30
SepehrYahyaee
a33466025d Fix PDF return data 2026-08-31 12:29:31 +03:30
a5d2f5a2b9 Merge pull request 'YARA-1241' (#279) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#279
2026-08-26 15:58:03 +03:30
SepehrYahyaee
226b63aefb Added new data for YARA-1241 2026-08-26 15:57:09 +03:30
5203c07154 merge upstream 2026-08-26 09:42:44 +03:30
SepehrYahyaee
6566b5f112 YARA-1246 2026-08-25 16:50:06 +03:30
24a38a6780 Merge pull request 'Added data for parsian to seed' (#278) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#278
2026-08-25 12:28:05 +03:30
SepehrYahyaee
dc3748ae94 Added data for parsian to seed 2026-08-25 12:27:21 +03:30
fe83c20e7e Merge pull request 'Added data seed script' (#277) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#277
2026-08-25 10:12:03 +03:30
SepehrYahyaee
e5a28238e5 Added data seed script 2026-08-25 10:11:19 +03:30
8cf7b7b229 Merge pull request 'YARA-1241' (#276) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#276
2026-08-24 16:51:16 +03:30
SepehrYahyaee
e4ef95a23e YARA-1241 2026-08-24 16:50:39 +03:30
5ec40f66dc Merge pull request 'YARA-985, YARA-1244' (#275) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#275
2026-08-24 16:34:27 +03:30
SepehrYahyaee
f6f4429ce7 YARA-985 2026-08-24 16:33:18 +03:30
SepehrYahyaee
2df7a889d3 YARA-1244 2026-08-24 11:11:31 +03:30
93edba412f Merge pull request 'Added case type for all GET APIs' (#274) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#274
2026-08-19 12:31:47 +03:30
SepehrYahyaee
3bd6bc6e9d Added case type for all GET APIs 2026-08-19 12:30:53 +03:30
SepehrYahyaee
26c80512ba Merge branch 'main' of git.ittalie.com:s.yahyaee/yara724-api 2026-08-19 10:22:06 +03:30
85fc83b564 merge upstream 2026-08-19 10:20:56 +03:30
SepehrYahyaee
f09f5b79f0 Added FA version of integrations documents 2026-08-18 11:24:06 +03:30
c7d77d30c4 Merge pull request 'fanavaran manuall test script + warn sms for fanavaran triggers at the last stage' (#273) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#273
2026-08-18 11:20:21 +03:30
693d602e49 sms template corrected and now fires at the end of the fanavaran stage (Expertise). Docs also added. 2026-08-18 11:18:33 +03:30
cb69b496b5 merge upstream 2026-08-18 10:15:33 +03:30
SepehrYahyaee
bfbcfcab37 Added documentation 2026-08-17 17:18:49 +03:30
1d56326456 Merge pull request 'Added 'perfomedBy' field to the Timeline' (#272) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#272
2026-08-17 10:26:25 +03:30
SepehrYahyaee
d5aa9f3f1b Added 'perfomedBy' field to the Timeline 2026-08-17 10:26:02 +03:30
d8a2be091f Merge pull request 'YARA-1078' (#271) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#271
2026-08-16 12:37:26 +03:30
SepehrYahyaee
01f8a5b12c YARA-1078 2026-08-16 12:35:18 +03:30
e6ea5c2e8a Merge pull request 'Delegated the PDF creation task for FE, and BE now only returns the necessary data for it' (#270) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#270
2026-08-16 11:53:04 +03:30
SepehrYahyaee
875b52d761 Delegated the PDF creation task for FE, and BE now only returns the necessary data for it 2026-08-16 11:52:26 +03:30
f5aa25edf8 Merge pull request 'YARA-1169' (#269) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#269
2026-08-16 10:59:31 +03:30
SepehrYahyaee
85d7881b2b YARA-1169 2026-08-16 10:18:43 +03:30
7259eec949 Merge pull request 'Fixed VIN' (#268) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#268
2026-08-10 17:14:11 +03:30
SepehrYahyaee
42f4c6e8e3 Fixed VIN 2026-08-10 17:13:44 +03:30
71b3b1d786 Merge pull request 'Fixed vin inquiry' (#267) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#267
2026-08-10 16:51:40 +03:30
SepehrYahyaee
baac633443 Fixed vin inquiry 2026-08-10 16:51:00 +03:30
13231736d8 Merge pull request 'fix damaged parts' (#266) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#266
2026-08-10 14:23:02 +03:30
SepehrYahyaee
cbed681c8f fix damaged parts 2026-08-10 14:22:37 +03:30
3e1cde739f Merge pull request 'Fixed v4 damaged area part' (#265) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#265
2026-08-10 14:08:24 +03:30
SepehrYahyaee
70d7f34402 Fixed v4 damaged area part 2026-08-10 14:07:56 +03:30
f57b1b1171 Merge pull request 'Added vin inquiry for v6, fixed new damaged part for v4' (#264) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#264
2026-08-10 13:48:34 +03:30
SepehrYahyaee
6791c71809 Added vin inquiry for v6, fixed new damaged part for v4 2026-08-10 13:48:05 +03:30
6d955cd608 Merge pull request 'Fixed VIN placement for when the users use vin-based inquiry' (#263) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#263
2026-08-10 12:15:31 +03:30
SepehrYahyaee
ca7200e17d Fixed VIN placement for when the users use vin-based inquiry 2026-08-10 12:14:51 +03:30
60ed80fc87 Merge pull request 'Fixed YARA-1217' (#262) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#262
2026-08-10 10:35:14 +03:30
SepehrYahyaee
210e96fcf1 Fixed YARA-1217 2026-08-10 10:34:20 +03:30
63038f630d Merge pull request 'YARA-1218' (#261) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#261
2026-08-09 14:08:46 +03:30
SepehrYahyaee
8e4c794d61 YARA-1218 2026-08-09 14:08:11 +03:30
0663b35157 Merge pull request 'YARA-1217' (#260) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#260
2026-08-09 11:03:49 +03:30
SepehrYahyaee
d4cd8c9343 YARA-1217 2026-08-09 11:03:16 +03:30
2e8a8197a4 Merge pull request 'YARA-1216' (#259) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#259
2026-08-09 10:37:13 +03:30
SepehrYahyaee
3821bf36ef YARA-1216 2026-08-09 10:36:45 +03:30
184 changed files with 34533 additions and 3392 deletions

1
.gitignore vendored
View File

@@ -186,4 +186,3 @@ dist
*.sh
!scripts/fanavaran-flow-test.sh
!scripts/fanavaran-auth.sh
*.json

BIN
docs/Yara724 flows.pdf Normal file

Binary file not shown.

103
docs/architecture.md Normal file
View File

@@ -0,0 +1,103 @@
docs/
│
├── README.md
│
├── architecture/
│ ├── overview.md
│ ├── principles.md
│ ├── layers.md
│ ├── modules.md
│ ├── workflow-engine.md
│ ├── event-driven.md
│ ├── database.md
│ ├── caching.md
│ ├── authentication.md
│ ├── authorization.md
│ ├── file-storage.md
│ ├── id-generation.md
│ ├── error-handling.md
│ ├── logging.md
│ └── diagrams/
│
├── adr/
│ ├── 0001-use-mongodb.md
│ ├── 0002-use-workflow-engine.md
│ ├── ...
│
├── engineering/
│ ├── structure.md
│ ├── coding-style.md
│ ├── naming.md
│ ├── comments.md
│ ├── exceptions.md
│ ├── validation.md
│ ├── dto-guidelines.md
│ ├── repositories.md
│ ├── services.md
│ ├── controllers.md
│ ├── testing.md
│ ├── code-review.md
│ ├── gitflow.md
│ ├── commit-convention.md
│ ├── branching.md
│ ├── dependency-rules.md
│ ├── security.md
│ └── performance.md
│
├── domain/
│ ├── glossary.md
│ ├── insurance-concepts.md
│ ├── entities.md
│ ├── events.md
│ ├── workflows.md
│ ├── business-rules.md
│ └── state-transitions.md
│
├── flows/
│ ├── company-a/
│ ├── company-b/
│ ├── company-c/
│ └── common/
│
├── api/
│ ├── rest.md
│ ├── versioning.md
│ ├── pagination.md
│ ├── errors.md
│ └── examples/
│
├── deployment/
│ ├── docker.md
│ ├── environments.md
│ ├── ci.md
│ ├── cd.md
│ ├── backups.md
│ └── monitoring.md
│
├── onboarding/
│ ├── setup.md
│ ├── first-day.md
│ ├── debugging.md
│ ├── faq.md
│ └── common-mistakes.md
│
├── operations/
│ ├── runbooks.md
│ ├── incident-response.md
│ ├── rca/
│ ├── postmortems/
│ └── troubleshooting.md
│
├── backlog/
│ ├── ideas.md
│ ├── technical-debt.md
│ ├── future-features.md
│ └── experiments.md
│
├── decisions/
│ ├── rejected-ideas.md
│ ├── deprecated.md
│ └── migration-plans.md
│
├── changelog.md
└── roadmap.md

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,228 @@
# راهنمای فرانت‌اند: ثبت پاسخ کارشناس خسارت (V2)
این مستند قرارداد API زیر را توضیح می‌دهد، به‌ویژه اعتبارسنجی مبلغ‌ها و ساختار خطاهایی که باید در فرم نمایش داده شوند.
```http
PUT /v2/expert-claim/reply/submit/:claimRequestId
Authorization: Bearer <actor-token>
Content-Type: application/json
```
اگر محیط شما API را زیر پیشوند `/api` منتشر می‌کند، مسیر نهایی به‌صورت `/api/v2/expert-claim/reply/submit/:claimRequestId` است.
## پیش‌نیازها
- کاربر باید نقش کارشناس مجاز داشته باشد.
- پرونده باید پیش از ارسال توسط همین کارشناس قفل شده باشد.
- `partId` هر ردیف باید از `damagedParts[].partId` در جزئیات پرونده انتخاب شود؛ شناسه دلخواه ارسال نکنید.
- دست‌کم یک ردیف در `parts` لازم است.
## بدنه درخواست
تمام مبلغ‌ها باید **string** باشند. ارقام فارسی/انگلیسی و جداکننده هزارگان پذیرفته می‌شوند؛ برای نمونه هر دو مقدار `"100000"` و `"۱۰۰,۰۰۰"` معتبرند.
```json
{
"description": "تعویض درب جلو لازم است",
"parts": [
{
"partId": 201,
"typeOfDamage": "تعویض",
"price": "2500000",
"salary": "400000",
"totalPayment": "2900000",
"factorNeeded": false,
"daghi": {
"option": "ارزش لوازم بازیافتی",
"price": "300000"
}
}
]
}
```
### فیلدهای سطح بالا
| فیلد | الزامی | توضیح |
| --- | --- | --- |
| `description` | خیر | یادداشت متنی کارشناس. می‌تواند ارسال نشود. |
| `parts` | بله | آرایه‌ای با حداقل یک ردیف قیمت‌گذاری. |
### فیلدهای هر ردیف `parts[i]`
| فیلد | الزامی | مقدار و قاعده |
| --- | --- | --- |
| `partId` | بله | عدد صحیح؛ باید متعلق به قطعات آسیب‌دیده همان پرونده باشد. |
| `typeOfDamage` | بله | دقیقاً یکی از `"تعمیر"` یا `"تعویض"`. |
| `price` | برای `"تعویض"` بله؛ برای `"تعمیر"` اختیاری | در صورت ارسال، مبلغ بین 100,000 تا 10,000,000,000 تومان. |
| `salary` | بله | مبلغ بین 100,000 تا 10,000,000,000 تومان. |
| `totalPayment` | بله | مبلغ بین 1,000,000 تا 100,000,000,000 ریال. فقط در جریان V1، مجموع این فیلدها در کل پرونده نباید از 530,000,000 ریال بیشتر شود. |
| `factorNeeded` | بله | مقدار Boolean واقعی (`true` یا `false`)؛ رشته ارسال نکنید. |
| `daghi` | برای `"تعویض"` بله | برای `"تعمیر"` لازم نیست و در ثبت نهایی حذف می‌شود. |
| `daghi.option` | در صورت وجود `daghi` بله | یکی از `"ارزش لوازم بازیافتی"`، `"تحویل داغی"`، `"فاقد ارزش"` یا `"با احتساب داغی"`. |
| `daghi.price` | فقط وقتی `option` برابر `"ارزش لوازم بازیافتی"` است | مبلغ بین 100,000 تا 10,000,000,000 تومان. |
| `daghi.branchId` | فقط وقتی `option` برابر `"تحویل داغی"` است | Mongo ObjectId معتبرِ شعبه. |
نکته‌ها:
- مقدار `0` برای هیچ مبلغ ارسالی این endpoint معتبر نیست؛ حداقل مبلغ 100,000 تومان است.
- محدودیت هر فیلد مبلغ مستقل است؛ سقف 530 میلیون ریال فقط برای **مجموع `totalPayment` تمام ردیف‌های پرونده V1** اعمال می‌شود. جریان‌های V2 تا V6 سقف مجموع ندارند.
- فیلدهای ناشناخته در body حذف می‌شوند. فرانت‌اند نباید برای انتقال داده به آن‌ها تکیه کند.
## پاسخ موفق
پاسخ `200` شامل مسیر بعدی workflow است. نمونه:
```json
{
"claimRequestId": "66c...",
"status": "COMPLETED",
"claimStatus": "APPROVED",
"currentStep": "CLAIM_COMPLETED",
"workflowNextStep": "CLAIM_COMPLETED",
"factorNeeded": false,
"mixedPricingAndFactors": false,
"allPartsFactorNeeded": false,
"isFinalReplyAfterObjection": false
}
```
مقادیر `status` و step به این وابسته‌اند که ردیف factor داشته باشید یا نه؛ فرانت‌اند باید از مقادیر پاسخ استفاده کند، نه اینکه مسیر بعدی را فقط از payload حدس بزند.
## قرارداد خطا
همه خطاهای این endpoint JSON هستند و NestJS مقدار `statusCode` را نیز به پاسخ اضافه می‌کند. برای نمایش پیام به کاربر از `message` استفاده کنید و برای منطق برنامه از `code` استفاده کنید؛ متن فارسی را با متن ثابت در فرانت‌اند جایگزین نکنید.
### 1. خطای ساختار DTO — `400`
برای نبودن فیلد الزامی، نوع اشتباه، enum نامعتبر، آرایه خالی، یا فرمت مبلغ نامعتبر، پاسخ زیر برمی‌گردد:
```json
{
"statusCode": 400,
"message": "اطلاعات ارسالی پاسخ کارشناسی معتبر نیست.",
"error": "EXPERT_REPLY_VALIDATION_ERROR",
"code": "EXPERT_REPLY_VALIDATION_ERROR",
"validationErrors": [
{
"field": "parts[0].salary",
"message": "باید مبلغ صحیح و غیرمنفی به تومان باشد."
},
{
"field": "parts[0].factorNeeded",
"message": "باید درست یا نادرست باشد."
}
]
}
```
`validationErrors` ممکن است چند خطا داشته باشد. کلید `field` دقیقاً برای اتصال به کنترل فرم است؛ مانند `parts[0].daghi.price` یا `parts[2].partId`.
### 2. خطای قواعد مبلغ و قیمت‌گذاری — `400`
این خطاها پس از اعتبارسنجی ساختار و پیش از تغییر وضعیت پرونده بررسی می‌شوند. برای مثال، مبلغ کمتر از حداقل:
```json
{
"statusCode": 400,
"message": "قیمت قطعه 201 باید حداقل ۱۰۰٬۰۰۰ و حداکثر ۱۰٬۰۰۰٬۰۰۰٬۰۰۰ تومان باشد.",
"error": "EXPERT_REPLY_VALIDATION_ERROR",
"code": "EXPERT_REPLY_VALIDATION_ERROR",
"field": "parts[0].price",
"partId": "201",
"rule": "amount_out_of_range",
"minAmount": 100000,
"maxAmount": 10000000000
}
```
`rule` یکی از این مقادیر است:
| مقدار | معنی |
| --- | --- |
| `required` | فیلد لازم ارسال نشده است. |
| `invalid_value` | مقدار enum یا شناسه معتبر نیست. |
| `invalid_amount` | مبلغ، عدد صحیح غیرمنفی به تومان نیست. |
| `amount_out_of_range` | مبلغ خارج از بازه `minAmount` و `maxAmount` است. |
در صورت `amount_out_of_range`، برای نمایش محدوده از اعداد `minAmount` و `maxAmount` استفاده کنید، نه از parse کردن متن `message`.
### 3. سقف مجموع مبلغ‌ها — `400`
```json
{
"statusCode": 400,
"message": "مجموع مبلغ قطعات (۵۴۰٬۰۰۰٬۰۰۰) از سقف مجاز (۵۳۰٬۰۰۰٬۰۰۰) ریال بیشتر است.",
"error": "PRICE_CAP_ERROR",
"code": "PRICE_CAP_ERROR",
"totalPrice": 540000000,
"priceCap": 530000000
}
```
این خطا فقط برای پرونده‌های V1 رخ می‌دهد و به یک ردیف مشخص وصل نیست. آن را در بالای جدول قیمت‌ها نمایش دهید و از `priceCap` جزئیات پرونده/خطا برای پیام UI استفاده کنید. مقدار `priceCap: null` یعنی پرونده سقف مجموع ندارد.
### 4. خطاهای قواعد پرونده و workflow
این خطاها ساختار مشترک زیر را دارند:
```json
{
"statusCode": 400,
"message": "قطعه با شناسه 201 در فهرست قطعات آسیب‌دیده پرونده وجود ندارد.",
"error": "EXPERT_REPLY_SUBMISSION_ERROR",
"code": "PART_NOT_ON_CLAIM",
"field": "partId",
"partId": 201
}
```
کدهای مهم:
| HTTP | `code` | رفتار پیشنهادی فرانت‌اند |
| --- | --- | --- |
| 404 | `CLAIM_NOT_FOUND` | پیام خطا و بازگشت به فهرست پرونده‌ها. |
| 400 | `CLAIM_NOT_REVIEWABLE` | جزئیات پرونده را refresh کنید؛ ثبت در وضعیت فعلی مجاز نیست. |
| 403 | `CLAIM_NOT_LOCKED` | کاربر باید ابتدا پرونده را قفل کند. |
| 403 | `CLAIM_LOCKED_BY_ANOTHER_EXPERT` | فرم را read-only کنید و پیام مناسب نمایش دهید. |
| 400 | `PART_NOT_ON_CLAIM` | داده قطعات را refresh کنید و ردیف خطادار را اصلاح/حذف کنید. |
| 400 | `PART_INVALID` / `PART_ID_INVALID` | ردیف دارای `partId` را اصلاح کنید. |
| 400 | `DUPLICATE_PART` | ردیف تکراری را حذف کنید. |
| 400 | `DAGHI_OPTION_REQUIRED` | فیلد `daghi.option` همان ردیف را نشان‌دار کنید. |
| 400 | `DAGHI_PRICE_REQUIRED` | فیلد `daghi.price` همان ردیف را نشان‌دار کنید. |
| 400 | `DAGHI_BRANCH_REQUIRED` / `DAGHI_BRANCH_INVALID` | فیلد `daghi.branchId` همان ردیف را نشان‌دار کنید. |
| 409 | `FINAL_REPLY_ALREADY_SUBMITTED` | ثبت مجدد ممنوع است؛ داده پرونده را refresh کنید. |
برای خطاهای داغی که از سرویس برمی‌گردند، `field` به شکل `parts[<index>].daghi.<property>` است.
## الگوی پیشنهادی هندل کردن خطا در فرانت‌اند
```ts
type ApiErrorBody = {
message?: string;
code?: string;
field?: string;
validationErrors?: Array<{ field: string; message: string }>;
minAmount?: number;
maxAmount?: number;
priceCap?: number;
};
function applySubmitError(body: ApiErrorBody) {
if (body.validationErrors?.length) {
for (const issue of body.validationErrors) {
setFieldError(issue.field, issue.message);
}
return;
}
if (body.field) {
setFieldError(body.field, body.message ?? "مقدار واردشده معتبر نیست.");
return;
}
setFormError(body.message ?? "ثبت پاسخ کارشناسی انجام نشد.");
}
```
قبل از ارسال، فرانت‌اند می‌تواند همین بازه مبلغ را برای تجربه کاربری بهتر بررسی کند؛ با این حال اعتبار نهایی همیشه پاسخ API است. جمع `totalPayment` را فقط وقتی `priceCap` جزئیات پرونده عدد است با سقف مقایسه کنید؛ `null` یعنی V2 تا V6 و بدون سقف مجموع.

View File

@@ -433,6 +433,23 @@ curl -X POST "$ESG_URL/inquiry/policyByPlate" \
}'
```
### Car By Chassis (Two-Factor VIN Inquiry)
Use the two-factor chassis route when the backend must match both the VIN and
the resolved third-party policyholder. Do not send `nationalCode` to the
one-factor `policyByChassis` route.
```sh
curl -X POST "$ESG_URL/inquiry/carByChassis" \
-H "Authorization: Bearer $ESG_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
--data '{
"nationalCode": "0012345678",
"chassisNo": "NAAR03HFFRDE07024"
}'
```
### Person Inquiry
ESG expects Jalali birth date, normalized as `YYYY-MM-DD`.

View File

@@ -0,0 +1,597 @@
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<title>مرجع یکپارچه‌سازی‌های خارجی</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: "Vazirmatn", "Tahoma", "Segoe UI", system-ui, sans-serif;
font-size: 14px; line-height: 1.8;
background: #ffffff; color: #1f2328; padding: 24px;
}
h1 { font-size: 20px; font-weight: 700; margin-bottom: 4px; }
.subtitle { font-size: 13px; color: #57606a; margin-bottom: 28px; }
h2 {
font-size: 15px; font-weight: 700;
margin-bottom: 10px; margin-top: 32px;
border-bottom: 1px solid #e5e7eb; padding-bottom: 6px;
}
h3 {
font-size: 12px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.03em;
color: #57606a; margin-bottom: 8px; margin-top: 14px;
}
.section-intro {
font-size: 13px; color: #57606a;
margin-bottom: 14px; line-height: 1.7;
}
.card {
border: 1px solid #e5e7eb; border-radius: 6px;
padding: 16px; background: #f7f8fa; margin-bottom: 16px;
}
.card.card-blue { border-right: 4px solid #3b82f6; }
.card.card-green { border-right: 4px solid #22c55e; }
.card.card-purple { border-right: 4px solid #8b5cf6; }
.card.card-orange { border-right: 4px solid #f97316; }
.card.card-teal { border-right: 4px solid #14b8a6; }
.card.card-indigo { border-right: 4px solid #6366f1; }
.card.card-gray { border-right: 4px solid #94a3b8; }
.card.card-red { border-right: 4px solid #ef4444; }
.card.card-yellow { border-right: 4px solid #eab308; }
table {
border-collapse: collapse; width: 100%;
font-size: 12px; margin-top: 4px; direction: rtl;
}
th {
background: #f1f5f9; font-weight: 600;
text-align: right; padding: 5px 8px; border: 1px solid #e5e7eb;
}
td { padding: 4px 8px; border: 1px solid #e5e7eb; vertical-align: top; }
tr:nth-child(even) td { background: #ffffff; }
code {
font-family: monospace; font-size: 11px; color: #3b82d4;
direction: ltr; unicode-bidi: embed;
}
.method {
font-family: monospace; font-size: 11px;
font-weight: 700; white-space: nowrap;
direction: ltr; unicode-bidi: embed;
}
.method.get { color: #059669; }
.method.post { color: #2563eb; }
.method.put { color: #d97706; }
.method.patch { color: #7c3aed; }
.note { font-size: 11px; color: #57606a; font-style: normal; margin-top: 6px; }
.warn { font-size: 11px; color: #9a3412; font-style: normal; margin-top: 6px; }
.status-badge {
display: inline-block; font-size: 11px; font-weight: 600;
padding: 1px 7px; border-radius: 10px;
}
.status-live { background: #dcfce7; color: #166534; }
.status-partial { background: #ffedd5; color: #9a3412; }
.status-disabled { background: #fee2e2; color: #991b1b; }
.status-internal { background: #f1f5f9; color: #475569; border: 1px solid #e2e8f0; }
.toc {
background: #f7f8fa; border: 1px solid #e5e7eb;
border-radius: 6px; padding: 14px 18px; margin-bottom: 28px;
}
.toc-title { font-size: 13px; font-weight: 700; margin-bottom: 8px; }
.toc ol { padding-right: 18px; padding-left: 0; }
.toc li { font-size: 13px; margin-bottom: 3px; }
.toc a { color: #3b82d4; text-decoration: none; }
.toc a:hover { text-decoration: underline; }
footer {
text-align: center; font-size: 12px; color: #57606a;
border-top: 1px solid #e5e7eb; margin-top: 40px; padding-top: 12px;
}
.max-wrap { max-width: 760px; margin: 0 auto; }
.flow-box {
background: #ffffff; border: 1px solid #e5e7eb; border-radius: 4px;
padding: 10px 14px; font-size: 12px; margin-top: 8px;
}
.flow-step {
display: flex; gap: 10px; align-items: flex-start; margin-bottom: 6px;
direction: rtl;
}
.flow-num {
flex-shrink: 0; width: 20px; height: 20px; border-radius: 50%;
background: #3b82d4; color: #fff; font-size: 11px; font-weight: 700;
display: flex; align-items: center; justify-content: center;
}
.flow-text { flex: 1; padding-top: 2px; }
.decision-tree {
font-size: 12px; background: #ffffff;
border: 1px solid #e5e7eb; border-radius: 4px; padding: 12px 16px;
margin-top: 8px; line-height: 1.9;
}
.decision-tree ul { padding-right: 20px; padding-left: 0; }
.decision-tree li { margin-bottom: 2px; }
.env-table th:last-child { width: 220px; }
pre {
font-family: monospace; font-size: 11px;
background: #f1f5f9; border: 1px solid #e5e7eb;
border-radius: 4px; padding: 10px 12px;
white-space: pre-wrap; word-break: break-all;
margin-top: 6px; color: #1f2328;
direction: ltr; unicode-bidi: embed;
}
</style>
</head>
<body>
<div class="max-wrap">
<h1>مرجع یکپارچه‌سازی‌های خارجی</h1>
<p class="subtitle">
تمام یکپارچه‌سازی‌های خروجی: کاربرد، زمان فعال‌شدن، نحوه احراز هویت،
رفتار retry، fallbackها و تمام متغیرهای محیطی. سرویس‌های داخلی
(کپچا، داده‌های پرس‌وجوی آفلاین) برای کامل‌بودن گنجانده شده‌اند.
</p>
<!-- TOC -->
<div class="toc">
<div class="toc-title">فهرست</div>
<ol>
<li><a href="#inquiry-routing">درخت تصمیم مسیریابی پرس‌وجو</a></li>
<li><a href="#fanavaran">فناوران — پلتفرم خسارت بیمه</a></li>
<li><a href="#sanhub">SandHub — درگاه پرس‌وجوی قدیمی</a></li>
<li><a href="#tejarat">پرس‌وجوی تجارت — درگاه block-inquiry (V2+)</a></li>
<li><a href="#esg">ESG — ارائه‌دهنده پرس‌وجوی تنانت پارسیان</a></li>
<li><a href="#sms">پیامک — درگاه‌های کاوه‌نگار و پارسیان</a></li>
<li><a href="#ai">سرویس هوش مصنوعی — تشخیص خسارت خودرو</a></li>
<li><a href="#car-pricing">سرویس قیمت خودرو — جستجوی ارزش بازار</a></li>
<li><a href="#offline-inquiry">پرس‌وجوی آفلاین — داده‌های fallback</a></li>
<li><a href="#env-ref">مرجع متغیرهای محیطی</a></li>
</ol>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="inquiry-routing">۱ — درخت تصمیم مسیریابی پرس‌وجو</h2>
<p class="section-intro">
هر فایل تقصیر با فراخوانی "run-inquiries" آغاز می‌شود که بیمه‌نامه طرف مقصر
را از یک ارائه‌دهنده خارجی دریافت می‌کند. اینکه کدام ارائه‌دهنده واقعاً فراخوانی
می‌شود به سه عامل بستگی دارد: تنانت (<code>CLIENT_ID</code>)، نوع فایل
(THIRD_PARTY در مقابل CAR_BODY) و اینکه آیا حالت API زنده در تنظیمات سیستم
فعال است یا خیر. لایه داده‌های پرس‌وجوی آفلاین در جلوی هر سه ارائه‌دهنده قرار دارد.
</p>
<div class="card card-indigo">
<h3>انتخاب ارائه‌دهنده</h3>
<div class="decision-tree">
<strong>برای هر پرس‌وجوی مبتنی بر پلاک:</strong>
<ul>
<li>درخواست همیشه با پلاک فعلی ارسال‌شده و بیمه‌گذار نهایی همان نوع بیمه انجام می‌شود. متادیتای انتقال اخیر هیچ استعلامی برای پلاک یا بیمه‌گذار قبلی ایجاد نمی‌کند.</li>
<li>۱. بررسی داده‌های آفلاین (MongoDB) — اگر داده مطابق یافت شد، آن را برگردانده و تمام HTTP را رد کن.</li>
<li>۲. اگر <code>CLIENT_ID=8</code> (تنانت پارسیان/ESG) → برای پلاک به <strong>ESG</strong> <code>/inquiry/policyByPlate</code> و برای VIN/شاسی به مسیر دوعاملی <code>/inquiry/carByChassis</code> مسیریابی می‌شود.</li>
<li>۳. در غیر این صورت → مسیریابی به <strong>پرس‌وجوی تجارت</strong> <code>/block-inquiry-tejarat</code> (THIRD_PARTY) یا <code>/block-inquiry-tejarat/badane</code> (CAR_BODY).</li>
<li>۴. اگر <code>system_settings.externalApis.sandHubUseLiveApi = false</code> (پیش‌فرض) → پاسخ mock برگردانده شود به جای انجام فراخوانی‌های HTTP.</li>
</ul>
<br>
<strong>برای بررسی‌های هویت شخصی، گواهینامه، مالکیت و شبا:</strong>
<ul>
<li>اگر <code>CLIENT_ID=8</code> → ESG <code>/inquiry/person</code> و <code>/inquiry/sheba</code>.</li>
<li>در غیر این صورت → تجارت/SandHub <code>/personal-inquiry/tejarat-no</code>، <code>/driver-license-check</code>، <code>/ownership</code>، <code>/sheba/sheba-tejaratno</code>.</li>
</ul>
<br>
<strong>تفاوت کلیدی — فرمت تاریخ تولد:</strong>
SandHub/تجارت تاریخ تولد <em>میلادی</em> انتظار دارند (داخلی از جلالی تبدیل می‌شود).
ESG مستقیماً تاریخ <em>جلالی</em> انتظار دارد.
</div>
<p class="note" style="margin-top:8px;">
اندپوینت‌های SandHub فقط در مسیرهای قدیمی کد استفاده می‌شوند. تمام جریان‌های فعال تقصیر V2+ از طریق ارائه‌دهندگان تجارت یا ESG می‌روند.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="fanavaran">۲ — فناوران <span class="status-badge status-live">فعال</span></h2>
<p class="section-intro">
فناوران (<code>apimanager.iraneit.com</code>) پلتفرم ملی پرونده خسارت بیمه است.
پس از اینکه کارشناس خسارت ارزیابی خود را ارسال می‌کند، سیستم به‌صورت خودکار
یک خسارت ساختاریافته را از طریق یک پروتکل چهار مرحله‌ای به فناوران ارسال می‌کند.
فناوران همچنین به‌عنوان منبع جستجوی code-listها (انواع تصادف، اجزای خودرو،
کدهای شهر و غیره) در سراسر پلتفرم عمل می‌کند.
</p>
<div class="card card-blue">
<h3>چرخه حیات احراز هویت</h3>
<div class="flow-box">
<div class="flow-step"><div class="flow-num">۱</div><div class="flow-text"><strong>GET AppToken</strong> — <code>POST /EITAuthentication/GetAppToken</code> با هدرهای <code>appname</code> + <code>secret</code>. هدر <code>apptoken</code> را برمی‌گرداند.</div></div>
<div class="flow-step"><div class="flow-num">۲</div><div class="flow-text"><strong>Login</strong> — <code>POST /EITAuthentication/Login</code> با هدرهای <code>appToken</code> + <code>userName</code> + <code>password</code>. هدر <code>authenticationToken</code> را برمی‌گرداند.</div></div>
<div class="flow-step"><div class="flow-num">۳</div><div class="flow-text"><strong>Cache</strong> — توکن در حافظه <em>و</em> پایدار در MongoDB (<code>fanavaran_auth_tokens</code>) ذخیره می‌شود. تا نیمه‌شب <strong>Asia/Tehran</strong> معتبر است — اولین فراخوانی پس از ۰۰:۰۰ یک توکن تازه دریافت می‌کند.</div></div>
<div class="flow-step"><div class="flow-num">۴</div><div class="flow-text"><strong>تمام فراخوانی‌های بعدی</strong> چهار هدر شامل می‌شوند: <code>authenticationToken</code>، <code>CorpId</code>، <code>ContractId</code>، <code>Location</code> — مختص تنانت، hardcoded به ازای هر کلید <code>FANAVARAN_CLIENT</code>.</div></div>
</div>
<p class="note" style="margin-top:8px;">
یک اثر انگشت پیکربندی (هش appName + secret + username + password + corpId + contractId + location)
یک ورود تازه را زمانی که هر مدرکی تغییر کند، حتی قبل از نیمه‌شب، مجبور می‌کند.
</p>
</div>
<div class="card card-blue">
<h3>پروتکل ارسال خسارت (۴ مرحله)</h3>
<div class="flow-box">
<div class="flow-step"><div class="flow-num">۱</div><div class="flow-text"><strong>خسارت پایه (GEN.03)</strong> — <code>POST /car/third-party-car-financial-claims</code>. داده‌های مالک، راننده، بیمه، وسیله نقلیه و تصادف را ارسال می‌کند. یک <code>claimId</code> و <code>claimNo</code> فناوران برمی‌گرداند که برای نمایش در پنل ذخیره می‌شوند.</div></div>
<div class="flow-step"><div class="flow-num">۲</div><div class="flow-text"><strong>موارد خسارت (GEN.05)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/dmg-cases</code>. یک ورودی به ازای هر قطعه آسیب‌دیده با شناسه کامپوننت، شدت و قیمت. سقف: کل ≤ ۵۳،۰۰۰،۰۰۰ تومان.</div></div>
<div class="flow-step"><div class="flow-num">۳</div><div class="flow-text"><strong>پیوست‌ها (GEN.07)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/files</code>. اسناد، تصاویر car-capture و ویدیوها که با شناسه فایل ارجاع داده شده‌اند.</div></div>
<div class="flow-step"><div class="flow-num">۴</div><div class="flow-text"><strong>کارشناسی (GEN.08)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/expertise</code>. متادیتای ارزیابی کارشناس (نقش کارشناس، تاریخ، نتیجه). ارسال را نهایی می‌کند.</div></div>
</div>
<p class="note" style="margin-top:8px;">
هر چهار مرحله در مجموعه <code>fanavaran_audit_logs</code> با بدنه کامل درخواست/پاسخ، وضعیت HTTP، مدت زمان و کد ردیابی برای اشکال‌زدایی ثبت می‌شوند.
</p>
</div>
<div class="card card-blue">
<h3>اندپوینت‌های Lookup</h3>
<p class="note">همه زیر <code>https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/</code>. نتایج روی دیسک (به ازای کلید مشتری) و در مجموعه MongoDB <code>lookups</code> کش می‌شوند. تنانت پارسیان قبل از درخواست API از DB می‌خواند؛ دیگران ابتدا به API می‌روند.</p>
<table>
<tr><th>کاربرد</th><th>مسیر</th></tr>
<tr><td>گزینه‌های dropdown برای accidentReason (نگاشت شده به شناسه‌های محلی)</td><td><code>/car/base-info/accident-causes</code></td></tr>
<tr><td>گزینه‌های accidentWay</td><td><code>/car/code-list/accident-report-type</code></td></tr>
<tr><td>طبقه‌بندی استفاده از وسیله نقلیه</td><td><code>/car/base-info/vehicle-use-types</code></td></tr>
<tr><td>روش پرداخت خسارت</td><td><code>/car/code-list/dmg-pay-method</code></td></tr>
<tr><td>گزینه‌های نوع گواهینامه</td><td><code>/car/base-info/driving-licence-types</code></td></tr>
<tr><td>طبقه‌بندی طرف مقصر</td><td><code>/car/code-list/accident-culprit-type</code></td></tr>
<tr><td>گزینه‌های محل بازرسی</td><td><code>/car/code-list/inspection-place</code></td></tr>
<tr><td>کدهای وضعیت کاهش قیمت</td><td><code>/car/code-list/drop-amount-status</code></td></tr>
<tr><td>کاتالوگ کامپوننت (نگاشت به قطعات بیرونی/داخلی)</td><td><code>/car/base-info/car-components</code></td></tr>
<tr><td>گزینه‌های شدت تصادف</td><td><code>/car/code-list/accident-level</code></td></tr>
<tr><td>تطبیق <code>INSURANCE_CORP_ID</code> ← corpId فناوران</td><td><code>/common/code-list/insurance-corp</code></td></tr>
<tr><td>انتخابگرهای شهر/استان</td><td><code>/common/base-info/cities</code>، <code>/common/base-info/Provinces</code></td></tr>
<tr><td>دریافت بیمه‌نامه کامل بر اساس شناسه پس از استعلام</td><td><code>/car/third-party-car-policies/{policyId}</code></td></tr>
<tr><td>جستجوی وسیله نقلیه بر اساس VIN</td><td><code>/car/vehicles/inquiry-by-vin?vin=…</code></td></tr>
<tr><td>فهرست بیمه‌نامه‌ها برای یک کد ملی</td><td><code>/common/Policies/inquiry-my-policies</code></td></tr>
<tr><td>دریافت رکورد مشتری بر اساس شناسه</td><td><code>/common/customers/{customerId}</code></td></tr>
<tr><td>جستجوی طرف بر اساس کد ملی + تاریخ تولد</td><td><code>/common/parties/inquiry-by-unique-identifier</code></td></tr>
</table>
</div>
<div class="card card-blue">
<h3>مدیریت خطا و انعطاف‌پذیری</h3>
<table>
<tr><th>توضیح</th><th>مکانیزم</th></tr>
<tr><td>۳ تلاش، ۵۰۰ ms ← ۱۰۰۰ ms backoff نمایی در تمام فراخوانی‌های HTTP.</td><td>Retry</td></tr>
<tr><td>وقتی فناوران پیام فارسی "دوباره تلاش کنید" (یا tracking-code 500) برمی‌گرداند، یک مکث ۵ دقیقه‌ای در سطح تنانت فعال می‌شود. تمام فراخوانی‌ها در این پنجره بلافاصله <code>503 ServiceUnavailable</code> دریافت می‌کنند — بدون فشار.</td><td>Backoff گذرا</td></tr>
<tr><td>در ۴۰۱، توکن از حافظه و MongoDB پاک می‌شود؛ فراخوانی بعدی GetAppToken + Login تازه را فعال می‌کند.</td><td>ابطال توکن</td></tr>
<tr><td>درخواست‌های همزمان ورود برای همان تنانت به یک Promise در حال پرواز جمع می‌شوند.</td><td>حذف تکراری Inflight</td></tr>
<tr><td>هر مرحله (GET_APP_TOKEN، LOGIN و هر چهار مرحله ارسال) در <code>fanavaran_audit_logs</code> با وضعیت STARTED / SUCCESS / FAILURE، هدرهای کامل، بدنه و مدت زمان نوشته می‌شود.</td><td>لاگ Audit</td></tr>
<tr><td>۲۰–۳۰ ثانیه به ازای هر فراخوانی HTTP.</td><td>Timeout</td></tr>
</table>
</div>
<div class="card card-blue">
<h3>پروفایل‌های تنانت (<code>FANAVARAN_CLIENT</code>)</h3>
<p class="section-intro" style="margin-top:6px; margin-bottom:8px;">سه پروفایل تنانت از پیش تعیین‌شده وجود دارد. پروفایل فعال توسط متغیر محیطی <code>FANAVARAN_CLIENT</code> انتخاب می‌شود. هر پروفایل <code>appName</code>، <code>secret</code>، <code>username</code>، <code>password</code>، <code>CorpId</code>، <code>ContractId</code> و <code>Location</code> هدرهای خود را به علاوه پیش‌فرض‌های payload (AccidentCityId و غیره) دارد.</p>
<table>
<tr><th>شرکت بیمه</th><th>کلید</th></tr>
<tr><td>بیمه پارسیان</td><td><code>parsian</code></td></tr>
<tr><td>بیمه تجارت نو</td><td><code>tejaratno</code></td></tr>
<tr><td>بیمه معلم</td><td><code>moallem</code></td></tr>
</table>
<p class="note" style="margin-top:8px;">
<code>INSURANCE_CORP_ID</code> یک رشته عنوان نمایشی است (مثلاً <em>"بیمه پارسیان"</em>) که در برابر فهرست زنده فناوران <code>insurance-corp</code> تطبیق داده می‌شود تا <code>corpId</code> عددی مورد استفاده در ارسال‌ها را تولید کند. شناسه تطبیق‌یافته روی دیسک کش می‌شود.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="sanhub">۳ — SandHub <span class="status-badge status-partial">قدیمی</span></h2>
<p class="section-intro">
SandHub درگاه پرس‌وجوی اصلی است. هنوز در کدبیس حضور دارد اما تمام جریان‌های
فعال تقصیر (V2+) به ارائه‌دهنده پرس‌وجوی تجارت منتقل شده‌اند. اندپوینت‌های
SandHub قابل فراخوانی هستند اما فقط از طریق مسیرهای قدیمی کد قابل دسترسی هستند.
حالت mock آن توسط همان تنظیم سیستم <code>sandHubUseLiveApi</code> کنترل می‌شود.
</p>
<div class="card card-gray">
<h3>احراز هویت</h3>
<p class="note">
<code>POST {SANHUB_BASE_URL}/user/login</code> با بدنه JSON نام کاربری + رمز عبور.
توکن در حافظه برای <strong>۵۵ دقیقه</strong> کش می‌شود. در ۴۰۱، توکن پاک می‌شود و یک تلاش مجدد انجام می‌شود.
۳ تلاش با ۱۰۰۰ ms ← ۲۰۰۰ ms backoff نمایی.
</p>
</div>
<div class="card card-gray">
<h3>اندپوینت‌ها</h3>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>/block-inquiry-tejarat</code></td><td>پرس‌وجوی بیمه‌نامه مبتنی بر پلاک (THIRD_PARTY). بدنه: <code>leftTwoDigits</code>، <code>serialLetter</code>، <code>threeDigits</code>، <code>rightTwoDigits</code>، <code>nationalCode</code>.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/block-inquiry-tejarat/badane</code></td><td>پرس‌وجوی بیمه‌نامه CAR_BODY. Timeout ۵۰ ثانیه (طولانی‌تر از استاندارد).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/personal-inquiry/tejarat-no</code></td><td>بررسی هویت شخصی. بدنه: <code>nationalCode</code> + <code>birthDate</code> <em>میلادی</em> (داخلی از جلالی تبدیل می‌شود).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/driver-license-check</code></td><td>اعتبارسنجی گواهینامه. پرچم <code>IsSucceed</code> را برمی‌گرداند.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/ownership</code></td><td>بررسی مالکیت وسیله نقلیه. پرچم <code>IsSuccess</code> را برمی‌گرداند.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/sheba/sheba-tejaratno</code></td><td>اعتبارسنجی شبا / حساب بانکی. <code>ReturnValue</code> + <code>HasError</code> را برمی‌گرداند.</td></tr>
</table>
<p class="note" style="margin-top:8px;">
تمام اندپوینت‌ها پاسخ‌های mock کامل را زمانی که <code>sandHubUseLiveApi=false</code> در تنظیمات سیستم (پیش‌فرض) پشتیبانی می‌کنند. داده‌های mock قطعی هستند و به‌صورت محلی بدون هیچ فراخوانی HTTP تولید می‌شوند.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="tejarat">۴ — پرس‌وجوی تجارت <span class="status-badge status-live">فعال</span></h2>
<p class="section-intro">
درگاه فعال block-inquiry برای تمام تنانت‌های غیر ESG. در هر فراخوانی V2+
<code>run-inquiries</code> که <code>CLIENT_ID ≠ 8</code> استفاده می‌شود.
URL پایه قابل پیکربندی است؛ در تولید به همان هاست SandHub اشاره می‌کند اما از
اعتبارنامه‌های جداگانه استفاده می‌کند.
</p>
<div class="card card-teal">
<h3>احراز هویت</h3>
<p class="note">
<code>POST {TEJARAT_INQUIRY_BASE_URL}/user/login</code> با بدنه JSON ایمیل + رمز عبور.
توکن برای <strong>۵۵ دقیقه</strong> کش می‌شود. ۲ تلاش با ۵۰۰ ms ← ۱۰۰۰ ms backoff.
جدا از اعتبارنامه‌های SandHub — از <code>TEJARAT_INQUIRY_EMAIL</code> / <code>TEJARAT_INQUIRY_PASSWORD</code> استفاده می‌کند.
</p>
</div>
<div class="card card-teal">
<h3>اندپوینت‌ها</h3>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>/block-inquiry-tejarat</code></td><td>پرس‌وجوی پلاک THIRD_PARTY. بدنه: فیلدهای پلاک + <code>nationalCode</code>. ابتدا داده آفلاین بررسی می‌شود.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/block-inquiry-tejarat/badane</code></td><td>پرس‌وجوی پلاک CAR_BODY. بدنه: <code>part1–part4</code> (عددی) + <code>nationalCode</code>. همیشه زنده می‌شود (mock برای مسیر badane وجود ندارد).</td></tr>
</table>
<p class="note" style="margin-top:8px;">
وقتی <code>sandHubUseLiveApi=false</code>، مسیر THIRD_PARTY یک پاسخ mock بدون HTTP برمی‌گرداند. مسیر CAR_BODY همیشه API زنده را صرف‌نظر از این پرچم فراخوانی می‌کند.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="esg">۵ — ESG <span class="status-badge status-live">فعال (CLIENT_ID=8)</span></h2>
<p class="section-intro">
ESG یک درگاه API بیمه داخلی است که منحصراً توسط تنانت پارسیان
(<code>CLIENT_ID=8</code>) استفاده می‌شود. برای تمام انواع پرس‌وجو زمانی که
این تنانت فعال است، جایگزین تجارت/SandHub می‌شود. شکل پاسخ متفاوتی دارد،
TTL توکن پویا دارد و تاریخ تولد را در فرمت <strong>جلالی</strong> انتظار دارد
(نه میلادی، برخلاف SandHub/تجارت).
</p>
<div class="card card-purple">
<h3>احراز هویت</h3>
<p class="note">
<code>POST {ESG_URL}/auth/login</code> با بدنه JSON <code>{ username, password }</code>.
TTL توکن از فیلد <code>expiresIn</code> پاسخ خوانده می‌شود (پیش‌فرض ۱۴ دقیقه).
۲ تلاش با ۵۰۰ ms ← ۱۰۰۰ ms backoff. در ۴۰۱، توکن پاک و یک تلاش مجدد.
URL پیش‌فرض: <code>http://192.168.20.22:8085</code> (شبکه داخلی).
</p>
</div>
<div class="card card-purple">
<h3>اندپوینت‌ها</h3>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/policyByPlate</code></td><td>جستجوی بیمه‌نامه مبتنی بر پلاک (THIRD_PARTY). بدنه: <code>nationalCode</code>، <code>plk1–plk4</code>. پاسخ قبل از ذخیره به فرمت قدیمی تجارت نگاشت می‌شود.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/carByChassis</code></td><td>جایگزین دوعاملی VIN/شاسی برای استعلام پلاک. توسط اندپوینت‌های <code>run-inquiries-vin</code> فراخوانی می‌شود. بدنه: <code>nationalCode</code>، <code>chassisNo</code>. مسیر تک‌عاملی <code>policyByChassis</code> استفاده نمی‌شود، چون فیلد <code>nationalCode</code> را نمی‌پذیرد.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/person</code></td><td>بررسی هویت شخصی. بدنه: <code>nationalCode</code>، <code>birthDate</code> (جلالی، نه میلادی).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/sheba</code></td><td>اعتبارسنجی شبا / حساب بانکی.</td></tr>
</table>
<p class="note" style="margin-top:8px;">
ESG هر پاسخ را به صورت <code>{ success: boolean, data: … }</code> می‌پیچد. در envelope نرمال‌شده خطا، بک‌اند مقدار <code>error.messageFa</code> را بدون تغییر به فراخواننده برمی‌گرداند؛ فیلدهای فنی مانند <code>message</code>، <code>providerMessage</code> و <code>providerCode</code> برای ثبت لاگ و دسته‌بندی حفظ می‌شوند. خطای کسب‌وکاری «یافت نشد» به‌عنوان قطعی سرویس گزارش نمی‌شود.
بررسی داده آفلاین-پرس‌وجو هنوز ابتدا اجرا می‌شود، قبل از هر فراخوانی HTTP ESG.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="sms">۶ — پیامک <span class="status-badge status-live">فعال</span></h2>
<p class="section-intro">
دو ارائه‌دهنده پیامک پشتیبانی می‌شوند: <strong>کاوه‌نگار</strong> (پیش‌فرض)
و <strong>درگاه پیامک پارسیان</strong>. ارائه‌دهنده فعال توسط متغیر محیطی
<code>SMS_PROVIDER</code> (یا <code>SMS</code>) انتخاب می‌شود. هر دو ارائه‌دهنده
رابط درگاه داخلی یکسانی را پیاده‌سازی می‌کنند بنابراین لایه ارکستراسیون
مستقل از ارائه‌دهنده است.
</p>
<div class="card card-green">
<h3>انتخاب ارائه‌دهنده</h3>
<table>
<tr><th>ارائه‌دهنده فعال</th><th>مقدار</th><th>متغیر محیطی</th></tr>
<tr><td>کاوه‌نگار — <code>api.kavenegar.com</code></td><td><code>kavenegar</code> (پیش‌فرض)</td><td><code>SMS_PROVIDER</code> (یا <code>SMS</code>)</td></tr>
<tr><td>درگاه پیامک پارسیان — <code>PARSIAN_SMS_URL</code></td><td><code>parsian</code></td><td><code>SMS_PROVIDER</code> (یا <code>SMS</code>)</td></tr>
</table>
</div>
<div class="card card-green">
<h3>اندپوینت‌های کاوه‌نگار</h3>
<p class="note">URL پایه: <code>https://api.kavenegar.com/v1/{SMS_API_KEY}/</code></p>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>زمان استفاده</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>sms/send.json</code></td><td>پیام‌های متن ساده (مثلاً متن‌های اطلاع‌رسانی مبتنی بر کلید ذخیره‌شده در مجموعه <code>sms_texts</code>).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>verify/lookup.json</code></td><td>تمام پیام‌های مبتنی بر قالب (OTPها، لینک‌های دعوت، اطلاع‌رسانی کارشناس). پارامترها: <code>receptor</code>، <code>token</code>[، <code>token2</code>، <code>token3</code>، <code>token10</code>]، <code>template</code>.</td></tr>
</table>
</div>
<div class="card card-green">
<h3>درگاه پیامک پارسیان</h3>
<p class="note">URL پایه از <code>PARSIAN_SMS_URL</code>. احراز هویت: هدر <code>X-PACKAGE-API-KEY</code> + <code>Authorization: Basic {PARSIAN_BASIC_TOKEN}</code>. به‌صورت GET با پارامترهای URL-encoded <code>ReceiverNumbers</code> و <code>Message</code> ارسال می‌کند. پیام‌های قالب قبل از ارسال به یک بدنه متن ساده پیش‌رندر می‌شوند (معادل verify/lookup ندارد).</p>
</div>
<div class="card card-green">
<h3>قالب‌های پیامک در حال استفاده</h3>
<table>
<tr><th>توکن‌ها</th><th>ماشه</th><th>نام قالب</th></tr>
<tr><td><code>token</code> = کد OTP</td><td>ورود OTP کاربر / اکتور، فراموشی رمز، OTPهای طرف</td><td><code>AUTH_SMS_TEMPLATE</code> (محیطی)</td></tr>
<tr><td><code>token</code> = publicId، <code>token2</code> = لینک</td><td>طرف دوم لینک دعوت تقصیر را از طریق پیامک دریافت می‌کند</td><td><code>yara724-invite-link</code></td></tr>
<tr><td><code>token</code> = نوع فایل، <code>token2</code> = نام خانوادگی کارشناس، <code>token3</code> = لینک</td><td>کارشناس میدانی لینک را برای یک طرف ارسال می‌کند</td><td><code>yara-field-expert-link</code></td></tr>
<tr><td><code>token</code> = publicId، <code>token2</code> = لینک</td><td>اطلاع به طرف که طرف دیگر با رأی کارشناس موافقت کرده است</td><td><code>yara-blame-agreement</code></td></tr>
<tr><td><code>token</code> = publicId، <code>token2</code> = لینک</td><td>طرف زیان‌دیده مطلع می‌شود که جریان خسارت را پس از تکمیل تقصیر باز کند</td><td><code>yara-claim-link</code></td></tr>
<tr><td><code>token</code> = "تصادف"/"خسارت"، <code>token2</code> = publicId، <code>token3</code> = نام خانوادگی کارشناس</td><td>کارشناس یک فایل تقصیر یا خسارت را قفل می‌کند</td><td><code>yara-expert-lock</code></td></tr>
<tr><td><code>token</code> = نوع فایل، <code>token2</code> = publicId، <code>token3</code> = لینک</td><td>کارشناس درخواست ارسال مجدد اسناد می‌دهد</td><td><code>yara-resend-documents</code></td></tr>
<tr><td><code>token</code> = نوع فایل، <code>token2</code> = publicId، <code>token3</code> = نام خانوادگی کارشناس، <code>token10</code> = لینک</td><td>طرف مطلع می‌شود که ارزیابی خسارت کارشناس را امضا کند</td><td><code>yara-signature</code></td></tr>
<tr><td><code>token</code> = publicId، <code>token2</code> = claimId فناوران، <code>token3</code> = claimNo فناوران</td><td>قالب قدیمی نگه‌داری شده است؛ ارسال خودکار پس از آخرین مرحله فناوران غیرفعال است</td><td><code>yara-fanavaran-claim</code></td></tr>
</table>
<p class="note" style="margin-top:8px;">
تمام فراخوانی‌های پیامک fire-and-forget هستند — هرگز throw نمی‌کنند. شکست‌ها log می‌شوند اما جریان اصلی را مسدود نمی‌کنند.
یک مجموعه MongoDB <code>sms_send_logs</code> هر پیام خروجی را با نوع آن (OTP در مقابل TEMPLATE)، ارائه‌دهنده، نام قالب و وضعیت موفقیت/شکست ثبت می‌کند.
پیام‌های متنی اطلاع‌رسانی (اختلاف طرفین، امضای یک طرف و غیره) در راه‌اندازی در مجموعه <code>sms_texts</code> seed می‌شوند و در زمان اجرا قابل ویرایش هستند.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="ai">۷ — سرویس هوش مصنوعی <span class="status-badge status-disabled">غیرفعال (کد موجود)</span></h2>
<p class="section-intro">
یک سرویس تشخیص خسارت خودرو مبتنی بر تصویر در کدبیس یکپارچه‌سازی شده است اما
فراخوانی‌های HTTP آن <strong>کاملاً comment شده‌اند</strong>. ماژول در راه‌اندازی
مقداردهی اولیه می‌شود، تلاش برای ورود می‌کند (در صورت شکست به صورت خاموش
بلعیده می‌شود)، و یک متد <code>aiRequestImage</code> را expose می‌کند — اما
فراخوانی‌های axios زیرین غیرفعال هستند. سرویس هیچ جریان تولیدی را تحت تأثیر
قرار نمی‌دهد.
</p>
<div class="card card-yellow">
<h3>رابط مورد نظر (زمانی که دوباره فعال شود)</h3>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>{AI_URL_V2}/auth/login</code></td><td>احراز هویت با نام کاربری + رمز عبور. <code>accessToken</code> را برمی‌گرداند.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>{AI_URL_V2}/auth/profile</code></td><td>دریافت <code>apiKey.key</code> مورد نیاز به‌عنوان هدر درخواست <code>gateway-api-key</code>.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>{AI_URL_V2}/services/car-damage/detector?version=ai-v7</code></td><td>ارسال تصویر قطعه خودرو (multipart). <code>downloadLink</code> با نتیجه حاشیه‌نویسی‌شده را برمی‌گرداند.</td></tr>
</table>
<p class="warn" style="margin-top:8px;">
وضعیت: هر سه فراخوانی در بلوک‌های <code>axios.request(…)</code> comment-شده پیچیده شده‌اند.
<code>CW_URL</code> در <code>.env.example</code> نیست. برای فعال‌سازی مجدد، فراخوانی‌های axios login، getApiKey و aiRequestImage را uncomment کنید و <code>AI_URL_V2</code>، <code>AI_USERNAME</code>، <code>AI_PASSWORD</code> را پیکربندی کنید.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="car-pricing">۸ — سرویس قیمت خودرو <span class="status-badge status-partial">نیمه‌فعال</span></h2>
<p class="section-intro">
فقط در طول محاسبه کاهش قیمت کارشناس-خسارت استفاده می‌شود. وقتی یک کارشناس
مقادیر شدت برای هر قطعه ارائه می‌دهد، سیستم قیمت‌های بازار بلادرنگ برای مدل
خودروی آسیب‌دیده را دریافت می‌کند، سپس کاهش قیمت را با استفاده از فرمول
محاسبه می‌کند: <strong>قیمت خودرو × ضریب سال × مجموع ضرایب قطعات ÷ ۴۰۰</strong>.
سرویس دو منبع داده (اندپوینت) دارد که به‌صورت موازی امتحان می‌شوند.
</p>
<div class="card card-orange">
<h3>اندپوینت‌ها</h3>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>{CW_URL}price?akharin</code></td><td>دریافت قیمت‌های بازار خودرو از منبع "آخرین". آرایه <code>{ carName, marketPrice }</code> را برمی‌گرداند.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>{CW_URL}price?hamrah</code></td><td>دریافت قیمت‌های بازار خودرو از منبع "همراه". همان شکل پاسخ.</td></tr>
</table>
<p class="note" style="margin-top:8px;">
هر دو اندپوینت امتحان می‌شوند؛ نتایج ادغام و حذف تکراری می‌شوند. بهترین تطابق برای
نام خودروی آسیب‌دیده با استفاده از <strong>فاصله Levenshtein</strong> (تطابق رشته فازی) پیدا می‌شود.
اگر هر دو اندپوینت شکست بخورند یا خالی برگردانند، محاسبه کاهش قیمت رد می‌شود (ناقص علامت‌گذاری می‌شود) — ارسال خسارت را مسدود نمی‌کند.
</p>
<p class="warn" style="margin-top:6px;">
<strong><code>CW_URL</code> در <code>.env.example</code> مستندسازی نشده است.</strong>
این سرویس در صورت تنظیم نشدن متغیر، به‌صورت خاموش هیچ کاهش قیمتی تولید نخواهد کرد.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="offline-inquiry">۹ — پرس‌وجوی آفلاین <span class="status-badge status-internal">داخلی / fallback</span></h2>
<p class="section-intro">
لایه پرس‌وجوی آفلاین فراخوانی‌های پرس‌وجوی مبتنی بر پلاک را قبل از اینکه هر
HTTP خارجی انجام شود رهگیری می‌کند. عمدتاً برای توسعه و تست (پلاک‌های شناخته‌شده
از پیش seed شده) استفاده می‌شود اما همچنین به‌عنوان fallback انعطاف‌پذیری
زمانی که سرویس‌های پرس‌وجوی زنده در دسترس نیستند عمل می‌کند. توسط یک پرچم
پایگاه‌داده زمان اجرا کنترل می‌شود، نه یک متغیر محیطی.
</p>
<div class="card card-gray">
<h3>نحوه کار</h3>
<table>
<tr><th>جزئیات</th><th>جنبه</th></tr>
<tr><td>مجموعه MongoDB <code>offline-inquiries</code>. اسناد شامل <code>clientKey</code>، فیلدهای نرمال‌شده پلاک، <code>nationalCode</code> و پاسخ از پیش ساخته‌شده <code>raw</code> + <code>mapped</code> برای برگرداندن هستند.</td><td>ذخیره‌سازی</td></tr>
<tr><td><code>system_settings.offlineInquiry.enabled</code> — پیش‌فرض <code>true</code>. تغییر از طریق <code>PATCH /super-admin/system-settings/offline-inquiry</code>.</td><td>سوئیچ اصلی</td></tr>
<tr><td>پلاک نرمال‌شده (فقط ارقام، عربی→فارسی) + کد ملی + کلید مشتری فناوران باید همه مطابقت داشته باشند. اگر پیدا شد، بلافاصله برگردانده می‌شود؛ هیچ فراخوانی HTTP انجام نمی‌شود.</td><td>ترتیب جستجو</td></tr>
<tr><td>فقط برای پرس‌وجوی block مبتنی بر پلاک (THIRD_PARTY) اعمال می‌شود. پرس‌وجوی CAR_BODY (<code>/badane</code>) همیشه API زنده را می‌زند.</td><td>محدوده</td></tr>
<tr><td><code>system_settings.externalApis.sandHubUseLiveApi</code> — وقتی <code>false</code> (پیش‌فرض)، حتی اگر هیچ داده آفلاینی مطابقت نداشته باشد، یک پاسخ mock داخلی برگردانده می‌شود به جای فراخوانی تجارت/ESG.</td><td>پرچم API زنده</td></tr>
</table>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="env-ref">۱۰ — مرجع متغیرهای محیطی</h2>
<p class="section-intro">
تمام متغیرهای محیطی در سراسر تمام یکپارچه‌سازی‌ها، گروه‌بندی‌شده بر اساس سرویس.
متغیرهای علامت‌گذاری‌شده با <strong>*</strong> در <code>.env.example</code> وجود ندارند.
</p>
<div class="card card-blue">
<h3>فناوران</h3>
<table class="env-table">
<tr><th>شرح</th><th>متغیر</th></tr>
<tr><td>کلید پروفایل تنانت فعال: <code>parsian</code> | <code>tejaratno</code> | <code>moallem</code></td><td><code>FANAVARAN_CLIENT</code></td></tr>
<tr><td>عنوان نمایشی شرکت بیمه‌گر (مثلاً <em>"بیمه پارسیان"</em>) — در راه‌اندازی در برابر فهرست insurance-corp فناوران به یک corpId عددی تطبیق داده می‌شود.</td><td><code>INSURANCE_CORP_ID</code></td></tr>
</table>
<p class="note" style="margin-top:8px;">اعتبارنامه‌های هر تنانت (appName، secret، username، password، CorpId، ContractId، Location) در <code>src/core/config/fanavaran-client.config.ts</code> زیر <code>SEED_FANAVARAN_CLIENT_PROFILES</code> hardcoded شده‌اند.</p>
</div>
<div class="card card-gray">
<h3>SandHub (قدیمی)</h3>
<table class="env-table">
<tr><th>شرح</th><th>متغیر</th></tr>
<tr><td>URL پایه برای SandHub. پیش‌فرض: <code>http://82.99.202.245:3027</code></td><td><code>SANHUB_BASE_URL</code></td></tr>
<tr><td>URL کامل ورود (معمولاً base + <code>/user/login</code>)</td><td><code>SANHUB_URL_LOGIN</code></td></tr>
<tr><td>ایمیل ورود SandHub</td><td><code>SANHUB_USERNAME</code></td></tr>
<tr><td>رمز عبور ورود SandHub</td><td><code>SANHUB_PASSWORD</code></td></tr>
</table>
</div>
<div class="card card-teal">
<h3>پرس‌وجوی تجارت</h3>
<table class="env-table">
<tr><th>شرح</th><th>متغیر</th></tr>
<tr><td>URL پایه. پیش‌فرض: <code>http://82.99.202.245:3027</code></td><td><code>TEJARAT_INQUIRY_BASE_URL</code></td></tr>
<tr><td>ایمیل ورود</td><td><code>TEJARAT_INQUIRY_EMAIL</code></td></tr>
<tr><td>رمز عبور ورود</td><td><code>TEJARAT_INQUIRY_PASSWORD</code></td></tr>
</table>
</div>
<div class="card card-purple">
<h3>ESG (فقط CLIENT_ID=8)</h3>
<table class="env-table">
<tr><th>شرح</th><th>متغیر</th></tr>
<tr><td>به <code>8</code> تنظیم کنید تا ارائه‌دهنده پرس‌وجوی ESG برای تنانت پارسیان فعال شود.</td><td><code>CLIENT_ID</code></td></tr>
<tr><td>URL پایه ESG. پیش‌فرض: <code>http://192.168.20.22:8085</code> (شبکه داخلی)</td><td><code>ESG_URL</code></td></tr>
<tr><td>نام کاربری ورود ESG</td><td><code>ESG_USERNAME</code></td></tr>
<tr><td>رمز عبور ورود ESG</td><td><code>ESG_PASSWORD</code></td></tr>
</table>
</div>
<div class="card card-green">
<h3>پیامک</h3>
<table class="env-table">
<tr><th>شرح</th><th>متغیر</th></tr>
<tr><td><code>kavenegar</code> (پیش‌فرض) یا <code>parsian</code></td><td><code>SMS_PROVIDER</code> (یا <code>SMS</code>)</td></tr>
<tr><td>کلید API کاوه‌نگار (الزامی وقتی provider = kavenegar)</td><td><code>SMS_API_KEY</code></td></tr>
<tr><td>نام قالب کاوه‌نگار برای پیام‌های OTP (مثلاً <code>yara-otp</code>)</td><td><code>AUTH_SMS_TEMPLATE</code></td></tr>
<tr><td>URL پایه درگاه پیامک پارسیان (الزامی وقتی provider = parsian)</td><td><code>PARSIAN_SMS_URL</code></td></tr>
<tr><td>مقدار هدر پیامک پارسیان <code>X-PACKAGE-API-KEY</code></td><td><code>PARSIAN_API_KEY</code></td></tr>
<tr><td>اعتبارنامه‌های رمزگذاری‌شده Base64 برای هدر <code>Authorization: Basic …</code></td><td><code>PARSIAN_BASIC_TOKEN</code></td></tr>
<tr><td>URL پایه فرانت‌اند — برای ساخت تمام لینک‌های دعوت + خسارت تعبیه‌شده در پیام‌های پیامک استفاده می‌شود</td><td><code>URL</code></td></tr>
</table>
</div>
<div class="card card-yellow">
<h3>سرویس هوش مصنوعی</h3>
<table class="env-table">
<tr><th>شرح</th><th>متغیر</th></tr>
<tr><td>URL پایه درگاه هوش مصنوعی. پیش‌فرض: <code>https://ai-gw.ittalie.ir</code> (استفاده نشده — سرویس غیرفعال است)</td><td><code>AI_URL_V2</code></td></tr>
<tr><td>نام کاربری ورود سرویس هوش مصنوعی (استفاده نشده)</td><td><code>AI_USERNAME</code></td></tr>
<tr><td>رمز عبور ورود سرویس هوش مصنوعی (استفاده نشده)</td><td><code>AI_PASSWORD</code></td></tr>
</table>
</div>
<div class="card card-orange">
<h3>سرویس قیمت خودرو</h3>
<table class="env-table">
<tr><th>شرح</th><th>متغیر</th></tr>
<tr><td>URL پایه برای API قیمت بازار خودرو (مثلاً <code>https://…/</code>). در <code>.env.example</code> نیست. کاهش قیمت به‌صورت خاموش رد می‌شود اگر تنظیم نشده باشد.</td><td><code>CW_URL</code> *</td></tr>
</table>
</div>
<div class="card card-gray">
<h3>عمومی / برنامه</h3>
<table class="env-table">
<tr><th>شرح</th><th>متغیر</th></tr>
<tr><td>پورت HTTP (پیش‌فرض ۳۰۰۰). توسط fallback insurance-corp فناوران برای فراخوانی اندپوینت جستجوی محلی خودش استفاده می‌شود.</td><td><code>PORT</code></td></tr>
<tr><td><code>true</code> / <code>false</code> — چالش کپچای ورود را فعال/غیرفعال می‌کند. داخلی، بدون سرویس خارجی.</td><td><code>CAPTCHA_ENABLED</code></td></tr>
<tr><td>TTL چالش کپچا به دقیقه.</td><td><code>EXP_CAPTCHA_TIME</code></td></tr>
<tr><td>TTL کد یکبار مصرف به دقیقه.</td><td><code>EXP_OTP_TIME</code></td></tr>
</table>
</div>
<footer>Made by Sepehr</footer>
</div>
</body>
</html>

View File

@@ -0,0 +1,594 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>External Integrations Reference</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, "Segoe UI", system-ui, sans-serif;
font-size: 14px; line-height: 1.6;
background: #ffffff; color: #1f2328; padding: 24px;
}
h1 { font-size: 20px; font-weight: 700; margin-bottom: 4px; }
.subtitle { font-size: 13px; color: #57606a; margin-bottom: 28px; }
h2 {
font-size: 15px; font-weight: 700;
margin-bottom: 10px; margin-top: 32px;
border-bottom: 1px solid #e5e7eb; padding-bottom: 6px;
}
h3 {
font-size: 12px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.05em;
color: #57606a; margin-bottom: 8px; margin-top: 14px;
}
.section-intro {
font-size: 13px; color: #57606a;
margin-bottom: 14px; line-height: 1.5;
}
.card {
border: 1px solid #e5e7eb; border-radius: 6px;
padding: 16px; background: #f7f8fa; margin-bottom: 16px;
}
.card.card-blue { border-left: 4px solid #3b82f6; }
.card.card-green { border-left: 4px solid #22c55e; }
.card.card-purple { border-left: 4px solid #8b5cf6; }
.card.card-orange { border-left: 4px solid #f97316; }
.card.card-teal { border-left: 4px solid #14b8a6; }
.card.card-indigo { border-left: 4px solid #6366f1; }
.card.card-gray { border-left: 4px solid #94a3b8; }
.card.card-red { border-left: 4px solid #ef4444; }
.card.card-yellow { border-left: 4px solid #eab308; }
table {
border-collapse: collapse; width: 100%;
font-size: 12px; margin-top: 4px;
}
th {
background: #f1f5f9; font-weight: 600;
text-align: left; padding: 5px 8px; border: 1px solid #e5e7eb;
}
td { padding: 4px 8px; border: 1px solid #e5e7eb; vertical-align: top; }
tr:nth-child(even) td { background: #ffffff; }
code { font-family: monospace; font-size: 11px; color: #3b82d4; }
.method {
font-family: monospace; font-size: 11px;
font-weight: 700; white-space: nowrap;
}
.method.get { color: #059669; }
.method.post { color: #2563eb; }
.method.put { color: #d97706; }
.method.patch { color: #7c3aed; }
.note { font-size: 11px; color: #57606a; font-style: italic; margin-top: 6px; }
.warn { font-size: 11px; color: #9a3412; font-style: italic; margin-top: 6px; }
.status-badge {
display: inline-block; font-size: 11px; font-weight: 600;
padding: 1px 7px; border-radius: 10px;
}
.status-live { background: #dcfce7; color: #166534; }
.status-partial { background: #ffedd5; color: #9a3412; }
.status-disabled { background: #fee2e2; color: #991b1b; }
.status-internal { background: #f1f5f9; color: #475569; border: 1px solid #e2e8f0; }
.toc {
background: #f7f8fa; border: 1px solid #e5e7eb;
border-radius: 6px; padding: 14px 18px; margin-bottom: 28px;
}
.toc-title { font-size: 13px; font-weight: 700; margin-bottom: 8px; }
.toc ol { padding-left: 18px; }
.toc li { font-size: 13px; margin-bottom: 3px; }
.toc a { color: #3b82d4; text-decoration: none; }
.toc a:hover { text-decoration: underline; }
footer {
text-align: center; font-size: 12px; color: #57606a;
border-top: 1px solid #e5e7eb; margin-top: 40px; padding-top: 12px;
}
.max-wrap { max-width: 760px; margin: 0 auto; }
.flow-box {
background: #ffffff; border: 1px solid #e5e7eb; border-radius: 4px;
padding: 10px 14px; font-size: 12px; margin-top: 8px;
}
.flow-step {
display: flex; gap: 10px; align-items: flex-start; margin-bottom: 6px;
}
.flow-num {
flex-shrink: 0; width: 20px; height: 20px; border-radius: 50%;
background: #3b82d4; color: #fff; font-size: 11px; font-weight: 700;
display: flex; align-items: center; justify-content: center;
}
.flow-text { flex: 1; padding-top: 2px; }
.decision-tree {
font-size: 12px; background: #ffffff;
border: 1px solid #e5e7eb; border-radius: 4px; padding: 12px 16px;
margin-top: 8px; line-height: 1.8;
}
.decision-tree ul { padding-left: 20px; }
.decision-tree li { margin-bottom: 2px; }
.env-table th:first-child { width: 220px; }
pre {
font-family: monospace; font-size: 11px;
background: #f1f5f9; border: 1px solid #e5e7eb;
border-radius: 4px; padding: 10px 12px;
white-space: pre-wrap; word-break: break-all;
margin-top: 6px; color: #1f2328;
}
</style>
</head>
<body>
<div class="max-wrap">
<h1>External Integrations Reference</h1>
<p class="subtitle">
Every outbound integration: what it does, when it fires, how auth works,
retry behaviour, fallbacks, and all environment variables. Internal-only
services (captcha, offline inquiry seed) are included for completeness.
</p>
<!-- TOC -->
<div class="toc">
<div class="toc-title">Contents</div>
<ol>
<li><a href="#inquiry-routing">Inquiry routing decision tree</a></li>
<li><a href="#fanavaran">Fanavaran — insurance claims platform</a></li>
<li><a href="#sanhub">SandHub — legacy inquiry gateway</a></li>
<li><a href="#tejarat">Tejarat inquiry — block-inquiry gateway (V2+)</a></li>
<li><a href="#esg">ESG — Parsian-tenant inquiry provider</a></li>
<li><a href="#sms">SMS — Kavenegar and Parsian gateways</a></li>
<li><a href="#ai">AI service — car damage detection</a></li>
<li><a href="#car-pricing">Car pricing service — market value lookup</a></li>
<li><a href="#offline-inquiry">Offline inquiry — fallback seed data</a></li>
<li><a href="#env-ref">Environment variable reference</a></li>
</ol>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="inquiry-routing">1 — Inquiry Routing Decision Tree</h2>
<p class="section-intro">
Every blame file starts with a "run-inquiries" call that fetches the
guilty party's insurance policy from an external provider. Which provider
is actually called depends on three factors: the tenant (<code>CLIENT_ID</code>),
the file type (THIRD_PARTY vs CAR_BODY), and whether live API mode is
enabled in system settings. The offline-inquiry seed layer sits in front
of all three providers.
</p>
<div class="card card-indigo">
<h3>Provider selection</h3>
<div class="decision-tree">
<strong>For every plate-based block inquiry:</strong>
<ul>
<li>The request always uses the submitted current plate and the resolved policyholder for that policy type. Recent-transfer metadata never triggers a previous-plate or previous-policyholder lookup.</li>
<li>1. Check offline-inquiry seeds (MongoDB) — if a matching seed exists, return it and skip all HTTP.</li>
<li>2. If <code>CLIENT_ID=8</code> (Parsian/ESG tenant) → route to <strong>ESG</strong> <code>/inquiry/policyByPlate</code> for plates or the two-factor <code>/inquiry/carByChassis</code> for VIN/chassis inquiries.</li>
<li>3. Otherwise → route to <strong>Tejarat inquiry</strong> <code>/block-inquiry-tejarat</code> (THIRD_PARTY) or <code>/block-inquiry-tejarat/badane</code> (CAR_BODY).</li>
<li>4. If <code>system_settings.externalApis.sandHubUseLiveApi = false</code> (default) → return mock response instead of making HTTP calls.</li>
</ul>
<br>
<strong>For personal-identity, driving-licence, ownership, and Sheba checks:</strong>
<ul>
<li>If <code>CLIENT_ID=8</code> → ESG <code>/inquiry/person</code> and <code>/inquiry/sheba</code>.</li>
<li>Otherwise → Tejarat/SandHub <code>/personal-inquiry/tejarat-no</code>, <code>/driver-license-check</code>, <code>/ownership</code>, <code>/sheba/sheba-tejaratno</code>.</li>
</ul>
<br>
<strong>Key difference — birth date format:</strong>
SandHub/Tejarat expect a <em>Gregorian</em> birth date (converted internally from Jalali).
ESG expects the <em>Jalali</em> date directly.
</div>
<p class="note" style="margin-top:8px;">
SandHub endpoints are only used in legacy code paths. All active V2+ blame flows go through the Tejarat or ESG providers.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="fanavaran">2 — Fanavaran <span class="status-badge status-live">live</span></h2>
<p class="section-intro">
Fanavaran (<code>apimanager.iraneit.com</code>) is the national insurance
damage-case platform. After the damage expert submits their assessment,
the system auto-submits a structured claim to Fanavaran through a
four-step protocol. Fanavaran also serves as the lookup source for
code-lists (accident types, car components, city codes, etc.) used
across the platform.
</p>
<div class="card card-blue">
<h3>Authentication lifecycle</h3>
<div class="flow-box">
<div class="flow-step"><div class="flow-num">1</div><div class="flow-text"><strong>GET AppToken</strong> — <code>POST /EITAuthentication/GetAppToken</code> with <code>appname</code> + <code>secret</code> headers. Returns <code>apptoken</code> header.</div></div>
<div class="flow-step"><div class="flow-num">2</div><div class="flow-text"><strong>Login</strong> — <code>POST /EITAuthentication/Login</code> with <code>appToken</code> + <code>userName</code> + <code>password</code> headers. Returns <code>authenticationToken</code> header.</div></div>
<div class="flow-step"><div class="flow-num">3</div><div class="flow-text"><strong>Cache</strong> — token is cached in memory <em>and</em> persisted to MongoDB (<code>fanavaran_auth_tokens</code>). Valid until midnight <strong>Asia/Tehran</strong> — the first call after 00:00 fetches a fresh token.</div></div>
<div class="flow-step"><div class="flow-num">4</div><div class="flow-text"><strong>All subsequent calls</strong> include four headers: <code>authenticationToken</code>, <code>CorpId</code>, <code>ContractId</code>, <code>Location</code> — tenant-specific, hardcoded per <code>FANAVARAN_CLIENT</code> key.</div></div>
</div>
<p class="note" style="margin-top:8px;">
A config fingerprint (hash of appName + secret + username + password + corpId + contractId + location)
forces a fresh login when any credential changes, even before midnight.
</p>
</div>
<div class="card card-blue">
<h3>Claim submission protocol (4 steps)</h3>
<div class="flow-box">
<div class="flow-step"><div class="flow-num">1</div><div class="flow-text"><strong>Base claim (GEN.03)</strong> — <code>POST /car/third-party-car-financial-claims</code>. Sends owner, driver, insurance, vehicle, and accident data. Returns a Fanavaran <code>claimId</code> and <code>claimNo</code>, which are persisted for panel display.</div></div>
<div class="flow-step"><div class="flow-num">2</div><div class="flow-text"><strong>Damage cases (GEN.05)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/dmg-cases</code>. One entry per damaged part with component ID, severity, and price. Cap: total ≤ 53 000 000 Toman.</div></div>
<div class="flow-step"><div class="flow-num">3</div><div class="flow-text"><strong>Attachments (GEN.07)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/files</code>. Documents, car-capture images, and videos referenced by file ID.</div></div>
<div class="flow-step"><div class="flow-num">4</div><div class="flow-text"><strong>Expertise (GEN.08)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/expertise</code>. Expert assessment metadata (expert role, date, result). Finalises the submission.</div></div>
</div>
<p class="note" style="margin-top:8px;">
All four steps are recorded in the <code>fanavaran_audit_logs</code> collection with full request/response bodies, HTTP status, duration, and tracking code for debugging.
</p>
</div>
<div class="card card-blue">
<h3>Lookup endpoints</h3>
<p class="note">All under <code>https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/</code>. Results are cached to disk (per client key) and in the <code>lookups</code> MongoDB collection. Parsian tenant reads DB before hitting the API; others go to the API first.</p>
<table>
<tr><th>Path</th><th>Used for</th></tr>
<tr><td><code>/car/base-info/accident-causes</code></td><td>accidentReason dropdown options (mapped to local IDs)</td></tr>
<tr><td><code>/car/code-list/accident-report-type</code></td><td>accidentWay options</td></tr>
<tr><td><code>/car/base-info/vehicle-use-types</code></td><td>vehicle usage classification</td></tr>
<tr><td><code>/car/code-list/dmg-pay-method</code></td><td>damage payment method</td></tr>
<tr><td><code>/car/base-info/driving-licence-types</code></td><td>licence type options</td></tr>
<tr><td><code>/car/code-list/accident-culprit-type</code></td><td>guilty-party classification</td></tr>
<tr><td><code>/car/code-list/inspection-place</code></td><td>inspection location options</td></tr>
<tr><td><code>/car/code-list/drop-amount-status</code></td><td>price-drop status codes</td></tr>
<tr><td><code>/car/base-info/car-components</code></td><td>component catalog (maps to outer/inner parts)</td></tr>
<tr><td><code>/car/code-list/accident-level</code></td><td>accident severity options</td></tr>
<tr><td><code>/common/code-list/insurance-corp</code></td><td>resolve <code>INSURANCE_CORP_ID</code> → Fanavaran corpId</td></tr>
<tr><td><code>/common/base-info/cities</code>, <code>/common/base-info/Provinces</code></td><td>city/province pickers</td></tr>
<tr><td><code>/car/third-party-car-policies/{policyId}</code></td><td>fetch full policy by ID after inquiry</td></tr>
<tr><td><code>/car/vehicles/inquiry-by-vin?vin=…</code></td><td>VIN-based vehicle lookup</td></tr>
<tr><td><code>/common/Policies/inquiry-my-policies</code></td><td>list policies for a national code</td></tr>
<tr><td><code>/common/customers/{customerId}</code></td><td>fetch customer record by ID</td></tr>
<tr><td><code>/common/parties/inquiry-by-unique-identifier</code></td><td>party lookup by national code + birth date</td></tr>
</table>
</div>
<div class="card card-blue">
<h3>Error handling &amp; resilience</h3>
<table>
<tr><th>Mechanism</th><th>Detail</th></tr>
<tr><td>Retry</td><td>3 attempts, 500 ms → 1 000 ms exponential backoff on all HTTP calls.</td></tr>
<tr><td>Transient backoff</td><td>When Fanavaran returns the Persian "try again later" message (or tracking-code 500), a tenant-wide 5-minute pause is activated. All calls during this window get <code>503 ServiceUnavailable</code> immediately — no hammering.</td></tr>
<tr><td>Token invalidation</td><td>On 401, token is cleared from memory and MongoDB; next call triggers a fresh GetAppToken + Login.</td></tr>
<tr><td>Inflight de-dup</td><td>Concurrent login requests for the same tenant are collapsed to a single in-flight Promise.</td></tr>
<tr><td>Audit log</td><td>Every step (GET_APP_TOKEN, LOGIN, and all four submission steps) is written to <code>fanavaran_audit_logs</code> with STARTED / SUCCESS / FAILURE status, full headers, body, and duration.</td></tr>
<tr><td>Timeout</td><td>20–30 s per HTTP call.</td></tr>
</table>
</div>
<div class="card card-blue">
<h3>Tenant profiles (<code>FANAVARAN_CLIENT</code>)</h3>
<p class="section-intro" style="margin-top:6px; margin-bottom:8px;">Three pre-seeded tenant profiles exist. The active one is chosen by the <code>FANAVARAN_CLIENT</code> env var. Each profile carries its own <code>appName</code>, <code>secret</code>, <code>username</code>, <code>password</code>, <code>CorpId</code>, <code>ContractId</code>, and <code>Location</code> headers, plus payload defaults (AccidentCityId, etc.).</p>
<table>
<tr><th>Key</th><th>Insurance company</th></tr>
<tr><td><code>parsian</code></td><td>Parsian Insurance</td></tr>
<tr><td><code>tejaratno</code></td><td>Tejaratno Insurance</td></tr>
<tr><td><code>moallem</code></td><td>Moallem Insurance</td></tr>
</table>
<p class="note" style="margin-top:8px;">
<code>INSURANCE_CORP_ID</code> is a display-caption string (e.g. <em>"بیمه پارسیان"</em>) that is resolved against the live Fanavaran <code>insurance-corp</code> list to produce the numeric <code>corpId</code> used in submissions. The resolved ID is cached to disk.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="sanhub">3 — SandHub <span class="status-badge status-partial">legacy</span></h2>
<p class="section-intro">
SandHub is the original inquiry gateway. It is still present in the
codebase but all active blame flows (V2+) have been migrated to the
Tejarat inquiry provider. SandHub endpoints remain callable but are
only reached through legacy code paths. Its mock mode is controlled
by the same <code>sandHubUseLiveApi</code> system setting.
</p>
<div class="card card-gray">
<h3>Auth</h3>
<p class="note">
<code>POST {SANHUB_BASE_URL}/user/login</code> with username + password JSON body.
Token cached in memory for <strong>55 minutes</strong>. On 401, token is cleared and one retry is made.
3 attempts with 1 000 ms → 2 000 ms exponential backoff.
</p>
</div>
<div class="card card-gray">
<h3>Endpoints</h3>
<table>
<tr><th style="width:70px">Method</th><th>Path</th><th>What it does</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>/block-inquiry-tejarat</code></td><td>Plate-based insurance policy inquiry (THIRD_PARTY). Body: <code>leftTwoDigits</code>, <code>serialLetter</code>, <code>threeDigits</code>, <code>rightTwoDigits</code>, <code>nationalCode</code>.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/block-inquiry-tejarat/badane</code></td><td>CAR_BODY policy inquiry. Timeout 50 s (longer than standard).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/personal-inquiry/tejarat-no</code></td><td>Personal identity check. Body: <code>nationalCode</code> + <em>Gregorian</em> <code>birthDate</code> (converted from Jalali internally).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/driver-license-check</code></td><td>Driving licence validation. Returns <code>IsSucceed</code> flag.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/ownership</code></td><td>Vehicle ownership check. Returns <code>IsSuccess</code> flag.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/sheba/sheba-tejaratno</code></td><td>Sheba / bank account validation. Returns <code>ReturnValue</code> + <code>HasError</code>.</td></tr>
</table>
<p class="note" style="margin-top:8px;">
All endpoints support full mock responses when <code>sandHubUseLiveApi=false</code> in system settings (default). Mock data is deterministic and produced locally without any HTTP calls.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="tejarat">4 — Tejarat Inquiry <span class="status-badge status-live">live</span></h2>
<p class="section-intro">
The active block-inquiry gateway for all non-ESG tenants. Used in every
V2+ <code>run-inquiries</code> call where <code>CLIENT_ID ≠ 8</code>.
The base URL is configurable; in production it points to the same host
as SandHub but uses separate credentials.
</p>
<div class="card card-teal">
<h3>Auth</h3>
<p class="note">
<code>POST {TEJARAT_INQUIRY_BASE_URL}/user/login</code> with email + password JSON body.
Token cached for <strong>55 minutes</strong>. 2 attempts with 500 ms → 1 000 ms backoff.
Separate from SandHub credentials — uses <code>TEJARAT_INQUIRY_EMAIL</code> / <code>TEJARAT_INQUIRY_PASSWORD</code>.
</p>
</div>
<div class="card card-teal">
<h3>Endpoints</h3>
<table>
<tr><th style="width:70px">Method</th><th>Path</th><th>What it does</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>/block-inquiry-tejarat</code></td><td>THIRD_PARTY plate inquiry. Body: plate fields + <code>nationalCode</code>. Offline seed checked first.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/block-inquiry-tejarat/badane</code></td><td>CAR_BODY plate inquiry. Body: <code>part1–part4</code> (numeric) + <code>nationalCode</code>. Always goes live (no mock for badane path).</td></tr>
</table>
<p class="note" style="margin-top:8px;">
When <code>sandHubUseLiveApi=false</code>, the THIRD_PARTY path returns a mock response without HTTP. The CAR_BODY path always calls the live API regardless of this flag.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="esg">5 — ESG <span class="status-badge status-live">live (CLIENT_ID=8)</span></h2>
<p class="section-intro">
ESG is an internal insurance API gateway used exclusively by the Parsian
tenant (<code>CLIENT_ID=8</code>). It replaces Tejarat/SandHub for all
inquiry types when this tenant is active. It has a different response
shape, a dynamic token TTL, and expects birth dates in <strong>Jalali</strong>
format (not Gregorian, unlike SandHub/Tejarat).
</p>
<div class="card card-purple">
<h3>Auth</h3>
<p class="note">
<code>POST {ESG_URL}/auth/login</code> with <code>{ username, password }</code> JSON body.
Token TTL is read from the response <code>expiresIn</code> field (default 14 min).
2 attempts with 500 ms → 1 000 ms backoff. On 401, token cleared and one retry.
Default URL: <code>http://192.168.20.22:8085</code> (internal network).
</p>
</div>
<div class="card card-purple">
<h3>Endpoints</h3>
<table>
<tr><th style="width:70px">Method</th><th>Path</th><th>What it does</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/policyByPlate</code></td><td>Plate-based policy lookup (THIRD_PARTY). Body: <code>nationalCode</code>, <code>plk1–plk4</code>. Response is mapped to the old Tejarat format before being stored.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/carByChassis</code></td><td>Two-factor VIN/chassis alternative to the plate inquiry. Called by <code>run-inquiries-vin</code> endpoints. Body: <code>nationalCode</code>, <code>chassisNo</code>. The one-factor <code>policyByChassis</code> route is not used because it rejects <code>nationalCode</code>.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/person</code></td><td>Personal identity check. Body: <code>nationalCode</code>, <code>birthDate</code> (Jalali, NOT Gregorian).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/sheba</code></td><td>Sheba / bank account validation.</td></tr>
</table>
<p class="note" style="margin-top:8px;">
ESG wraps every response as <code>{ success: boolean, data: … }</code>. For normalized error envelopes, the backend returns <code>error.messageFa</code> unchanged to the caller; technical fields such as <code>message</code>, <code>providerMessage</code>, and <code>providerCode</code> remain available for logging and classification. A business-level not-found response is not reported as a provider outage.
The offline-inquiry seed check still runs first, before any ESG HTTP call.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="sms">6 — SMS <span class="status-badge status-live">live</span></h2>
<p class="section-intro">
Two SMS providers are supported: <strong>Kavenegar</strong> (default)
and <strong>Parsian SMS Gateway</strong>. The active provider is chosen
by the <code>SMS_PROVIDER</code> (or <code>SMS</code>) env var. Both
providers implement the same internal gateway interface so the
orchestration layer is provider-agnostic.
</p>
<div class="card card-green">
<h3>Provider selection</h3>
<table>
<tr><th>Env var</th><th>Value</th><th>Active provider</th></tr>
<tr><td><code>SMS_PROVIDER</code> (or <code>SMS</code>)</td><td><code>kavenegar</code> (default)</td><td>Kavenegar — <code>api.kavenegar.com</code></td></tr>
<tr><td><code>SMS_PROVIDER</code> (or <code>SMS</code>)</td><td><code>parsian</code></td><td>Parsian SMS Gateway — <code>PARSIAN_SMS_URL</code></td></tr>
</table>
</div>
<div class="card card-green">
<h3>Kavenegar endpoints</h3>
<p class="note">Base URL: <code>https://api.kavenegar.com/v1/{SMS_API_KEY}/</code></p>
<table>
<tr><th style="width:70px">Method</th><th>Path</th><th>When used</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>sms/send.json</code></td><td>Plain-text messages (e.g. key-based notification texts stored in <code>sms_texts</code> collection).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>verify/lookup.json</code></td><td>All template-based messages (OTPs, invite links, expert notifications). Params: <code>receptor</code>, <code>token</code>[, <code>token2</code>, <code>token3</code>, <code>token10</code>], <code>template</code>.</td></tr>
</table>
</div>
<div class="card card-green">
<h3>Parsian SMS Gateway</h3>
<p class="note">Base URL from <code>PARSIAN_SMS_URL</code>. Auth: <code>X-PACKAGE-API-KEY</code> header + <code>Authorization: Basic {PARSIAN_BASIC_TOKEN}</code>. Sends as a GET with URL-encoded <code>ReceiverNumbers</code> and <code>Message</code> query params. Template messages are pre-rendered into a plain text body before sending (no verify/lookup equivalent).</p>
</div>
<div class="card card-green">
<h3>SMS templates in use</h3>
<table>
<tr><th>Template name</th><th>Trigger</th><th>Tokens</th></tr>
<tr><td><code>AUTH_SMS_TEMPLATE</code> (env)</td><td>User / actor OTP login, forget-password, party OTPs</td><td><code>token</code> = OTP code</td></tr>
<tr><td><code>yara724-invite-link</code></td><td>Second party receives blame invite link via SMS</td><td><code>token</code> = publicId, <code>token2</code> = link</td></tr>
<tr><td><code>yara-field-expert-link</code></td><td>Field expert sends link to a party</td><td><code>token</code> = file type, <code>token2</code> = expert surname, <code>token3</code> = link</td></tr>
<tr><td><code>yara-blame-agreement</code></td><td>Notify party that the other side agreed to the expert verdict</td><td><code>token</code> = publicId, <code>token2</code> = link</td></tr>
<tr><td><code>yara-claim-link</code></td><td>Damaged party notified to open claim flow after blame is complete</td><td><code>token</code> = publicId, <code>token2</code> = link</td></tr>
<tr><td><code>yara-expert-lock</code></td><td>Expert locks a blame or claim file</td><td><code>token</code> = "تصادف"/"خسارت", <code>token2</code> = publicId, <code>token3</code> = expert surname</td></tr>
<tr><td><code>yara-resend-documents</code></td><td>Expert requests document resend</td><td><code>token</code> = file kind, <code>token2</code> = publicId, <code>token3</code> = link</td></tr>
<tr><td><code>yara-signature</code></td><td>Party notified to sign the expert's damage assessment</td><td><code>token</code> = file kind, <code>token2</code> = publicId, <code>token3</code> = expert surname, <code>token10</code> = link</td></tr>
<tr><td><code>yara-fanavaran-claim</code></td><td>Retained legacy template; automatic dispatch after the final Fanavaran stage is disabled</td><td><code>token</code> = publicId, <code>token2</code> = Fanavaran claimId, <code>token3</code> = Fanavaran claimNo</td></tr>
</table>
<p class="note" style="margin-top:8px;">
All SMS calls are fire-and-forget — they never throw. Failures are logged but do not block the main flow.
An <code>sms_send_logs</code> MongoDB collection records every outbound message with its kind (OTP vs TEMPLATE), provider, template name, and success/failure status.
Notification text messages (parties-disagree, one-party-signed, etc.) are seeded into the <code>sms_texts</code> collection on startup and editable at runtime.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="ai">7 — AI Service <span class="status-badge status-disabled">disabled (code present)</span></h2>
<p class="section-intro">
An image-based car damage detection service is integrated in the
codebase but its HTTP calls are <strong>fully commented out</strong>.
The module initialises on startup, attempts a login (silently swallowed
if it fails), and exposes an <code>aiRequestImage</code> method — but
the underlying axios calls are disabled. The service does not affect
any production flow.
</p>
<div class="card card-yellow">
<h3>Intended interface (when re-enabled)</h3>
<table>
<tr><th style="width:70px">Method</th><th>Path</th><th>What it does</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>{AI_URL_V2}/auth/login</code></td><td>Authenticate with username + password. Returns <code>accessToken</code>.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>{AI_URL_V2}/auth/profile</code></td><td>Fetch <code>apiKey.key</code> needed as the <code>gateway-api-key</code> request header.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>{AI_URL_V2}/services/car-damage/detector?version=ai-v7</code></td><td>Submit a car part image (multipart). Returns <code>downloadLink</code> with annotated result.</td></tr>
</table>
<p class="warn" style="margin-top:8px;">
Status: all three calls are wrapped in commented-out <code>axios.request(…)</code> blocks.
<code>CW_URL</code> is not in <code>.env.example</code>. To re-enable, uncomment the login, getApiKey, and aiRequestImage axios calls, and configure <code>AI_URL_V2</code>, <code>AI_USERNAME</code>, <code>AI_PASSWORD</code>.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="car-pricing">8 — Car Pricing Service <span class="status-badge status-partial">partially active</span></h2>
<p class="section-intro">
Used only during damage-expert price-drop calculation. When an expert
provides per-part severity values the system fetches real-time market
prices for the damaged car model, then computes the price-drop using
the formula: <strong>carPrice × yearCoefficient × sumOfPartCoefficients ÷ 400</strong>.
The service has two data sources (endpoints) that are tried in parallel.
</p>
<div class="card card-orange">
<h3>Endpoints</h3>
<table>
<tr><th style="width:70px">Method</th><th>Path</th><th>What it does</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>{CW_URL}price?akharin</code></td><td>Fetch car market prices from the "Akharin" source. Returns array of <code>{ carName, marketPrice }</code>.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>{CW_URL}price?hamrah</code></td><td>Fetch car market prices from the "Hamrah" source. Same response shape.</td></tr>
</table>
<p class="note" style="margin-top:8px;">
Both endpoints are tried; results are merged and de-duplicated. The best match for
the damaged car's name is found using <strong>Levenshtein distance</strong> (fuzzy string match).
If both endpoints fail or return empty, the price-drop calculation is skipped (marked incomplete) — it does not block claim submission.
</p>
<p class="warn" style="margin-top:6px;">
<strong><code>CW_URL</code> is not documented in <code>.env.example</code>.</strong>
This service will silently produce no price-drop if the variable is unset.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="offline-inquiry">9 — Offline Inquiry <span class="status-badge status-internal">internal / fallback</span></h2>
<p class="section-intro">
The offline inquiry layer intercepts plate-based inquiry calls before
any external HTTP is made. It is primarily used for development and
testing (pre-seeded known plates) but also acts as a resilience fallback
when live inquiry services are unavailable. It is controlled by a
runtime database flag, not an env var.
</p>
<div class="card card-gray">
<h3>How it works</h3>
<table>
<tr><th>Aspect</th><th>Detail</th></tr>
<tr><td>Storage</td><td>MongoDB collection <code>offline-inquiries</code>. Documents contain <code>clientKey</code>, normalised plate fields, <code>nationalCode</code>, and the pre-built <code>raw</code> + <code>mapped</code> response to return.</td></tr>
<tr><td>Master switch</td><td><code>system_settings.offlineInquiry.enabled</code> — defaults to <code>true</code>. Toggle via <code>PATCH /super-admin/system-settings/offline-inquiry</code>.</td></tr>
<tr><td>Lookup order</td><td>Normalised plate (digits-only, Arabic→Persian) + national code + Fanavaran client key must all match. If found, returned immediately; no HTTP call is made.</td></tr>
<tr><td>Scope</td><td>Only applies to plate-based block-inquiry (THIRD_PARTY). CAR_BODY inquiry (<code>/badane</code>) always hits the live API.</td></tr>
<tr><td>Live API flag</td><td><code>system_settings.externalApis.sandHubUseLiveApi</code> — when <code>false</code> (default), even if no offline seed matches, a built-in mock response is returned rather than calling Tejarat/ESG.</td></tr>
</table>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="env-ref">10 — Environment Variable Reference</h2>
<p class="section-intro">
All env vars across all integrations, grouped by service.
Variables marked <strong>*</strong> are not present in <code>.env.example</code>.
</p>
<div class="card card-blue">
<h3>Fanavaran</h3>
<table class="env-table">
<tr><th>Variable</th><th>Description</th></tr>
<tr><td><code>FANAVARAN_CLIENT</code></td><td>Active tenant profile key: <code>parsian</code> | <code>tejaratno</code> | <code>moallem</code></td></tr>
<tr><td><code>INSURANCE_CORP_ID</code></td><td>Display caption of the insurer company (e.g. <em>"بیمه پارسیان"</em>) — resolved to a numeric corpId at startup against the Fanavaran insurance-corp list.</td></tr>
</table>
<p class="note" style="margin-top:8px;">Per-tenant credentials (appName, secret, username, password, CorpId, ContractId, Location) are hardcoded in <code>src/core/config/fanavaran-client.config.ts</code> under <code>SEED_FANAVARAN_CLIENT_PROFILES</code>.</p>
</div>
<div class="card card-gray">
<h3>SandHub (legacy)</h3>
<table class="env-table">
<tr><th>Variable</th><th>Description</th></tr>
<tr><td><code>SANHUB_BASE_URL</code></td><td>Base URL for SandHub. Default: <code>http://82.99.202.245:3027</code></td></tr>
<tr><td><code>SANHUB_URL_LOGIN</code></td><td>Full login URL (usually base + <code>/user/login</code>)</td></tr>
<tr><td><code>SANHUB_USERNAME</code></td><td>SandHub login email</td></tr>
<tr><td><code>SANHUB_PASSWORD</code></td><td>SandHub login password</td></tr>
</table>
</div>
<div class="card card-teal">
<h3>Tejarat inquiry</h3>
<table class="env-table">
<tr><th>Variable</th><th>Description</th></tr>
<tr><td><code>TEJARAT_INQUIRY_BASE_URL</code></td><td>Base URL. Default: <code>http://82.99.202.245:3027</code></td></tr>
<tr><td><code>TEJARAT_INQUIRY_EMAIL</code></td><td>Login email</td></tr>
<tr><td><code>TEJARAT_INQUIRY_PASSWORD</code></td><td>Login password</td></tr>
</table>
</div>
<div class="card card-purple">
<h3>ESG (CLIENT_ID=8 only)</h3>
<table class="env-table">
<tr><th>Variable</th><th>Description</th></tr>
<tr><td><code>CLIENT_ID</code></td><td>Set to <code>8</code> to activate the ESG inquiry provider for the Parsian tenant.</td></tr>
<tr><td><code>ESG_URL</code></td><td>ESG base URL. Default: <code>http://192.168.20.22:8085</code> (internal network)</td></tr>
<tr><td><code>ESG_USERNAME</code></td><td>ESG login username</td></tr>
<tr><td><code>ESG_PASSWORD</code></td><td>ESG login password</td></tr>
</table>
</div>
<div class="card card-green">
<h3>SMS</h3>
<table class="env-table">
<tr><th>Variable</th><th>Description</th></tr>
<tr><td><code>SMS_PROVIDER</code> (or <code>SMS</code>)</td><td><code>kavenegar</code> (default) or <code>parsian</code></td></tr>
<tr><td><code>SMS_API_KEY</code></td><td>Kavenegar API key (required when provider = kavenegar)</td></tr>
<tr><td><code>AUTH_SMS_TEMPLATE</code></td><td>Kavenegar template name for OTP messages (e.g. <code>yara-otp</code>)</td></tr>
<tr><td><code>PARSIAN_SMS_URL</code></td><td>Parsian SMS Gateway base URL (required when provider = parsian)</td></tr>
<tr><td><code>PARSIAN_API_KEY</code></td><td>Parsian SMS <code>X-PACKAGE-API-KEY</code> header value</td></tr>
<tr><td><code>PARSIAN_BASIC_TOKEN</code></td><td>Base64-encoded credentials for <code>Authorization: Basic …</code> header</td></tr>
<tr><td><code>URL</code></td><td>Frontend base URL — used to build all invite + claim links embedded in SMS messages</td></tr>
</table>
</div>
<div class="card card-yellow">
<h3>AI service</h3>
<table class="env-table">
<tr><th>Variable</th><th>Description</th></tr>
<tr><td><code>AI_URL_V2</code></td><td>AI gateway base URL. Default: <code>https://ai-gw.ittalie.ir</code> (unused — service is disabled)</td></tr>
<tr><td><code>AI_USERNAME</code></td><td>AI service login username (unused)</td></tr>
<tr><td><code>AI_PASSWORD</code></td><td>AI service login password (unused)</td></tr>
</table>
</div>
<div class="card card-orange">
<h3>Car pricing service</h3>
<table class="env-table">
<tr><th>Variable</th><th>Description</th></tr>
<tr><td><code>CW_URL</code> *</td><td>Base URL for car market price API (e.g. <code>https://…/</code>). Not in <code>.env.example</code>. Price-drop silently skipped if unset.</td></tr>
</table>
</div>
<div class="card card-gray">
<h3>General / app</h3>
<table class="env-table">
<tr><th>Variable</th><th>Description</th></tr>
<tr><td><code>PORT</code></td><td>HTTP port (default 3000). Used by the Fanavaran insurance-corp fallback to call its own local lookup endpoint.</td></tr>
<tr><td><code>CAPTCHA_ENABLED</code></td><td><code>true</code> / <code>false</code> — enables/disables login CAPTCHA challenge. Internal, no external service.</td></tr>
<tr><td><code>EXP_CAPTCHA_TIME</code></td><td>CAPTCHA challenge TTL in minutes.</td></tr>
<tr><td><code>EXP_OTP_TIME</code></td><td>OTP TTL in minutes.</td></tr>
</table>
</div>
<footer>Made by Sepehr</footer>
</div>
</body>
</html>

View File

@@ -1,6 +1,6 @@
---
last_updated: 2026-08-08
tags: [fanavaran, api, write, gen03, gen07, gen08, gen12]
tags: [fanavaran, api, write, gen03, gen07, gen08, gen12, gen44]
source: fanavaran-module-docs
---
@@ -115,7 +115,8 @@ Full shape: see skill reference sample and `buildFanavaranSubmitPayload` / `appl
### Validation (YARA)
- Only `THIRD_PARTY` claims (not `CAR_BODY`)
- `THIRD_PARTY` → `third-party-car-financial-claims`; `CAR_BODY` → `vehicle-hull-claims`
- Hull skips `dmg-cases` (resource does not exist)
- `PolicyId` required on submit (`requirePolicyId: true`)
- Skip if local `claimId` / `claimNo` already set
- `EstimateAmount` normalized to positive
@@ -127,8 +128,7 @@ Full shape: see skill reference sample and `buildFanavaranSubmitPayload` / `appl
| `Id` | `claimCases.claimId` |
| `ClaimNo` | `claimCases.claimNo` |
History: `FANAVARAN_EARLY_AUTO_SUBMIT_SUCCEEDED`.
SMS owner via `SmsOrchestrationService` (deduped `fanavaranSync.baseClaim.smsNotifiedAt`).
History: `FANAVARAN_EARLY_AUTO_SUBMIT_SUCCEEDED`.
### Error handling
@@ -216,6 +216,26 @@ After local `SELECT_OUTER_PARTS` — before image upload.
Manual: `POST /v2/fanavaran/{client}/claim-cases/{id}/damage-case/submit`.
If `DriverId` is still missing, submit registers the damaged party with GEN.44 first (see below). Preview only warns.
---
## 4.1 GEN.44 — Other people (unknown person)
Doc: `CMN.GENR.APIH.GEN.44`
| Item | Value |
|------|-------|
| نام عملیات | ثبت سایر اشخاص |
| Endpoint | `POST /Api/BimeApi/v2.0/common/other-people` |
| Method | `POST` |
Also: `GET/PUT .../common/other-people/{Id}`. Local GET: `GET /lookups/other-people/:personId`.
Used when parties inquiry has no row for the damaged party. Iranian create needs national code + Jalali birth date; Fanavaran fills name/gender from ثبت احوال. Lookup ids: `CityId`, `GenderId`, `IsIranian` (`ans`), `PersonKindId`, `NationalityId`, `MaritalStatus`.
YARA: `registerFanavaranOtherPerson` during damage-case submit. Store `Id` as `driverId` / `otherPersonId`. Best-effort; do not create on preview; do not block the local claim flow.
---
## 5. GEN.07 — Attachments
@@ -309,6 +329,7 @@ Requires `claimId` + `dmgCaseId` (soft-ensures earlier stages).
| `Id` | `claimCases.expertiseId` |
History: `FANAVARAN_EXPERTISE_AUTO_SUBMIT_SUCCEEDED`.
No owner SMS is sent after expertise; the returned identifier is persisted only.
Proven Parsian: `expertiseId=403144`, `ClaimExpertId=29`.
Manual: `POST /v2/fanavaran/{client}/claim-cases/{id}/expertise/submit`.

View File

@@ -1,5 +1,5 @@
---
last_updated: 2026-08-09
last_updated: 2026-09-14
tags: [fanavaran, api, read, lookups]
source: fanavaran-module-docs
---
@@ -66,6 +66,10 @@ Used when building GEN.12 to resolve `DriverId`.
Prefer cache before live call.
If inquiry returns no person, damage-case **submit** registers them with GEN.44 (`POST /common/other-people`). See [01-write-apis.md](./01-write-apis.md) and the skill reference `other-people.md`. Preview only warns; it does not create.
Local GET: `GET /lookups/inquiry-by-unique-identifier` and `GET /lookups/other-people/:personId`.
---
## 3. Remote lookup catalogue
@@ -80,6 +84,7 @@ Base: `https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0`
| dmg-pay-method | `/car/code-list/dmg-pay-method` | `GET /lookups/dmg-pay-method` | Pay method |
| driving-licence-types | `/car/base-info/driving-licence-types` | `GET /lookups/driving-licence-types` | Licence type |
| accident-culprit-type | `/car/code-list/accident-culprit-type` | `GET /lookups/accident-culprit-type` | Culprit type |
| detection-accident-culprits | `/car/base-info/detection-accident-culprits` | `GET /lookups/detection-accident-culprits` | GEN.03 `AuthorityCulpritId` |
| inspection-place | `/car/code-list/inspection-place` | `GET /lookups/inspection-place` | GEN.08 |
| drop-amount-status | `/car/code-list/drop-amount-status` | `GET /lookups/drop-amount-status` | GEN.08 |
| car-components | `/car/base-info/car-components` | `GET /lookups/car-components` | `DmgSectionId` |
@@ -95,6 +100,12 @@ Base: `https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0`
| dmg-history-status | `/car/code-list/dmg-case-history-status` | `GET /lookups/dmg-history-status` | GEN.12 |
| used-place | `/car/code-list/used-place` | `GET /lookups/used-place` | Used place |
| dmg-business-line | `/car/code-list/dmg-business-line` | `GET /lookups/dmg-business-line` | Business line |
| gender | `/common/code-list/gender` | `GET /lookups/gender` | GEN.44 `GenderId` |
| marital-status | `/common/code-list/marital-status` | `GET /lookups/marital-status` | GEN.44 `MaritalStatus` |
| ans | `/common/code-list/ans` | `GET /lookups/ans` | GEN.44 `IsIranian` / yes-no |
| countries | `/common/base-info/countries` | `GET /lookups/countries` | GEN.44 `NationalityId` |
| person-kind | `/common/code-list/person-kind` | `GET /lookups/person-kind` | GEN.44 `PersonKindId` |
| cii-validation-status | `/common/code-list/cii-validation-status` | `GET /lookups/cii-validation-status` | GEN.44 CII status |
### Generic accessor
@@ -121,6 +132,7 @@ Auth: Bearer + `AuthGuard`. Active tenant from `FANAVARAN_CLIENT` / `CLIENT_ID`.
| `GET /lookups/dmg-pay-method` | `/car/code-list/dmg-pay-method` |
| `GET /lookups/driving-licence-types` | `/car/base-info/driving-licence-types` |
| `GET /lookups/accident-culprit-type` | `/car/code-list/accident-culprit-type` |
| `GET /lookups/detection-accident-culprits` | `/car/base-info/detection-accident-culprits` |
| `GET /lookups/inspection-place` | `/car/code-list/inspection-place` |
| `GET /lookups/drop-amount-status` | `/car/code-list/drop-amount-status` |
| `GET /lookups/car-components` | `/car/base-info/car-components` |
@@ -135,6 +147,12 @@ Auth: Bearer + `AuthGuard`. Active tenant from `FANAVARAN_CLIENT` / `CLIENT_ID`.
| `GET /lookups/dmg-history-status` | `/car/code-list/dmg-case-history-status` |
| `GET /lookups/used-place` | `/car/code-list/used-place` |
| `GET /lookups/dmg-business-line` | `/car/code-list/dmg-business-line` |
| `GET /lookups/gender` | `/common/code-list/gender` |
| `GET /lookups/marital-status` | `/common/code-list/marital-status` |
| `GET /lookups/ans` | `/common/code-list/ans` |
| `GET /lookups/countries` | `/common/base-info/countries` |
| `GET /lookups/person-kind` | `/common/code-list/person-kind` |
| `GET /lookups/cii-validation-status` | `/common/code-list/cii-validation-status` |
| `GET /lookups/fanavaran` | catalogue metadata (names + URLs) |
| `GET /lookups/fanavaran/{lookupName}` | any configured name (incl. `insurance-corp`) |
@@ -143,7 +161,11 @@ Auth: Bearer + `AuthGuard`. Active tenant from `FANAVARAN_CLIENT` / `CLIENT_ID`.
| Nest route | Fanavaran path / notes |
|------------|------------------------|
| `GET /lookups/inquiry-by-vin?vin=` | `/car/vehicles/inquiry-by-vin` |
| `GET /lookups/inquiry-by-unique-identifier?nationalCode=&birthday=` | `/common/parties/inquiry-by-unique-identifier` |
| `GET /lookups/other-people/:personId` | `/common/other-people/{Id}` (GEN.44) |
| `GET /lookups/my-policies?nationalCode=&insuranceLineId=` | `/common/Policies/inquiry-my-policies` |
| `GET /lookups/processed-third-party-policy?nationalCode=&vin=&plaque…` | Last matching ثالث policy for that car + vehicle |
| `GET /lookups/processed-body-policy?nationalCode=&vin=&plaque…` | Last matching بدنه policy for that car + vehicle |
| `GET /lookups/third-party-policy/:policyId` | `/car/third-party-car-policies/{id}` |
| `GET /lookups/body-policy/:policyId` | `/car/vehicle-hull-policies/{id}` |

View File

@@ -87,8 +87,8 @@ File: `claim-request-management.service.ts`
| Name | Relevant values | Fanavaran use |
|------|-----------------|---------------|
| Claim type | `THIRD_PARTY` only | Fanavaran path enabled |
| | `CAR_BODY` | **Not** submitted to Fanavaran |
| Claim type | `THIRD_PARTY` | `third-party-car-financial-claims` (base → dmg-cases → files → expertise) |
| | `CAR_BODY` | `vehicle-hull-claims` (base → files → expertise; **no dmg-cases**) |
| `ClaimCaseStatus` | local workflow | Triggers stages indirectly |
| History event types | `FANAVARAN_*_SUCCEEDED/FAILED` | Audit trail on claim |

View File

@@ -31,7 +31,7 @@ source: fanavaran-module-docs
| Validation: FileTypeId ∈ tenant lookup | logic ✅ | allowed ids ✅ |
| Business: THIRD_PARTY only | ✅ | ❌ |
| Provisional address / dummy licence | ✅ today | may customize later |
| SMS template after base claim | shared orchestration | provider/templates may ✅ |
| SMS template after expertise | shared orchestration | provider/templates may ✅ |
| Mongo collections / audit schema | ✅ | ❌ |
| Offline inquiry seeds | may exist per client | ✅ |

View File

@@ -15,13 +15,13 @@ Start local claim
→ Validate THIRD_PARTY + data readiness
→ Auth (cached token)
→ Policy inquiry (resolve-once PolicyId)
→ GEN.03 base claim → store claimId / claimNo → SMS owner
→ GEN.03 base claim → store claimId / claimNo
→ SELECT_OUTER_PARTS
→ GEN.12 damage case → store dmgCaseId
→ Upload docs/images locally
→ GEN.07 attachments (per file, best-effort)
→ Expert pricing ready
→ GEN.08 expertise → store expertiseId
→ GEN.08 expertise → store expertiseId (no SMS)
→ End (local completion independent of Fanavaran success)
```
@@ -56,11 +56,16 @@ sequenceDiagram
YARA->>FV: POST third-party-car-financial-claims (GEN.03)
FV-->>YARA: Id, ClaimNo
YARA->>DB: claimId, claimNo, history SUCCESS
YARA->>YARA: SMS owner (deduped)
User->>YARA: SELECT_OUTER_PARTS
YARA->>YARA: autoSubmitFanavaranDamageCase...
Note over YARA: soft-ensure base if missing
alt DriverId missing
YARA->>FV: GET parties/inquiry-by-unique-identifier
FV-->>YARA: empty
YARA->>FV: POST common/other-people (GEN.44)
FV-->>YARA: person Id
end
YARA->>FV: POST .../dmg-cases (GEN.12)
FV-->>YARA: DmgCaseId
YARA->>DB: dmgCaseId
@@ -77,6 +82,7 @@ sequenceDiagram
YARA->>FV: POST .../expertise (GEN.08)
FV-->>YARA: ExpertiseId
YARA->>DB: expertiseId, history SUCCESS
Note over YARA: Post-expertise owner SMS is disabled
```
## Flow diagram (stages + soft-ensure)

View File

@@ -17,7 +17,7 @@ source: fanavaran-module-docs
| Tenant config | boot + System Settings | `fanavaran-client-config.service.ts`, `system-settings.*` |
| Orchestration | `ClaimRequestManagementModule` | `claim-request-management.service.ts` |
| Policy select | same | `fanavaran-policy-selection.ts` |
| SMS after base | `SmsOrchestrationModule` | `sms-orchestration.service.ts` |
| SMS after expertise | Disabled by product decision | No message is dispatched |
---
@@ -49,7 +49,7 @@ source: fanavaran-module-docs
| Send | GEN.03 JSON + business headers |
| Receive | `Id`, `ClaimNo` |
| Store | `claimId`, `claimNo`, `fanavaranSync.baseClaim.*`, history |
| Consumers | Damage/expertise soft-ensure; UI claim detail; SMS |
| Consumers | Damage/expertise soft-ensure; UI claim detail |
### C. Damage case
@@ -133,8 +133,9 @@ Expert reply path calls expertise auto-submit (~10576).
| `claimCases.dmgCaseId` | GEN.12 |
| `claimCases.expertiseId` | GEN.08 |
| `claimCases.fanavaranSync.*` | All |
| `claimCases.fanavaranSync.damageCase.otherPersonId` | GEN.44 |
| `claimCases.history[]` | Success/fail events |
| `blameCases.parties[].person.fanavaranDriverId` | GEN.12 driver resolve |
| `blameCases.parties[].person.fanavaranDriverId` | GEN.12 driver resolve / GEN.44 |
| `fanavaranAuditLogs` | Every real HTTP step |
| `fanavaranAuthTokens` | Auth cache |
| `fanavaranClientConfigs` | Tenant profiles |

View File

@@ -28,7 +28,7 @@ source: fanavaran-module-docs
| YARA | Fanavaran |
|------|-----------|
| `damage.selectedParts` labels | `Desc` (`/` joined) |
| Party `nationalCodeOfDriver` + birthday (+ insurer flag) → inquiry | `DriverId` |
| Party `nationalCodeOfDriver` + birthday (+ insurer flag) → inquiry, else GEN.44 create | `DriverId` |
| `claimCase.vehicle.carType` → vehicle-kinds | `VehicleKindId` |
| `INSURANCE_CORP_ID` caption → insurance-corp | `InsuranceCorpId` |
| Inquiry `ShsNum` / Chassis* | `ChassisNo` |
@@ -43,6 +43,20 @@ source: fanavaran-module-docs
| Tenant defaults | `DmgCaseTypeId`, `DmgHistoryStatus`, `PlaqueKindId`, `PlaqueSampleId`, `FaultPercent`, `AccidentVehicleUsedId`, `LicenceTypeId` |
| Provisional | `EstimateAmount` = 1000 |
## GEN.44 — Other people
| YARA | Fanavaran |
|------|-----------|
| Party national code | `NationalCode` |
| Party birthday (Jalali) | `BirthYear` / `BirthMonth` / `BirthDay` |
| `fullName` split | `Name` / `LastName` (registry may overwrite) |
| Party phone / user mobile | `Mobile` |
| User address / city / gender | `Address`, `CityId`, `GenderId` |
| `ans` lookup بله | `IsIranian` |
| `person-kind` حقیقی | `PersonKindId` |
Create only on GEN.12 submit when inquiry missed. Cache `otherPersonId` + `driverId`.
## GEN.07 — Attachments
| YARA | Fanavaran |
@@ -83,7 +97,7 @@ source: fanavaran-module-docs
| YARA | Note |
|------|------|
| `CAR_BODY` claims | No Fanavaran submit |
| `CAR_BODY` dmg-cases | Hull has no GEN.12 resource; skip that stage |
| Videos | Not uploaded via GEN.07 in current code |
| Full police report fields | Often null unless later enriched |

View File

@@ -21,7 +21,7 @@ source: fanavaran-module-docs
| Dependency | Role |
|------------|------|
| **SMS provider** | Notify owner after successful GEN.03 (`SmsOrchestrationService`; same as login SMS stack) |
| **SMS provider** | Notify owner after successful GEN.08 expertise (`SmsOrchestrationService`; same as login SMS stack) |
| **Offline inquiry seeds** | Can supply driver/policy test data without live inquiry |
| **Redis / Queue** | **Not** used for Fanavaran stage orchestration today — retries are in-process `setTimeout` + Mongo `nextRetryAt` |

View File

@@ -69,4 +69,4 @@ Per HTTP step:
## SMS
After successful base claim, owner SMS may be sent once (`smsNotifiedAt`). Failure to SMS should not roll back Fanavaran claim create; treat as separate notification concern.
The post-expertise owner SMS is disabled. A successful final Fanavaran response only persists the returned `expertiseId` and synchronization state; it does not dispatch a notification.

View File

@@ -0,0 +1,442 @@
---
last_updated: 2026-09-16
tags: [fanavaran, car-body, hull, curl, vhud, parsian]
source: fanavaran-apis skill + VHUD GEN docs
---
# Car-body (بدنه) Fanavaran — manual curl flow
Direct Fanavaran BimeApi calls for **vehicle-hull-claims** (VHUD GEN.03 / GEN.05 / GEN.06).
Use this when debugging production without YARA, or to compare with YARA preview payloads.
Credentials below match **`PARSIAN_FANAVARAN_CONFIG`** in `claim-request-management.service.ts` and `scripts/fanavaran-auth.sh parsian`. Tokens (`APP_TOKEN`, `AUTH_TOKEN`) change daily — run auth curls first or `./scripts/fanavaran-auth.sh parsian` then `source files/fanavaran-auth/parsian/tokens.env`.
## Base URLs
| Purpose | URL |
|---------|-----|
| Auth | `https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/...` |
| Business (BimeApi v2 car) | `https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/...` |
## Variables (set after each step)
```bash
# Parsian (ready values)
export FANAVARAN_APPNAME='ParsianService'
export FANAVARAN_SECRET='P@r30@n$erv!ce'
export FANAVARAN_USERNAME='ParsianServiceUser'
export FANAVARAN_PASSWORD='P@r30@n123'
export FANAVARAN_CORP_ID='543'
export FANAVARAN_CONTRACT_ID='28' # confirm hull ContractId with Fanavaran if files fail
export FANAVARAN_LOCATION='210050'
export APP_TOKEN='' # from GetAppToken response header apptoken
export AUTH_TOKEN='' # from Login response header/body
export HULL_CLAIM_ID='5023617' # GEN.03 response Id — کد رایانه (NOT ClaimNo); replace after POST
export HULL_CLAIM_NO='2268' # display only
export EXPERTISE_ID='' # GEN.06 response Id
```
### One-shot auth (Parsian) — copy/paste
```bash
curl -sS -D /tmp/fanavaran-apptoken.headers -o /tmp/fanavaran-apptoken.body \
-X POST 'https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/GetAppToken' \
-H 'appname: ParsianService' \
-H 'secret: P@r30@n$erv!ce' \
-H 'Content-Length: 0'
export APP_TOKEN="$(awk -F': ' 'tolower($1)=="apptoken"{gsub(/\r/,"",$2);print $2;exit}' /tmp/fanavaran-apptoken.headers)"
curl -sS -D /tmp/fanavaran-login.headers -o /tmp/fanavaran-login.body \
-X POST 'https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/Login' \
-H "appToken: ${APP_TOKEN}" \
-H 'userName: ParsianServiceUser' \
-H 'password: P@r30@n123' \
-H 'Content-Length: 0'
export AUTH_TOKEN="$(awk -F': ' 'tolower($1)=="authenticationtoken"{gsub(/\r/,"",$2);print $2;exit}' /tmp/fanavaran-login.headers)"
echo "APP_TOKEN=${APP_TOKEN}"
echo "AUTH_TOKEN=${AUTH_TOKEN}"
```
Or from repo root: `./scripts/fanavaran-auth.sh parsian && source files/fanavaran-auth/parsian/tokens.env` (uses `AUTHENTICATION_TOKEN` — alias `export AUTH_TOKEN="$AUTHENTICATION_TOKEN"`).
### Id vs ClaimNo (critical)
| Field | Persian | Use in URL? |
|-------|---------|-------------|
| **`Id`** | کد رایانه پرونده | **Yes** — `/vehicle-hull-claims/{Id}/files` and `/expertise` |
| **`ClaimNo`** | شماره پرونده | **No** — do not put `2268` in the path |
Example: `Id=5023617`, `ClaimNo=2268` → use **`5023617`** everywhere below.
---
## Auth — GetAppToken
**Doc:** API.GENR (EIT Authentication)
```bash
curl -sS -D /tmp/fanavaran-apptoken.headers -o /tmp/fanavaran-apptoken.body \
-X POST 'https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/GetAppToken' \
-H 'appname: ParsianService' \
-H 'secret: P@r30@n$erv!ce' \
-H 'Content-Length: 0'
```
Read **`apptoken`** (or `appToken`) from response **headers**:
```bash
grep -i apptoken /tmp/fanavaran-apptoken.headers
export APP_TOKEN='<paste apptoken header value>'
```
Body is usually empty. No JSON body on this call.
---
## Auth — Login
```bash
curl -sS -D /tmp/fanavaran-login.headers -o /tmp/fanavaran-login.body \
-X POST 'https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/Login' \
-H "appToken: ${APP_TOKEN}" \
-H 'userName: ParsianServiceUser' \
-H 'password: P@r30@n123' \
-H 'Content-Length: 0'
```
Read **`authenticationToken`** from response header or JSON body:
```bash
grep -i authenticationtoken /tmp/fanavaran-login.headers
# or
cat /tmp/fanavaran-login.body
export AUTH_TOKEN='<paste token>'
```
Token is typically valid until **Asia/Tehran midnight** (same as YARA `FanavaranAuthService`).
---
## Business headers (all stages below)
Every BimeApi call needs:
```text
authenticationToken: <AUTH_TOKEN>
CorpId: <FANAVARAN_CORP_ID>
ContractId: <FANAVARAN_CONTRACT_ID>
Location: <FANAVARAN_LOCATION>
Content-Type: application/json # except multipart files
```
---
## YARA preview (copy JSON bodies from production)
Before curling Fanavaran directly, get the exact payload from Parsian production YARA (same tenant auth as above):
| Stage | YARA preview (Parsian) |
|-------|-------------------------|
| 1 Base claim | `GET /v2/fanavaran/parsian/claim-cases/{claimCaseId}/base-claim/preview` |
| 2 Damage case | **N/A for CAR_BODY** — skipped |
| 3 Attachments | `GET /v2/fanavaran/parsian/claim-cases/{claimCaseId}/attachments/preview` |
| 4 Expertise | `GET /v2/fanavaran/parsian/claim-cases/{claimCaseId}/expertise/preview` |
Expert UI BFF (if used): `POST /car-damage/expert/api/fanavaran` with `{"claimCaseId":"..."}` runs remaining stages via the app.
Save preview `payload` into a file, e.g. `stage1-body.json`, then use `@stage1-body.json` in curl.
Example hull-only GEN.03 body (proven Parsian case): [`stage1-hull-body.json`](./stage1-hull-body.json).
**Enrichment before POST (YARA does this automatically):**
| Field | Source |
|-------|--------|
| `ActualPremium` | `GET /lookups/body-policy/{PolicyId}` → `TotalPremium` |
| `AccidentCulpritId` | `GET /lookups/inquiry-by-unique-identifier?nationalCode=…&birthday=…` → `driverId` (fallback: policy `CustomerId`) |
| `CustomerFaultPercent` | **100** (required; درصد تقصير بيمه گذار) |
| `AuthorityCulpritId` | `GET /lookups/detection-accident-culprits` → بیمه‌گذار (required; مرجع تعيين مقصر) |
Success example: `Id=5026065`, `ClaimNo=1747` — use **`5026065`** for files/expertise.
---
## Stage 1 — GEN.03 Create hull base claim
**Doc:** CAR.VHUD.APIH.GEN.03
**Method:** `POST`
**URL:** `https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims`
```bash
curl -sS -X POST \
'https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims' \
-H "authenticationToken: ${AUTH_TOKEN}" \
-H "CorpId: ${FANAVARAN_CORP_ID}" \
-H "ContractId: ${FANAVARAN_CONTRACT_ID}" \
-H "Location: ${FANAVARAN_LOCATION}" \
-H 'Content-Type: application/json' \
-d @stage1-body.json
```
**Body:** paste from YARA **base-claim preview** `payload`. Must be **hull GEN.03 shape** (include `AccidentTypeId`, `IsLicenseReplaced`, `CostSeparationToDmgSections`, `IsOwnerChanged`; do **not** send ثالث-only fields like `DamagedCount`, `HasOtherCulprit`, `CompensationReferenceId`, plaque block on base).
**GEN.03 required lookups (Parsian live):**
| Field | Lookup | Typical collision value |
|-------|--------|-------------------------|
| `AccidentTypeId` | `GET /lookups/vehicle-hull-accident-types` | **2** = تصادف(حادثه) |
| `CostSeparationToDmgSections` | `GET /lookups/ans` | **0** = خیر |
| `IsOwnerChanged` | `GET /lookups/ans` | **0** = خیر |
| `IsLicenseReplaced` | — | **`null`** (must be empty; do not send `0` or ثالث `IsLicenseReplacement`) |
| `CulpritTypeId` | `GET /lookups/vehicle-hull-accident-culprit-type` | **301** = حادثه مقصر شناخته شده دارد (not ثالث `337`) |
| `CustomerFaultPercent` | — | **100** |
| `AuthorityCulpritId` | `GET /lookups/detection-accident-culprits` | بیمه‌گذار (`Id` from lookup, fallback **1**) |
Fanavaran direct: `car/base-info/vehicle-hull-accident-types`, `common/code-list/ans`, `car/code-list/vehicle-hull-accident-culprit-type`.
**Success:** JSON includes at least:
```json
{ "Id": 5023617, "ClaimNo": 2268, ... }
```
```bash
export HULL_CLAIM_ID='5023617' # use Id
export HULL_CLAIM_NO='2268' # do not use in nested URLs
```
### Optional — GET base claim (verify Id exists)
```bash
curl -sS -X GET \
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims/${HULL_CLAIM_ID}" \
-H "authenticationToken: ${AUTH_TOKEN}" \
-H "CorpId: ${FANAVARAN_CORP_ID}" \
-H "ContractId: ${FANAVARAN_CONTRACT_ID}" \
-H "Location: ${FANAVARAN_LOCATION}"
```
### Optional — PUT hull shape (if files/expertise say پرونده خسارت بدنه یافت نشد)
If stage 1 was created with a wrong (ثالث-shaped) body, **PUT** the same hull GEN.03 field set onto the existing `Id` (body = preview payload after hull mapping):
```bash
curl -sS -X PUT \
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims/${HULL_CLAIM_ID}" \
-H "authenticationToken: ${AUTH_TOKEN}" \
-H "CorpId: ${FANAVARAN_CORP_ID}" \
-H "ContractId: ${FANAVARAN_CONTRACT_ID}" \
-H "Location: ${FANAVARAN_LOCATION}" \
-H 'Content-Type: application/json' \
-d @stage1-body-hull.json
```
YARA does this automatically before attachments/expertise when product is CAR_BODY.
---
## Stage 2 — Damage case (ثالث GEN.12) — **does not exist for hull**
**Do not call:**
```text
POST .../vehicle-hull-claims/{id}/dmg-cases ❌ Feature:url(...) not found
```
Third-party only:
```text
POST .../third-party-car-financial-claims/{id}/dmg-cases
```
For car-body YARA: damage-case preview/submit **returns skipped**. Flow is **1 → 3 → 4**.
---
## Stage 3 — GEN.05 Attachments (one file per request)
**Doc:** CAR.VHUD.APIH.GEN.05
**Method:** `POST`
**URL:** `https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims/{HULL_CLAIM_ID}/files`
**Parsian `FileTypeId`:** `63` (سایر مدارک خسارت) — proven on ثالث; verify in `file-types` lookup for hull if rejected.
### JSON metadata (multipart part)
YARA sends this as multipart field **`Param`** (`application/json`):
```json
{
"FileName": "photo_example.jpg",
"FileTypeId": 63,
"ClaimId": 5023617
}
```
`FileName` must **exactly** match the uploaded file name. `ClaimId` = same as URL `{HULL_CLAIM_ID}` (`Id`, not `ClaimNo`).
### curl (matches YARA `postFanavaranMultipart`: Param + Param1)
```bash
export FILE_PATH='/path/to/photo_example.jpg'
export FILE_NAME='photo_example.jpg'
curl -sS -X POST \
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims/${HULL_CLAIM_ID}/files" \
-H "authenticationToken: ${AUTH_TOKEN}" \
-H "CorpId: ${FANAVARAN_CORP_ID}" \
-H "ContractId: ${FANAVARAN_CONTRACT_ID}" \
-H "Location: ${FANAVARAN_LOCATION}" \
-F "Param={\"FileName\":\"${FILE_NAME}\",\"FileTypeId\":63,\"ClaimId\":${HULL_CLAIM_ID}};type=application/json" \
-F "Param1=@${FILE_PATH};filename=${FILE_NAME}"
```
Repeat once per image (YARA does not batch hull uploads).
**Success example:** `{ "Id": 2008737, "Status": 2003, ... }`
### Optional — list files (GET)
```bash
curl -sS -X GET \
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims/${HULL_CLAIM_ID}/files" \
-H "authenticationToken: ${AUTH_TOKEN}" \
-H "CorpId: ${FANAVARAN_CORP_ID}" \
-H "ContractId: ${FANAVARAN_CONTRACT_ID}" \
-H "Location: ${FANAVARAN_LOCATION}"
```
---
## Stage 4 — GEN.06 Expertise
**Doc:** CAR.VHUD.APIH.GEN.06
**Method:** `POST`
**URL:** `https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims/{HULL_CLAIM_ID}/expertise`
**Body:** paste from YARA **expertise preview** `payload` (GEN.06 — **not** ثالث GEN.08).
| Field | Source (YARA) |
|-------|----------------|
| `VehicleCurrentValue` | `evaluation.priceDrop.carPrice` if set; else **`GET /lookups/body-policy/{PolicyId}` → `VehicleValue`** |
| `DmgSections[].DmgKindId` | **`5485`** (تخریب) from **`GET /lookups/vehicle-hull-dmg-kind`** |
| `DmgSectionCosts[].DmgCostKindId` | **`1`** (Id for «مجموع لوازم») from **`GET /lookups/vehicle-hull-dmg-cost-kinds`** — not `DmgCostCategoryId` |
Hull expects fields such as:
- `ClaimExpertId` (Parsian assessor: **29** = `ExpertiseClaimExpertId`)
- `Wage`, `ComponentReplacementCost`, `WasteValue`, `DropAmount`, `VehicleCurrentValue`
- `MotorNo`, `ChassisNo`, `VIN`, `PlaqueNo`, `PlaqueSerial`, `BuiltYear`
- `DmgSections[]` with `Count`, `Desc`, `AccessoryKindId`, `DmgSectionCosts[]`
- **No** `DmgCaseId`, `InspectionPlaceId`, `DropAmountStatus`, `RepairWage`
```bash
curl -sS -X POST \
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims/${HULL_CLAIM_ID}/expertise" \
-H "authenticationToken: ${AUTH_TOKEN}" \
-H "CorpId: ${FANAVARAN_CORP_ID}" \
-H "ContractId: ${FANAVARAN_CONTRACT_ID}" \
-H "Location: ${FANAVARAN_LOCATION}" \
-H 'Content-Type: application/json' \
-d @stage4-expertise-body.json
```
**Success:** `{ "Id": 71927, "ClaimId": 78776, ... }` → store as `expertiseId`.
---
## Stage map (YARA vs Fanavaran)
| YARA / user stage | Fanavaran doc | HTTP | Exists for بدنه? |
|-------------------|---------------|------|------------------|
| Auth | GetAppToken + Login | POST | Yes |
| 1 Base claim | GEN.03 | POST `/vehicle-hull-claims` | Yes |
| 1b Sync hull body | GEN.03 | PUT `/vehicle-hull-claims/{Id}` | Yes (fix row) |
| 2 Damage case | GEN.12 (ثالث) | POST `.../dmg-cases` | **No — skip** |
| 3 Attachments | GEN.05 | POST `.../{Id}/files` | Yes |
| 4 Expertise | GEN.06 | POST `.../{Id}/expertise` | Yes |
## Not used for normal collision car-body
| Doc | URL |
|-----|-----|
| GEN.01 bulk import | POST `/vehicle-hull-import-claims` |
| GEN.02 import files | POST `/vehicle-hull-import-claims/{PrvId}/files` |
| GEN.04 culprits | POST `.../{Id}/culprits` |
## Related docs
- Skill summary: `.agents/skills/fanavaran-apis/references/car-body-cases.md`
- VHUD extracts: `.agents/skills/fanavaran-apis/references/vehicle-hull/`
- Local auth script: `scripts/fanavaran-auth.sh parsian`
---
## Parsian — full flow script (after auth)
Run the **One-shot auth** block above first. Save YARA preview JSON to `stage1-body.json` and `stage4-expertise-body.json`. Set `FILE_PATH` / `FILE_NAME` for stage 3.
```bash
# Stage 1 — POST hull base claim
curl -sS -X POST \
'https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims' \
-H "authenticationToken: ${AUTH_TOKEN}" \
-H 'CorpId: 543' \
-H 'ContractId: 28' \
-H 'Location: 210050' \
-H 'Content-Type: application/json' \
-d @stage1-body.json
# Stage 1 optional — GET
curl -sS -X GET \
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims/${HULL_CLAIM_ID}" \
-H "authenticationToken: ${AUTH_TOKEN}" \
-H 'CorpId: 543' \
-H 'ContractId: 28' \
-H 'Location: 210050'
# Stage 1 optional — PUT hull sync
curl -sS -X PUT \
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims/${HULL_CLAIM_ID}" \
-H "authenticationToken: ${AUTH_TOKEN}" \
-H 'CorpId: 543' \
-H 'ContractId: 28' \
-H 'Location: 210050' \
-H 'Content-Type: application/json' \
-d @stage1-body-hull.json
# Stage 2 — (skip — no dmg-cases for hull)
# Stage 3 — POST one file
FILE_PATH='/path/to/photo.jpg'
FILE_NAME='photo.jpg'
curl -sS -X POST \
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims/${HULL_CLAIM_ID}/files" \
-H "authenticationToken: ${AUTH_TOKEN}" \
-H 'CorpId: 543' \
-H 'ContractId: 28' \
-H 'Location: 210050' \
-F "Param={\"FileName\":\"${FILE_NAME}\",\"FileTypeId\":63,\"ClaimId\":${HULL_CLAIM_ID}};type=application/json" \
-F "Param1=@${FILE_PATH};filename=${FILE_NAME}"
# Stage 3 optional — GET files list
curl -sS -X GET \
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims/${HULL_CLAIM_ID}/files" \
-H "authenticationToken: ${AUTH_TOKEN}" \
-H 'CorpId: 543' \
-H 'ContractId: 28' \
-H 'Location: 210050'
# Stage 4 — POST expertise
curl -sS -X POST \
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims/${HULL_CLAIM_ID}/expertise" \
-H "authenticationToken: ${AUTH_TOKEN}" \
-H 'CorpId: 543' \
-H 'ContractId: 28' \
-H 'Location: 210050' \
-H 'Content-Type: application/json' \
-d @stage4-expertise-body.json
```

View File

@@ -0,0 +1,154 @@
# Car-body (بدنه) — Fanavaran stage bodies (current YARA)
Snapshot of **what hull submit actually sends** after `toFanavaranHullBaseClaimPayload` / `toFanavaranHullExpertisePayload`.
Staged flow (VHUD):
1. Base: `POST /car/vehicle-hull-claims` (GEN.03)
2. **No dmg-cases** (`vehicle-hull-claims/{id}/dmg-cases` does not exist)
3. Attachments: `POST …/{Id}/files` (GEN.05)
4. Expertise: `POST …/{Id}/expertise` (GEN.06)
Use hull `ContractId` (`hullContractId`, else tenant `contractId`). Nested URLs use GEN.03 **`Id`**, never `ClaimNo`.
A PUT to `/vehicle-hull-claims/{Id}` may run before files/expertise; Parsian often has **no PUT route**. That PUT is best-effort and must not block GEN.05/06.
**Send rule:** send the **hull whitelist only**. Do not send ثالث-only keys (`DamagedCount`, `HasOtherCulprit`, `CompensationReferenceId`, `DocReceivedDate`, `IsLicenseReplacement`, plaque block on base, …).
**Source tags:** same as `third-party-stage-fields.md` (`system` / `lookup` / `inquiry` / `user` / `staff` / `derived`).
---
## Stage 1 — Hull base claim (GEN.03)
Built by `buildFanavaranSubmitPayload` then `enrichCarBodyHullBaseClaimPayload` then `toFanavaranHullBaseClaimPayload`.
**Fanavaran has rejected as required (must not be null):**
`CustomerFaultPercent`, `AuthorityCulpritId`, `AccidentTypeId`, `CostSeparationToDmgSections`, `IsOwnerChanged`, `PolicyId`, `ClaimExpertId`, dates/time, `CulpritTypeId`.
**Must be null:** `IsLicenseReplaced` (error if `0` / mapped from ثالث `IsLicenseReplacement`).
| Field | Persian | Type | Null? | Current value / source |
|-------|---------|------|-------|------------------------|
| PolicyId | شناسه بیمه‌نامه بدنه | number | **no** | **inquiry** first-party hull policy (`processed-body-policy` / `body-policy/{id}` / stored inquiry `policyId`) |
| ClaimExpertId | کارشناس مسئول پرونده | number | **no** | **staff** FileMaker `CarBodyClaimExpertId` on V4/V5, else tenant |
| AccidentDate | تاریخ حادثه | string Jalali | **no** | **user** first-party `statement.accidentDate`, else createdAt |
| AccidentTime | ساعت حادثه | string `HH:mm` | **no** | **user** `statement.accidentTime` if set |
| AnnouncementDate | تاریخ اعلام | string Jalali | **no** | **derived** = AccidentDate |
| AccidentLocationAddress | نشانی محل حادثه | string | **no** | **system** `"استان تهران شهر تهران"` |
| AccidentCauseId | علت حادثه | number | **no** | **system** `6` · lookup `accident-causes` |
| AccidentCityId | شهر حادثه | number | **no** | **system** `701` · lookup `cities` |
| AccidentReportTypeId | نحوه اعلام | number | **no** | **system** `155` · lookup `accident-report-type` |
| AccidentTypeId | نوع حادثه بدنه | number | **no** | **system** `2` تصادف(حادثه) · **lookup** `GET /lookups/vehicle-hull-accident-types` |
| CulpritTypeId | نوع مقصر بدنه | number | **no** | **system** `300` (ثالث `337` remapped) · **lookup** `GET /lookups/vehicle-hull-accident-culprit-type` |
| AuthorityCulpritId | مرجع تعیین مقصر | number | **no** | **lookup** `GET /lookups/detection-accident-culprits` (caption بیمه‌گذار, fallback `1`) |
| AccidentCulpritId | شناسه مقصر فناوران | number | preferred | **inquiry** parties `inquiry-by-unique-identifier`, fallback hull policy `CustomerId` |
| CustomerFaultPercent | درصد تقصیر بیمه‌گذار | number | **no** | **system** `100` |
| CostSeparationToDmgSections | تفکیک هزینه به نقاط | number (ans) | **no** | **system** `0` خیر · lookup `ans` |
| IsOwnerChanged | مالک تغییر یافته؟ | number (ans) | **no** | **system** `0` · lookup `ans` |
| IsLicenseReplaced | گواهینامه تعویضی است؟ | number | **must be null** | never copy ثالث `IsLicenseReplacement` |
| ActualPremium | حق بیمه واقعی | number | preferred | **inquiry** hull policy `TotalPremium` via `GET /lookups/body-policy/{PolicyId}` |
| EstimateAmount | برآورد | number | **no** | **user** expert prices / provisional |
| CulpritLicenceNo | شماره گواهینامه | string | **no** | dummy / **user** |
| CulpritLicenceIssuDate | تاریخ صدور گواهینامه | string Jalali | **no** | **system** `"1394/10/13"` |
| CulpritLicenceTypeId | نوع گواهینامه | number | sent | tenant `2` · lookup `driving-licence-types` |
| AccidentVehicleUsedId | کاربری خودرو | number | **no** | **inquiry** VIN/policy, else tenant |
| PoliceOfficerId | افسر | number | sent | **system** `1` |
| IsAccidentOutOfBorder | خارج از مرز | number | sent | **system** `0` |
| ArchiveNo | بایگانی | string | yes | null |
| EntryDate | تاریخ ورود | string | yes | null |
| CulpritLicenceForeignCityName | شهر خارجی گواهینامه | string | yes | null |
| PoliceReportSeri / Serial / Desc | کروکی | string | yes | null until **user** |
| CulpritLevelTwoLicenceIssuDate | صدور گواهینامه پایه ۲ | string | yes | null |
| TrackingCode | کد پیگیری | string | yes | null |
| CourtArchiveNo | بایگانی دادگاه | string | yes | null |
| PlaqueReplacementDate | تاریخ تعویض پلاک | string | yes | null |
| StatusChangeDate | تغییر وضعیت | string | yes | null |
| ClaimCompletionDate | تکمیل پرونده | string | yes | null |
| DmgAssessorFirstCreationTime | زمان ارزیاب | string | yes | null |
| IsSurplusArticleEighthLaw | مازاد ماده ۸ | number | yes | null |
| GlassBreakReasonId | علت شکست شیشه | number | yes | null |
| CulpritLicenceCountryId / CityId | محل صدور گواهینامه | number | yes | null |
Also persisted locally (not a GEN.03 field): `claimCase.vehicle.carPrice` from hull policy **`VehicleValue`**.
**Inquiries at this stage**
- Hull policy: `GET /lookups/body-policy/{PolicyId}` → `TotalPremium`, `CustomerId`, `VehicleValue`, `VehicleId`
- Parties: `GET /lookups/inquiry-by-unique-identifier`
- Optional VIN / vehicle used-id
---
## Stage 2 — Damage case
**Skipped.** YARA returns `fanavaranSync.damageCase.status = skipped`. Do not POST `dmg-cases`.
---
## Stage 3 — Attachments (GEN.05)
Multipart `Param` + `Param1`. Proven `FileTypeId=63`.
| Field | Persian | Type | Null? | Source |
|-------|---------|------|-------|--------|
| FileName | نام فایل | string | **no** | **user** (exact match to binary) |
| FileTypeId | نوع مدرک | number | **no** | **lookup** `file-types` / tenant `ClaimFileTypeId` |
| ClaimId | کد رایانه پرونده بدنه | number | **no** | stage 1 `Id` |
---
## Stage 4 — Expertise (GEN.06)
Not ثالث GEN.08: no `DmgCaseId`, `InspectionPlaceId`, `RepairWage` on the root. Uses `Wage` + vehicle identity.
`ClaimExpertId` = FileReviewer `CarBodyExpertiseClaim` on V4/V5, else tenant `ExpertiseClaimExpertId`.
| Field | Persian | Type | Null? | Current source |
|-------|---------|------|-------|----------------|
| ClaimExpertId | کارشناس ارزیاب بدنه | number | **no** | **staff** / tenant |
| DmgAssessmentDate | تاریخ بازدید | string Jalali | **no** | **derived** expert `submittedAt` |
| InspectionTime | ساعت بازدید | string | **no** | **derived** |
| MotorNo | شماره موتور | string | preferred | **inquiry** hull vehicle / party mapped `MtrNum` |
| ChassisNo | شماره شاسی | string | preferred | **inquiry** `ShsNum` |
| BuiltYear | سال ساخت | number | preferred | **inquiry** `BuiltYear` / `PrdDte` |
| VIN | VIN | string | preferred | **inquiry** |
| PlaqueNo | پلاک رشته‌ای | string | preferred | **inquiry** plk1+letter+plk3 |
| PlaqueSerial | سریال پلاک (ایران) | string | preferred | **inquiry** |
| ColorId | رنگ | number | preferred | **inquiry** `GET` vehicle by policy `VehicleId` |
| Wage | اجرت کل | number | **no** | **user** sum `salary` |
| ComponentReplacementCost | قطعه کل | number | **no** | **user** sum `price` |
| WasteValue | داغی کل | number | **no** | **user** daghi |
| RepairDuration | مدت تعمیر (روز) | number | **no** | **system** `3` |
| VehicleCurrentValue | ارزش روز خودرو | number | preferred | **user** `vehicle.carPrice` / `vehicle.price`, else **inquiry** policy `VehicleValue` |
| DropAmount | افت قیمت | number | **no** | **user** price-drop total or `0` |
| CarryAndRescueCost | حمل و نجات | number | yes | null |
| WreckHighestValue | بالاترین ارزش لاشه | number | yes | null |
| InspectionDeduction | کسر بازدید | number | yes | null |
| DmgAndWasteDesc | شرح خسارت و داغی | string | yes | null |
| WentDistanceByExpert | مسافت | number | yes | null |
| IsDestruction | اسقاط؟ | number | yes | null |
| PlaqueDesignId | طرح پلاک | number | yes | null |
| PlaqueCityId | شهر پلاک | number | yes | null |
| AccidentPercent | درصد حادثه | number | yes | null |
| TheftCases | موارد سرقت | array | empty `[]` | system |
| DmgSections[].Count | تعداد | number | **no** | **system** `1` |
| DmgSections[].Desc | شرح قطعه | string | **no** | **user** part label |
| DmgSections[].WasteValue | داغی ردیف | number | **no** | **user** |
| DmgSections[].AccessoryKindId | نوع قطعه کاتالوگ | number | preferred | **user** `partId` · lookup `car-components` |
| DmgSections[].DmgKindId | نوع آسیب بدنه | number | **no** | **lookup** `vehicle-hull-dmg-kind` caption تخریب, fallback `5485` |
| DmgSections[].VehicleHullAccessoryId | شناسه لوازم بدنه | number | **no** | **system** `3043330` (فابریک) for **every** row |
| DmgSections[].DmgSectionCosts[].Caption | عنوان هزینه | string | **no** | **user** label |
| DmgSections[].DmgSectionCosts[].Amount | مبلغ (قطعه+اجرت) | number | **no** | **user** |
| DmgSections[].DmgSectionCosts[].DmgCostKindId | نوع هزینه | number | **no** | **lookup** `vehicle-hull-dmg-cost-kinds` caption مجموع لوازم, fallback `1` |
**Lookups used:** `vehicle-hull-dmg-kind`, `vehicle-hull-dmg-cost-kinds`, `detection-accident-culprits`, `vehicle-hull-accident-types`, `vehicle-hull-accident-culprit-type`, `ans`, optional `vehicle-hull-policies/{id}/dmg-accessories` (no longer used for AccessoryId).
**Inquiries:** `body-policy/{PolicyId}`, `vehicleById` (color + identity), parties inquiry, VIN/plate inquiry on blame.
---
## YARA preview / submit
Same routes as ثالث (`/base-claim`, `/attachments`, `/expertise`).
`/damage-case` preview/submit **skips** for `CAR_BODY`.

View File

@@ -0,0 +1,201 @@
# Fanavaran intake design — user vs inquiry (proposal)
This file is a **proposed product design**, not a description of every current default. Use it to decide which YARA screens should collect data and which calls should stay automatic.
Two intake channels only:
1. **Inquiries & lookups** (Fanavaran / ESG / SandHub — no extra typing when they succeed)
2. **User input** (owner, expert, file-maker, file-reviewer)
Persian titles match Fanavaran field captions where known. Types are what we send on the wire.
Related: [third-party-stage-fields.md](./third-party-stage-fields.md), [car-body-stage-fields.md](./car-body-stage-fields.md).
---
## Shared identity (both products)
Collect once on the blame file. Inquiries reuse these keys.
| Persian | Field / storage | Type | Channel | Why |
|---------|-----------------|------|---------|-----|
| کد ملی بیمه‌گذار | `nationalCodeOfInsurer` | string(10) | **user** | Policy inquiry key |
| تاریخ تولد (جلالی) | `birthday` | string `yyyy/mm/dd` or `yyyyMMdd` | **user** | Parties inquiry + GEN.44 |
| کد ملی راننده | `nationalCodeOfDriver` | string(10) | **user** if `driverIsInsurer=false` | Parties inquiry |
| راننده همان بیمه‌گذار است؟ | `driverIsInsurer` | boolean | **user** | Chooses which national code / GEN.44 |
| شماره موبایل | phone | string | **user** | OTP / SMS, not Fanavaran |
| VIN / شماره شاسی | vin, chassis | string | **inquiry first**, **user** if miss | VIN inquiry, vehicle GET |
| شماره موتور | motorNo | string | **inquiry**, else **user** | GEN.06 / GEN.12 |
| پلاک (۲ رقم، حرف، ۳ رقم، ایران) | plate parts | string/number | **user** (photo/OCR later) then **inquiry** confirm | Plaque + policy match |
| سال ساخت | builtYear | number | **inquiry**, else **user** | GEN.12 / GEN.06 |
| نوع بدنه خودرو (سواری/شاسی‌بلند/…) | `vehicle.carType` | enum string | **user** | Fallback `VehicleKindId` lookup |
**Auto after identity**
| Persian | Fanavaran field | Type | Call |
|---------|-----------------|------|------|
| شناسه بیمه‌نامه ثالث | `PolicyId` (ثالث) | number | `my-policies` / `processed-third-party-policy` |
| شناسه بیمه‌نامه بدنه | `PolicyId` (بدنه) | number | `processed-body-policy` / `body-policy/{id}` |
| شناسه شخص فناوران | `DriverId` / `AccidentCulpritId` | number | `inquiry-by-unique-identifier` → else GEN.44 |
| شناسه مشتری بیمه‌نامه | `CustomerId` | number | hull policy GET |
| حق بیمه | `ActualPremium` | number | hull `TotalPremium` |
| ارزش روز خودرو (بیمه‌نامه) | `VehicleCurrentValue` seed | number | hull `VehicleValue` → `vehicle.carPrice` |
| رنگ | `ColorId` | number | vehicle GET |
| نوع کاربری | `AccidentVehicleUsedId` | number | vehicle `UsedId` + `vehicle-use-types` |
| نوع خودرو فناوران | `VehicleKindId` | number | VIN `VehicleKindId` or `vehicle-kinds` |
| مشخصات پلاک فناوران | PlaqueKindId / SampleId / … | number/string | VIN / vehicle GET |
---
## Accident facts (both) — mostly user
| Persian | Field | Type | Channel |
|---------|-------|------|---------|
| تاریخ حادثه | AccidentDate | Jalali date | **user** |
| ساعت حادثه | AccidentTime | `HH:mm` | **user** |
| نشانی محل حادثه | AccidentLocationAddress | string | **user** (today hardcoded تهران) |
| شهر محل حادثه | AccidentCityId | number | **user** pick from `cities` (today `701`) |
| علت حادثه | AccidentCauseId | number | **user** pick from `accident-causes` (today `6`) |
| نحوه اعلام حادثه | AccidentReportTypeId | number | **user** or default from `accident-report-type` |
| کروکی — سری / سریال / شرح | PoliceReport* | string | **user** optional; null ok |
| خارج از مرز؟ | IsAccidentOutOfBorder | ans number | **user** default خیر |
---
## Third-party only — proposed screens
### File create (مقصر + زیان‌دیده)
| Persian | Field | Type | Channel |
|---------|-------|------|---------|
| نقش طرفین | FIRST / SECOND | enum | **user** |
| مقصر کیست | guiltyPartyId | id | **user** expert decision |
| نوع مقصر | CulpritTypeId | number | **user** or default `accident-culprit-type` |
| شماره گواهینامه مقصر | CulpritLicenceNo | string | **user** (stop dummy when captured) |
| نوع گواهینامه مقصر | CulpritLicenceTypeId | number | **user** from `driving-licence-types` |
| تاریخ صدور گواهینامه | CulpritLicenceIssuDate | Jalali date | **user** |
| گواهینامه تعویضی؟ | IsLicenseReplacement | ans | **user** default خیر |
| قطعات آسیب‌دیده زیان‌دیده | selectedParts | catalog ids | **user** · `car-components` |
| نوع خودرو زیان‌دیده | carType | enum | **user** |
### Expert pricing (ثالث)
| Persian | Field | Type | Channel |
|---------|-------|------|---------|
| قیمت قطعه | parts[].price | money | **user** |
| اجرت | parts[].salary | money | **user** |
| داغی | parts[].daghi | money/object | **user** |
| شدت آسیب | AccidentLevel / typeOfDamage | number | **user** · `accident-level` |
| افت قیمت — دارد/ندارد + مبلغ | DropAmount* | number | **user** · `drop-amount-status` |
| محل بازدید | InspectionPlaceId | number | **user** or branch default · `inspection-place` |
| قیمت روز خودرو (اختیاری ثالث) | DamagedVehicleCurrentPrice | money | **user** |
### Staff (V4/V5)
| Persian | Field | Type | Channel |
|---------|-------|------|---------|
| کد کارشناس پرونده ثالث | FileMaker.ThirdPartyClaimExpertId | string/number | **staff admin** |
| کد ارزیاب ثالث | FileReviewer.ThirdPartyExpertiseClaim | string/number | **staff admin** |
Do not ask the owner for these.
---
## Car-body only — proposed screens
Hull is **first party**. One vehicle, one policy.
### File create
| Persian | Field | Type | Channel |
|---------|-------|------|---------|
| نوع حادثه بدنه | AccidentTypeId | number | **user** from `vehicle-hull-accident-types` (default تصادف = 2) |
| نوع مقصر بدنه | CulpritTypeId | number | **user** from `vehicle-hull-accident-culprit-type` (default بیمه‌گذار = 300) |
| مرجع تعیین مقصر | AuthorityCulpritId | number | **user** from `detection-accident-culprits` (default بیمه‌گذار) |
| درصد تقصیر بیمه‌گذار | CustomerFaultPercent | number 0–100 | **user** if not 100%; today always 100 |
| تفکیک هزینه به نقاط | CostSeparationToDmgSections | ans | **user** default خیر |
| مالک تغییر یافته؟ | IsOwnerChanged | ans | **user** default خیر |
| گواهینامه تعویضی؟ | IsLicenseReplaced | ans or empty | **must stay empty/null** unless Fanavaran later requires a value |
| قطعات آسیب‌دیده | selectedParts | catalog ids | **user** |
### Expert pricing (بدنه)
| Persian | Field | Type | Channel |
|---------|-------|------|---------|
| قیمت قطعه / اجرت / داغی | price, salary, daghi | money | **user** |
| قیمت روز خودرو | vehicle.carPrice | money | **user** required; seed from policy `VehicleValue` |
| افت قیمت | DropAmount / priceDrop.total | money | **user** |
| مدت تعمیر (روز) | RepairDuration | number | **system** `3` unless product wants **user** |
| نوع آسیب ردیف | DmgKindId | number | default تخریب; optional **user** · `vehicle-hull-dmg-kind` |
| نوع هزینه ردیف | DmgCostKindId | number | default مجموع لوازم · `vehicle-hull-dmg-cost-kinds` |
| لوازم فابریک | VehicleHullAccessoryId | number | **system** `3043330` (do not ask user) |
### Staff (V4/V5)
| Persian | Field | Type | Channel |
|---------|-------|------|---------|
| کد کارشناس پرونده بدنه | FileMaker.CarBodyClaimExpertId | string/number | **staff admin** |
| کد ارزیاب بدنه | FileReviewer.CarBodyExpertiseClaim | string/number | **staff admin** |
If the matching code is missing, YARA must not create/review that product line.
---
## Documents (both) — user files
| Persian | Typical key | Channel |
|---------|-------------|---------|
| کارت ملی / گواهینامه / کارت خودرو / پلاک فلزی | requiredDocuments | **user** photos |
| عکس زاویه خودرو / قطعه | damage + car-angle captures | **user** |
| فاکتور تعمیر | factor images | **user** when `factorNeeded` |
`FileTypeId` is **lookup** (`file-types`), not typed by the owner.
---
## Lookups catalogue (for UI dropdowns)
Expose these as select lists; store the numeric `Id`.
| Persian UI | Lookup route | Used on |
|------------|--------------|---------|
| علت حادثه | `/lookups/accident-causes` | GEN.03 |
| نوع اعلام حادثه | `/lookups/accident-report-type` | GEN.03 |
| شهر | `/lookups/cities` | GEN.03 city |
| بله/خیر | `/lookups/ans` | hull flags |
| نوع مقصر ثالث | `/lookups/accident-culprit-type` | ثالث GEN.03 |
| نوع مقصر بدنه | `/lookups/vehicle-hull-accident-culprit-type` | بدنه GEN.03 |
| نوع حادثه بدنه | `/lookups/vehicle-hull-accident-types` | بدنه GEN.03 |
| مرجع تعیین مقصر | `/lookups/detection-accident-culprits` | بدنه GEN.03 |
| نوع گواهینامه | `/lookups/driving-licence-types` | GEN.03 / GEN.12 |
| کاربری خودرو | `/lookups/vehicle-use-types` | GEN.03 / GEN.12 |
| نوع خودرو | `/lookups/vehicle-kinds` | GEN.12 |
| قطعات | `/lookups/car-components` | parts / DmgSectionId |
| شدت آسیب | `/lookups/accident-level` | ثالث expertise |
| محل بازدید | `/lookups/inspection-place` | ثالث expertise |
| وضعیت افت | `/lookups/drop-amount-status` | ثالث expertise |
| نوع آسیب بدنه | `/lookups/vehicle-hull-dmg-kind` | بدنه expertise |
| نوع هزینه بدنه | `/lookups/vehicle-hull-dmg-cost-kinds` | بدنه expertise |
| نوع فایل | `/lookups/file-types` | attachments |
| جنسیت / تأهل / تابعیت / نوع شخص | gender, marital-status, countries, person-kind, ans | GEN.44 only if inquiry misses |
Inquiry helpers (not dropdowns): `inquiry-by-vin`, `inquiry-by-unique-identifier`, `my-policies`, `processed-third-party-policy`, `processed-body-policy`, `third-party-policy/:id`, `body-policy/:id`.
---
## Suggested flow (what to collect, in order)
```text
1. User: phone + national code + birthday + plate (and VIN if known)
2. Inquiry: policy + vehicle + person Id
3. If person missing → GEN.44 using user national code + birthday (submit time)
4. User: accident date/time/address + (بدنه) accident type / culprit authority
5. User: damaged parts
6. Expert: prices, daghi, carPrice (بدنه)
7. System: ClaimExpertId / expertise ClaimExpertId from staff profiles
8. System: POST stages in order (ثالث 1→2→3→4, بدنه 1→3→4)
```
**Do not ask the user for:** Fanavaran `PolicyId`, `DriverId`, `CustomerId`, `ColorId`, `VehicleKindId`, `VehicleHullAccessoryId` (فابریک), `ActualPremium`, `FileTypeId`, expert numeric ids.
**Stop using dummies when the screen exists:** `CulpritLicenceNo`, `CulpritLicenceIssuDate`, `AccidentLocationAddress`, `AccidentCityId`, `AccidentCauseId`. Until then, current defaults keep Fanavaran from rejecting the create.

View File

@@ -0,0 +1,199 @@
# Third-party (ثالث) — Fanavaran stage bodies (current YARA)
Snapshot of **what `ClaimRequestManagementService` actually sends** for `THIRD_PARTY` as of this version.
- Base: `POST /car/third-party-car-financial-claims` (GEN.03)
- Damage case: `POST …/{claimId}/dmg-cases` (GEN.12)
- Attachments: `POST …/{claimId}/files` (GEN.07, multipart)
- Expertise: `POST …/{claimId}/expertise` (GEN.08)
**Send rule:** keep keys in the JSON even when the value is `null`. Fanavaran treats omitted keys differently from explicit `null`.
**Source columns**
| Tag | Meaning |
|-----|---------|
| **system** | Hardcoded YARA default / tenant `fanavaranClientConfigs` |
| **lookup** | Fanavaran codebook (`GET /lookups/…`) |
| **inquiry** | Live Fanavaran inquiry (policies, VIN, parties, vehicle GET) |
| **user** | Collected in YARA UI (owner, expert, file-maker) |
| **staff** | FileMaker `ThirdPartyClaimExpertId` / FileReviewer `ThirdPartyExpertiseClaim` on V4/V5; else tenant defaults |
| **derived** | Computed from other fields (dates, sums) |
Parsian tenant defaults used below: `ClaimExpertId=154`, `ExpertiseClaimExpertId=29`, `ClaimFileTypeId=63`, `AccidentCityId=701`, `AccidentReportTypeId=155`, `CulpritTypeId=337`, `CulpritLicenceTypeId=2`, `CompensationReferenceId=167`.
---
## Stage 1 — Base claim (GEN.03)
**Must send (non-null):** `PolicyId`, `ClaimExpertId`, `AccidentDate`, `AnnouncementDate`, `AccidentTime`, `AccidentCauseId`, `AccidentCityId`, `AccidentReportTypeId`, `CulpritTypeId`, `CulpritLicenceTypeId`, `CulpritLicenceNo`, `CulpritLicenceIssuDate`, `DamagedCount`, `EstimateAmount` (> 0), `AccidentLocationAddress`, `HasOtherCulprit`, `IsFatalAccident`, `IsAccidentOutOfBorder`, `IsPlaqueChanged`, `IsLicenseMatchWithVehicleKind`, `IsLicenseReplacement`, `PoliceOfficerId`, `AccidentVehicleUsedId`.
**May be null** (and currently are, unless noted): everything else in the table.
| Field | Persian | Type | Null? | Current source | Notes |
|-------|---------|------|-------|----------------|-------|
| PolicyId | شناسه بیمه‌نامه مقصر | number | **no** | **inquiry** `GET /lookups/my-policies` (guilty national code, line ثالث) | Cached on `fanavaranSync.baseClaim.policyId` |
| ClaimExpertId | کارشناس مسئول پرونده | number | **no** | **staff** FileMaker `ThirdPartyClaimExpertId` on V4/V5, else tenant | GEN.03 role, not expertise |
| AccidentDate | تاریخ حادثه | string (Jalali `yyyy/mm/dd`) | **no** | **user** accident datetime, else blame `createdAt` | |
| AnnouncementDate | تاریخ اعلام | string Jalali | **no** | **derived** = AccidentDate | |
| DocReceivedDate | تاریخ وصول مدارک | string Jalali | **no** | **derived** = AccidentDate | Third-party only (not on hull whitelist) |
| AccidentTime | ساعت حادثه | string `HH:mm` | **no** | **user** / **derived** from createdAt | |
| AccidentLocationAddress | نشانی محل حادثه | string | **no** | **system** `"استان تهران شهر تهران"` | Should later be **user** |
| AccidentCauseId | علت حادثه | number | **no** | **system** `6` | Lookup: `GET /lookups/accident-causes`. Expert `accidentReason.fanavaran` exists but is not yet wired as the sent id |
| AccidentCityId | شهر محل حادثه | number | **no** | **system** `701` تهران | Lookup: `GET /lookups/cities` |
| AccidentReportTypeId | نحوه اعلام حادثه | number | **no** | **system** `155` | Lookup: `GET /lookups/accident-report-type` |
| AccidentVehicleUsedId | نوع کاربری خودرو | number | **no** | **inquiry** VIN / policy vehicle `UsedId`, else tenant `1` | Lookup: `GET /lookups/vehicle-use-types` |
| CulpritTypeId | نوع مقصر | number | **no** | **system** `337` | Lookup: `GET /lookups/accident-culprit-type` (ثالث, not hull) |
| CulpritLicenceTypeId | نوع گواهینامه مقصر | number | **no** | **system** `2` | Lookup: `GET /lookups/driving-licence-types` |
| CulpritLicenceNo | شماره گواهینامه مقصر | string | **no** | **user** party licence if stored, else dummy `9705463515` | Must never be empty |
| CulpritLicenceIssuDate | تاریخ صدور گواهینامه | string Jalali | **no** | **system** `"1394/10/13"` | Should later be **user** |
| EstimateAmount | مبلغ برآورد | number | **no** | **user** expert part prices, else provisional `1000` | Must be > 0 |
| CompensationReferenceId | مرجع جبران | number | **no** | **system** `167` | Tenant default |
| DamagedCount | تعداد زیان‌دیدگان | number | **no** | **system** `1` | |
| HasOtherCulprit | مقصر دیگر دارد؟ | number (ans) | **no** | **system** `0` | Lookup `GET /lookups/ans` |
| IsFatalAccident | فوت دارد؟ | number | **no** | **system** `0` | |
| IsAccidentOutOfBorder | خارج از مرز؟ | number | **no** | **system** `0` | |
| IsPlaqueChanged | پلاک تعویضی؟ | number | **no** | **system** `0` | |
| IsLicenseMatchWithVehicleKind | گواهینامه متناسب؟ | number | **no** | **system** `1` | |
| IsLicenseReplacement | گواهینامه تعویضی؟ | number | **no** | **system** `0` | Third-party uses this name (hull uses `IsLicenseReplaced` and must be null) |
| PoliceOfficerId | افسر پلیس | number | **no** | **system** `1` | |
| ActualPremium | حق بیمه واقعی | number | yes | unused on ثالث create | |
| ArchiveNo | شماره بایگانی | string | yes | — | |
| AuthorityCulpritId | مرجع تعیین مقصر | number | yes | **null** on ثالث | Hull requires this |
| AccidentCulpritId | شناسه مقصر در فناوران | number | yes | **null** on ثالث | Hull fills from parties inquiry |
| ClaimCompletionDate | تاریخ تکمیل | string | yes | — | |
| CostSeparationToDmgSections | تفکیک هزینه به نقاط | number | yes | **null** on ثالث | Hull sends `0` |
| CouponNo | شماره کوپن | string | yes | — | |
| CourtArchiveNo | شماره بایگانی دادگاه | string | yes | — | |
| CustomerFaultPercent | درصد تقصیر بیمه‌گذار | number | yes | **null** on ثالث | Hull sends `100` |
| CulpritLicenceCityId | شهر صدور گواهینامه | number | yes | Lookup `cities` | |
| CulpritLicenceCountryId | کشور صدور گواهینامه | number | yes | Lookup `countries` | |
| CulpritLicenceForeignCityName | شهر خارجی صدور | string | yes | — | |
| DmgAssessorFirstCreationTime | زمان ایجاد ارزیاب | string | yes | — | |
| EntryDate | تاریخ ورود | string | yes | — | |
| GlassBreakReasonId | علت شکست شیشه | number | yes | — | |
| IsOwnerChanged | مالک تغییر کرده؟ | number | yes | **null** | Hull sends `0` |
| IsSurplusArticleEighthLaw | مازاد ماده ۸ | number | yes | — | |
| PlaqueCityId … PlaqueSerial | پلاک (بلوک پایه) | mixed | yes | **null** on GEN.03 ثالث | Plaque belongs on GEN.12 |
| PoliceReportDesc / Seri / Serial | مشخصات کروکی | string | yes | **user** later | |
| PreviousPolicyEndDate | پایان بیمه‌نامه قبلی | string | empty `""` | **system** | |
| StatusChangeDate | تاریخ تغییر وضعیت | string | yes | — | |
| TrackingCode | کد پیگیری | string | yes | — | |
| UnknownCulpritCauseId | علت مقصر ناشناس | number | yes | — | |
**Inquiries used at this stage**
- `GET /lookups/my-policies?nationalCode=&insuranceLineId=` (ثالث)
- Optional VIN → `AccidentVehicleUsedId`
**User input already needed (even if some are still defaulted)**
- Guilty party national code (to find PolicyId)
- Accident date/time
- Expert priced parts (EstimateAmount) when available
---
## Stage 2 — Damage case (GEN.12)
Nested under Fanavaran `claimId` (`Id` from stage 1, never `ClaimNo`).
**Must send (non-null in proven Parsian):** `DriverId`, `VehicleKindId`, `AccidentVehicleUsedId`, `DmgCaseTypeId`, `DmgHistoryStatus`, `FaultPercent`, `InsuranceCorpId`, `LicenceNo`, `LicenceTypeId`, `LicenceIssuDate`, `EstimateAmount`, `Desc`, plaque block (`PlaqueKindId`, `PlaqueSampleId`, left/middle/right/serial), `BeginDate`/`EndDate` when inquiry has them.
If `DriverId` is missing, **submit** (not preview) registers GEN.44 `POST /common/other-people` and stores `otherPersonId`.
| Field | Persian | Type | Null? | Current source |
|-------|---------|------|-------|----------------|
| DriverId | شناسه راننده زیان‌دیده در فناوران | number | **no** on submit | **inquiry** `GET /lookups/inquiry-by-unique-identifier` (national code + Jalali birthday). Fallback **GEN.44** create |
| OwnerId | شناسه مالک | number | yes | currently `null` |
| VehicleKindId | نوع خودرو | number | **no** | **inquiry** VIN vehicle `VehicleKindId`, else **lookup** `GET /lookups/vehicle-kinds` matched to `claim.vehicle.carType` |
| AccidentVehicleUsedId | کاربری | number | **no** | **inquiry** vehicle `UsedId` / use-types, else tenant |
| VIN | شماره شاسی ۱۷ رقمی | string | preferred | **inquiry** plate/VIN inquiry |
| ChassisNo | شماره شاسی | string | preferred | **inquiry** |
| MotorNo | شماره موتور | string | preferred | **inquiry** |
| BuiltYear | سال ساخت | number | preferred | **inquiry** VIN / mapped `PrdDte` |
| PlaqueKindId | نوع پلاک | number | **no** | **inquiry** vehicle GET, else national-plate default |
| PlaqueSampleId | نمونه پلاک | number | **no** | same |
| PlaqueLeftNo | دو رقم سمت چپ | string | **no** | **user** plate / **inquiry** |
| PlaqueMiddleCodeId | حرف پلاک (کد فناوران) | number | **no** | **derived** from plate letter |
| PlaqueRightNo | سه رقم | string | **no** | **user** / **inquiry** |
| PlaqueSerial | ایران | string | **no** | **user** / **inquiry** |
| PlaqueNo | پلاک رشته‌ای | string | preferred | **derived** |
| PlaqueCityId | شهر پلاک | number | yes | **inquiry** vehicle |
| PolicyNo | شماره چاپی بیمه‌نامه زیان‌دیده | string | preferred | **inquiry** ESG/Tejarat mapped |
| PolicyCINumber | کد یکتای بیمه‌نامه | string | preferred | **inquiry** `PlcyUnqCod` |
| BeginDate | شروع پوشش | string Jalali | preferred | **inquiry** |
| EndDate | پایان پوشش | string Jalali | preferred | **inquiry** |
| PreviousPolicyEndDate | پایان بیمه‌نامه قبلی | string | empty ok | **derived** from EndDate |
| InsuranceCorpId | شرکت بیمه زیان‌دیده | number | **no** | **lookup** insurance-corp for tenant |
| DmgCaseTypeId | نوع مورد خسارت | number | **no** | **system** `175` · lookup `dmg-case-type` |
| DmgHistoryStatus | وضعیت سابقه خسارت | number | **no** | **system** `5214` · lookup `dmg-history-status` |
| FaultPercent | درصد تقصیر | number | **no** | **system** `100` |
| DriverIsOwner | راننده همان مالک؟ | number | **no** | **user** `driverIsInsurer` → 1/0 |
| LicenceNo | شماره گواهینامه زیان‌دیده | string | **no** | **user**, else dummy |
| LicenceTypeId | نوع گواهینامه | number | **no** | **system** same as CulpritLicenceTypeId |
| LicenceIssuDate | تاریخ صدور | string Jalali | **no** | **user** birthday used as stand-in, else dummy date |
| LicenceCityId / CountryId / ForeignCityName | محل صدور گواهینامه | mixed | yes | currently null |
| EstimateAmount | برآورد مورد خسارت | number | **no** | **system** provisional constant |
| Desc | شرح قطعات آسیب‌دیده | string | **no** | **user** selected outer parts labels joined with `/` |
| DmgCaseId | (response) | number | — | store as local `dmgCaseId` |
**Lookups:** `vehicle-kinds`, `vehicle-use-types`, `dmg-case-type`, `dmg-history-status`, `insurance-corp`, `file-types` (later), `driving-licence-types`, `ans`.
**Inquiries:** parties by national code + birthday; VIN inquiry; optional vehicle GET; plate inquiry on blame.
---
## Stage 3 — Attachments (GEN.07)
Multipart: field `Param` = JSON, field `Param1` = file bytes. One file per request. Parsian `FileTypeId=63` (سایر مدارک خسارت).
| Field | Persian | Type | Null? | Current source |
|-------|---------|------|-------|----------------|
| FileName | نام فایل | string | **no** | **user** upload name (must match binary name) |
| FileTypeId | نوع مدرک | number | **no** | **lookup** `GET /lookups/file-types` via tenant `ClaimFileTypeId` |
| Files[].FileName / FileTypeId | تکرار متادیتا | object | **no** | same |
| ClaimId | (hull only on Param) | number | hull yes | third-party URL already has claimId |
Files themselves are **user** (documents, damage photos, factors).
---
## Stage 4 — Expertise (GEN.08)
Requires local `dmgCaseId`. `ClaimExpertId` here is the **assessor** role (Parsian `29`), from FileReviewer `ThirdPartyExpertiseClaim` on V4/V5.
| Field | Persian | Type | Null? | Current source |
|-------|---------|------|-------|----------------|
| DmgCaseId | شناسه مورد خسارت | number | **no** | stage 2 response |
| ClaimExpertId | کارشناس ارزیاب | number | **no** | **staff** / tenant `ExpertiseClaimExpertId` |
| DmgAssessmentDate | تاریخ بازدید | string Jalali | **no** | **derived** expert reply `submittedAt` |
| InspectionTime | ساعت بازدید | string `HH:mm` | **no** | **derived** |
| InspectionPlaceId | محل بازدید | number | **no** | **system** `282` · lookup `GET /lookups/inspection-place` |
| RepairWage | اجرت کل | number | **no** | **user** sum of part `salary` |
| ComponentReplacementCost | قطعه کل | number | **no** | **user** sum of part `price` |
| WasteValue | داغی کل | number | **no** | **user** daghi prices |
| DropAmountStatus | وضعیت افت | number | **no** | **system** `5458` · lookup `drop-amount-status` |
| DropAmountAdditionsDeductions | مبلغ افت | number | **no** | **user** `evaluation.priceDrop.total` or `0` |
| ComponentReplacementTaxAndToll | مالیات قطعه | number | **no** | **system** `0` |
| DamagedVehicleCurrentPrice | قیمت روز خودرو | number | yes | **user** price-drop `carPrice` |
| WentDistanceByExpert | مسافت کارشناس | number | yes | currently `null` |
| DmgSections[].DmgSectionId | شناسه نقطه آسیب | number | **no** | **user** catalog `partId` · lookup `car-components` |
| DmgSections[].AccidentLevel | شدت آسیب | number | **no** | **user** `typeOfDamage` or default `5456` · lookup `accident-level` |
| DmgSections[].Desc | شرح | string | **no** | **user** |
| DmgSections[].ComponentReplacementCost | قیمت قطعه | number | **no** | **user** |
| DmgSections[].RepairWage | اجرت | number | **no** | **user** |
| DmgSections[].WasteValue | داغی | number | **no** | **user** |
---
## YARA preview / submit
| Stage | Preview | Submit |
|-------|---------|--------|
| 1 | `GET /v2/fanavaran/{client}/claim-cases/{id}/base-claim/preview` | `POST …/base-claim/submit` |
| 2 | `GET …/damage-case/preview` | `POST …/damage-case/submit` |
| 3 | `GET …/attachments/preview` | `POST …/attachments/submit` |
| 4 | `GET …/expertise/preview` | `POST …/expertise/submit` |
Nested Fanavaran URLs always use stored `claimId` (کد رایانه), never `claimNo`.

View File

@@ -0,0 +1,233 @@
# راهنمای فرانت‌اند برای ارسال اطلاعات استعلام
این سند قرارداد نهایی فرانت‌اند برای مرحله استعلام است. از این به بعد اطلاعات اشخاص و خودرو باید با ساختار نقش‌محور زیر ارسال شود. فیلدهای تخت قدیمی مانند `nationalCodeOfDriver` و `nationalCodeOfInsurer` دیگر ورودی معتبر نیستند.
## ساختار کلی درخواست
برای پرونده `THIRD_PARTY`:
```json
{
"driver": {
"nationalCode": "0012345678",
"birthday": "1370/01/01",
"hasDrivingLicense": true,
"licenseNumber": "123456789",
"licenseType": "1"
},
"vehicleOwner": { "sameAs": "DRIVER" },
"thirdPartyPolicyholder": { "sameAs": "VEHICLE_OWNER" },
"vehicle": {
"registrationState": "CURRENT",
"currentPlate": {
"leftDigits": "44",
"centerAlphabet": "ب",
"centerDigits": "111",
"ir": "22"
},
"isNewCar": false
},
"sheba": "IR123456789012345678901234"
}
```
برای پرونده `CAR_BODY`، نقش بیمه‌گذار بدنه هم الزامی است:
```json
{
"driver": {
"nationalCode": "0012345678",
"birthday": "1370/01/01",
"hasDrivingLicense": false
},
"vehicleOwner": { "sameAs": "DRIVER" },
"thirdPartyPolicyholder": {
"nationalCode": "0023456789",
"birthday": "1360/02/02"
},
"carBodyPolicyholder": {
"nationalCode": "0034567890",
"birthday": "1350/03/03"
},
"vehicle": {
"currentPlate": {
"leftDigits": "44",
"centerAlphabet": "ب",
"centerDigits": "111",
"ir": "22"
},
"isNewCar": false
},
"sheba": "IR123456789012345678901234"
}
```
`sheba` فقط در routeهایی که قبلاً اطلاعات بانکی را در مرحله استعلام دریافت می‌کردند ارسال می‌شود؛ در V6 مرکز تماس، شماره شبا در این body نیست و بعداً توسط کاربر دریافت می‌شود.
## هر استعلام با اطلاعات کدام نقش انجام می‌شود؟
فرانت‌اند فقط اشخاص را با ساختار نقش‌محور ارسال می‌کند؛ انتخاب کد ملی مناسب برای هر سرویس در بک‌اند انجام می‌شود:
| استعلام | کد ملی مورد استفاده | شناسه خودرو/بانکی |
| --- | --- | --- |
| بیمه شخص ثالث با پلاک یا VIN | `thirdPartyPolicyholder.nationalCode` | پلاک یا `vehicle.vin` |
| بیمه بدنه با پلاک یا VIN | `carBodyPolicyholder.nationalCode` | پلاک یا `vehicle.vin` |
| تطبیق شبا | `vehicleOwner.nationalCode` | `sheba` |
| گواهینامه | `driver.nationalCode` | `driver.licenseNumber` |
بنابراین کد ملی راننده نباید به‌جای بیمه‌گذار یا مالک ارسال یا تکرار شود. اگر چند نقش متعلق به یک نفر است، `sameAs` ارتباط را مشخص می‌کند و بک‌اند همان شخص را برای استعلام مربوط به هر نقش انتخاب می‌کند.
در مرحله بانکی بعدی جریان V6، برای پرونده‌های جدید فقط `sheba` لازم است. بک‌اند کد ملی مالک خودرو را از `participants` و `participantRoles` ذخیره‌شده در پرونده تقصیر می‌خواند. فیلدهای قدیمی `nationalCodeOfInsurer` یا `nationalCodeOfOwner` فقط برای پرونده‌های تاریخی فاقد اطلاعات نقش‌محور fallback هستند؛ اگر همراه پرونده جدید ارسال شوند باید با مالک ذخیره‌شده یکسان باشند.
پنل‌های کارشناسی اطلاعات کامل اشخاص را در `parties[].participants` و نگاشت نقش‌ها را در `parties[].participantRoles` دریافت می‌کنند. اطلاعات راننده حتی اگر در استعلام بیمه یا شبا استفاده نشود در همین ساختار ذخیره و نمایش داده می‌شود.
## نقش‌ها و فیلدهای هر شخص
هر نقش باید یکی از این دو حالت را داشته باشد، نه هر دو را:
1. اطلاعات یک شخص جدید؛ یا
2. ارجاع با `sameAs` به شخصی که قبلاً در همین درخواست معرفی شده است.
| فیلد | کاربرد | وضعیت |
| --- | --- | --- |
| `nationalCode` | کد ملی شخص | برای شخص جدید الزامی |
| `birthday` | تاریخ تولد جلالی | برای شخص جدید الزامی |
| `fullName` | نام نمایشی شخص | اختیاری |
| `sameAs` | اتصال این نقش به نقش دیگر | به‌جای اطلاعات شخص جدید |
| `hasDrivingLicense` | داشتن گواهینامه راننده | برای نقش راننده الزامی |
| `licenseNumber` | شماره گواهینامه | اگر `hasDrivingLicense=true` الزامی |
| `licenseType` | نوع گواهینامه | اگر `hasDrivingLicense=true` الزامی |
مقادیر مجاز `sameAs` عبارت‌اند از:
- `DRIVER`
- `VEHICLE_OWNER`
- `THIRD_PARTY_POLICYHOLDER`
- `CAR_BODY_POLICYHOLDER`
برای `sameAs` هیچ‌کدام از `nationalCode`، `birthday`، `fullName`، اطلاعات گواهینامه یا `phoneNumber` را در همان آبجکت نفرستید.
شماره تلفن بخشی از هویت استعلام نیست و در این DTOها وجود ندارد. احراز هویت پیامکی و شماره تماس طرفین از این مرحله جداست.
## اطلاعات خودرو و پلاک
| فیلد | کاربرد | وضعیت |
| --- | --- | --- |
| `vehicle.registrationState` | وضعیت ثبت رسمی خودرو | `CURRENT` یا `RECENTLY_TRANSFERRED`؛ پیش‌فرض `CURRENT` |
| `vehicle.currentPlate` | پلاک رسمی فعلی و شناسه اصلی خودرو | الزامی |
| `vehicle.previousPlate` | پلاک قبلی در انتقال اخیر | فقط در `RECENTLY_TRANSFERRED` |
| `vehicle.previousPolicyholderNationalCode` | کد ملی بیمه‌گذار مربوط به پلاک قبلی | فقط در `RECENTLY_TRANSFERRED` و الزامی |
| `vehicle.vin` | شماره شاسی/VIN | در انتقال اخیر الزامی؛ در صورت ارسال دقیقاً ۱۷ کاراکتر |
| `vehicle.isNewCar` | نو بودن خودرو | اختیاری |
اجزای پلاک:
```json
{
"leftDigits": "44",
"centerAlphabet": "ب",
"centerDigits": "111",
"ir": "22"
}
```
`leftDigits`، `centerDigits` و `ir` را می‌توان به‌صورت string یا number فرستاد؛ ارسال string پیشنهاد می‌شود تا صفرهای ابتدایی از بین نروند. `centerAlphabet` باید حرف فارسی پلاک باشد.
### انتقال اخیر
```json
{
"vehicle": {
"registrationState": "RECENTLY_TRANSFERRED",
"currentPlate": {
"leftDigits": "44",
"centerAlphabet": "ب",
"centerDigits": "111",
"ir": "22"
},
"previousPlate": {
"leftDigits": "55",
"centerAlphabet": "ج",
"centerDigits": "222",
"ir": "33"
},
"previousPolicyholderNationalCode": "0098765432",
"vin": "NAAM01E15HK123456"
}
}
```
اطلاعات انتقال اخیر فقط به‌عنوان متادیتای پرونده ذخیره می‌شوند. در route پلاک، سیستم فقط `currentPlate` را با کد ملی بیمه‌گذار نهاییِ مرتبط با نوع بیمه استعلام می‌کند و هیچ fallbackای به `previousPlate` یا `previousPolicyholderNationalCode` ندارد. در route شماره شاسی نیز فقط `vehicle.vin` با همان بیمه‌گذار نهایی استعلام می‌شود.
حتی در route مربوط به VIN، آبجکت `vehicle` از قرارداد مشترک استفاده می‌کند و `currentPlate` در قرارداد فعلی الزامی است. مقدار VIN در `vehicle.vin` قرار می‌گیرد، نه در فیلد سطح بالای `vin`.
## ترتیب پیشنهادی نمایش فرم
1. پلاک فعلی و وضعیت انتقال خودرو را بگیرید.
2. اگر انتقال اخیر بود، پلاک قبلی، کد ملی بیمه‌گذار پلاک قبلی و VIN را بگیرید.
3. اطلاعات راننده و وضعیت گواهینامه را بگیرید.
4. بپرسید مالک خودرو همان راننده است یا شخص دیگری؛ در حالت یکسان از `sameAs` استفاده کنید.
5. بیمه‌گذار شخص ثالث را از بین راننده، مالک یا شخص دیگر انتخاب کنید.
6. در `CAR_BODY` همین کار را برای بیمه‌گذار بدنه انجام دهید.
7. خلاصه اطلاعات را به کاربر نشان دهید و سپس درخواست استعلام را ارسال کنید.
## مسیرهای اصلی
بدنه درخواست در همه این مسیرها همین ساختار را دارد:
| جریان | مسیر پلاک | مسیر VIN |
| --- | --- | --- |
| کاربر V2 | `/v2/blame-request-management/initial-form/:requestId` | `/v2/blame-request-management/initial-form-vin/:requestId` |
| کارشناس/پرونده‌ساز V3 تا V5 | `.../run-inquiries/:requestId` | `.../run-inquiries-vin/:requestId` |
| مرکز تماس V6 | `/v6/call-center-blame/run-inquiry/:requestId` | `/v6/call-center-blame/run-inquiry-vin/:requestId` |
در جریان‌های V3 تا V5، فراخوان اول برای طرف مقصر (`FIRST`) و فراخوان دوم، فقط در `THIRD_PARTY`، برای طرف زیان‌دیده (`SECOND`) است. اطلاعات بیمه‌گذار برای هر دو طرف الزامی است.
## تفاوت اطلاعات مقصر و زیان‌دیده
ساختار نقش‌محور اشخاص و خودرو برای هر دو طرف یکسان است، اما ترتیب و قواعد کسب‌وکار آن‌ها تفاوت دارد:
| پرونده و طرف | نقش‌ها و رفتار |
| --- | --- |
| `THIRD_PARTY / FIRST` (مقصر) | راننده، مالک و بیمه‌گذار شخص ثالثِ خودروی مقصر ارسال می‌شوند. شبا در این فراخوان لازم نیست. بیمه‌نامه مقصر باید متعلق به شرکت بیمه همین سامانه باشد. |
| `THIRD_PARTY / SECOND` (زیان‌دیده) | راننده، مالک و بیمه‌گذار شخص ثالثِ خودروی زیان‌دیده ارسال می‌شوند. این مرحله فقط بعد از امضای مقصر و احراز OTP زیان‌دیده اجرا می‌شود. `sheba` الزامی است و با کد ملی `vehicleOwner` اعتبارسنجی می‌شود. بیمه‌گذار زیان‌دیده نیز همیشه باید مشخص باشد. |
| `CAR_BODY / FIRST` (بیمه‌گذار/زیان‌دیده بدنه) | هر چهار نقش راننده، مالک، بیمه‌گذار شخص ثالث و بیمه‌گذار بدنه ارسال می‌شوند. `sheba` در همین فراخوان الزامی است و با کد ملی `vehicleOwner` اعتبارسنجی می‌شود. استعلام بدنه با کد ملی `carBodyPolicyholder` انجام می‌شود. |
در V2 و V6 که شبا در مرحله جداگانه از کاربر دریافت می‌شود، شبا داخل درخواست استعلام مقصر ارسال نمی‌شود؛ بک‌اند هنگام مرحله بانکی آن را با کد ملی مالک ذخیره‌شده تطبیق می‌دهد.
## فیلدهایی که نباید ارسال شوند
این فیلدها دیگر بخشی از قرارداد ورودی نیستند و ارسال آن‌ها باعث خطای اعتبارسنجی می‌شود:
```text
nationalCodeOfDriver
driverBirthday
driverLicense
licenseType // در سطح بالا؛ مقدار صحیح داخل driver است
nationalCodeOfInsurer
insurerBirthday
insurerLicense
driverIsInsurer
userNoCertificate
plate // در سطح بالا؛ مقدار صحیح داخل vehicle.currentPlate است
plateId
vin // در سطح بالا؛ مقدار صحیح داخل vehicle.vin است
isNewCar // در سطح بالا؛ مقدار صحیح داخل vehicle.isNewCar است
phoneNumber // در participantها
unknown // حذف شده؛ بیمه‌گذار همیشه باید مشخص باشد
```
## خطاهای رایج فرانت‌اند
- ارسال `vehicleOwner` به‌صورت خالی؛ باید شخص جدید یا `sameAs` باشد.
- استفاده از `sameAs` همراه با `nationalCode` یا `birthday`.
- ارسال `carBodyPolicyholder` برای `THIRD_PARTY`.
- ارسال `unknown` برای هر نقش؛ این فیلد دیگر پذیرفته نمی‌شود.
- فرستادن `previousPlate` بدون `registrationState=RECENTLY_TRANSFERRED`.
- فرستادن `RECENTLY_TRANSFERRED` بدون `previousPlate`، `previousPolicyholderNationalCode` یا `vin`.
- فرستادن `previousPolicyholderNationalCode` برای خودروی دارای وضعیت `CURRENT`.
- قرار دادن VIN یا پلاک در سطح بالای body.
- ارسال شماره تلفن در آبجکت شخص.
- تکرار کد ملی راننده یا بیمه‌گذار در مرحله شبا؛ تطبیق شبا همیشه با مالک خودرو انجام می‌شود.
مستند مدل دامنه و جزئیات تصمیم معماری در [inquiry-participants-proposal.fa.md](./inquiry-participants-proposal.fa.md) قرار دارد.

View File

@@ -0,0 +1,118 @@
# پیشنهاد مدل اشخاص در مرحله استعلام
وضعیت: پیاده‌سازی‌شده در ۱۴۰۵/۰۶/۲۲
دامنه: تمام جریان‌های استعلام کاربر، کارشناس، پرونده‌ساز و مرکز تماس
نسخه انگلیسی: [inquiry-participants-proposal.md](./inquiry-participants-proposal.md)
## interface پیاده‌سازی‌شده
فیلدهای نقش‌ها و آبجکت الزامی `vehicle` که در ادامه آمده‌اند، مستقیماً در body تمام درخواست‌های استعلام فعلی پذیرفته می‌شوند. این تغییر شامل فرم اولیه کاربر و mirror کارشناس/ثبت‌کننده در V2، جریان کارشناس V3، جریان‌های پرونده‌ساز V4/V5، مرکز تماس V6 و مسیرهای تک‌درخواستی حضوری است. routeهای پلاک و VIN از قوانین مشترک اشخاص استفاده می‌کنند. فیلدهای تخت راننده/بیمه‌گذار و شماره تلفن، ورودی استعلام نیستند.
پاسخ‌ها و جزئیات پرونده برای نقش‌های عملیاتی، در صورت وجود داده، فیلدهای نرمال‌شده `participants`، `participantRoles`، `vehicle.registrationState`، `vehicle.previousPlateId` و `vehicle.previousPolicyholderNationalCode` را نمایش می‌دهند.
## مسئله
قرارداد فعلی استعلام عمدتاً فقط راننده و شخصی با عنوان `insurer` را نگه می‌دارد. این مدل کامل نیست و نام‌گذاری نیز دقیق نیست: شخص، **بیمه‌گذار** است و **بیمه‌گر** شرکت بیمه است.
برای هر وسیله نقلیه در یک `Party` نقش‌های هویتی زیر وجود دارد:
| نوع پرونده | نقش‌های الزامی |
| ------------- | ------------------------------------------------------------ |
| `THIRD_PARTY` | راننده، مالک وسیله نقلیه، بیمه‌گذار شخص ثالث |
| `CAR_BODY` | راننده، مالک وسیله نقلیه، بیمه‌گذار شخص ثالث، بیمه‌گذار بدنه |
ممکن است یک شخص چند نقش را داشته باشد، اما سیستم نباید یکسان بودن آن‌ها را فرض کند.
## پیشنهاد
پیش از اجرای استعلام، یک **مرحله کوتاه تعیین اشخاص** اضافه شود. ابتدا نسبت اشخاص پرسیده شود و اطلاعات فقط برای افراد متفاوت دریافت شود. دریافت بدون شرط اطلاعات کامل همه اشخاص مناسب نیست. همچنین افزودن فلگ‌های دوتایی متعدد مانند `driverIsOwner` و `ownerIsPolicyholder` باعث ابهام، تناقض و رشد سریع حالت‌ها می‌شود.
به‌جای آن، هر نقش یا اطلاعات یک شخص جدید را داشته باشد یا به نقش قبلی ارجاع دهد:
```json
{
"driver": {
"nationalCode": "0012345678",
"birthday": "1370/01/01",
"hasDrivingLicense": true,
"licenseNumber": "123456789",
"licenseType": "1"
},
"vehicleOwner": { "sameAs": "DRIVER" },
"thirdPartyPolicyholder": { "sameAs": "VEHICLE_OWNER" },
"carBodyPolicyholder": {
"nationalCode": "0098765432",
"birthday": "1365/02/03"
}
}
```
فیلد `carBodyPolicyholder` برای `THIRD_PARTY` مجاز نیست و برای `CAR_BODY` الزامی است. هر نقش باید فقط یکی از دو حالت «اطلاعات شخص» یا `sameAs` را داشته باشد. بک‌اند این ورودی را به فهرست اشخاص یکتا و اتصال نقش‌ها به آن‌ها تبدیل می‌کند.
هر بیمه‌گذار باید با اطلاعات هویتی یا `sameAs` به یک شخص مشخص متصل شود. گزینه حذف‌شده `unknown` برای هیچ نقشی پذیرفته نمی‌شود؛ بنابراین استعلام بیمه به‌دلیل نامشخص بودن هویت بیمه‌گذار رد یا عمداً اجرا‌نشده ثبت نمی‌شود.
برای راننده، `hasDrivingLicense` الزامی است. اگر مقدار آن `true` باشد، هر دو فیلد `licenseNumber` و `licenseType` نیز الزامی هستند؛ اگر مقدار آن `false` باشد، استعلام گواهینامه عمداً اجرا نمی‌شود.
## انتقال مالکیت اخیر و پلاک قبلی
نقش اشخاص و شناسه‌های خودرو دو موضوع جدا هستند. اگر خودرو به‌تازگی فروخته یا خریداری شده باشد، ممکن است اطلاعات رسمی یا بیمه‌نامه هنوز به پلاک قبلی متصل باشد. این وضعیت باید صریح ثبت شود و پلاک قبلی نباید جایگزین پلاک فعلی شود:
```json
{
"vehicle": {
"registrationState": "RECENTLY_TRANSFERRED",
"currentPlate": {
"leftDigits": "44",
"centerAlphabet": "ب",
"centerDigits": "111",
"ir": "22"
},
"previousPlate": {
"leftDigits": "55",
"centerAlphabet": "ج",
"centerDigits": "222",
"ir": "33"
},
"previousPolicyholderNationalCode": "0098765432",
"vin": "NAAM01E15HK123456"
}
}
```
مقدار پیش‌فرض `registrationState` برابر `CURRENT` است و برای این مسیر استثنایی مقدار `RECENTLY_TRANSFERRED` استفاده می‌شود. در انتقال اخیر، `previousPlate`، `previousPolicyholderNationalCode` و `vin` الزامی‌اند؛ فیلدهای مربوط به پلاک قبلی در حالت عادی `CURRENT` نباید ارسال شوند. این اطلاعات انتقال فقط به‌عنوان متادیتای پرونده نگه‌داری می‌شوند و پلاک فعلی همچنان شناسه اصلی خودرو است.
در route پلاک، هماهنگ‌کننده دقیقاً یک استعلام بیمه انجام می‌دهد: `currentPlate` همراه با بیمه‌گذار نهایی همان نوع بیمه. در route شماره شاسی نیز `vehicle.vin` با همان بیمه‌گذار نهایی ارسال می‌شود. `previousPlate` هیچ‌گاه استعلام نمی‌شود، `previousPolicyholderNationalCode` به ارائه‌دهنده استعلام ارسال نمی‌شود و VIN برای انتخاب نتیجه پلاک قبلی به کار نمی‌رود.
## ترتیب پیشنهادی فرم
1. پلاک فعلی دریافت و درباره انتقال مالکیت اخیر پرسیده شود. در صورت انتقال اخیر، پلاک قبلی، کد ملی بیمه‌گذار مربوط به پلاک قبلی و VIN/شماره شاسی نیز دریافت شوند.
2. اطلاعات هویتی و گواهینامه راننده دریافت شود.
3. پرسیده شود آیا مالک خودرو همان راننده است؛ فقط در صورت تفاوت، اطلاعات مالک دریافت شود.
4. برای بیمه‌گذار شخص ثالث یکی از «راننده»، «مالک» یا «شخص دیگر» انتخاب شود؛ فقط برای شخص دیگر فرم جدید نمایش داده شود.
5. در `CAR_BODY` همین انتخاب برای بیمه‌گذار بدنه انجام شود و امکان انتخاب هر شخص ثبت‌شده یا شخص دیگر وجود داشته باشد.
6. خلاصه اشخاص نمایش داده شود و سپس استعلام‌ها اجرا شوند.
به این ترتیب مسیر رایج کوتاه می‌ماند و همه ترکیب‌های معتبر نیز پشتیبانی می‌شوند.
## محل منطق در بک‌اند
یک resolver مشترک برای اشخاص ساخته شود و تمام routeهای استعلام از آن استفاده کنند. interface این ماژول باید:
- نقش‌های لازم را بر اساس نوع پرونده اعتبارسنجی و ارجاع‌های نامعتبر یا حلقوی `sameAs` را رد کند؛
- شخص نهایی هر نقش را برگرداند؛
- هویت درست را به استعلام مرتبط بدهد: گواهینامه ← راننده، مالکیت و تطبیق شبا ← مالک خودرو، بیمه شخص ثالث با پلاک/VIN ← بیمه‌گذار شخص ثالث، بیمه بدنه با پلاک/VIN ← بیمه‌گذار بدنه؛
- شبا را در استعلام شخص مطالبه‌کننده خسارت (`SECOND` زیان‌دیده در `THIRD_PARTY` و طرف اول در `CAR_BODY`) الزامی کند و با کد ملی مالک خودرو اعتبارسنجی کند؛ در استعلام `FIRST` مقصر پرونده ثالث شبا دریافت نمی‌شود؛
- فقط پلاک فعلی یا VIN ارسال‌شده را با بیمه‌گذار نهایی همان نوع بیمه استعلام کند؛ متادیتای انتقال قبلی نباید مسیریابی استعلام را تغییر دهد؛
- استعلام هویت را برای هر شخص یکتا فقط یک بار اجرا کند؛
- اشخاص نرمال‌شده و نقش‌های آن‌ها را در `Party` مربوط ذخیره کند.
- `participants` و `participantRoles` ذخیره‌شده را بدون حذف اطلاعات در جزئیات پرونده پنل‌های کارشناسی و پرونده خسارت متصل نمایش دهد تا اطلاعات راننده و سایر نقش‌ها برای بررسی در دسترس بماند.
جریان‌های V2 کاربر/کارشناس، V3، V4، V5 و V6 باید adapter همین قوانین مشترک باشند و منطق نسبت اشخاص را جداگانه پیاده‌سازی نکنند.
## مرز قرارداد
ورودی استعلام فقط شامل آبجکت‌های ساختاریافته اشخاص و خودرو است. بک‌اند فیلدهای تختی مانند `nationalCodeOfDriver`، `nationalCodeOfInsurer`، `driverIsInsurer`، `plate`/`vin` سطح بالا و `phoneNumber` را با خطای اعتبارسنجی رد می‌کند. احراز هویت تلفنی و جریان‌های تماس با طرفین از جمع‌آوری هویت برای استعلام جدا هستند.
## تصمیم پیشنهادی
راه‌حل مناسب، **دریافت شرطی اطلاعات همراه با اتصال صریح نقش‌ها** است. این روش بدون طولانی کردن مسیر اکثر کاربران، اطلاعات کامل فراهم می‌کند، از تناقض فلگ‌ها جلوگیری می‌کند و یک مدل یکسان برای همه جریان‌های استعلام می‌سازد.

View File

@@ -0,0 +1,118 @@
# Inquiry participant identity proposal
Status: implemented on 2026-09-13
Scope: every user, expert, FileMaker, and call-center inquiry flow
Persian version: [inquiry-participants-proposal.fa.md](./inquiry-participants-proposal.fa.md)
## Implemented interface
The role fields and required `vehicle` object shown below are accepted directly in every existing inquiry request body. This covers V2 user and expert/registrar mirror initial forms, V3 expert flow, V4/V5 FileMaker flows, V6 call-center flow, and the one-shot in-person completion paths. Plate and VIN routes share the same participant rules. Flat driver/insurer fields and phone numbers are not inquiry inputs.
Responses and file-detail views for operational actors expose normalized `participants`, `participantRoles`, `vehicle.registrationState`, `vehicle.previousPlateId`, and `vehicle.previousPolicyholderNationalCode` where available.
## Problem
The current inquiry contract mainly models a driver and a value named `insurer`. That is incomplete and the name is misleading: a person is the **policyholder**; the **insurer** is the insurance company.
Each vehicle-side `Party` can have these identity roles:
| Case type | Required roles |
| ------------- | ---------------------------------------------------------------------- |
| `THIRD_PARTY` | Driver, vehicle owner, third-party policyholder |
| `CAR_BODY` | Driver, vehicle owner, third-party policyholder, car-body policyholder |
One person may hold several roles, but the system must not assume that they do.
## Recommendation
Add a short **participant-identification step before inquiry**. Ask relationship questions and collect details only for distinct people. Do not ask for every person's complete data unconditionally, and do not add pairwise flags such as `driverIsOwner`, `ownerIsPolicyholder`, and `driverIsBodyPolicyholder`; that becomes ambiguous and grows combinatorially.
Use explicit role references instead:
```json
{
"driver": {
"nationalCode": "0012345678",
"birthday": "1370/01/01",
"hasDrivingLicense": true,
"licenseNumber": "123456789",
"licenseType": "1"
},
"vehicleOwner": { "sameAs": "DRIVER" },
"thirdPartyPolicyholder": { "sameAs": "VEHICLE_OWNER" },
"carBodyPolicyholder": {
"nationalCode": "0098765432",
"birthday": "1365/02/03"
}
}
```
`carBodyPolicyholder` is forbidden for `THIRD_PARTY` and required for `CAR_BODY`. A role is either a new person's identity or a `sameAs` reference, never both. The backend should normalize this input into unique participants plus role assignments.
Every policyholder must resolve to a known participant through identity fields or `sameAs`. The removed `unknown` option is rejected for every role, so policy inquiries are never skipped because a policyholder identity is missing.
For Driver, `hasDrivingLicense` is required. When it is `true`, both `licenseNumber` and `licenseType` are required; when it is `false`, the licence inquiry is intentionally skipped.
## Recent ownership transfer and previous plate
Participant roles and vehicle identifiers are separate concerns. When a vehicle has recently been sold or purchased, the current official record or policy may still be connected to its previous plate. Model this explicitly instead of replacing the current plate:
```json
{
"vehicle": {
"registrationState": "RECENTLY_TRANSFERRED",
"currentPlate": {
"leftDigits": "44",
"centerAlphabet": "ب",
"centerDigits": "111",
"ir": "22"
},
"previousPlate": {
"leftDigits": "55",
"centerAlphabet": "ج",
"centerDigits": "222",
"ir": "33"
},
"previousPolicyholderNationalCode": "0098765432",
"vin": "NAAM01E15HK123456"
}
}
```
`registrationState` is `CURRENT` by default or `RECENTLY_TRANSFERRED` for this exceptional path. `previousPlate`, `previousPolicyholderNationalCode`, and `vin` are required when `registrationState=RECENTLY_TRANSFERRED`; the previous-plate fields are forbidden for the normal `CURRENT` path. These transfer fields are retained only as case metadata, and the current plate remains the vehicle's primary identifier.
For a plate route, the inquiry orchestrator performs exactly one policy lookup: `currentPlate` with the resolved policyholder for that policy type. For a VIN route, it uses `vehicle.vin` with the same resolved policyholder. It never queries `previousPlate`, never sends `previousPolicyholderNationalCode` to an inquiry provider, and does not use VIN to select a previous-plate result.
## Suggested UI sequence
1. Collect the current plate and ask whether the vehicle was recently transferred. If yes, collect the previous plate, its policyholder's national code, and VIN/chassis.
2. Collect driver identity and licence details.
3. Ask whether the vehicle owner is the driver; collect owner identity only when different.
4. Ask whether the third-party policyholder is the driver, the owner, or another person; collect identity only for “another person”.
5. For `CAR_BODY`, ask the same question for the car-body policyholder, allowing any already entered person or another person.
6. Show a short review, then run the inquiries.
This keeps the common case fast while representing all valid combinations.
## Backend seam
Create one shared participant resolver used by every inquiry route. Its interface should:
- validate required roles by case type and reject circular/invalid `sameAs` references;
- return the resolved person for each role;
- route the correct identity to each inquiry: driver licence → Driver, ownership and Sheba validation → Vehicle Owner, third-party policy by plate/VIN → Third-party Policyholder, car-body policy by plate/VIN → Car-body Policyholder;
- require Sheba in the claimant inquiry (`THIRD_PARTY` damaged/SECOND party and `CAR_BODY` first party) and validate it against the resolved Vehicle Owner; the `THIRD_PARTY` guilty/FIRST inquiry does not collect Sheba;
- query only the submitted current plate or VIN with the resolved policyholder for that policy type; previous-transfer metadata must not affect inquiry routing;
- run personal identity inquiry once per distinct person;
- persist normalized participants and role assignments on the relevant `Party`.
- expose the persisted `participants` and `participantRoles` unchanged in expert-facing blame and linked-claim details so driver and other role data remain available for review.
V2 user/expert routes, V3, V4, V5, and V6 should be adapters over this shared rule set rather than implementing their own relationship logic.
## Contract boundary
The structured participant and vehicle objects are the only accepted inquiry input. The backend rejects flat fields such as `nationalCodeOfDriver`, `nationalCodeOfInsurer`, `driverIsInsurer`, top-level `plate`/`vin`, and `phoneNumber` with a validation error. Phone-based authentication and party contact flows remain separate from inquiry identity collection.
## Decision
Prefer **conditional collection plus explicit role assignments**. It provides complete data without burdening most users, prevents contradictory booleans, and gives all inquiry flows one consistent domain model.

View File

@@ -0,0 +1,26 @@
# اطلاعات پنل‌ها و PDF
## مواردی که نبود و اضافه شد
- نام شخص در پنل‌های فلو ۴ و ۵
- کد ملی بیمه‌گذار و راننده در پنل‌های فلو ۴ و ۵
- مشخص‌بودن یکسان‌بودن راننده و بیمه‌گذار در فلو ۴ و ۵
- نوع، شماره و تاریخ گواهینامه در پنل‌های فلو ۴ و ۵
- تاریخ تولد بیمه‌گذار و راننده در پنل‌های فلو ۴ و ۵
- پلاک و VIN در پنل‌های فلو ۴ و ۵
- شماره شبا در پنل‌های فلو ۴ و ۵
- کدهای تکمیلی فناوران در پنل‌ها: بیمه‌نامه، راننده، نوع خودرو، نوع/نمونه پلاک، کاربری خودرو و شرکت بیمه
- نمایش نوع واقعی گواهینامه، مثل «پایه یک»، در PDF
## مواردی که از قبل وجود داشت
- نام و کد ملی افراد در پنل‌های کارشناس و بیمه‌گر
- کد ملی راننده، در صورت متفاوت‌بودن با بیمه‌گذار
- شماره شبا در پنل بیمه‌گر و PDF
- اطلاعات راننده در PDF، وقتی راننده با بیمه‌گذار متفاوت است
- پلاک خودرو در API؛ فرانت باید از `vehicle.plateId` بخواند
- کدهای اصلی فناوران: شماره خسارت، شماره پرونده، شناسه پرونده خسارت و شناسه کارشناسی
## موردی که هنوز نداریم
- `expedited` → در بک‌اند فیلد و قرارداد API ندارد.

View File

@@ -0,0 +1,625 @@
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<title>مرجع نقش‌های پنل</title>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: "Vazirmatn", "Tahoma", "Segoe UI", system-ui, sans-serif;
font-size: 14px;
line-height: 1.8;
background: #ffffff;
color: #1f2328;
padding: 24px;
}
h1 { font-size: 20px; font-weight: 700; margin-bottom: 4px; }
.subtitle { font-size: 13px; color: #57606a; margin-bottom: 28px; }
h2 {
font-size: 15px; font-weight: 700;
margin-bottom: 10px; margin-top: 32px;
border-bottom: 1px solid #e5e7eb; padding-bottom: 6px;
}
h3 {
font-size: 12px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.03em;
color: #57606a; margin-bottom: 8px; margin-top: 14px;
}
.section-intro {
font-size: 13px; color: #57606a;
margin-bottom: 14px; line-height: 1.7;
}
/* Role header strip */
.role-header {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 6px;
}
.role-name {
font-size: 15px;
font-weight: 700;
}
.role-enum {
font-family: monospace;
font-size: 11px;
color: #3b82d4;
background: #f0f7ff;
border: 1px solid #bfdbfe;
border-radius: 4px;
padding: 1px 6px;
direction: ltr;
unicode-bidi: embed;
}
.badge {
display: inline-block;
font-size: 11px; font-weight: 600;
padding: 1px 7px; border-radius: 10px;
margin-left: 4px; margin-bottom: 3px;
}
.badge-blue { background: #dbeafe; color: #1d4ed8; }
.badge-green { background: #dcfce7; color: #166534; }
.badge-purple { background: #ede9fe; color: #5b21b6; }
.badge-orange { background: #ffedd5; color: #9a3412; }
.badge-teal { background: #ccfbf1; color: #0f766e; }
.badge-indigo { background: #e0e7ff; color: #3730a3; }
.badge-gray { background: #f1f5f9; color: #475569; border: 1px solid #e2e8f0; }
.badge-red { background: #fee2e2; color: #991b1b; }
/* Cards */
.card {
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 16px;
background: #f7f8fa;
margin-bottom: 16px;
}
.card.card-blue { border-right: 4px solid #3b82f6; }
.card.card-green { border-right: 4px solid #22c55e; }
.card.card-purple { border-right: 4px solid #8b5cf6; }
.card.card-orange { border-right: 4px solid #f97316; }
.card.card-teal { border-right: 4px solid #14b8a6; }
.card.card-indigo { border-right: 4px solid #6366f1; }
.card.card-gray { border-right: 4px solid #94a3b8; }
.card.card-red { border-right: 4px solid #ef4444; }
/* Endpoint tables */
table {
border-collapse: collapse;
width: 100%;
font-size: 12px;
margin-top: 4px;
direction: rtl;
}
th {
background: #f1f5f9; font-weight: 600;
text-align: right; padding: 5px 8px;
border: 1px solid #e5e7eb;
}
td {
padding: 4px 8px; border: 1px solid #e5e7eb;
vertical-align: top;
}
tr:nth-child(even) td { background: #ffffff; }
code {
font-family: monospace; font-size: 11px; color: #3b82d4;
direction: ltr; unicode-bidi: embed;
}
.method {
font-family: monospace; font-size: 11px;
font-weight: 700; white-space: nowrap;
direction: ltr; unicode-bidi: embed;
}
.method.get { color: #059669; }
.method.post { color: #2563eb; }
.method.put { color: #d97706; }
.method.patch { color: #7c3aed; }
.method.delete { color: #dc2626; }
.dep { color: #94a3b8; font-style: italic; font-size: 11px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 860px) { .grid-2 { grid-template-columns: 1fr; } }
.note {
font-size: 11px; color: #57606a; font-style: normal;
margin-top: 6px;
}
.toc {
background: #f7f8fa; border: 1px solid #e5e7eb;
border-radius: 6px; padding: 14px 18px;
margin-bottom: 28px;
}
.toc-title { font-size: 13px; font-weight: 700; margin-bottom: 8px; }
.toc ol { padding-right: 18px; padding-left: 0; }
.toc li { font-size: 13px; margin-bottom: 3px; }
.toc a { color: #3b82d4; text-decoration: none; }
.toc a:hover { text-decoration: underline; }
footer {
text-align: center; font-size: 12px; color: #57606a;
border-top: 1px solid #e5e7eb;
margin-top: 40px; padding-top: 12px;
}
.max-wrap { max-width: 760px; margin: 0 auto; }
/* Role overview table */
.overview-table { font-size: 12px; margin-bottom: 24px; }
.overview-table th { white-space: nowrap; }
.overview-table td:last-child { font-weight: 600; white-space: nowrap; }
</style>
</head>
<body>
<div class="max-wrap">
<h1>مرجع نقش‌های پنل</h1>
<p class="subtitle">
آنچه هر نقش می‌تواند ببیند و انجام دهد — اندپوینت‌ها، مسئولیت‌ها و
مراحل فرآیند. سوپر ادمین در این مستند نیست.
</p>
<!-- Table of Contents -->
<div class="toc">
<div class="toc-title">نقش‌های پوشش‌داده‌شده</div>
<ol>
<li><a href="#insurer">بیمه‌گر (COMPANY) — ادمین تنانت شرکت بیمه</a></li>
<li><a href="#blame-expert">کارشناس تقصیر (EXPERT) — صف بررسی اختلاف</a></li>
<li><a href="#damage-expert">کارشناس خسارت (DAMAGE_EXPERT) — قیمت‌گذاری خسارت</a></li>
<li><a href="#field-expert">کارشناس میدانی (FIELD_EXPERT) — ثبت حضوری در صحنه</a></li>
<li><a href="#file-maker">فایل‌ساز (FILE_MAKER) — روایت طرفین در V4/V5</a></li>
<li><a href="#file-reviewer">بازبین فایل (FILE_REVIEWER) — ارزیابی خسارت V4/V5</a></li>
<li><a href="#registrar">ثبات (REGISTRAR) — ثبت اداری حضوری</a></li>
<li><a href="#call-center">مرکز تماس (CALL_CENTER) — ثبت تلفنی V6</a></li>
</ol>
</div>
<!-- ── Role overview table ────────────────────────────────────── -->
<h2>نمای کلی نقش‌ها</h2>
<table class="overview-table">
<tr>
<th>وظیفه اصلی</th>
<th>محدوده</th>
<th>پنل ورود</th>
<th>enum نقش</th>
</tr>
<tr>
<td>مشاهده تمام فایل‌ها؛ مدیریت شعب و کارشناسان؛ گزارش‌گیری؛ امتیازدهی به کارشناسان</td>
<td>سطح تنانت</td>
<td>پورتال بیمه‌گر</td>
<td><code>company</code></td>
</tr>
<tr>
<td>قفل‌کردن پرونده‌های تقصیر، بررسی اسناد طرفین، صدور رأی یا درخواست ارسال مجدد</td>
<td>صف DISAGREEMENT تنانت</td>
<td>پنل کارشناس تقصیر</td>
<td><code>expert</code></td>
</tr>
<tr>
<td>قفل‌کردن خسارت، قیمت‌گذاری، اعتبارسنجی فاکتورها، درخواست ارسال مجدد/بازدید</td>
<td>صف خسارت تنانت</td>
<td>پنل خسارت</td>
<td><code>damage_expert</code></td>
</tr>
<tr>
<td>ثبت حضوری تقصیر + خسارت در V2/V3؛ دسترسی به پنل‌های تقصیر/خسارت</td>
<td>فایل‌های ساخته‌شده توسط خود</td>
<td>پنل کارشناس میدانی</td>
<td><code>field_expert</code></td>
</tr>
<tr>
<td>روایت طرفین V4/V5 (OTP، استعلام، جزئیات، امضا)؛ تأیید خسارت در V5</td>
<td>فایل‌های ساخته‌شده توسط خود</td>
<td>پنل فایل‌ساز</td>
<td><code>file_maker</code></td>
</tr>
<tr>
<td>ارزیابی خسارت V4/V5 (فیلدهای تصادف، قطعات و عکس‌ها؛ تأیید خطوط قیمت‌گذاری‌شده در جریان ترکیبیِ فاکتور در صورت نیاز)</td>
<td>فایل‌های تخصیص‌یافته</td>
<td>پنل بازبین فایل</td>
<td><code>file_reviewer</code></td>
</tr>
<tr>
<td>ثبت حضوری اداری تقصیر + خسارت به نمایندگی از طرفین</td>
<td>فایل‌های ساخته‌شده توسط خود</td>
<td>پنل ثبات</td>
<td><code>registrar</code></td>
</tr>
<tr>
<td>ثبت تلفنی V6: اجرای استعلام، ارسال لینک؛ کاربر بقیه را تکمیل می‌کند</td>
<td>فایل‌های ساخته‌شده توسط خود</td>
<td>پنل مرکز تماس</td>
<td><code>call_center</code></td>
</tr>
</table>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="insurer">۱ — بیمه‌گر <span class="role-enum">company</span></h2>
<p class="section-intro">
به ازای هر تنانت شرکت بیمه یک اکتور <code>company</code> وجود دارد. پورتال بیمه‌گر
لایه مدیریتی است: می‌تواند همه چیز زیر تنانت خود را ببیند، لیست کارشناسان را مدیریت
کند، شعب را اداره کند، تنظیمات رسانه‌ای هر تنانت را پیکربندی کند و گزارش‌های آماری
استخراج کند. بیمه‌گر هرگز مستقیماً با مراحل تقصیر/خسارت درگیر نمی‌شود — فقط نظاره‌گر
و امتیازدهنده است.
</p>
<div class="card card-blue">
<h3>مدیریت فایل — <code>expert-insurer/</code></h3>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/files</code></td><td>فهرست تمام فایل‌های تقصیر + خسارت تنانت (ادغام‌شده بر اساس publicId). فیلترپذیر بر اساس وضعیت، نوع فایل، جستجو، مرتب‌سازی، صفحه.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/files/:publicId</code></td><td>جزئیات کامل یک فایل بر اساس publicId.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/files/:publicId/timeline</code></td><td>تایم‌لاین فعالیت به ترتیب زمانی (تمام رویدادهای تاریخچه: منبع، نوع، اکتور، متادیتا).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/files/:publicId/report</code></td><td>داده‌های ساختاریافته گزارش برای تولید PDF (بخش‌های مالک، راننده، بیمه، خودرو، تصادف).</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>expert-insurer/files/:publicId/rating</code></td><td>امتیازدهی به کارشناسان یک فایل (۱–۵ در هر بُعد: روش تصادف، به‌موقع‌بودن، دقت علت، دقت شناسایی مقصر، امتیاز ربات).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/report/unified-file-statuses</code></td><td>کاتالوگ وضعیت یکپارچه + تعداد به ازای هر وضعیت برای کل پرتفولیوی تنانت. فیلترپذیر بر اساس fileType و بازه تاریخ.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/report/status-counts</code></td><td class="dep">منسوخ‌شده — از unified-file-statuses استفاده کنید.</td></tr>
</table>
</div>
<div class="card card-blue">
<h3>مدیریت شعب — <code>expert-insurer/branches</code></h3>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/branches</code></td><td>فهرست تمام شعب این بیمه‌گر. پارامترها: جستجو، بازه تاریخ from/to، فیلتر isActive.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>expert-insurer/branches</code></td><td>افزودن شعبه جدید (نام، کد، آدرس، شهر، تلفن و غیره).</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>expert-insurer/branches/:branchId/status</code></td><td>فعال یا غیرفعال کردن یک شعبه.</td></tr>
</table>
</div>
<div class="card card-blue">
<h3>مدیریت لیست کارشناسان — <code>expert-insurer/experts</code></h3>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>expert-insurer/experts/blame</code></td><td>ایجاد حساب کارشناس تقصیر جدید زیر این بیمه‌گر.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>expert-insurer/experts/claim</code></td><td>ایجاد حساب کارشناس خسارت جدید زیر این بیمه‌گر.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>expert-insurer/experts/file-maker</code></td><td>ایجاد حساب فایل‌ساز جدید زیر این بیمه‌گر.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>expert-insurer/experts/file-reviewer</code></td><td>ایجاد حساب بازبین فایل جدید زیر این بیمه‌گر.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/experts/list</code></td><td>فهرست صفحه‌بندی‌شده تمام حساب‌های کارشناس در این تنانت.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/experts/top</code></td><td>برترین کارشناسان تقصیر و خسارت رتبه‌بندی‌شده بر اساس میانگین امتیاز کلی (حداکثر ۱۰ نفر از هر نوع).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/top-experts</code></td><td>نام مستعار experts/top (سازگاری با فرانت‌اند).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/:expertId</code></td><td>فایل‌های رسیدگی‌شده توسط یک کارشناس (ردیف‌های خلاصه — تقصیر یا خسارت بسته به نوع کارشناس).</td></tr>
</table>
</div>
<div class="card card-blue">
<h3>آمار و گزارش‌ها</h3>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/statistics</code></td><td>کارت‌های KPI: totalFilesReviewed، averageUserRating، inPersonCount، filesThisMonth، objectionPercentage و غیره. فیلترپذیر بر اساس بازه تاریخ.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/top-files</code></td><td>۱۰ فایل خسارت برتر بر اساس بالاترین امتیاز (ترکیبی از امتیاز بیمه‌گر + کاربر).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/expert-work-log</code></td><td>لاگ کاری هر کارشناس: totalHandled، currentlyChecking، distinctFilesCheckedInPeriod. فیلترپذیر بر اساس expertKind و بازه تاریخ.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>reports/report/insurer/requests</code></td><td>تعداد خسارت + وضعیت تقصیر + تعداد فایل یکپارچه برای تنانت.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>reports/report/insurer/per-month-requests</code></td><td>همان خلاصه، تفکیک‌شده بر اساس ۵ ماه تقویمی اخیر.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>reports/report/insurer/checked-requests</code></td><td>همان خلاصه، فیلترشده بر اساس بازه زمانی اختیاری createdAt.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>reports/report/insurer/expert-work-log</code></td><td>لاگ کاری کارشناسان (مجموعه‌های کارشناس تقصیر و خسارت، نه کارشناسان میدانی).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>reports/report/insurer/expert-work-log/per-month</code></td><td>همان لاگ کاری تفکیک‌شده بر اساس ماه تقویمی (۵ ماه اخیر).</td></tr>
</table>
</div>
<div class="card card-blue">
<h3>تنظیمات تنانت — <code>client-panel/</code></h3>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>client-panel/settings</code></td><td>دریافت محدودیت‌های رسانه‌ای هر تنانت (حداکثر بایت ویدیو/تصویر/صوت) و پنجره زمانی تصادف CAR_BODY (روز).</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>client-panel/settings</code></td><td>به‌روزرسانی جزئی این تنظیمات. نمی‌تواند از سقف‌های سطح سیستم تجاوز کند.</td></tr>
</table>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="blame-expert">۲ — کارشناس تقصیر <span class="role-enum">expert</span></h2>
<p class="section-intro">
فایل‌های تقصیر در صف DISAGREEMENT را بررسی می‌کند — پرونده‌هایی که دو طرف درباره
مقصر بودن توافق ندارند. پس از بررسی اسناد و اظهارات طرفین، کارشناس پرونده را قفل
می‌کند، سپس یا رأی صادر می‌کند، درخواست ارسال مجدد اسناد می‌دهد، یا نتیجه بازدید
حضوری را ثبت می‌کند. تمام اندپوینت‌ها زیر <code>v2/expert-blame/</code> هستند.
</p>
<div class="card card-orange">
<h3>فرآیند</h3>
<p class="note">
۱ مرور فهرست ← ۲ تخصیص (قفل) پرونده ← ۳ بررسی مدارک طرفین (ویدیو، صدا، اسناد) ←
۴الف صدور رأی <em>یا</em> ۴ب درخواست ارسال مجدد اسناد <em>یا</em> ۴پ ثبت بازدید حضوری.
</p>
</div>
<div class="card card-orange">
<h3>اندپوینت‌ها — <code>v2/expert-blame/</code></h3>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-blame/</code></td><td>فهرست پرونده‌های تقصیر در صف DISAGREEMENT (موجود، قفل‌شده توسط من، یا تصمیم‌گرفته‌شده توسط من). پارامترها: search، sortBy، sortOrder، page، limit، unifiedStatus، fileType.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-blame/:id</code></td><td>جزئیات کامل یک پرونده تقصیر (اظهارات، عکس، صدا، ویدیو طرفین).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>v2/expert-blame/:id/assign</code></td><td>بررسی در دسترس بودن و قفل پرونده برای این کارشناس. بازمی‌گرداند: <code>assigned</code>، <code>already_assigned_to_you</code>، یا ۴۰۹ در صورتی که شخص دیگری آن را نگه داشته باشد.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-blame/reply/submit/:id</code></td><td>ارسال رأی (accidentWay، accidentReason، accidentType، تصمیم طرف مقصر). پرونده را آزاد می‌کند و به COMPLETED منتقل می‌کند.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-blame/reply/resend/:id</code></td><td>درخواست از طرفین برای بارگذاری مجدد اسناد. تقصیر را به WAITING_FOR_RESEND تنظیم می‌کند. یک درخواست ارسال مجدد در هر چرخه عمر.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-blame/reply/inPerson/:id</code></td><td>ثبت اینکه بازدید حضوری انجام شده و صدور رأی.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-blame/report/unified-file-statuses</code></td><td>کاتالوگ وضعیت + تعداد به ازای هر وضعیت برای پرتفولیوی این کارشناس.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-blame/report/status-counts</code></td><td class="dep">منسوخ‌شده — از unified-file-statuses استفاده کنید.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-blame/lock/:id</code></td><td class="dep">اندپوینت قفل منسوخ‌شده — از POST assign استفاده کنید.</td></tr>
</table>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="damage-expert">۳ — کارشناس خسارت <span class="role-enum">damage_expert</span></h2>
<p class="section-intro">
فایل‌های خسارت را پس از ارسال مدارک خسارت توسط کاربر بررسی می‌کند. کارشناس
هر قطعه آسیب‌دیده را قیمت‌گذاری می‌کند، به‌صورت اختیاری کاهش قیمت (استهلاک)
محاسبه می‌کند، و می‌تواند از کاربر بخواهد مدارک را مجدداً ارسال کند، حضوری مراجعه
کند، یا فاکتورهای تعمیرگاه را هنگام نیاز به قیمت‌گذاری کارگاهی بارگذاری کند.
تمام اندپوینت‌ها زیر <code>v2/expert-claim/</code> هستند.
</p>
<div class="card card-red">
<h3>فرآیند</h3>
<p class="note">
۱ مرور فهرست ← ۲ تخصیص (قفل) خسارت ← ۳ بررسی عکس‌ها و اسناد خسارت ←
۴ ویرایش اختیاری قطعات انتخاب‌شده یا محاسبه کاهش قیمت ←
۵الف ارسال پاسخ قیمت‌گذاری‌شده <em>یا</em> ۵ب درخواست ارسال مجدد <em>یا</em> ۵پ درخواست بازدید حضوری ←
۶ در صورت وجود قطعات فاکتوردار: اعتبارسنجی فاکتورهای تعمیرگاه بارگذاری‌شده.
</p>
</div>
<div class="card card-red">
<h3>اندپوینت‌ها — <code>v2/expert-claim/</code></h3>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/requests</code></td><td>فهرست خسارت‌ها در صف <code>WAITING_FOR_DAMAGE_EXPERT</code> + صف اعتبارسنجی فاکتور. پارامترها: search، sortBy، page، limit، unifiedStatus، fileType.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/request/:claimRequestId</code></td><td>جزئیات کامل خسارت، به‌همراه <code>priceCap</code> مؤثر: ۵۳۰٬۰۰۰٬۰۰۰ ریال برای V1 و <code>null</code> برای V2 تا V6 یا وقتی سقف غیرفعال است.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>v2/expert-claim/assign/:claimRequestId</code></td><td>قفل خسارت برای این کارشناس. بازمی‌گرداند: <code>assigned</code>، <code>already_assigned_to_you</code>، یا ۴۰۹.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/request/:claimRequestId/price-drop</code></td><td>محتوای کاهش قیمت: برچسب‌های شدت، کاتالوگ ضریب، قطعات آسیب‌دیده + نگاشت، سال پیشنهادی خودرو از استعلام تقصیر.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/request/:claimRequestId/price-drop</code></td><td>محاسبه و ذخیره کاهش قیمت: قیمت خودرو × ضریب سال × مجموع ضرایب ÷ ۴۰۰.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/reply/submit/:claimRequestId</code></td><td>ارسال پاسخ ارزیابی خسارت (لیست قطعات قیمت‌گذاری‌شده، داغی، branchId). فقط V1: کل ≤ ۵۳۰٬۰۰۰٬۰۰۰ ریال؛ V2 تا V6 بدون سقف مجموع هستند.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/reply/resend/:claimRequestId</code></td><td>درخواست از کاربر برای ارسال مجدد اسناد/عکس‌ها. یک ارسال مجدد در هر چرخه خسارت؛ در صورت تکمیل قبلی ۴۲۲ برمی‌گرداند.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/:claimRequestId/visit</code></td><td>درخواست از کاربر برای مراجعه حضوری. خسارت را آزاد می‌کند، وضعیت claimStatus را به NEEDS_REVISION تنظیم می‌کند.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/validate-factors/:claimRequestId</code></td><td>اعتبارسنجی فاکتورهای تعمیرگاه بارگذاری‌شده. سقف ۵۳۰٬۰۰۰٬۰۰۰ ریال تمام خطوط فقط برای V1 اعمال می‌شود؛ V2 تا V6 بدون سقف هستند.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/request/:claimRequestId/damaged-parts</code></td><td>ویرایش قطعات آسیب‌دیده انتخاب‌شده در حالی که خسارت توسط این کارشناس قفل است (EXPERT_REVIEWING).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/outer-parts-catalog</code></td><td>کاتالوگ قطعات بیرونی خودرو فناوران (مشترک با جریان کاربر).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/inner-parts-catalog</code></td><td>JSON ثابت کاتالوگ قطعات داخلی خودرو.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/branches</code></td><td>شعب بیمه‌گر برای تنانت این کارشناس (برای انتخاب داغی/شعبه در پیلود پاسخ).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/stream/:id/video</code></td><td>پخش ویدیوی خسارت (ویدیوی دور زدن خودرو یا ویدیوی تصادف). پارامتر: <code>query=car-capture|accident</code>.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/report/unified-file-statuses</code></td><td>کاتالوگ وضعیت + تعداد برای پرتفولیوی خسارت این کارشناس.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/report/status-counts</code></td><td class="dep">منسوخ‌شده — از unified-file-statuses استفاده کنید.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/lock/:claimRequestId</code></td><td class="dep">اندپوینت قفل منسوخ‌شده — از POST assign استفاده کنید.</td></tr>
</table>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="field-expert">۴ — کارشناس میدانی <span class="role-enum">field_expert</span></h2>
<p class="section-intro">
به صحنه تصادف می‌رود و فرم‌های هر دو طرف را حضوری پر می‌کند (جریان V2 mirror / V3).
کارشناس میدانی همچنین دسترسی خواندن به پنل‌های expert-blame و expert-claim دارد
(محدود به فایل‌های خودش). تنها نقشی است که هم <strong>ثبت تقصیر</strong> و هم
<strong>ثبت خسارت</strong> را در یک جلسه انجام می‌دهد.
</p>
<div class="card card-green">
<h3>ثبت تقصیر — <code>v2/expert-initiated/blame-request-management/</code></h3>
<p class="note">آینه‌ای از API تقصیر کاربر. فرانت‌اند همان صفحات را با تغییر فقط پیشوند مسیر بازاستفاده می‌کند.</p>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>POST /</code></td><td>ایجاد فایل تقصیر IN_PERSON.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>send-party-otp/:id</code></td><td>ارسال OTP به یک طرف از طریق شماره تلفن (بدون لینک دعوت).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>verify-party-otp/:id</code></td><td>تأیید OTP یک طرف و اتصال حساب آنها.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>blame-confession/:id</code></td><td>ثبت اعتراف تقصیر طرف.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>car-body-form/:id</code></td><td>[فقط CAR_BODY] فرم نوع تصادف.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>run-inquiries/:id</code> / <code>run-inquiries-vin/:id</code></td><td>فرم اولیه / استعلام پلاک یا VIN برای طرف فعلی.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-video/:id</code></td><td>بارگذاری ویدیوی طرف اول.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>add-detail-location/:id</code></td><td>افزودن موقعیت GPS برای طرف فعلی.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-voice/:id</code></td><td>بارگذاری ضبط صوتی برای طرف فعلی.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>add-detail-description/:id</code></td><td>افزودن توضیحات برای طرف فعلی.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>add-second-party/:phone/:id/</code></td><td>پیشروی به طرف دوم (بدون ارسال لینک SMS).</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>sign/:id</code></td><td>بارگذاری امضای طرف (اول سپس دوم، پارامتر partyRole).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>accident-fields/:id</code></td><td>ذخیره فیلدهای تصادف و تکمیل فوری تقصیر (بدون صف کارشناس).</td></tr>
</table>
</div>
<div class="card card-green">
<h3>ثبت تقصیر + خسارت V3 — <code>v3/expert-initiated/blame-request-management/</code></h3>
<p class="note">ترتیب مراحل بازسازمان‌دهی‌شده: ابتدا تمام روایت طرفین، سپس ارزیابی خسارت. هم تقصیر هم خسارت در این کنترلر واحد مدیریت می‌شوند.</p>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>POST /</code> ← <code>send-party-otp</code> ← <code>verify-party-otp</code> ← <code>run-inquiries</code> ← <code>add-detail-*</code> ← <code>sign</code> (×۲)</td><td>مرحله روایت طرفین (مراحل ۱–۸) — اندپوینت‌های یکسان با mirror، همان قرارداد.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>accident-fields/:id</code></td><td>مرحله ۹: ذخیره فیلدهای تصادف پس از امضای هر دو طرف.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>claim-id/:requestId</code></td><td>مرحله ۱۰: دریافت شناسه خسارت ایجادشده به‌صورت خودکار.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-document/:claimId</code></td><td>مرحله ۱۱: بارگذاری اسناد گواهینامه / کارت خودرو.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>select-outer-parts/:claimId</code> / <code>select-other-parts/:claimId</code></td><td>مراحل ۱۲–۱۳: انتخاب قطعات آسیب‌دیده.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>capture-part/:claimId</code></td><td>مرحله ۱۴: عکس‌برداری از قطعات + زوایا.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>car-capture/:claimId</code></td><td>مرحله ۱۵: ویدیوی دور زدن خودرو.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-video/:requestId</code></td><td>مرحله ۱۶: ویدیوی تصادف تقصیر (نهایی) ← WAITING_FOR_EXPERT (THIRD_PARTY) یا COMPLETED (CAR_BODY).</td></tr>
</table>
</div>
<div class="card card-green">
<h3>دسترسی به پنل expert-blame + expert-claim (خواندن + اقدام روی فایل‌های خود)</h3>
<p class="note">
FIELD_EXPERT مسیر <code>v2/expert-blame/</code> را محدود به فایل‌های ساخته‌شده توسط خودش می‌بیند (نه صف اختلاف).
همچنین <code>v2/expert-claim/</code> را برای خسارت‌های مرتبط با فایل‌های تقصیرش می‌بیند.
اندپوینت‌های یکسان با پنل‌های کارشناس تقصیر و کارشناس خسارت در بالا.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="file-maker">۵ — فایل‌ساز <span class="role-enum">file_maker</span></h2>
<p class="section-intro">
اولین اکتور در تقسیم V4/V5. فایل‌ساز روایت طرفین را در محل انجام می‌دهد:
OTPها، استعلام‌ها، موقعیت/توضیحات/صدا و امضاها برای هر دو طرف.
همچنین اسناد اولیه خسارت (گواهینامه‌ها، کارت‌های خودرو) را بارگذاری می‌کند. پس از
امضای دوم، فایل برای تحویل به بازبین فایل «مهرومومه» می‌شود. در V5، فایل‌ساز
در انتها بازمی‌گردد تا خسارت تکمیل‌شده را تأیید یا رد کند. پس از تأیید،
کارشناس پرونده را به‌صورت دستی به فناوران ارسال می‌کند.
</p>
<div class="card card-purple">
<h3>ثبت تقصیر — <code>v4/file-maker/blame-request-management/</code> و <code>v5/…</code></h3>
<p class="note">اندپوینت‌های V4 و V5 یکسان هستند — فقط پیشوند تغییر می‌کند. V5 هنگام ایجاد <code>requiresFileMakerApproval=true</code> را تنظیم می‌کند.</p>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>POST /</code></td><td>ایجاد فایل تقصیر IN_PERSON.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>my-files</code></td><td>فهرست تمام فایل‌های تقصیر ایجادشده توسط این فایل‌ساز.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>my-files/:requestId</code></td><td>جزئیات کامل یک فایل (طرفین، گردش کار، شناسه خسارت مرتبط).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>claim-id/:requestId</code></td><td>دریافت شناسه خسارت ایجادشده به‌صورت خودکار پس از استعلام طرف مقصر.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>send-party-otp/:id</code> / <code>verify-party-otp/:id</code></td><td>ارسال + تأیید OTP برای یک طرف در هر بار (ابتدا مقصر، سپس زیان‌دیده).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>car-body-form/:id</code></td><td>[فقط CAR_BODY] فرم نوع تصادف.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>run-inquiries/:id</code> / <code>run-inquiries-vin/:id</code></td><td>اجرای استعلام پلاک یا VIN. فراخوانی اول = مقصر (+ خودکار خسارت ایجاد می‌کند). فراخوانی دوم = زیان‌دیده (فقط THIRD_PARTY).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>add-detail-location/:id</code> / <code>add-detail-description/:id</code> / <code>upload-voice/:id</code></td><td>افزودن موقعیت، توضیحات و صدا برای طرف فعلی (پارامتر partyRole، FIRST/SECOND را انتخاب می‌کند).</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>sign/:id</code></td><td>بارگذاری امضای طرف (partyRole=FIRST سپس SECOND). پس از امضای دوم، فایل مهرومومه می‌شود.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-document/:claimId</code></td><td>بارگذاری گواهینامه / کارت‌های خودرو روی خسارت ایجادشده به‌صورت خودکار.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>capture-requirements/:claimId</code></td><td>الزامات عکس‌برداری آگاه از مرحله (فازها: اسناد پیش از عکس‌برداری در مقابل قطعات آسیب‌دیده + شاسی/موتور).</td></tr>
</table>
</div>
<div class="card card-purple">
<h3>تأیید خسارت V5 — <code>v5/file-maker/claim-approval/</code></h3>
<p class="note">فقط در V5 استفاده می‌شود. پس از بررسی کارشناس خسارت و اعتبارسنجی فاکتورهای لازم، خسارت وارد <code>WAITING_FOR_FILE_MAKER_APPROVAL</code> می‌شود؛ امضای نهایی مالک لازم نیست.</p>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>approve/:claimId</code></td><td>تأیید خسارت تکمیل‌شده ← وضعیت خسارت <code>COMPLETED</code> می‌شود. کارشناس در زمان مناسب آن را به‌صورت دستی به فناوران ارسال می‌کند.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>reject/:claimId</code></td><td>رد به بازبین فایل ← خسارت به WAITING_FOR_DAMAGE_EXPERT برمی‌گردد. محدودیت: حداکثر ۲ رد در هر خسارت؛ تلاش سوم ۴۲۲ با کد <code>FILE_MAKER_REJECTION_LIMIT_EXCEEDED</code> برمی‌گرداند.</td></tr>
</table>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="file-reviewer">۶ — بازبین فایل <span class="role-enum">file_reviewer</span></h2>
<p class="section-intro">
دومین اکتور در تقسیم V4/V5. بازبین فایل فایل‌های مهرومومه‌شده (پس از اتمام کار
فایل‌ساز) را تحویل می‌گیرد و مرحله کامل ارزیابی خسارت را انجام می‌دهد: فیلدهای
تصادف، دریافت الزامات عکس‌برداری، بارگذاری اسناد (شاسی/موتور)، انتخاب قطعات،
عکس‌های قطعات و ویدیوی دور زدن خودرو. تقصیر با car-capture به
COMPLETED علامت‌گذاری می‌شود. بازبین فایل همچنین دسترسی خواندن به پنل expert-claim
برای خسارت‌هایی که بررسی می‌کند دارد.
</p>
<div class="card card-teal">
<h3>ارزیابی خسارت — <code>v4/file-reviewer/blame-request-management/</code> و <code>v5/…</code></h3>
<p class="note">اندپوینت‌های V4 و V5 یکسان هستند — فقط پیشوند تغییر می‌کند.</p>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>my-files</code></td><td>فهرست فایل‌های مهروموم‌شدهٔ فایل‌ساز که در شرکت بیمهٔ این بازبین برای دریافت آماده‌اند، به‌علاوهٔ فایل‌های تخصیص‌یافته به خود او.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>my-files/:requestId</code></td><td>جزئیات کامل یک فایل آماده برای دریافت یا تخصیص‌یافته، در شرکت بیمهٔ همین بازبین (طرفین، گردش کار، فیلدهای کارشناس، شناسه خسارت مرتبط).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>claim-id/:requestId</code></td><td>دریافت شناسه خسارت ایجادشده به‌صورت خودکار (از استعلام طرف مقصر فایل‌ساز).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>accident-fields/:requestId</code></td><td>مرحله ۱ (بازبین): ذخیره فیلدهای تصادف (accidentWay، accidentReason، accidentType).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>capture-requirements/:claimId</code></td><td>الزامات عکس‌برداری آگاه از مرحله (فاز اسناد پیش از عکس‌برداری در مقابل فاز عکس‌برداری قطعات).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-document/:claimId</code></td><td>بارگذاری اسناد شاسی / موتور / پلاک فلزی.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>select-outer-parts/:claimId</code></td><td>انتخاب قطعات آسیب‌دیده بیرونی (بدنه).</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>select-other-parts/:claimId</code></td><td>انتخاب سایر قطعات آسیب‌دیده (غیر بدنه).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>capture-part/:claimId</code></td><td>عکس‌برداری از قطعات + زوایا برای هر قطعه آسیب‌دیده انتخاب‌شده.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>car-capture/:claimId</code></td><td>ویدیوی دور زدن خودرو (آخرین مرحله عکس‌برداری بازبین). خسارت ← <code>WAITING_FOR_DAMAGE_EXPERT</code>، تقصیر ← <code>COMPLETED</code>.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>claim-sign/:claimId</code></td><td>فقط برای خسارت‌های ترکیبیِ قیمت/فاکتور: ثبت موافقت با خطوط قیمت‌گذاری‌شده پیش از بارگذاری فاکتور. امضای نهایی مالک دیگر لازم نیست.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-video/:requestId</code></td><td class="dep">در V4/V5 بی‌اثر است — تقصیر از قبل توسط car-capture COMPLETED شده. موفقیت idempotent برمی‌گرداند.</td></tr>
</table>
</div>
<div class="card card-teal">
<h3>دسترسی به پنل expert-claim</h3>
<p class="note">
FILE_REVIEWER در نقش‌های مجاز برای <code>v2/expert-claim/</code> است.
می‌تواند جزئیات خسارت را مشاهده کند و جریان assign/lock را برای خسارت‌های
مرتبط با فایل‌هایش اجرا کند. نمی‌تواند به‌طور مستقل درخواست ارسال مجدد
کارشناس خسارت را آغاز کند.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="registrar">۷ — ثبات <span class="role-enum">registrar</span></h2>
<p class="section-intro">
نقش اداری که تقصیر و خسارت حضوری را به نمایندگی از طرفین ثبت می‌کند.
از جریان OTP دسته‌ای استفاده می‌کند (OTPهای هر دو طرف به‌صورت همزمان ارسال و
تأیید می‌شوند) به جای OTP یک‌به‌یک که توسط کارشناسان میدانی استفاده می‌شود.
پس از تقصیر، ثبات آینه API خسارت کاربر را دنبال می‌کند تا انتخاب قطعات، اسناد
و عکس‌برداری را پر کند. سپس فایل وارد چرخه عادی بررسی کارشناس خسارت می‌شود.
</p>
<div class="card card-gray">
<h3>ثبت تقصیر — <code>registrar-initiated-blame/</code></h3>
<p class="note">توجه: <code>@ApiExcludeController</code> — مسیرها وجود دارند اما در مستندات Swagger نمایش داده نمی‌شوند.</p>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>registrar-initiated-blame/create</code></td><td>ایجاد فایل تقصیر IN_PERSON.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>registrar-initiated-blame/my-files</code></td><td>فهرست تمام فایل‌های تقصیر ایجادشده توسط این ثبات.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>registrar-initiated-blame/blame/:requestId</code></td><td>جزئیات کامل یک فایل تقصیر.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>registrar-initiated-blame/send-party-otps/:id</code></td><td>ارسال OTP به هر دو طرف به‌صورت همزمان.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>registrar-initiated-blame/verify-party-otps/:id</code></td><td>تأیید OTPهای هر دو طرف در یک فراخوانی.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>registrar-initiated-blame/complete-blame-data/:id</code></td><td>ارسال تمام داده‌های فرم تقصیر هر دو طرف در یک پیلود.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>registrar-initiated-blame/upload-video/:id</code></td><td>بارگذاری ویدیوی تقصیر.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>registrar-initiated-blame/upload-voice/:id</code></td><td>بارگذاری ضبط صوتی.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>registrar-initiated-blame/add-accident-fields/:id</code></td><td>ذخیره فیلدهای تصادف و تکمیل تقصیر.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>registrar-initiated-blame/upload-party-signature/:id</code></td><td>بارگذاری امضای یک طرف (partyRole=FIRST/SECOND).</td></tr>
</table>
</div>
<div class="card card-gray">
<h3>ثبت خسارت — <code>v2/registrar/claim-request-management/</code></h3>
<p class="note">آینه‌ای از API خسارت کاربر. فرانت‌اند همان صفحات خسارت را با تغییر فقط پیشوند بازاستفاده می‌کند.</p>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>create-from-blame/:blameId</code></td><td>ایجاد خسارت از یک فایل تقصیر تکمیل‌شده.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>outer-parts-catalog</code> / <code>car-other-part</code></td><td>کاتالوگ قطعات (قطعات بیرونی بدنه + JSON سایر قطعات).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>branches/:insuranceId</code></td><td>فهرست شعب بیمه‌گر (برای انتخاب شعبه در مرحله امضای خسارت).</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>select-outer-parts/:claimId</code></td><td>انتخاب قطعات آسیب‌دیده بیرونی.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>select-other-parts/:claimId</code></td><td>انتخاب سایر قطعات آسیب‌دیده + اطلاعات بانکی.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-document/:claimId</code></td><td>بارگذاری اسناد خسارت (گواهینامه، کارت خودرو).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>capture-part/:claimId</code></td><td>عکس‌برداری از قطعات + زوایا.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>car-capture/:claimId</code></td><td>ویدیوی دور زدن خودرو (مرحله نهایی) ← WAITING_FOR_DAMAGE_EXPERT.</td></tr>
</table>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="call-center">۸ — مرکز تماس <span class="role-enum">call_center</span></h2>
<p class="section-intro">
ثبت تقصیر تلفنی V6 را مدیریت می‌کند. اپراتور داده‌های طرف مقصر را از طریق تلفن
جمع‌آوری می‌کند، استعلام بیمه را اجرا می‌کند و لینک تقصیر را از طریق SMS ارسال
می‌کند. کاربر سپس بقیه فرم را از طریق جریان استاندارد V2 تکمیل می‌کند
(با رد شدن مرحله فرم اولیه/استعلام). کار اپراتور مرکز تماس پس از send-link
پایان می‌یابد؛ می‌تواند پیشرفت را از طریق اندپوینت‌های خواندن پایش کند.
</p>
<div class="card card-indigo">
<h3>اندپوینت‌ها — <code>v6/call-center-blame/</code></h3>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>create</code></td><td>ایجاد فایل تقصیر LINK. بدنه: <code>{ type: "THIRD_PARTY" | "CAR_BODY" }</code>.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>run-inquiry/:requestId</code></td><td>اجرای استعلام بیمه پلاک + کد ملی برای طرف مقصر. نتیجه را روی سند تقصیر ذخیره می‌کند.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>run-inquiry-vin/:requestId</code></td><td>VIN/شاسی جایگزین برای run-inquiry. از جستجوی شاسی ESG استفاده می‌کند.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>send-link/:requestId</code></td><td>در صورت لزوم کاربر را ثبت‌نام می‌کند، به‌عنوان طرف اول ذخیره می‌کند، لینک دعوت تقصیر را از طریق SMS ارسال می‌کند. بدنه: <code>{ phoneNumber }</code>.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>my-files</code></td><td>فهرست تمام فایل‌های تقصیر شروع‌شده توسط این اپراتور.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>blame/:requestId</code></td><td>وضعیت فعلی و مرحله گردش کار یک فایل (برای بررسی اینکه کاربر لینک را باز کرده و پیشرفت کرده است).</td></tr>
</table>
<p class="note" style="margin-top:8px;">
پس از <code>send-link</code>، کاربر فرم را از طریق
<code>v2/blame-request-management/</code> (جریان استاندارد V2) تکمیل می‌کند.
مرحله فرم اولیه / استعلام به‌صورت خودکار رد می‌شود
(<code>skipInitialFormStep=true</code>). جریان خسارت پایین‌دستی همان
جریان استاندارد خسارت V2 است.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2>مشترک: احراز هویت اکتورها</h2>
<p class="section-intro">
تمام اکتورهای پنل (هر نقش به جز <code>user</code>) از طریق همان اندپوینت
<code>POST actor/login</code> با کپچا احراز هویت می‌کنند. بازنشانی رمز عبور از
طریق OTP ایمیل است. خواندن و ویرایش پروفایل نیز مشترک است.
</p>
<div class="card card-gray">
<h3>اندپوینت‌ها — <code>actor/</code></h3>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>actor/captcha</code></td><td>صدور یک چالش کپچای ورود جدید (captchaId + تصویر SVG را برمی‌گرداند).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>actor/login</code></td><td>احراز هویت هر نقش اکتور. بدنه: role، username/email/nationalCode، password، captchaId، captcha. توکن‌های JWT دسترسی + رفرش را برمی‌گرداند.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>actor/forget-password</code></td><td>ارسال OTP بازنشانی رمز عبور به ایمیل.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>actor/forget-password-verify</code></td><td>تأیید OTP و تنظیم رمز عبور جدید.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>actor/profile</code></td><td>دریافت پروفایل اکتور فعلی.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>actor/profile</code></td><td>به‌روزرسانی پروفایل اکتور فعلی.</td></tr>
</table>
</div>
<footer>Made by Sepehr</footer>
</div>
</body>
</html>

View File

@@ -0,0 +1,617 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Panel Roles Reference</title>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, "Segoe UI", system-ui, sans-serif;
font-size: 14px;
line-height: 1.6;
background: #ffffff;
color: #1f2328;
padding: 24px;
}
h1 { font-size: 20px; font-weight: 700; margin-bottom: 4px; }
.subtitle { font-size: 13px; color: #57606a; margin-bottom: 28px; }
h2 {
font-size: 15px; font-weight: 700;
margin-bottom: 10px; margin-top: 32px;
border-bottom: 1px solid #e5e7eb; padding-bottom: 6px;
}
h3 {
font-size: 12px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.05em;
color: #57606a; margin-bottom: 8px; margin-top: 14px;
}
.section-intro {
font-size: 13px; color: #57606a;
margin-bottom: 14px; line-height: 1.5;
}
/* Role header strip */
.role-header {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 6px;
}
.role-name {
font-size: 15px;
font-weight: 700;
}
.role-enum {
font-family: monospace;
font-size: 11px;
color: #3b82d4;
background: #f0f7ff;
border: 1px solid #bfdbfe;
border-radius: 4px;
padding: 1px 6px;
}
.badge {
display: inline-block;
font-size: 11px; font-weight: 600;
padding: 1px 7px; border-radius: 10px;
margin-right: 4px; margin-bottom: 3px;
}
.badge-blue { background: #dbeafe; color: #1d4ed8; }
.badge-green { background: #dcfce7; color: #166534; }
.badge-purple { background: #ede9fe; color: #5b21b6; }
.badge-orange { background: #ffedd5; color: #9a3412; }
.badge-teal { background: #ccfbf1; color: #0f766e; }
.badge-indigo { background: #e0e7ff; color: #3730a3; }
.badge-gray { background: #f1f5f9; color: #475569; border: 1px solid #e2e8f0; }
.badge-red { background: #fee2e2; color: #991b1b; }
/* Cards */
.card {
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 16px;
background: #f7f8fa;
margin-bottom: 16px;
}
.card.card-blue { border-left: 4px solid #3b82f6; }
.card.card-green { border-left: 4px solid #22c55e; }
.card.card-purple { border-left: 4px solid #8b5cf6; }
.card.card-orange { border-left: 4px solid #f97316; }
.card.card-teal { border-left: 4px solid #14b8a6; }
.card.card-indigo { border-left: 4px solid #6366f1; }
.card.card-gray { border-left: 4px solid #94a3b8; }
/* Endpoint tables */
table {
border-collapse: collapse;
width: 100%;
font-size: 12px;
margin-top: 4px;
}
th {
background: #f1f5f9; font-weight: 600;
text-align: left; padding: 5px 8px;
border: 1px solid #e5e7eb;
}
td {
padding: 4px 8px; border: 1px solid #e5e7eb;
vertical-align: top;
}
tr:nth-child(even) td { background: #ffffff; }
code {
font-family: monospace; font-size: 11px; color: #3b82d4;
}
.method {
font-family: monospace; font-size: 11px;
font-weight: 700; white-space: nowrap;
}
.method.get { color: #059669; }
.method.post { color: #2563eb; }
.method.put { color: #d97706; }
.method.patch { color: #7c3aed; }
.method.delete { color: #dc2626; }
.dep { color: #94a3b8; font-style: italic; font-size: 11px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 860px) { .grid-2 { grid-template-columns: 1fr; } }
.note {
font-size: 11px; color: #57606a; font-style: italic;
margin-top: 6px;
}
.toc {
background: #f7f8fa; border: 1px solid #e5e7eb;
border-radius: 6px; padding: 14px 18px;
margin-bottom: 28px;
}
.toc-title { font-size: 13px; font-weight: 700; margin-bottom: 8px; }
.toc ol { padding-left: 18px; }
.toc li { font-size: 13px; margin-bottom: 3px; }
.toc a { color: #3b82d4; text-decoration: none; }
.toc a:hover { text-decoration: underline; }
footer {
text-align: center; font-size: 12px; color: #57606a;
border-top: 1px solid #e5e7eb;
margin-top: 40px; padding-top: 12px;
}
.max-wrap { max-width: 760px; margin: 0 auto; }
/* Role overview table */
.overview-table { font-size: 12px; margin-bottom: 24px; }
.overview-table th { white-space: nowrap; }
.overview-table td:first-child { font-weight: 600; white-space: nowrap; }
</style>
</head>
<body>
<div class="max-wrap">
<h1>Panel Roles Reference</h1>
<p class="subtitle">
What every actor role can see and do — endpoints, responsibilities, and
process steps. Super-admin excluded.
</p>
<!-- Table of Contents -->
<div class="toc">
<div class="toc-title">Roles covered</div>
<ol>
<li><a href="#insurer">Insurer (COMPANY) — the insurance-company tenant admin</a></li>
<li><a href="#blame-expert">Blame Expert (EXPERT) — disagreement review queue</a></li>
<li><a href="#damage-expert">Damage Expert (DAMAGE_EXPERT) — claim pricing</a></li>
<li><a href="#field-expert">Field Expert (FIELD_EXPERT) — on-scene in-person filing</a></li>
<li><a href="#file-maker">File Maker (FILE_MAKER) — V4/V5 party narrative</a></li>
<li><a href="#file-reviewer">File Reviewer (FILE_REVIEWER) — V4/V5 damage assessment</a></li>
<li><a href="#registrar">Registrar (REGISTRAR) — office-based filing</a></li>
<li><a href="#call-center">Call Center (CALL_CENTER) — V6 phone-initiated filing</a></li>
</ol>
</div>
<!-- ── Role overview table ────────────────────────────────────── -->
<h2>Role Overview</h2>
<table class="overview-table">
<tr>
<th>Role enum</th>
<th>Login panel</th>
<th>Scope</th>
<th>Primary job</th>
</tr>
<tr>
<td><code>company</code></td>
<td>Insurer portal</td>
<td>Tenant-wide</td>
<td>View all files; manage branches, experts; run reports; rate experts</td>
</tr>
<tr>
<td><code>expert</code></td>
<td>Blame expert panel</td>
<td>Tenant DISAGREEMENT queue</td>
<td>Lock blame cases, review party submissions, submit verdict or request resend</td>
</tr>
<tr>
<td><code>damage_expert</code></td>
<td>Claim/damage panel</td>
<td>Tenant claim queue</td>
<td>Lock claims, price damage, validate repair factors, request resend/visit</td>
</tr>
<tr>
<td><code>field_expert</code></td>
<td>Field expert panel</td>
<td>Own created files</td>
<td>V2/V3 in-person blame + claim filing; also sees blame/claim review panels</td>
</tr>
<tr>
<td><code>file_maker</code></td>
<td>FileMaker panel</td>
<td>Own created files</td>
<td>V4/V5 party narrative (OTPs, inquiries, details, signatures); V5 claim approval</td>
</tr>
<tr>
<td><code>file_reviewer</code></td>
<td>FileReviewer panel</td>
<td>Assigned files</td>
<td>V4/V5 damage assessment (accident fields, parts, captures; mixed-factor priced-line acceptance when needed)</td>
</tr>
<tr>
<td><code>registrar</code></td>
<td>Registrar panel</td>
<td>Own created files</td>
<td>Office-based in-person blame + claim filing on behalf of parties</td>
</tr>
<tr>
<td><code>call_center</code></td>
<td>Call-center panel</td>
<td>Own created files</td>
<td>V6 phone-initiated blame: run inquiry, send link; user completes the rest</td>
</tr>
</table>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="insurer">1 — Insurer <span class="role-enum">company</span></h2>
<p class="section-intro">
One <code>company</code> actor per insurance-company tenant. The insurer portal is the
management layer: it can see everything under its tenant, manage the expert roster,
manage branches, configure per-tenant media settings, and pull statistical reports.
The insurer never touches blame/claim steps directly — it only observes and rates.
</p>
<div class="card card-blue">
<h3>File management — <code>expert-insurer/</code></h3>
<table>
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/files</code></td><td>List all blame + claim files for the tenant (merged by publicId). Filterable by status, file type, search, sort, page.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/files/:publicId</code></td><td>Full detail for one file by publicId.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/files/:publicId/timeline</code></td><td>Chronological activity timeline (all history events: source, type, actor, metadata).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/files/:publicId/report</code></td><td>Structured report data for PDF generation (owner, driver, insurance, vehicle, accident sections).</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>expert-insurer/files/:publicId/rating</code></td><td>Rate the experts on a file (1–5 per dimension: collision method, timeliness, cause accuracy, guilty-ID accuracy, bot rating).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/report/unified-file-statuses</code></td><td>Unified status catalog + per-status counts for the whole tenant portfolio. Filterable by fileType and date range.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/report/status-counts</code></td><td class="dep">Deprecated — prefer unified-file-statuses.</td></tr>
</table>
</div>
<div class="card card-blue">
<h3>Branch management — <code>expert-insurer/branches</code></h3>
<table>
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/branches</code></td><td>List all branches for this insurer. Query: search, from/to date, isActive filter.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>expert-insurer/branches</code></td><td>Add a new branch (name, code, address, city, phone, etc.).</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>expert-insurer/branches/:branchId/status</code></td><td>Activate or deactivate a branch.</td></tr>
</table>
</div>
<div class="card card-blue">
<h3>Expert roster management — <code>expert-insurer/experts</code></h3>
<table>
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>expert-insurer/experts/blame</code></td><td>Create a new blame-expert account under this insurer.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>expert-insurer/experts/claim</code></td><td>Create a new damage-expert (claim) account under this insurer.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>expert-insurer/experts/file-maker</code></td><td>Create a new FileMaker account under this insurer.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>expert-insurer/experts/file-reviewer</code></td><td>Create a new FileReviewer account under this insurer.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/experts/list</code></td><td>Paginated list of all expert accounts on this tenant.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/experts/top</code></td><td>Top blame vs claim experts ranked by overall average rating (up to 10 each).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/top-experts</code></td><td>Alias for experts/top (frontend compat).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/:expertId</code></td><td>Files handled by one expert (slim summary rows — blame or claim depending on expert type).</td></tr>
</table>
</div>
<div class="card card-blue">
<h3>Statistics &amp; reports</h3>
<table>
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/statistics</code></td><td>KPI cards: totalFilesReviewed, averageUserRating, inPersonCount, filesThisMonth, objectionPercentage, etc. Filterable by date range.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/top-files</code></td><td>Top 10 highest-rated claim files (combined insurer + user score).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/expert-work-log</code></td><td>Per-expert work log: totalHandled, currentlyChecking, distinctFilesCheckedInPeriod. Filterable by expertKind and date range.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>reports/report/insurer/requests</code></td><td>Claim + blame status bucket counts + unified file count for the tenant.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>reports/report/insurer/per-month-requests</code></td><td>Same summary, broken down by the last 5 calendar months.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>reports/report/insurer/checked-requests</code></td><td>Same summary filtered by optional createdAt date range.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>reports/report/insurer/expert-work-log</code></td><td>Expert work log (blame + damage expert collections, not field experts).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>reports/report/insurer/expert-work-log/per-month</code></td><td>Same work log per calendar month (last 5).</td></tr>
</table>
</div>
<div class="card card-blue">
<h3>Tenant settings — <code>client-panel/</code></h3>
<table>
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>client-panel/settings</code></td><td>Get per-tenant media limits (video/image/voice maxBytes) and CAR_BODY accident window (days).</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>client-panel/settings</code></td><td>Update those settings (partial). Cannot exceed system-level route ceilings.</td></tr>
</table>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="blame-expert">2 — Blame Expert <span class="role-enum">expert</span></h2>
<p class="section-intro">
Reviews blame files in the DISAGREEMENT queue — cases where the two parties do not
agree on who is at fault. After reviewing submitted documents and party statements
the expert locks the case, then either submits a verdict, asks the parties to resend
documents, or records an in-person visit outcome. All endpoints are under
<code>v2/expert-blame/</code>.
</p>
<div class="card card-orange">
<h3>Process</h3>
<p class="note">
1 Browse list → 2 Assign (lock) the case → 3 Review party evidence (videos, voices, documents) →
4a Submit verdict <em>or</em> 4b Request document resend <em>or</em> 4c Record in-person visit.
</p>
</div>
<div class="card card-orange">
<h3>Endpoints — <code>v2/expert-blame/</code></h3>
<table>
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-blame/</code></td><td>List blame cases in the DISAGREEMENT queue (available, locked by me, or decided by me). Query: search, sortBy, sortOrder, page, limit, unifiedStatus, fileType.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-blame/:id</code></td><td>Full detail for one blame case (party statements, photos, voices, videos).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>v2/expert-blame/:id/assign</code></td><td>Check availability and lock the case to this expert. Returns <code>assigned</code>, <code>already_assigned_to_you</code>, or 409 if someone else holds it.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-blame/reply/submit/:id</code></td><td>Submit verdict (accidentWay, accidentReason, accidentType, guilty party decision). Unlocks the case and moves it to COMPLETED.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-blame/reply/resend/:id</code></td><td>Request parties to re-upload documents. Sets blame to WAITING_FOR_RESEND. One resend request per lifecycle.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-blame/reply/inPerson/:id</code></td><td>Record that an in-person visit was made and submit verdict.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-blame/report/unified-file-statuses</code></td><td>Status catalog + per-status counts for this expert's portfolio.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-blame/report/status-counts</code></td><td class="dep">Deprecated — prefer unified-file-statuses.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-blame/lock/:id</code></td><td class="dep">Deprecated lock endpoint — use POST assign.</td></tr>
</table>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="damage-expert">3 — Damage Expert <span class="role-enum">damage_expert</span></h2>
<p class="section-intro">
Reviews claim files after the user has submitted their damage evidence. The expert
prices each damaged part, optionally calculates a price-drop (depreciation), and
can ask the user to resend documents, come in person, or upload repair factor invoices
when workshop pricing is needed. All endpoints are under <code>v2/expert-claim/</code>.
</p>
<div class="card card-red">
<h3>Process</h3>
<p class="note">
1 Browse list → 2 Assign (lock) the claim → 3 Review damage photos and documents →
4 Optionally edit selected parts or calculate price-drop →
5a Submit priced reply <em>or</em> 5b Request resend <em>or</em> 5c Request in-person visit →
6 If factor parts present: validate uploaded factor invoices.
</p>
</div>
<div class="card card-red">
<h3>Endpoints — <code>v2/expert-claim/</code></h3>
<table>
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/requests</code></td><td>List claims in <code>WAITING_FOR_DAMAGE_EXPERT</code> queue + factor-validation queue. Query: search, sortBy, page, limit, unifiedStatus, fileType.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/request/:claimRequestId</code></td><td>Full claim detail: damaged parts, captured images, documents, priceDrop, blameCase party data, video URLs, and the effective <code>priceCap</code> (530,000,000 Rial for V1; <code>null</code> for V2–V6 or when disabled). Completed claims include Fanavaran claimNo / claimId when available.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>v2/expert-claim/assign/:claimRequestId</code></td><td>Lock claim to this expert. Returns <code>assigned</code>, <code>already_assigned_to_you</code>, or 409.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/request/:claimRequestId/price-drop</code></td><td>Price-drop context: severity labels, coefficient catalog, damaged parts + mapping, suggested car year from blame inquiry.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/request/:claimRequestId/price-drop</code></td><td>Calculate and persist price-drop: carPrice × yearCoeff × sumOfCoeffs ÷ 400.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/reply/submit/:claimRequestId</code></td><td>Submit damage assessment reply (priced parts list and daghi). <code>daghi.branchId</code> is used only with the <code>تحویل داغی</code> option. V1 only: total ≤ 530,000,000 Rial; V2–V6 are uncapped. A priced-only claim completes immediately; factor claims continue through factor collection/validation. No final owner signature or automatic Fanavaran submission.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/reply/resend/:claimRequestId</code></td><td>Request user to resend documents/photos. One resend per claim lifecycle; returns 422 if already fulfilled.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/:claimRequestId/visit</code></td><td>Ask user to come in person. Unlocks claim, sets claimStatus to NEEDS_REVISION.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/validate-factors/:claimRequestId</code></td><td>Validate uploaded repair factor invoices. Approve or reject each factor line with totalPayment. The 530,000,000 Rial all-lines cap applies only to V1. Auto-completes when all lines are decided.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/request/:claimRequestId/damaged-parts</code></td><td>Edit selected damaged parts while the claim is locked by this expert (EXPERT_REVIEWING).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/outer-parts-catalog</code></td><td>Fanavaran outer car-components catalog (shared with user flow).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/inner-parts-catalog</code></td><td>Static inner car-parts catalog JSON.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/branches</code></td><td>Insurer branches for this expert's tenant (for daghi/branch selection in reply payload).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/stream/:id/video</code></td><td>Stream claim video (car-capture walk-around or accident video). Query: <code>query=car-capture|accident</code>.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/report/unified-file-statuses</code></td><td>Status catalog + counts for this expert's claim portfolio.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/report/status-counts</code></td><td class="dep">Deprecated — prefer unified-file-statuses.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/lock/:claimRequestId</code></td><td class="dep">Deprecated lock endpoint — use POST assign.</td></tr>
</table>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="field-expert">4 — Field Expert <span class="role-enum">field_expert</span></h2>
<p class="section-intro">
Goes to the accident scene and fills both parties' forms in-person (V2 mirror / V3 flows).
The field expert also has read access to the expert-blame and expert-claim panels
(scoped to their own files). They are the only role that spans both
<strong>blame filing</strong> and <strong>claim filing</strong> in the same session.
</p>
<div class="card card-green">
<h3>Blame filing — <code>v2/expert-initiated/blame-request-management/</code></h3>
<p class="note">Mirror of the user blame API. Frontend reuses same pages by swapping prefix only.</p>
<table>
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>POST /</code></td><td>Create IN_PERSON blame file.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>send-party-otp/:id</code></td><td>Send OTP to one party by phone number (no invite link).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>verify-party-otp/:id</code></td><td>Verify one party's OTP and bind their account.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>blame-confession/:id</code></td><td>Record party's blame confession.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>car-body-form/:id</code></td><td>[CAR_BODY only] Accident type form.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>run-inquiries/:id</code> / <code>run-inquiries-vin/:id</code></td><td>Initial form / plate or VIN inquiry for current party.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-video/:id</code></td><td>Upload first-party video.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>add-detail-location/:id</code></td><td>Add GPS location for current party.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-voice/:id</code></td><td>Upload voice recording for current party.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>add-detail-description/:id</code></td><td>Add description for current party.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>add-second-party/:phone/:id/</code></td><td>Advance to second party (no SMS link sent).</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>sign/:id</code></td><td>Upload party signature (FIRST then SECOND, partyRole param).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>accident-fields/:id</code></td><td>Save accident fields and complete blame immediately (no expert queue).</td></tr>
</table>
</div>
<div class="card card-green">
<h3>V3 blame + claim filing — <code>v3/expert-initiated/blame-request-management/</code></h3>
<p class="note">Reorganised step order: all party narrative first, then damage assessment. Blame and claim both handled in this single controller.</p>
<table>
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>POST /</code> → <code>send-party-otp</code> → <code>verify-party-otp</code> → <code>run-inquiries</code> → <code>add-detail-*</code> → <code>sign</code> (×2)</td><td>Party narrative phase (steps 1–8) — identical endpoints to mirror, same contract.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>accident-fields/:id</code></td><td>Step 9: save accident fields after both parties have signed.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>claim-id/:requestId</code></td><td>Step 10: get auto-created claim ID.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-document/:claimId</code></td><td>Step 11: upload licence / car card documents.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>select-outer-parts/:claimId</code> / <code>select-other-parts/:claimId</code></td><td>Steps 12–13: select damaged parts.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>capture-part/:claimId</code></td><td>Step 14: capture part photos + angles.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>car-capture/:claimId</code></td><td>Step 15: walk-around video.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-video/:requestId</code></td><td>Step 16: blame accident video (final) → WAITING_FOR_EXPERT (THIRD_PARTY) or COMPLETED (CAR_BODY).</td></tr>
</table>
</div>
<div class="card card-green">
<h3>Expert-blame + expert-claim panel (read + action on own files)</h3>
<p class="note">
FIELD_EXPERT sees <code>v2/expert-blame/</code> scoped to their own created files (not the disagreement queue).
They also see <code>v2/expert-claim/</code> for claims linked to their blame files.
Same endpoints as blame-expert and damage-expert panels above.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="file-maker">5 — File Maker <span class="role-enum">file_maker</span></h2>
<p class="section-intro">
The first actor in the V4/V5 split. FileMaker handles the party narrative on-site:
OTPs, inquiries, location/description/voice, and signatures for both parties.
They also upload the initial claim documents (licences, car cards). After the second
signature the file is "sealed" for FileReviewer pickup. In V5, FileMaker comes
back at the end to approve or reject the completed claim. After approval, an
expert submits the case to Fanavaran manually.
</p>
<div class="card card-purple">
<h3>Blame filing — <code>v4/file-maker/blame-request-management/</code> and <code>v5/…</code></h3>
<p class="note">V4 and V5 endpoints are identical — only the prefix changes. V5 sets <code>requiresFileMakerApproval=true</code> at creation.</p>
<table>
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>POST /</code></td><td>Create IN_PERSON blame file.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>my-files</code></td><td>List all blame files created by this FileMaker.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>my-files/:requestId</code></td><td>Full detail for one file (parties, workflow, linked claim ID).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>claim-id/:requestId</code></td><td>Get the auto-created claim ID after guilty-party run-inquiries.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>send-party-otp/:id</code> / <code>verify-party-otp/:id</code></td><td>Send + verify OTP for one party at a time (guilty first, then damaged).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>car-body-form/:id</code></td><td>[CAR_BODY only] Accident type form.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>run-inquiries/:id</code> / <code>run-inquiries-vin/:id</code></td><td>Run plate or VIN inquiry. First call = guilty (+ auto-creates claim). Second call = damaged (THIRD_PARTY only).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>add-detail-location/:id</code> / <code>add-detail-description/:id</code> / <code>upload-voice/:id</code></td><td>Add location, description, and voice for current party (partyRole param selects FIRST/SECOND).</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>sign/:id</code></td><td>Upload party signature (partyRole=FIRST then SECOND). After second signature, file is sealed.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-document/:claimId</code></td><td>Upload licences / car cards against the auto-created claim.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>capture-requirements/:claimId</code></td><td>Step-aware capture requirements (phases: pre-capture docs vs damaged parts + chassis/engine).</td></tr>
</table>
</div>
<div class="card card-purple">
<h3>V5 claim approval — <code>v5/file-maker/claim-approval/</code></h3>
<p class="note">Used only in V5. After damage expert review and any required factor validation, claim enters <code>WAITING_FOR_FILE_MAKER_APPROVAL</code>; no final owner signature is needed.</p>
<table>
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>approve/:claimId</code></td><td>Approve the completed claim → claim becomes <code>COMPLETED</code>. An expert submits to Fanavaran manually when ready.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>reject/:claimId</code></td><td>Reject back to FileReviewer → claim returns to WAITING_FOR_DAMAGE_EXPERT. Limit: max 2 rejections per claim; 3rd attempt returns 422 <code>FILE_MAKER_REJECTION_LIMIT_EXCEEDED</code>.</td></tr>
</table>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="file-reviewer">6 — File Reviewer <span class="role-enum">file_reviewer</span></h2>
<p class="section-intro">
The second actor in the V4/V5 split. FileReviewer picks up sealed files (after
FileMaker is done) and performs the full damage assessment pass: accident fields,
capture requirements lookup, document upload (chassis/engine), part selection,
part photos and walk-around video. The blame is marked COMPLETED
by car-capture. FileReviewer also has read access to the expert-claim panel for
claims they are reviewing.
</p>
<div class="card card-teal">
<h3>Damage assessment — <code>v4/file-reviewer/blame-request-management/</code> and <code>v5/…</code></h3>
<p class="note">V4 and V5 endpoints are identical — only the prefix changes.</p>
<table>
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>my-files</code></td><td>List FileMaker-sealed files available to claim in this reviewer’s insurer, plus files already assigned to this FileReviewer.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>my-files/:requestId</code></td><td>Full detail for one available or assigned file in this reviewer’s insurer (parties, workflow, expert fields, linked claim ID).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>claim-id/:requestId</code></td><td>Get the auto-created claim ID (from FileMaker's guilty-party inquiry).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>accident-fields/:requestId</code></td><td>Step 1 (FileReviewer): save accident fields (accidentWay, accidentReason, accidentType).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>capture-requirements/:claimId</code></td><td>Step-aware capture requirements (pre-capture docs phase vs capture-parts phase).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-document/:claimId</code></td><td>Upload chassis / engine / metal-plate documents.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>select-outer-parts/:claimId</code></td><td>Select outer (body) damaged parts.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>select-other-parts/:claimId</code></td><td>Select other (non-body) damaged parts.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>capture-part/:claimId</code></td><td>Capture part photos + angles for each selected damaged part.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>car-capture/:claimId</code></td><td>Walk-around video (final FileReviewer capture step). Claim → <code>WAITING_FOR_DAMAGE_EXPERT</code>, blame → <code>COMPLETED</code>.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>claim-sign/:claimId</code></td><td>For mixed priced/factor claims only: record acceptance of priced lines before factor uploads. A final owner signature is no longer required.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-video/:requestId</code></td><td class="dep">No-op in V4/V5 — blame already COMPLETED by car-capture. Returns idempotent success.</td></tr>
</table>
</div>
<div class="card card-teal">
<h3>Expert-claim panel access</h3>
<p class="note">
FILE_REVIEWER is in the allowed roles for <code>v2/expert-claim/</code>.
They can view claim details and run the assign/lock flow for claims associated
with their files. They cannot initiate a damage-expert resend independently.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="registrar">7 — Registrar <span class="role-enum">registrar</span></h2>
<p class="section-intro">
Office-based role that files in-person blame and claim on behalf of parties.
Uses a bulk-OTP flow (both parties' OTPs sent and verified in one call each)
rather than the one-at-a-time OTP used by field experts. After blame, the
registrar mirrors the user claim API to fill part selection, documents, and
captures. The file then enters the normal damage-expert review lifecycle.
</p>
<div class="card card-gray">
<h3>Blame filing — <code>registrar-initiated-blame/</code></h3>
<p class="note">Note: <code>@ApiExcludeController</code> — routes exist but not surfaced in Swagger docs.</p>
<table>
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>registrar-initiated-blame/create</code></td><td>Create IN_PERSON blame file.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>registrar-initiated-blame/my-files</code></td><td>List all blame files created by this registrar.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>registrar-initiated-blame/blame/:requestId</code></td><td>Full detail for one blame file.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>registrar-initiated-blame/send-party-otps/:id</code></td><td>Send OTPs to both parties simultaneously.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>registrar-initiated-blame/verify-party-otps/:id</code></td><td>Verify both parties' OTPs in one call.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>registrar-initiated-blame/complete-blame-data/:id</code></td><td>Submit all blame form data for both parties in one payload.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>registrar-initiated-blame/upload-video/:id</code></td><td>Upload blame video.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>registrar-initiated-blame/upload-voice/:id</code></td><td>Upload voice recording.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>registrar-initiated-blame/add-accident-fields/:id</code></td><td>Save accident fields and complete blame.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>registrar-initiated-blame/upload-party-signature/:id</code></td><td>Upload a party's signature (partyRole=FIRST/SECOND).</td></tr>
</table>
</div>
<div class="card card-gray">
<h3>Claim filing — <code>v2/registrar/claim-request-management/</code></h3>
<p class="note">Mirror of the user claim API. Frontend reuses same claim pages by swapping prefix only.</p>
<table>
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>create-from-blame/:blameId</code></td><td>Create claim from a completed blame file.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>outer-parts-catalog</code> / <code>car-other-part</code></td><td>Parts catalogs (outer body parts + other parts JSON).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>branches/:insuranceId</code></td><td>Insurer branch list for <code>daghi.branchId</code> when the expert selects the <code>تحویل داغی</code> option.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>select-outer-parts/:claimId</code></td><td>Select outer damaged parts.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>select-other-parts/:claimId</code></td><td>Select other damaged parts + bank info.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-document/:claimId</code></td><td>Upload claim documents (licences, car card).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>capture-part/:claimId</code></td><td>Capture part photos + angles.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>car-capture/:claimId</code></td><td>Walk-around video (final step) → WAITING_FOR_DAMAGE_EXPERT.</td></tr>
</table>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="call-center">8 — Call Center <span class="role-enum">call_center</span></h2>
<p class="section-intro">
Handles V6 phone-initiated blame filing. The agent collects the guilty party's
data over the phone, runs the insurance inquiry, and sends the blame link via
SMS. The user then completes the rest of the form through the standard V2 flow
(with the initial-form/inquiry step skipped). The call-center agent's job ends
after send-link; they can monitor progress via the read endpoints.
</p>
<div class="card card-indigo">
<h3>Endpoints — <code>v6/call-center-blame/</code></h3>
<table>
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>create</code></td><td>Create a LINK blame file. Body: <code>{ type: "THIRD_PARTY" | "CAR_BODY" }</code>.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>run-inquiry/:requestId</code></td><td>Run plate + national-code insurance inquiry for the guilty party. Stores result on blame document.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>run-inquiry-vin/:requestId</code></td><td>VIN/chassis alternative to run-inquiry. Uses ESG chassis lookup.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>send-link/:requestId</code></td><td>Register user if needed, store as first party, send blame invite link via SMS. Body: <code>{ phoneNumber }</code>.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>my-files</code></td><td>List all blame files started by this agent.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>blame/:requestId</code></td><td>Current status and workflow step for one file (to check if user has opened the link and progressed).</td></tr>
</table>
<p class="note" style="margin-top:8px;">
After <code>send-link</code> the user completes the form via
<code>v2/blame-request-management/</code> (standard V2 flow).
The initial-form / inquiry step is automatically skipped
(<code>skipInitialFormStep=true</code>). Downstream claim flow is the
standard V2 claim flow.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2>Shared: Actor Authentication</h2>
<p class="section-intro">
All panel actors (every role except <code>user</code>) authenticate through the same
<code>POST actor/login</code> endpoint with captcha. Password reset is via email OTP.
Profile reads and edits are also shared.
</p>
<div class="card card-gray">
<h3>Endpoints — <code>actor/</code></h3>
<table>
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>actor/captcha</code></td><td>Issue a new login captcha challenge (returns captchaId + SVG image).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>actor/login</code></td><td>Authenticate any actor role. Body: role, username/email/nationalCode, password, captchaId, captcha. Returns JWT access + refresh tokens.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>actor/forget-password</code></td><td>Send password-reset OTP to email.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>actor/forget-password-verify</code></td><td>Verify OTP and set new password.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>actor/profile</code></td><td>Get current actor's profile.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>actor/profile</code></td><td>Update current actor's profile.</td></tr>
</table>
</div>
<footer>Made by Sepehr</footer>
</div>
</body>
</html>

View File

@@ -0,0 +1,402 @@
# مستند فرانت‌اند API گزارش PDF پرونده
## هدف API
این API داده‌ی ساخت‌یافته‌ی لازم برای تولید PDF پرونده در پنل بیمه‌گر را برمی‌گرداند.
خروجی آن ترکیبی از اطلاعات پرونده‌ی تقصیر (`blame`) و پرونده‌ی خسارت (`claim`) است و طوری طراحی شده که فرانت‌اند بدون وابستگی به مدل‌های داخلی بک‌اند، فقط با `sections` و `fields` بتواند PDF را رندر کند.
---
## آدرس API
```http
GET /expert-insurer/files/:publicId/report
```
### پارامتر مسیر
- `publicId`: شناسه عمومی پرونده
---
## ساختار کلی پاسخ
```json
{
"title": "گزارش پرونده بیمه گر",
"publicId": "RPT832-00406",
"requestNo": "BL-RPT832-000406",
"sections": []
}
```
### فیلدهای سطح بالا
#### `title`
عنوان کلی گزارش.
#### `publicId`
شناسه عمومی پرونده.
#### `requestNo`
شماره درخواست، اگر در داده‌های پرونده موجود باشد.
#### `sections`
آرایه‌ای از سکشن‌های گزارش.
هر سکشن یک عنوان دارد و شامل تعدادی ردیف اطلاعات (`fields`) است.
---
## ساختار هر سکشن
```json
{
"title": "زمان‌بندی پرونده",
"fields": [
{
"label": "تاریخ و ساعت ثبت پرونده",
"value": "1405/06/02 07:02"
}
]
}
```
### `title`
عنوان فارسی سکشن، آماده‌ی نمایش در PDF.
### `fields`
لیست ردیف‌های اطلاعاتی همان سکشن.
---
## ساختار هر فیلد
```json
{
"label": "شماره بیمه‌نامه",
"value": "POL-12345"
}
```
### `label`
عنوان فارسی فیلد.
### `value`
مقدار فیلد.
برای نمایش مستقیم در PDF استفاده می‌شود.
---
## سکشن‌های ممکن در پاسخ
سکشن‌ها معمولاً با ترتیب زیر برمی‌گردند، ولی فرانت‌اند بهتر است به‌جای تکیه بر ایندکس آرایه، سکشن را با `title` پیدا کند:
1. `زمان‌بندی پرونده`
2. `مالک خودروی زیان دیده`
3. `مالک خودروی مقصر`
4. `راننده خودروی زیان دیده`
5. `بیمه شخص ثالث زیان‌دیده`
6. `بیمه بدنه زیان‌دیده`
7. `بیمه شخص ثالث مقصر`
8. `بیمه بدنه مقصر`
9. `اطلاعات خودروی زیان‌دیده`
10. `اطلاعات خودروی مقصر`
11. `اظهارات و اقرار زیان‌دیده`
12. `اظهارات و اقرار مقصر`
13. `کدهای فناوران`
14. `نتیجه ارزیابی`
15. `گزارش حادثه`
نکته:
- بعضی سکشن‌ها بسته به نوع پرونده ممکن است وجود نداشته باشند.
- در پرونده‌های بدنه (`CAR_BODY`) اگر طرفین عملاً یک نفر باشند، سکشن‌های مربوط به مقصر ممکن است حذف شوند.
- در پرونده‌های شخص ثالث (`THIRD_PARTY`) انتظار می‌رود اطلاعات هر دو طرف به‌صورت تفکیک‌شده برگردد.
---
# توضیح سکشن‌ها
## 1) `زمان‌بندی پرونده`
برای نمایش زمان‌های مهم پرونده.
فیلدهای مهم:
- `تاریخ و ساعت ثبت پرونده`
- `تاریخ و ساعت ثبت نتیجه ارزیابی`
نکته:
- این تاریخ‌ها در بک‌اند فرمت شده‌اند و آماده‌ی نمایش هستند.
---
## 2) `مالک خودروی زیان دیده`
اطلاعات مالک یا صاحب خودروی زیان‌دیده.
فیلدهای رایج:
- `نام`
- `شماره تلفن`
- `کد ملی`
- `تاریخ تولد`
- `شماره شبا`
نکته:
- `شماره شبا` معمولاً برای زیان‌دیده مهم است و ممکن است فقط در همین سکشن وجود داشته باشد.
---
## 3) `مالک خودروی مقصر`
اطلاعات مالک خودروی مقصر.
فیلدهای رایج:
- `نام`
- `شماره تلفن`
- `کد ملی`
- `تاریخ تولد`
نکته:
- این سکشن مخصوص پرونده‌های شخص ثالث اهمیت دارد تا اطلاعات مالک هر دو طرف در PDF موجود باشد.
---
## 4) `راننده خودروی زیان دیده`
اگر راننده با مالک/بیمه‌گذار متفاوت باشد، این سکشن برمی‌گردد.
فیلدهای رایج:
- `نام`
- `نوع گواهینامه`
- `تاریخ گواهینامه`
- `شماره تلفن`
- `کد ملی`
- `تاریخ تولد`
- `شماره گواهینامه`
نکته:
- اگر راننده و مالک یکی باشند، این سکشن ممکن است وجود نداشته باشد.
---
## 5) `بیمه شخص ثالث زیان‌دیده`
اطلاعات بیمه شخص ثالث طرف زیان‌دیده.
فیلدهای رایج:
- `شماره بیمه‌نامه`
- `شرکت بیمه`
- `تاریخ شروع بیمه‌نامه`
- `تاریخ پایان بیمه‌نامه`
- `سقف تعهد مالی`
- `پوشش‌ها`
---
## 6) `بیمه بدنه زیان‌دیده`
اطلاعات بیمه بدنه‌ی طرف زیان‌دیده.
فیلدهای رایج:
- `شماره بیمه‌نامه`
- `شرکت بیمه`
- `تاریخ شروع بیمه‌نامه`
- `تاریخ پایان بیمه‌نامه`
- `پوشش‌ها`
---
## 7) `بیمه شخص ثالث مقصر`
اطلاعات بیمه شخص ثالث طرف مقصر.
فیلدهای رایج:
- `شماره بیمه‌نامه`
- `شرکت بیمه`
- `تاریخ شروع بیمه‌نامه`
- `تاریخ پایان بیمه‌نامه`
- `سقف تعهد مالی`
- `پوشش‌ها`
---
## 8) `بیمه بدنه مقصر`
اطلاعات بیمه بدنه‌ی طرف مقصر.
فیلدهای رایج:
- `شماره بیمه‌نامه`
- `شرکت بیمه`
- `تاریخ شروع بیمه‌نامه`
- `تاریخ پایان بیمه‌نامه`
- `پوشش‌ها`
---
## 9) `اطلاعات خودروی زیان‌دیده`
جزئیات خودروی زیان‌دیده.
فیلدها می‌توانند شامل موارد زیر باشند:
- `خودرو / پلاک`
- `خودرو / نام خودرو`
- `خودرو / مدل خودرو`
- `خودرو / نوع خودرو`
- `VIN`
- `شماره موتور`
- `شماره شاسی`
- `رنگ اصلی`
- `رنگ فرعی`
- `سیستم`
- `تیپ`
- `کاربری`
- `ظرفیت`
- `تعداد سیلندر`
نکته:
- بسته به منبع داده، ممکن است بعضی فیلدها با برچسب‌های نزدیک به هم ولی از دو منبع مختلف برگردند.
---
## 10) `اطلاعات خودروی مقصر`
جزئیات خودروی طرف مقصر.
فیلدها مشابه سکشن خودروی زیان‌دیده هستند.
---
## 11) `اظهارات و اقرار زیان‌دیده`
اطلاعات مربوط به اظهارات طرف زیان‌دیده.
فیلدهای رایج:
- `نقش طرف`
- `نام`
- `ادعای خسارت`
- `پذیرش نظر کارشناس`
- `توضیحات طرف`
نکته:
- معمولاً `اقرار به تقصیر` برای زیان‌دیده نمایش داده نمی‌شود.
---
## 12) `اظهارات و اقرار مقصر`
اطلاعات مربوط به اظهارات طرف مقصر.
فیلدهای رایج:
- `نقش طرف`
- `نام`
- `اقرار به تقصیر`
- `پذیرش نظر کارشناس`
- `توضیحات طرف`
نکته:
- معمولاً `ادعای خسارت` برای مقصر نمایش داده نمی‌شود.
---
## 13) `کدهای فناوران`
کدها و شناسه‌های فنی مرتبط با پرونده در فناوران.
فیلدهای ممکن:
- `شماره پرونده فناوران`
- `کد پرونده فناوران`
- `کد کیس خسارت فناوران`
- `کد کارشناسی فناوران`
- `کد بیمه‌نامه فناوران`
- `کد راننده فناوران`
- `کد نوع خودرو فناوران`
- `کد شرکت بیمه فناوران`
---
## 14) `نتیجه ارزیابی`
اطلاعات نتیجه‌ی ارزیابی کارشناس خسارت.
فیلدهای مهم:
- `نتیجه ارزیابی`
- `کارشناس ارزیاب`
- `تاریخ و ساعت ثبت ارزیابی`
- `پاسخ / توضیحات کارشناس`
---
## 15) `گزارش حادثه`
خلاصه‌ی اطلاعات حادثه، وضعیت پرونده و برخی خروجی‌های کارشناسی.
فیلدهای رایج:
- `تاریخ حادثه`
- `ساعت حادثه`
- `کارشناس(ان)`
- `موقعیت (عرض و طول جغرافیایی)`
- `وضعیت آب و هوا`
- `وضعیت جاده`
- `وضعیت نور`
- `وضعیت مقصر`
- `وضعیت خسارت`
- `نظر کارشناس مقصر`
- `نحوه برخورد`
- `علت حادثه`
- `نوع حادثه`
- `توضیحات طرف`
---
## نکات مهم برای فرانت‌اند
### 1) فقط بر اساس `sections` و `fields` رندر کنید
ساختار اصلی خروجی این است:
```ts
response.sections[].title
response.sections[].fields[].label
response.sections[].fields[].value
```
---
### 2) به ایندکس سکشن‌ها وابسته نشوید
ممکن است بعضی سکشن‌ها در بعضی پرونده‌ها وجود نداشته باشند.
بهتر است سکشن را با `title` پیدا کنید.
---
### 3) نبودن بعضی سکشن‌ها طبیعی است
مثلاً:
- `راننده خودروی زیان دیده`
- سکشن‌های مربوط به مقصر در بعضی پرونده‌های بدنه
- بعضی داده‌های فناوران
---
### 4) مقدار `-` یعنی داده‌ای برای نمایش وجود نداشته
اگر سکشنی داده‌ی واقعی نداشته باشد، ممکن است فقط این مقدار را داشته باشد:
```json
{
"label": "اطلاعات",
"value": "-"
}
```
---
### 5) برچسب‌ها فارسی و آماده‌ی نمایش هستند
فیلدهای `title` و `label` نیازی به ترجمه‌ی مجدد در فرانت‌اند ندارند.
---
## نمونه‌ی ساده‌ی رندر در فرانت‌اند
```ts
for (const section of response.sections) {
renderSectionTitle(section.title)
for (const field of section.fields) {
renderRow(field.label, field.value ?? "-")
}
}
```
---
## خلاصه
این API برای تولید PDF پرونده، داده‌ها را به‌صورت کامل و تفکیک‌شده برمی‌گرداند، از جمله:
- زمان‌بندی پرونده
- اطلاعات مالک زیان‌دیده
- اطلاعات مالک مقصر
- اطلاعات راننده در صورت متفاوت بودن
- بیمه‌نامه‌های تفکیک‌شده‌ی ثالث و بدنه برای هر طرف
- اطلاعات خودرو برای هر دو طرف
- اظهارات و اقرار هر دو طرف
- کدهای فناوران
- نتیجه ارزیابی و توضیحات کارشناس
- گزارش حادثه

1422
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"seed:parsian-tehran": "ts-node scripts/seed-parsian-tehran.ts",
"seed:reports-fixtures": "ts-node scripts/seed-insurer-reports-fixtures.ts",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
@@ -49,6 +50,7 @@
"svg-captcha": "^1.4.0"
},
"devDependencies": {
"@compodoc/compodoc": "^2.0.0",
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",

View File

@@ -2,143 +2,13 @@
"clientCode": 8,
"branches": [
{
"code": "100100",
"name": "واحدصدورالکترونيکي",
"fullName": "واحدصدورالکترونيکي(100100)",
"code": "210120",
"name": "شعبه والفجر",
"fullName": "شعبه والفجر(210120)",
"city": "تهران",
"state": "تهران",
"address": "خيابان وليعصر_بلوار ميرداماد_پلاک 22",
"phoneNumber": "8259",
"isActive": true
},
{
"code": "110011",
"name": "ستاد مرکزي",
"fullName": "ستاد مرکزي(110011)",
"city": "تهران",
"state": "تهران",
"address": "تهران، خيابان وليعصر، بالاتراز ميرداماد، خيابان قباديان غربي، پلاك22",
"phoneNumber": "8259",
"isActive": true
},
{
"code": "111130",
"name": "شعبه ويژه ميرداماد",
"fullName": "شعبه ويژه ميرداماد(111130)",
"city": "تهران",
"state": "تهران",
"address": "تهران، خيابان وليعصر، بالاتراز ميرداماد، خيابان قباديان غربي، پلاك22",
"phoneNumber": "8259",
"isActive": true
},
{
"code": "120021",
"name": "سرپرستي منطقه يک كشور",
"fullName": "سرپرستي منطقه يک كشور(120021)",
"city": "تهران",
"state": "تهران",
"address": "تهران،خيابان وليعصر ،خيابان قباديان غربي ،پلاک 22 ، طبقه همکف",
"phoneNumber": "0218259",
"isActive": true
},
{
"code": "130031",
"name": "سرپرستي منطقه مركزي كشور",
"fullName": "سرپرستي منطقه مركزي كشور(130031)",
"city": "تهران",
"state": "تهران",
"address": "اصفهان، خيابان امام خميني (ره) - بعد از چهارراه شريف - کوچه شهيد احمدي (85)",
"phoneNumber": "03133328257",
"isActive": true
},
{
"code": "140041",
"name": "سرپرستي منطقه شمالغرب کشور",
"fullName": "سرپرستي منطقه شمالغرب کشور(140041)",
"city": "تهران",
"state": "تهران",
"address": "تبريز- خيابان ائل گلي - فلکه خيام - نبش فلکه رجائي - بيمه پارسيان",
"phoneNumber": "04133832289",
"isActive": true
},
{
"code": "150051",
"name": "سرپرستي منطقه جنوب كشور",
"fullName": "سرپرستي منطقه جنوب كشور(150051)",
"city": "تهران",
"state": "تهران",
"address": "شيراز ـ فلکه فرودگاه (ميدان بسيج) ـ ابتداي بلوار سياحتگر",
"phoneNumber": "01738315473",
"isActive": true
},
{
"code": "150053",
"name": "سرپرست منطقه جنوب شرقي کشور",
"fullName": "سرپرست منطقه جنوب شرقي کشور(150053)",
"city": "کرمان",
"state": "کرمان",
"address": "کرمان، حافظ، بعد از چهارراه جامي، پلاک 153",
"phoneNumber": "03432718000",
"isActive": true
},
{
"code": "160061",
"name": "سرپرستي منطقه شرق كشور",
"fullName": "سرپرستي منطقه شرق كشور(160061)",
"city": "تهران",
"state": "تهران",
"address": "مشهد ـ خيام شمالي ـ نبش خيام شمالي 36",
"phoneNumber": "05137659005",
"isActive": true
},
{
"code": "170071",
"name": "سرپرستي منطقه غرب كشور",
"fullName": "سرپرستي منطقه غرب كشور(170071)",
"city": "تهران",
"state": "تهران",
"address": "کرمانشاه . ميدان مرکزي خيابان خرم نبش کوي بسيج ساختمان عرفان",
"phoneNumber": "08338431017",
"isActive": true
},
{
"code": "180081",
"name": "سرپرستي منطقه شمال شرق کشور",
"fullName": "سرپرستي منطقه شمال شرق کشور(180081)",
"city": "ساري",
"state": "مازندران",
"address": "ساري، شعبه ساري",
"phoneNumber": "01133207241",
"isActive": true
},
{
"code": "180083",
"name": "سرپرست منطقه شمال کشوري",
"fullName": "سرپرست منطقه شمال کشوري(180083)",
"city": "رشت",
"state": "گيلان",
"address": "رشت، بلوار آيت اله رودباري،کدپستي:4144761893",
"phoneNumber": "01333512135",
"isActive": true
},
{
"code": "190092",
"name": "سرپرستي جنوب غربي کشور",
"fullName": "سرپرستي جنوب غربي کشور(190092)",
"city": "اهواز",
"state": "خوزستان",
"address": "اهواز،تقاطع بلوار ساحلي گلستان (خيابان فروردين)،نبش خيابان نصرت شمالي ،پلاک 701 کد پستي 6155977139",
"phoneNumber": "06133743877",
"isActive": true
},
{
"code": "210040",
"name": "شعبه شرق تهران",
"fullName": "شعبه شرق تهران(210040)",
"city": "تهران",
"state": "تهران",
"address": "تهران، خيابان دماوند، بعداز چهارراه تهرانپارس، روبروي تعميرگاه مرکزي شماره يک سايپا، پلاک129",
"phoneNumber": "77393783-4",
"address": "تهران، اميرآبادشمالي، شهرک والفجر، ضلع جنوب غربي ميدان استادخسرو سينايي",
"phoneNumber": "86051332",
"isActive": true
},
{
@@ -151,6 +21,26 @@
"phoneNumber": "66021968",
"isActive": true
},
{
"code": "110011",
"name": "ستاد مرکزي",
"fullName": "ستاد مرکزي(110011)",
"city": "تهران",
"state": "تهران",
"address": "تهران، خيابان وليعصر، بالاتراز ميرداماد، خيابان قباديان غربي، پلاك22",
"phoneNumber": "8259",
"isActive": true
},
{
"code": "210040",
"name": "شعبه شرق تهران",
"fullName": "شعبه شرق تهران(210040)",
"city": "تهران",
"state": "تهران",
"address": "تهران، خيابان دماوند، بعداز چهارراه تهرانپارس، روبروي تعميرگاه مرکزي شماره يک سايپا، پلاک129",
"phoneNumber": "77393783-4",
"isActive": true
},
{
"code": "210110",
"name": "شعبه پونک",
@@ -162,24 +52,14 @@
"isActive": true
},
{
"code": "210120",
"name": "شعبه والفجر",
"fullName": "شعبه والفجر(210120)",
"code": "111130",
"name": "شعبه ويژه ميرداماد",
"fullName": "شعبه ويژه ميرداماد(111130)",
"city": "تهران",
"state": "تهران",
"address": "تهران، اميرآبادشمالي، شهرک والفجر، ضلع جنوب غربي ميدان استادخسرو سينايي",
"phoneNumber": "86051332",
"isActive": true
},
{
"code": "210150",
"name": "شعبه شمال شرق تهران",
"fullName": "شعبه شمال شرق تهران(210150)",
"city": "تهران",
"state": "تهران",
"address": "تهران، ضلع شمال غربي ميدان بني هاشم، نبش خيابان كشوري، پلاك13",
"phoneNumber": "26244319",
"address": "تهران، خيابان وليعصر، بالاتراز ميرداماد، خيابان قباديان غربي، پلاك22",
"phoneNumber": "8259",
"isActive": true
}
]
}
}

View File

@@ -0,0 +1,464 @@
{
"clientCode": 8,
"fileMakers": [
{
"nationalCode": "0061077410",
"mobile": "09125864643",
"firstName": "احمد",
"lastName": "بستان پيرا",
"locations": [
{
"id": "210110",
"name": "شعبه پونک"
}
],
"ThirdPartyClaimExpertId": "32",
"CarBodyClaimExpertId": "33"
},
{
"nationalCode": "0492009856",
"mobile": "09128465836",
"firstName": "اسداله",
"lastName": "نجفي پور",
"locations": [
{
"id": "210040",
"name": "شعبه شرق تهران(210040)"
}
],
"ThirdPartyClaimExpertId": "91",
"CarBodyClaimExpertId": "92"
},
{
"nationalCode": "0062277553",
"mobile": "09125012274",
"firstName": "اسماعيل",
"lastName": "سلطانمحمدي",
"locations": [
{
"id": "210050",
"name": "شعبه غرب تهران(210050)"
}
],
"ThirdPartyClaimExpertId": "4221",
"CarBodyClaimExpertId": "4222"
},
{
"nationalCode": "0492295557",
"mobile": "09125409101",
"firstName": "اکبر",
"lastName": "بيگ محمدي",
"locations": [
{
"id": "111130",
"name": "شعبه ويژه ميرداماد"
},
{
"id": "110011",
"name": "ستاد مرکزي"
}
],
"ThirdPartyClaimExpertId": "278"
},
{
"nationalCode": "3220096573",
"mobile": "09104933206",
"firstName": "پدرام",
"lastName": "حاتمي",
"locations": [
{
"id": "210120",
"name": "شعبه والفجر"
}
],
"ThirdPartyClaimExpertId": "219"
},
{
"nationalCode": "0078954010",
"mobile": "09128894315",
"firstName": "پروانه",
"lastName": "آدابي",
"locations": [
{
"id": "210040",
"name": "شعبه شرق تهران(210040)"
}
],
"ThirdPartyClaimExpertId": "739",
"CarBodyClaimExpertId": "740"
},
{
"nationalCode": "0075127105",
"mobile": "09122938429",
"firstName": "حميد",
"lastName": "غوثي هوجقان",
"locations": [
{
"id": "210040",
"name": "شعبه شرق تهران(210040)"
}
],
"CarBodyClaimExpertId": "3215",
"ThirdPartyClaimExpertId": "3214"
},
{
"nationalCode": "0081114494",
"mobile": "09374439044",
"firstName": "داود",
"lastName": "صديق",
"locations": [
{
"id": "210120",
"name": "شعبه والفجر"
}
],
"ThirdPartyClaimExpertId": "222",
"CarBodyClaimExpertId": "223"
},
{
"nationalCode": "0084130938",
"mobile": "09392558640",
"firstName": "رسول",
"lastName": "کرکي",
"locations": [
{
"id": "210050",
"name": "شعبه غرب تهران(210050)"
}
],
"ThirdPartyClaimExpertId": "4662"
},
{
"nationalCode": "0014103788",
"mobile": "09118861247",
"firstName": "سجاد",
"lastName": "حشمت",
"locations": [
{
"id": "210040",
"name": "شعبه شرق تهران(210040)"
}
],
"ThirdPartyClaimExpertId": "4245",
"CarBodyClaimExpertId": "4246"
},
{
"nationalCode": "0065938100",
"mobile": "09122366860",
"firstName": "سهيلا",
"lastName": "شکوري قره چيق",
"locations": [
{
"id": "210040",
"name": "شعبه شرق تهران(210040)"
}
],
"CarBodyClaimExpertId": "89",
"ThirdPartyClaimExpertId": "88"
},
{
"nationalCode": "5779888949",
"mobile": "09371290042",
"firstName": "عاطفه",
"lastName": "نصيري",
"locations": [
{
"id": "210040",
"name": "شعبه شرق تهران(210040)"
}
],
"CarBodyClaimExpertId": "4455",
"ThirdPartyClaimExpertId": "4454"
},
{
"nationalCode": "0069608210",
"mobile": "09120766792",
"firstName": "عباس",
"lastName": "سلطاني محمدي",
"locations": [
{
"id": "210040",
"name": "شعبه شرق تهران(210040)"
}
],
"ThirdPartyClaimExpertId": "742",
"CarBodyClaimExpertId": "743"
},
{
"nationalCode": "0065409027",
"mobile": "09125045732",
"firstName": "علي",
"lastName": "اصلاني حاجي آبادي",
"locations": [
{
"id": "210120",
"name": "شعبه والفجر"
}
],
"ThirdPartyClaimExpertId": "4338",
"CarBodyClaimExpertId": "4339"
},
{
"nationalCode": "4570007309",
"mobile": "09104873190",
"firstName": "علي",
"lastName": "قرباني",
"locations": [
{
"id": "210120",
"name": "شعبه والفجر"
}
],
"CarBodyClaimExpertId": "4540",
"ThirdPartyClaimExpertId": "4539"
},
{
"nationalCode": "0077396881",
"mobile": "09120000001",
"firstName": "علي",
"lastName": "مهرجو",
"locations": [
{
"id": "210040",
"name": "شعبه شرق تهران(210040)"
}
],
"CarBodyClaimExpertId": "4652",
"ThirdPartyClaimExpertId": "4650"
},
{
"nationalCode": "0068289782",
"mobile": "09124196488",
"firstName": "عليرضا",
"lastName": "درگاهي",
"locations": [
{
"id": "210120",
"name": "شعبه والفجر"
}
],
"ThirdPartyClaimExpertId": "57",
"CarBodyClaimExpertId": "58"
},
{
"nationalCode": "0066868521",
"mobile": "09129344240",
"firstName": "عليرضا",
"lastName": "گودرزي پور",
"locations": [
{
"id": "210050",
"name": "شعبه غرب تهران(210050)"
}
],
"ThirdPartyClaimExpertId": "4663"
},
{
"nationalCode": "0061947466",
"mobile": "09125045283",
"firstName": "غزال",
"lastName": "عطائي",
"locations": [
{
"id": "210050",
"name": "شعبه غرب تهران(210050)"
}
],
"CarBodyClaimExpertId": "4220",
"ThirdPartyClaimExpertId": "4219"
},
{
"nationalCode": "0062500767",
"mobile": "09124464022",
"firstName": "غلامرضا",
"lastName": "حسن پوراقدم",
"locations": [
{
"id": "210050",
"name": "شعبه غرب تهران(210050)"
}
],
"CarBodyClaimExpertId": "4452",
"ThirdPartyClaimExpertId": "4451"
},
{
"nationalCode": "0019675526",
"mobile": "09120399833",
"firstName": "فاطمه",
"lastName": "عظيميان",
"locations": [
{
"id": "210120",
"name": "شعبه والفجر"
}
],
"CarBodyClaimExpertId": "4340",
"ThirdPartyClaimExpertId": "4341"
},
{
"nationalCode": "0056888082",
"mobile": "09122406750",
"firstName": "قاسم",
"lastName": "نصراللهي",
"locations": [
{
"id": "210050",
"name": "شعبه غرب تهران(210050)"
}
],
"ThirdPartyClaimExpertId": "4664"
},
{
"nationalCode": "3932143930",
"mobile": "09125255267",
"firstName": "محسن",
"lastName": "کرمي",
"locations": [
{
"id": "210120",
"name": "شعبه والفجر"
}
],
"CarBodyClaimExpertId": "4415",
"ThirdPartyClaimExpertId": "4414"
},
{
"nationalCode": "0013269755",
"mobile": "09195502061",
"firstName": "محمد",
"lastName": "ابراهيمي",
"locations": [
{
"id": "210050",
"name": "شعبه غرب تهران(210050)"
}
],
"CarBodyClaimExpertId": "4092",
"ThirdPartyClaimExpertId": "4091"
},
{
"nationalCode": "0011834803",
"mobile": "09127059125",
"firstName": "محمد",
"lastName": "ترابي",
"locations": [
{
"id": "210050",
"name": "شعبه غرب تهران(210050)"
}
],
"ThirdPartyClaimExpertId": "1010",
"CarBodyClaimExpertId": "1011"
},
{
"nationalCode": "0493217789",
"mobile": "09126966943",
"firstName": "مصطفي",
"lastName": "محمدزاده قورقچي",
"locations": [
{
"id": "210050",
"name": "شعبه غرب تهران(210050)"
}
],
"ThirdPartyClaimExpertId": "4665"
},
{
"nationalCode": "0080501281",
"mobile": "09356468730",
"firstName": "موسي",
"lastName": "موسي نژاد",
"locations": [
{
"id": "210040",
"name": "شعبه شرق تهران(210040)"
}
],
"CarBodyClaimExpertId": "745",
"ThirdPartyClaimExpertId": "744"
},
{
"nationalCode": "0078209129",
"mobile": "09126038117",
"firstName": "مهدي",
"lastName": "شاملوفرد",
"locations": [
{
"id": "210050",
"name": "شعبه غرب تهران(210050)"
}
],
"ThirdPartyClaimExpertId": "4666"
},
{
"nationalCode": "0074009141",
"mobile": "09339103762",
"firstName": "مهدي",
"lastName": "معمارباشي",
"locations": [
{
"id": "210120",
"name": "شعبه والفجر"
}
],
"ThirdPartyClaimExpertId": "216",
"CarBodyClaimExpertId": "217"
},
{
"nationalCode": "3720211207",
"mobile": "09379120944",
"firstName": "مهسا",
"lastName": "وطن نيا",
"locations": [
{
"id": "210110",
"name": "شعبه پونک"
}
],
"ThirdPartyClaimExpertId": "34",
"CarBodyClaimExpertId": "35"
},
{
"nationalCode": "0079616801",
"mobile": "09379669839",
"firstName": "مهيار",
"lastName": "کيائي",
"locations": [
{
"id": "210050",
"name": "شعبه غرب تهران(210050)"
}
],
"ThirdPartyClaimExpertId": "1004",
"CarBodyClaimExpertId": "1005"
},
{
"nationalCode": "0079815278",
"mobile": "09195883178",
"firstName": "ميگل",
"lastName": "ميرشکاري",
"locations": [
{
"id": "210040",
"name": "شعبه شرق تهران(210040)"
}
],
"ThirdPartyClaimExpertId": "4646",
"CarBodyClaimExpertId": "4648"
},
{
"nationalCode": "0010736638",
"mobile": "09355242492",
"firstName": "ناصر",
"lastName": "عيوضي",
"locations": [
{
"id": "210040",
"name": "شعبه شرق تهران(210040)"
}
],
"ThirdPartyClaimExpertId": "228",
"CarBodyClaimExpertId": "229"
}
]
}

View File

@@ -0,0 +1,161 @@
{
"clientCode": 8,
"fileReviewers": [
{
"nationalCode": "0051967839",
"mobile": "09121354859",
"firstName": "حسين",
"lastName": "جعفري",
"locations": [
{
"id": "210120",
"name": "شعبه والفجر"
}
],
"ThirdPartyExpertiseClaim": "3542",
"CarBodyExpertiseClaim": "3541"
},
{
"nationalCode": "0084130938",
"mobile": "09392558640",
"firstName": "رسول",
"lastName": "کرکي",
"locations": [
{
"id": "210050",
"name": "شعبه غرب تهران(210050)"
}
],
"ThirdPartyExpertiseClaim": "29",
"CarBodyExpertiseClaim": "28"
},
{
"nationalCode": "1262982308",
"mobile": "09130121246",
"firstName": "روح الله",
"lastName": "سلمانيان مقدم نياسري",
"locations": [
{
"id": "210120",
"name": "شعبه والفجر"
}
],
"CarBodyExpertiseClaim": "3705"
},
{
"nationalCode": "0066868521",
"mobile": "09129344240",
"firstName": "عليرضا",
"lastName": "گودرزي پور",
"locations": [
{
"id": "210050",
"name": "شعبه غرب تهران(210050)"
}
],
"CarBodyExpertiseClaim": "15",
"ThirdPartyExpertiseClaim": "16"
},
{
"nationalCode": "0440245151",
"mobile": "09130606183",
"firstName": "فرهاد",
"lastName": "ملکي مونقي",
"locations": [
{
"id": "110011",
"name": "ستاد مرکزي"
}
],
"ThirdPartyExpertiseClaim": "60",
"CarBodyExpertiseClaim": "61"
},
{
"nationalCode": "0056888082",
"mobile": "09122406750",
"firstName": "قاسم",
"lastName": "نصراللهي",
"locations": [
{
"id": "210050",
"name": "شعبه غرب تهران(210050)"
}
],
"ThirdPartyExpertiseClaim": "4645"
},
{
"nationalCode": "0670358118",
"mobile": "09124421539",
"firstName": "مجيد",
"lastName": "اميري",
"locations": [
{
"id": "210040",
"name": "شعبه شرق تهران(210040)"
}
],
"ThirdPartyExpertiseClaim": "3985",
"CarBodyExpertiseClaim": "3986"
},
{
"nationalCode": "0083730397",
"mobile": "09125759960",
"firstName": "مجيد",
"lastName": "کاظمي دولت سرا",
"locations": [
{
"id": "210120",
"name": "شعبه والفجر"
}
],
"ThirdPartyExpertiseClaim": "3992",
"CarBodyExpertiseClaim": "3991"
},
{
"nationalCode": "0493217789",
"mobile": "09126966943",
"firstName": "مصطفي",
"lastName": "محمدزاده قورقچي",
"locations": [
{
"id": "210050",
"name": "شعبه غرب تهران(210050)"
}
],
"ThirdPartyExpertiseClaim": "3545",
"CarBodyExpertiseClaim": "3544"
},
{
"nationalCode": "0076988961",
"mobile": "09108357378",
"firstName": "مهدي",
"lastName": "روشن دل",
"locations": [
{
"id": "210110",
"name": "شعبه پونک"
}
],
"CarBodyExpertiseClaim": "3988",
"ThirdPartyExpertiseClaim": "3989"
},
{
"nationalCode": "0078209129",
"mobile": "09126038117",
"firstName": "مهدي",
"lastName": "شاملوفرد",
"locations": [
{
"id": "210050",
"name": "شعبه غرب تهران(210050)"
},
{
"id": "210110",
"name": "شعبه پونک"
}
],
"CarBodyExpertiseClaim": "73",
"ThirdPartyExpertiseClaim": "72"
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,16 @@
/**
* One-time seed for Parsian (clientCode=8) Tehran branches + field experts.
* One-time seed for Parsian (clientCode=8) Tehran branches + file reviewers + file makers.
*
* Usage (before starting the app):
* npm run seed:parsian-tehran
*
* Optional env:
* SEED_PARSIAN_TEHRAN_DEFAULT_PASSWORD=Parsian@724
*
* Backward-compatible env alias:
* SEED_FIELD_EXPERT_DEFAULT_PASSWORD=Parsian@724
*/
import { readFileSync, existsSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import * as crypto from "node:crypto";
import mongoose, { Schema, Types } from "mongoose";
@@ -23,17 +26,29 @@ type BranchSeed = {
isActive?: boolean;
};
type FieldExpertSeed = {
type ExpertLocationSeed = {
id: string;
name: string;
};
type FileReviewerSeed = {
nationalCode: string;
mobile?: string;
firstName: string;
lastName: string;
branchCode: string;
branchName?: string;
city?: string;
state?: string;
title?: string;
expertCode?: string;
locations: ExpertLocationSeed[];
ThirdPartyExpertiseClaim?: string;
CarBodyExpertiseClaim?: string;
};
type FileMakerSeed = {
nationalCode: string;
mobile?: string;
firstName: string;
lastName: string;
locations: ExpertLocationSeed[];
ThirdPartyClaimExpertId?: string;
CarBodyClaimExpertId?: string;
};
function stripQuotes(value: string): string {
@@ -76,7 +91,6 @@ function loadEnvFile() {
process.env[key] = stripQuotes(value);
}
// Expand ${VAR} placeholders (same as Nest ConfigModule expandVariables).
for (let pass = 0; pass < 5; pass++) {
let changed = false;
for (const key of Object.keys(process.env)) {
@@ -110,7 +124,7 @@ function resolveMongoUri(): string {
return uri;
}
async function ensureFieldExpertIndexes(collection: mongoose.Collection) {
async function ensureUserIndexes(collection: mongoose.Collection) {
const indexes = await collection.indexes();
const emailIndex = indexes.find((idx) => idx.key?.email === 1);
if (emailIndex && !emailIndex.sparse) {
@@ -134,6 +148,17 @@ function hashPassword(password: string): Promise<string> {
});
}
function normalizeLocations(locations: ExpertLocationSeed[]): ExpertLocationSeed[] {
const deduped = new Map<string, ExpertLocationSeed>();
for (const location of locations ?? []) {
const id = String(location?.id ?? "").trim();
const name = String(location?.name ?? "").trim();
if (!id || !name || deduped.has(id)) continue;
deduped.set(id, { id, name });
}
return [...deduped.values()];
}
const ClientSchema = new Schema(
{
clientName: { type: Object, required: true },
@@ -159,7 +184,15 @@ const BranchSchema = new Schema(
BranchSchema.index({ clientKey: 1, code: 1 }, { unique: true });
const FieldExpertSchema = new Schema(
const ExpertLocationSchema = new Schema(
{
id: { type: String, required: true },
name: { type: String, required: true },
},
{ _id: false, id: false, versionKey: false },
);
const FileReviewerSchema = new Schema(
{
firstName: { type: String, required: true },
lastName: { type: String, required: true },
@@ -168,21 +201,180 @@ const FieldExpertSchema = new Schema(
nationalCode: { type: String, index: true, sparse: true },
clientKey: { type: Schema.Types.ObjectId, index: true },
branchId: { type: Schema.Types.ObjectId, index: true },
locations: { type: [ExpertLocationSchema], default: [] },
password: { type: String, required: true },
mobile: { type: String },
phone: { type: String },
role: { type: String, default: "field_expert" },
role: { type: String, default: "file_reviewer" },
otp: { type: String, default: "" },
expertCode: { type: String, required: false },
ThirdPartyExpertiseClaim: { type: String, required: false },
CarBodyExpertiseClaim: { type: String, required: false },
},
{ collection: "field-expert", versionKey: false, timestamps: true },
{ collection: "file-reviewer", versionKey: false, timestamps: true },
);
FieldExpertSchema.index(
FileReviewerSchema.index(
{ clientKey: 1, nationalCode: 1 },
{ unique: true, sparse: true },
);
const FileMakerSchema = new Schema(
{
firstName: { type: String, required: true },
lastName: { type: String, required: true },
email: { type: String, unique: true, sparse: true },
username: { type: String },
nationalCode: { type: String, index: true, sparse: true },
clientKey: { type: Schema.Types.ObjectId, index: true },
branchId: { type: Schema.Types.ObjectId, index: true },
locations: { type: [ExpertLocationSchema], default: [] },
password: { type: String, required: true },
mobile: { type: String },
phone: { type: String },
role: { type: String, default: "file_maker" },
otp: { type: String, default: "" },
expertCode: { type: String, required: false },
ThirdPartyClaimExpertId: { type: String, required: false },
CarBodyClaimExpertId: { type: String, required: false },
},
{ collection: "file-maker", versionKey: false, timestamps: true },
);
FileMakerSchema.index(
{ clientKey: 1, nationalCode: 1 },
{ unique: true, sparse: true },
);
function resolveSeedLocations(
seedLocations: ExpertLocationSeed[],
branchMetaByCode: Map<string, { _id: Types.ObjectId; name: string }>,
) {
const resolved: { id: string; name: string; branchId: Types.ObjectId }[] = [];
const skippedCodes: string[] = [];
for (const location of normalizeLocations(seedLocations)) {
const branch = branchMetaByCode.get(location.id);
if (!branch) {
skippedCodes.push(location.id);
continue;
}
resolved.push({
id: location.id,
name: branch.name || location.name,
branchId: branch._id,
});
}
return {
resolved,
skippedCodes,
primaryBranchId: resolved[0]?.branchId,
};
}
async function upsertRoleUsers({
label,
seeds,
model,
clientKey,
hashedPassword,
branchMetaByCode,
role,
codeFields,
}: {
label: string;
seeds: Array<Record<string, any>>;
model: mongoose.Model<any>;
clientKey: Types.ObjectId;
hashedPassword: string;
branchMetaByCode: Map<string, { _id: Types.ObjectId; name: string }>;
role: string;
codeFields: string[];
}) {
let created = 0;
let updated = 0;
let skipped = 0;
for (const seed of seeds) {
const { resolved, skippedCodes, primaryBranchId } = resolveSeedLocations(
seed.locations,
branchMetaByCode,
);
if (skippedCodes.length > 0) {
console.warn(
`Skipping unknown ${label} locations for ${seed.nationalCode}: ${skippedCodes.join(
", ",
)}`,
);
}
if (!primaryBranchId || resolved.length === 0) {
console.warn(
`Skipping ${label} ${seed.nationalCode}: no valid branch locations remained`,
);
skipped++;
continue;
}
const setPayload: Record<string, unknown> = {
firstName: seed.firstName,
lastName: seed.lastName,
username: seed.nationalCode,
nationalCode: seed.nationalCode,
clientKey,
branchId: primaryBranchId,
locations: resolved.map(({ id, name }) => ({ id, name })),
role,
otp: "",
};
if (seed.mobile) {
setPayload.mobile = seed.mobile;
}
const unsetPayload: Record<string, ""> = {
expertCode: "",
};
for (const field of codeFields) {
if (seed[field]) {
setPayload[field] = seed[field];
} else {
unsetPayload[field] = "";
}
}
const existing = await model.findOne({
clientKey,
nationalCode: seed.nationalCode,
});
if (existing) {
await model.updateOne(
{ _id: existing._id },
{
$set: {
...setPayload,
password: existing.password,
},
$unset: unsetPayload,
},
);
updated++;
} else {
await model.create({
...setPayload,
password: hashedPassword,
});
created++;
}
}
return { created, updated, skipped };
}
async function main() {
loadEnvFile();
const mongoUri = resolveMongoUri();
@@ -191,12 +383,24 @@ async function main() {
const branchesFile = JSON.parse(
readFileSync(join(dataDir, "branches.json"), "utf8"),
) as { clientCode: number; branches: BranchSeed[] };
const expertsFile = JSON.parse(
readFileSync(join(dataDir, "field-experts.json"), "utf8"),
) as { clientCode: number; fieldExperts: FieldExpertSeed[] };
const fileReviewersFile = JSON.parse(
readFileSync(join(dataDir, "file-reviewers.json"), "utf8"),
) as { clientCode: number; fileReviewers: FileReviewerSeed[] };
const fileMakersFile = JSON.parse(
readFileSync(join(dataDir, "file-makers.json"), "utf8"),
) as { clientCode: number; fileMakers: FileMakerSeed[] };
if (
branchesFile.clientCode !== fileReviewersFile.clientCode ||
branchesFile.clientCode !== fileMakersFile.clientCode
) {
throw new Error("Seed data clientCode mismatch between branch/reviewer/maker files");
}
const defaultPassword =
process.env.SEED_FIELD_EXPERT_DEFAULT_PASSWORD ?? "123321";
process.env.SEED_PARSIAN_TEHRAN_DEFAULT_PASSWORD ??
process.env.SEED_FIELD_EXPERT_DEFAULT_PASSWORD ??
"123321";
const hashedPassword = await hashPassword(defaultPassword);
await mongoose.connect(mongoUri, {
@@ -204,11 +408,14 @@ async function main() {
tlsAllowInvalidCertificates:
process.env.MONGO_TLS_ALLOW_INVALID_CERTS === "true",
});
const Client = mongoose.model("ClientSeedClient", ClientSchema);
const Branch = mongoose.model("ClientSeedBranch", BranchSchema);
const FieldExpert = mongoose.model("ClientSeedFieldExpert", FieldExpertSchema);
const FileReviewer = mongoose.model("ClientSeedFileReviewer", FileReviewerSchema);
const FileMaker = mongoose.model("ClientSeedFileMaker", FileMakerSchema);
await ensureFieldExpertIndexes(FieldExpert.collection);
await ensureUserIndexes(FileReviewer.collection);
await ensureUserIndexes(FileMaker.collection);
const client = await Client.findOne({
clientCode: branchesFile.clientCode,
@@ -220,7 +427,7 @@ async function main() {
}
const clientKey = new Types.ObjectId(String(client._id));
const branchIdByCode = new Map<string, Types.ObjectId>();
const branchMetaByCode = new Map<string, { _id: Types.ObjectId; name: string }>();
let branchesCreated = 0;
let branchesUpdated = 0;
@@ -239,67 +446,45 @@ async function main() {
phoneNumber: branch.phoneNumber,
isActive: branch.isActive ?? true,
};
if (existing) {
await Branch.updateOne({ _id: existing._id }, { $set: payload });
branchIdByCode.set(branch.code, existing._id as Types.ObjectId);
branchMetaByCode.set(branch.code, {
_id: existing._id as Types.ObjectId,
name: branch.name,
});
branchesUpdated++;
} else {
const created = await Branch.create(payload);
branchIdByCode.set(branch.code, created._id as Types.ObjectId);
branchMetaByCode.set(branch.code, {
_id: created._id as Types.ObjectId,
name: branch.name,
});
branchesCreated++;
}
}
let expertsCreated = 0;
let expertsUpdated = 0;
let expertsSkipped = 0;
const fileReviewersResult = await upsertRoleUsers({
label: "file-reviewer",
seeds: fileReviewersFile.fileReviewers,
model: FileReviewer,
clientKey,
hashedPassword,
branchMetaByCode,
role: "file_reviewer",
codeFields: ["ThirdPartyExpertiseClaim", "CarBodyExpertiseClaim"],
});
for (const expert of expertsFile.fieldExperts) {
const branchId = branchIdByCode.get(expert.branchCode);
if (!branchId) {
console.warn(
`Skipping ${expert.nationalCode}: unknown branch ${expert.branchCode}`,
);
expertsSkipped++;
continue;
}
const payload = {
firstName: expert.firstName,
lastName: expert.lastName,
username: expert.nationalCode,
nationalCode: expert.nationalCode,
clientKey,
branchId,
password: hashedPassword,
mobile: expert.mobile,
role: "field_expert",
otp: "",
expertCode: expert.expertCode,
};
const existing = await FieldExpert.findOne({
clientKey,
nationalCode: expert.nationalCode,
});
if (existing) {
await FieldExpert.updateOne(
{ _id: existing._id },
{
$set: {
...payload,
// Do not rotate password on re-seed unless explicitly desired.
password: existing.password,
},
},
);
expertsUpdated++;
} else {
await FieldExpert.create(payload);
expertsCreated++;
}
}
const fileMakersResult = await upsertRoleUsers({
label: "file-maker",
seeds: fileMakersFile.fileMakers,
model: FileMaker,
clientKey,
hashedPassword,
branchMetaByCode,
role: "file_maker",
codeFields: ["ThirdPartyClaimExpertId", "CarBodyClaimExpertId"],
});
console.log("Parsian Tehran seed completed.");
console.log({
@@ -307,11 +492,15 @@ async function main() {
clientKey: String(clientKey),
branchesCreated,
branchesUpdated,
expertsCreated,
expertsUpdated,
expertsSkipped,
fileReviewersCreated: fileReviewersResult.created,
fileReviewersUpdated: fileReviewersResult.updated,
fileReviewersSkipped: fileReviewersResult.skipped,
fileMakersCreated: fileMakersResult.created,
fileMakersUpdated: fileMakersResult.updated,
fileMakersSkipped: fileMakersResult.skipped,
defaultPassword,
loginHint: "Use nationalCode + password on POST /actor/login with role field_expert",
loginHint:
"Use nationalCode + password on POST /actor/login with role file_reviewer or file_maker",
});
await mongoose.disconnect();

View File

@@ -20,6 +20,12 @@ export enum ClaimRequiredDocumentType {
GUILTY_CAR_CARD_FRONT = "guilty_car_card_front",
GUILTY_CAR_CARD_BACK = "guilty_car_card_back",
GUILTY_METAL_PLATE = "guilty_metal_plate",
/**
* V4/V5 only — a photo of the guilty car's damaged area, captured by the
* FileReviewer during the CAPTURE_PART_DAMAGES phase (after all car angles).
*/
GUILTY_DAMAGE_AREA = "guilty_damage_area",
}
export enum CarAngle {

View File

@@ -1,4 +1,4 @@
export enum TypeOfDamage {
Repair = "repair",
Change = "change",
Repair = "تعمیر",
Change = "تعویض",
}

View File

@@ -1,36 +0,0 @@
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,589 @@
import { buildInsurerFileReport } from "./case-expert-report.builder";
import { PR } from "./persian-report-labels";
describe("buildInsurerFileReport", () => {
const getFieldValue = (
report: ReturnType<typeof buildInsurerFileReport>,
sectionTitle: string,
fieldLabel: string,
) => {
const section = report.sections.find((item) => item.title === sectionTitle);
expect(section).toBeDefined();
const field = section?.fields.find((item) => item.label === fieldLabel);
expect(field).toBeDefined();
return field?.value;
};
it("separates insurance blocks and exposes Fanavaran/timeline/evaluation data", () => {
const fileCreatedAt = new Date("2026-08-10T09:53:23.588Z");
const evaluationSubmittedAt = new Date("2026-08-10T12:30:45.000Z");
const report = buildInsurerFileReport({
overview: {
publicId: "A00010",
requestNo: "REQ-10",
createdAt: fileCreatedAt,
},
blame: {
type: "THIRD_PARTY",
blameStatus: "AGREED",
createdAt: fileCreatedAt,
parties: [
{
role: "FIRST",
person: {
userId: "guilty-user-id",
fullName: "مقصر نمونه",
phoneNumber: "09120000000",
nationalCodeOfInsurer: "0987654321",
insurerBirthday: 13650115,
clientId: "client-guilty",
},
statement: {
admitsGuilt: true,
acceptsExpertOpinion: true,
description: "توضیحات مقصر",
},
vehicle: {
carName: "206",
carModel: "1401",
plate: {
leftDigits: 98,
centerAlphabet: "ج",
centerDigits: 765,
ir: 22,
},
},
insurance: {
policyNumber: "TP-GUILTY-001",
company: "بیمه ثالث مقصر",
startDate: "1405/01/01",
endDate: "1406/01/01",
financialCeiling: "900000000",
carBodyInsurance: {
policyNumber: "CB-GUILTY-001",
insurerCompany: "بیمه بدنه مقصر",
startDate: "1405/02/01",
endDate: "1406/02/01",
},
},
},
{
role: "SECOND",
participants: [
{
participantId: "DRIVER",
fullName: "راننده نمونه",
nationalCode: "0012345678",
birthday: "1370/01/01",
licenseNumber: "LIC-1",
},
{
participantId: "VEHICLE_OWNER",
fullName: "زیان دیده نمونه",
nationalCode: "1234567890",
birthday: "1370/01/01",
},
],
participantRoles: {
driver: "DRIVER",
vehicleOwner: "VEHICLE_OWNER",
thirdPartyPolicyholder: "VEHICLE_OWNER",
},
person: {
userId: "damaged-user-id",
fullName: "زیان دیده نمونه",
phoneNumber: "09121111111",
nationalCodeOfInsurer: "1234567890",
clientId: "client-damaged",
insurerBirthday: 13700101,
},
statement: {
claimsDamage: true,
acceptsExpertOpinion: false,
description: "توضیحات زیان‌دیده",
accidentDate: "2026-08-10",
accidentTime: "13:23",
},
insurance: {
policyNumber: "TP-DAMAGED-001",
company: "بیمه ثالث زیان‌دیده",
startDate: "1405/03/01",
endDate: "1406/03/01",
financialCeiling: "700000000",
carBodyInsurance: {
policyNumber: "CB-DAMAGED-001",
insurerCompany: "بیمه بدنه زیان‌دیده",
startDate: "1405/04/01",
endDate: "1406/04/01",
coverages: ["سرقت", "آتش‌سوزی"],
},
},
vehicle: {
carName: "207",
carModel: "1402",
plate: {
leftDigits: 12,
centerAlphabet: "ب",
centerDigits: 345,
ir: 11,
},
},
},
],
expert: {
decision: {
guiltyPartyId: "guilty-user-id",
description: "مقصر شناخته شد",
fields: {
accidentWay: { label: "از جلو" },
accidentReason: { label: "عدم رعایت فاصله" },
accidentType: { label: "برخورد" },
},
},
},
},
claim: {
claimStatus: "APPROVED",
claimNo: 111,
claimId: 222,
dmgCaseId: 333,
expertiseId: 444,
owner: { fullName: "زیان دیده نمونه" },
vehicle: {
carName: "207",
carModel: "1402",
carType: "sedan",
},
fanavaranSync: {
baseClaim: {
policyId: 555,
driverId: 666,
vehicleKindId: 777,
insuranceCorpId: 888,
},
},
evaluation: {
damageExpertReplyFinal: {
submittedAt: evaluationSubmittedAt,
description: "نیاز به تعویض سپر جلو",
actorDetail: { actorName: "کارشناس خسارت نمونه" },
parts: [
{
partId: 12,
carPartDamage: { label_fa: "سپر جلو" },
typeOfDamage: "تعویض",
price: 2_500_000,
salary: 500_000,
totalPayment: 3_000_000,
factorNeeded: false,
daghi: { option: "تحویل داغی" },
},
],
},
},
},
});
expect(getFieldValue(report, PR.ownerSection, PR.name)).toBe(
"زیان دیده نمونه",
);
expect(
getFieldValue(report, PR.damagedParticipantsSection, PR.driverRole),
).toContain("راننده نمونه");
expect(
getFieldValue(
report,
PR.damagedParticipantsSection,
PR.thirdPartyPolicyholderRole,
),
).toContain("1234567890");
expect(getFieldValue(report, PR.guiltyOwnerSection, PR.name)).toBe(
"مقصر نمونه",
);
expect(getFieldValue(report, PR.guiltyOwnerSection, PR.phone)).toBe(
"09120000000",
);
expect(getFieldValue(report, PR.guiltyOwnerSection, PR.nationalCode)).toBe(
"0987654321",
);
expect(
getFieldValue(
report,
PR.damagedThirdPartyInsuranceSection,
PR.policyNumber,
),
).toBe("TP-DAMAGED-001");
expect(
getFieldValue(
report,
PR.guiltyThirdPartyInsuranceSection,
PR.policyNumber,
),
).toBe("TP-GUILTY-001");
expect(
getFieldValue(report, PR.damagedCarBodyInsuranceSection, PR.policyNumber),
).toBe("CB-DAMAGED-001");
expect(
getFieldValue(report, PR.guiltyCarBodyInsuranceSection, PR.policyNumber),
).toBe("CB-GUILTY-001");
expect(
getFieldValue(report, PR.damagedVehicleSection, "خودرو / نام خودرو"),
).toBe("207");
expect(
getFieldValue(report, PR.guiltyVehicleSection, "خودرو / نام خودرو"),
).toBe("206");
expect(
getFieldValue(report, PR.damagedStatementSection, PR.partyDescription),
).toBe("توضیحات زیان‌دیده");
expect(
getFieldValue(report, PR.damagedStatementSection, PR.claimsDamage),
).toBe("بله");
expect(
getFieldValue(
report,
PR.damagedStatementSection,
PR.acceptsExpertOpinion,
),
).toBe("خیر");
expect(
getFieldValue(report, PR.guiltyStatementSection, PR.partyDescription),
).toBe("توضیحات مقصر");
expect(
getFieldValue(report, PR.guiltyStatementSection, PR.admitsGuilt),
).toBe("بله");
expect(
getFieldValue(report, PR.guiltyStatementSection, PR.acceptsExpertOpinion),
).toBe("بله");
expect(
getFieldValue(report, PR.fanavaranSection, PR.fanavaranClaimNo),
).toBe("111");
expect(
getFieldValue(report, PR.fanavaranSection, PR.fanavaranExpertiseId),
).toBe("444");
expect(
getFieldValue(report, PR.fanavaranSection, PR.fanavaranPolicyId),
).toBe("555");
expect(getFieldValue(report, PR.timelineSection, PR.fileRegisteredAt)).toBe(
"1405/05/19 13:23",
);
expect(
getFieldValue(report, PR.timelineSection, PR.evaluationRegisteredAt),
).toBe("1405/05/19 16:00");
expect(
getFieldValue(report, PR.evaluationSection, PR.evaluationResult),
).toBe("تأیید شده");
expect(
getFieldValue(report, PR.evaluationSection, PR.evaluationExpert),
).toBe("کارشناس خسارت نمونه");
expect(
getFieldValue(report, PR.evaluationSection, PR.evaluationResponse),
).toBe("نیاز به تعویض سپر جلو");
expect(getFieldValue(report, "قیمت قطعات", "قطعه ۱ / نام قطعه")).toBe(
"سپر جلو",
);
expect(getFieldValue(report, "قیمت قطعات", "قطعه ۱ / قیمت قطعه")).toBe(
"۲٬۵۰۰٬۰۰۰ تومان",
);
expect(getFieldValue(report, "قیمت قطعات", "قطعه ۱ / مبلغ کل")).toBe(
"۳٬۰۰۰٬۰۰۰ تومان",
);
});
it("localizes legacy English car-body environmental conditions", () => {
const report = buildInsurerFileReport({
overview: { publicId: "A00012" },
blame: {
type: "CAR_BODY",
parties: [
{
role: "FIRST",
person: { userId: "damaged", fullName: "بیمه‌گذار" },
statement: {
weatherCondition: "CLEAR",
roadCondition: "wet",
lightCondition: "LOW_LIGHT",
},
},
],
},
});
expect(getFieldValue(report, PR.accidentSection, PR.weather)).toBe("صاف");
expect(getFieldValue(report, PR.accidentSection, PR.road)).toBe("مرطوب");
expect(getFieldValue(report, PR.accidentSection, PR.light)).toBe("کم‌نور");
});
it("exposes the damage expert part-price result as a separate section", () => {
const report = buildInsurerFileReport({
overview: { publicId: "A00013" },
claim: {
claimStatus: "APPROVED",
evaluation: {
damageExpertReply: {
description: "تعویض قطعه ضروری است",
parts: [
{
carPartDamage: { label_fa: "چراغ جلو" },
price: 4_000_000,
totalPayment: 4_000_000,
},
],
},
},
},
});
expect(getFieldValue(report, "قیمت قطعات", "قطعه ۱ / نام قطعه")).toBe(
"چراغ جلو",
);
expect(getFieldValue(report, "قیمت قطعات", "قطعه ۱ / قیمت قطعه")).toBe(
"۴٬۰۰۰٬۰۰۰ تومان",
);
});
it("falls back to claim history for the evaluation submission time", () => {
const report = buildInsurerFileReport({
overview: { publicId: "A00014" },
claim: {
evaluation: {
damageExpertReply: { description: "ارزیابی ثبت شد" },
},
history: [
{
type: "EXPERT_REPLY_SUBMITTED",
timestamp: "2026-08-10T12:30:45.000Z",
},
],
},
});
expect(
getFieldValue(report, PR.timelineSection, PR.evaluationRegisteredAt),
).toBe("1405/05/19 16:00");
expect(
getFieldValue(report, PR.evaluationSection, PR.evaluationSubmittedAt),
).toBe("1405/05/19 16:00");
});
it("localizes every boolean value", () => {
const report = buildInsurerFileReport({
overview: { publicId: "A00015" },
blame: {
type: "CAR_BODY",
parties: [
{
role: "FIRST",
person: { userId: "damaged", fullName: "بیمه‌گذار" },
vehicle: {
isNew: true,
hasEndorsement: false,
source: "ESG_CAR_BODY_INQUIRY",
inquiry: { source: "TEJARAT_BLOCK_INQUIRY" },
},
},
],
},
});
const fields = report.sections.flatMap((section) => section.fields);
expect(
fields.find((field) => field.label === "خودرو / خودرو نو")?.value,
).toBe("بله");
expect(fields.some((field) => field.value === "خیر")).toBe(true);
expect(fields.some((field) => field.value === "true")).toBe(false);
expect(fields.some((field) => field.value === "false")).toBe(false);
});
it("hides ESG and TEJARAT inquiry source details", () => {
const report = buildInsurerFileReport({
overview: { publicId: "A00016" },
blame: {
type: "CAR_BODY",
parties: [
{
role: "FIRST",
person: { userId: "damaged", fullName: "بیمه‌گذار" },
vehicle: {
source: "ESG_CAR_BODY_INQUIRY",
inquiry: { source: "TEJARAT_BLOCK_INQUIRY" },
},
},
],
},
});
const fields = report.sections.flatMap((section) => section.fields);
expect(fields.some((field) => String(field.value).includes("ESG"))).toBe(
false,
);
expect(
fields.some((field) => String(field.value).includes("TEJARAT")),
).toBe(false);
expect(fields.some((field) => field.label.includes("منبع"))).toBe(false);
});
it("uses the stored V4 licence type for a driver who differs from the policyholder", () => {
const report = buildInsurerFileReport({
overview: { publicId: "A00011" },
blame: {
type: "THIRD_PARTY",
parties: [
{
role: "FIRST",
person: { userId: "guilty", fullName: "مقصر" },
},
{
role: "SECOND",
person: {
userId: "damaged",
fullName: "بیمه‌گذار زیان‌دیده",
driverIsInsurer: false,
nationalCodeOfDriver: "0123456789",
driverLicense: "L-123",
licenseType: "پایه یک",
},
},
],
expert: { decision: { guiltyPartyId: "guilty" } },
},
});
expect(getFieldValue(report, PR.driverSection, PR.nationalCode)).toBe(
"0123456789",
);
expect(getFieldValue(report, PR.driverSection, PR.licenseType)).toBe(
"پایه یک",
);
});
it("localizes participant and recent-transfer inquiry data", () => {
const report = buildInsurerFileReport({
overview: { publicId: "A00017" },
blame: {
type: "THIRD_PARTY",
parties: [
{
role: "FIRST",
person: { userId: "guilty", fullName: "مقصر" },
},
{
role: "SECOND",
person: { userId: "damaged", fullName: "زیان‌دیده" },
participants: [
{
participantId: "DRIVER",
fullName: "راننده",
nationalCode: "0012345678",
birthday: "1370/01/01",
hasDrivingLicense: true,
licenseNumber: "123456789",
licenseType: "BASE_2",
},
],
participantRoles: {
driver: "DRIVER",
vehicleOwner: "DRIVER",
thirdPartyPolicyholder: "DRIVER",
},
vehicle: {
registrationState: "RECENTLY_TRANSFERRED",
previousPlateId: "55ج222ایران33",
previousPolicyholderNationalCode: "0098765432",
currentPlate: {
leftDigits: "44",
centerAlphabet: "ب",
centerDigits: "111",
ir: "22",
},
inquiry: {
plateKind: "PREVIOUS",
attempts: [
{
plateKind: "CURRENT",
plate: {
leftDigits: "44",
centerAlphabet: "ب",
centerDigits: "111",
ir: "22",
},
succeeded: false,
error: "not found",
},
{
plateKind: "PREVIOUS",
succeeded: true,
usable: true,
},
],
},
},
},
],
expert: { decision: { guiltyPartyId: "guilty" } },
},
claim: {
vehicle: { carType: "SEDAN" },
},
});
expect(
getFieldValue(
report,
PR.damagedVehicleSection,
"خودرو / وضعیت پلاک و مالکیت",
),
).toBe("انتقال مالکیت اخیر");
expect(
getFieldValue(report, PR.damagedVehicleSection, "خودرو / نوع خودرو"),
).toBe("سواری");
expect(
getFieldValue(report, PR.damagedVehicleSection, "خودرو / پلاک قبلی"),
).toBe("55ج222ایران33");
expect(
getFieldValue(
report,
PR.damagedVehicleSection,
"خودرو / پلاک فعلی / دو رقم چپ پلاک",
),
).toBe("44");
const driver = getFieldValue(
report,
PR.damagedParticipantsSection,
PR.driverRole,
);
expect(driver).toContain("گواهینامه دارد: بله");
expect(driver).toContain("نوع گواهینامه: پایه دو");
expect(
getFieldValue(
report,
PR.damagedVehicleSection,
"خودرو / سوابق تلاش استعلام / تلاش ۱ / نوع پلاک استعلام‌شده",
),
).toBe("پلاک فعلی");
expect(
getFieldValue(
report,
PR.damagedVehicleSection,
"خودرو / سوابق تلاش استعلام / تلاش ۱ / خطا",
),
).toBe("موردی یافت نشد");
const renderedText = report.sections
.flatMap((section) => [
section.title,
...section.fields.flatMap((field) => [field.label, field.value]),
])
.join(" ");
expect(renderedText).not.toMatch(
/RECENTLY_TRANSFERRED|BASE_2|registrationState|previousPlateId|plateKind|not found|CURRENT|PREVIOUS/,
);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -4,14 +4,12 @@ import {
HttpException,
InternalServerErrorException,
Param,
StreamableFile,
UseGuards,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiOperation,
ApiParam,
ApiProduces,
ApiResponse,
ApiTags,
} from "@nestjs/swagger";
@@ -21,6 +19,7 @@ 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";
import { InsurerFileReportViewModel } from "./case-expert-report.types";
@ApiTags("expert-insurer-panel")
@Controller("expert-insurer")
@@ -32,36 +31,30 @@ export class CaseExpertReportInsurerController {
private readonly caseExpertReportService: CaseExpertReportService,
) {}
@Get("files/:publicId/report.pdf")
@Get("files/:publicId/report")
@ApiOperation({
summary: "Download insurer file report PDF",
summary: "Get insurer file report data",
description:
"Generates a PDF for the shared publicId (blame + claim combined): damaged owner, driver when different, insurance, vehicle, and accident report sections.",
"Returns structured insurer PDF data for the given publicId (blame + claim combined), including separated guilty/damaged insurance blocks, third-party/body policy details, Fanavaran codes, case/evaluation timestamps, and the evaluation result with expert response.",
})
@ApiParam({ name: "publicId" })
@ApiProduces("application/pdf")
@ApiResponse({ status: 200, description: "PDF file" })
@ApiResponse({ status: 200, description: "Report data" })
@ApiResponse({ status: 404, description: "File not found for this publicId" })
async downloadInsurerReport(
async getInsurerReport(
@CurrentUser() insurer: { clientKey?: string },
@Param("publicId") publicId: string,
): Promise<StreamableFile> {
): Promise<InsurerFileReportViewModel> {
try {
const { buffer, filename } =
await this.caseExpertReportService.generateForInsurer(
publicId,
insurer,
);
return new StreamableFile(buffer, {
type: "application/pdf",
disposition: `attachment; filename="${filename}"`,
});
return await this.caseExpertReportService.generateForInsurer(
publicId,
insurer,
);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error
? error.message
: "Failed to generate insurer file report PDF",
: "Failed to retrieve insurer file report data",
);
}
}

View File

@@ -1,13 +1,12 @@
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],
providers: [CaseExpertReportService],
exports: [CaseExpertReportService],
})
export class CaseExpertReportModule {}

View File

@@ -1,20 +1,18 @@
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";
import { InsurerFileReportViewModel } 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> {
): Promise<InsurerFileReportViewModel> {
const clientKey = actor?.clientKey;
if (!clientKey) {
throw new NotFoundException("Insurer context not found");
@@ -24,7 +22,6 @@ export class CaseExpertReportService {
clientKey,
publicId,
);
const model = buildInsurerFileReport(file);
return this.pdfService.render(model);
return buildInsurerFileReport(file);
}
}

View File

@@ -14,8 +14,3 @@ export type InsurerFileReportViewModel = {
requestNo?: string;
sections: InsurerFileReportSection[];
};
export type InsurerFileReportPdfResult = {
buffer: Buffer;
filename: string;
};

View File

@@ -4,9 +4,22 @@ export const PR = {
requestNo: "شماره درخواست",
empty: "-",
ownerSection: "مالک خودروی زیان دیده",
guiltyOwnerSection: "مالک خودروی مقصر",
damagedParticipantsSection: "اشخاص و نقش‌های خودروی زیان‌دیده",
guiltyParticipantsSection: "اشخاص و نقش‌های خودروی مقصر",
driverSection: "راننده خودروی زیان دیده",
insuranceSection: "اطلاعات بیمه (بدنه و شخص ثالث)",
damagedThirdPartyInsuranceSection: "بیمه شخص ثالث زیان‌دیده",
damagedCarBodyInsuranceSection: "بیمه بدنه زیان‌دیده",
guiltyThirdPartyInsuranceSection: "بیمه شخص ثالث مقصر",
guiltyCarBodyInsuranceSection: "بیمه بدنه مقصر",
damagedVehicleSection: "اطلاعات خودروی زیان‌دیده",
guiltyVehicleSection: "اطلاعات خودروی مقصر",
vehicleSection: "اطلاعات خودرو",
damagedStatementSection: "اظهارات و اقرار زیان‌دیده",
guiltyStatementSection: "اظهارات و اقرار مقصر",
timelineSection: "زمان‌بندی پرونده",
fanavaranSection: "کدهای فناوران",
evaluationSection: "نتیجه ارزیابی",
accidentSection: "گزارش حادثه",
name: "نام",
phone: "شماره تلفن",
@@ -16,10 +29,50 @@ export const PR = {
licenseType: "نوع گواهینامه",
licenseDate: "تاریخ گواهینامه",
licenseNumber: "شماره گواهینامه",
hasDrivingLicense: "گواهینامه دارد",
driverLicense: "گواهینامه راننده",
insuranceCompany: "شرکت بیمه",
policyNumber: "شماره بیمه‌نامه",
policyStartDate: "تاریخ شروع بیمه‌نامه",
policyEndDate: "تاریخ پایان بیمه‌نامه",
financialCeiling: "سقف تعهد مالی",
coverages: "پوشش‌ها",
fileRegisteredAt: "تاریخ و ساعت ثبت پرونده",
evaluationRegisteredAt: "تاریخ و ساعت ثبت نتیجه ارزیابی",
fanavaranClaimNo: "شماره پرونده فناوران",
fanavaranClaimId: "کد پرونده فناوران",
fanavaranDamageCaseId: "کد کیس خسارت فناوران",
fanavaranExpertiseId: "کد کارشناسی فناوران",
fanavaranPolicyId: "کد بیمه‌نامه فناوران",
fanavaranDriverId: "کد راننده فناوران",
fanavaranVehicleKindId: "کد نوع خودرو فناوران",
fanavaranInsuranceCorpId: "کد شرکت بیمه فناوران",
evaluationResult: "نتیجه ارزیابی",
evaluationExpert: "کارشناس ارزیاب",
evaluationSubmittedAt: "تاریخ و ساعت ثبت ارزیابی",
evaluationResponse: "پاسخ / توضیحات کارشناس",
evaluationPartsSection: "قیمت قطعات",
part: "قطعه",
partName: "نام قطعه",
damageType: "نوع خسارت",
partPrice: "قیمت قطعه",
repairSalary: "اجرت تعمیر",
totalPayment: "مبلغ کل",
factorNeeded: "نیازمند فاکتور",
daghi: "داغی",
admitsGuilt: "اقرار به تقصیر",
claimsDamage: "ادعای خسارت",
acceptsExpertOpinion: "پذیرش نظر کارشناس",
partyRole: "نقش طرف",
driverRole: "راننده",
vehicleOwnerRole: "مالک وسیله نقلیه",
thirdPartyPolicyholderRole: "بیمه‌گذار شخص ثالث",
carBodyPolicyholderRole: "بیمه‌گذار بدنه",
data: "اطلاعات",
date: "تاریخ",
time: "زمان",
accidentDate: "تاریخ حادثه",
accidentTime: "ساعت حادثه",
experts: "کارشناس(ان)",
location: "موقعیت (عرض و طول جغرافیایی)",
weather: "وضعیت آب و هوا",
@@ -142,6 +195,31 @@ const KEY_LABELS: Record<string, string> = {
StatusTypeCode: "کد وضعیت",
label_fa: "برچسب فارسی",
catalogKey: "کلید کاتالوگ",
participantId: "شناسه شخص",
participants: "اشخاص استعلام",
participantRoles: "نقش‌های اشخاص",
nationalCode: "کد ملی",
birthday: "تاریخ تولد",
fullName: "نام و نام خانوادگی",
phoneNumber: "شماره تلفن",
hasDrivingLicense: "گواهینامه دارد",
licenseNumber: "شماره گواهینامه",
registrationState: "وضعیت پلاک و مالکیت",
currentPlate: "پلاک فعلی",
previousPlate: "پلاک قبلی",
previousPlateId: "پلاک قبلی",
previousPolicyholderNationalCode: "کد ملی بیمه‌گذار پلاک قبلی",
vehicleVin: "شماره شاسی (VIN)",
driver: "راننده",
vehicleOwner: "مالک وسیله نقلیه",
thirdPartyPolicyholder: "بیمه‌گذار شخص ثالث",
carBodyPolicyholder: "بیمه‌گذار بدنه",
sameAs: "همان شخص",
plateKind: "نوع پلاک استعلام‌شده",
succeeded: "موفق",
usable: "قابل استفاده",
attempts: "سوابق تلاش استعلام",
error: "خطا",
};
const STATUS_LABELS: Record<string, string> = {
@@ -157,6 +235,102 @@ const STATUS_LABELS: Record<string, string> = {
false: "خیر",
};
const REGISTRATION_STATE_LABELS: Record<string, string> = {
CURRENT: "عادی (پلاک فعلی)",
RECENTLY_TRANSFERRED: "انتقال مالکیت اخیر",
};
const PLATE_KIND_LABELS: Record<string, string> = {
CURRENT: "پلاک فعلی",
PREVIOUS: "پلاک قبلی",
};
const PARTICIPANT_ROLE_VALUE_LABELS: Record<string, string> = {
DRIVER: "راننده",
VEHICLE_OWNER: "مالک وسیله نقلیه",
THIRD_PARTY_POLICYHOLDER: "بیمه‌گذار شخص ثالث",
CAR_BODY_POLICYHOLDER: "بیمه‌گذار بدنه",
FIRST: "طرف اول",
SECOND: "طرف دوم",
};
const CASE_TYPE_LABELS: Record<string, string> = {
THIRD_PARTY: "شخص ثالث",
CAR_BODY: "بدنه",
};
const VEHICLE_TYPE_LABELS: Record<string, string> = {
SEDAN: "سواری",
SUV: "شاسی‌بلند",
HATCHBACK: "هاچ‌بک",
PICKUP: "وانت",
VAN: "ون",
};
const VALIDITY_LABELS: Record<string, string> = {
ACTIVE: "فعال",
INACTIVE: "غیرفعال",
VALID: "معتبر",
INVALID: "نامعتبر",
EXPIRED: "منقضی‌شده",
};
const DAMAGE_TYPE_LABELS: Record<string, string> = {
REPAIR: "تعمیر",
CHANGE: "تعویض",
REPLACE: "تعویض",
};
const INQUIRY_ERROR_LABELS: Record<string, string> = {
NOT_FOUND: "موردی یافت نشد",
NO_RECORD_FOUND: "موردی یافت نشد",
TIMEOUT: "مهلت پاسخ استعلام به پایان رسید",
REQUEST_FAILED: "استعلام ناموفق بود",
FAILED: "استعلام ناموفق بود",
UNAVAILABLE: "سرویس استعلام در دسترس نیست",
};
const LICENSE_TYPE_LABELS: Record<string, string> = {
"1": "پایه یک",
"2": "پایه دو",
"3": "پایه سه",
BASE_1: "پایه یک",
BASE1: "پایه یک",
GRADE_1: "پایه یک",
BASE_2: "پایه دو",
BASE2: "پایه دو",
GRADE_2: "پایه دو",
BASE_3: "پایه سه",
BASE3: "پایه سه",
GRADE_3: "پایه سه",
};
const WEATHER_LABELS: Record<string, string> = {
clear: "صاف",
sunny: "صاف",
rainy: "بارانی",
rain: "بارانی",
snowy: "برفی",
snow: "برفی",
foggy: "مه‌آلود",
fog: "مه‌آلود",
};
const ROAD_LABELS: Record<string, string> = {
muddy: "گلی",
icy: "یخ‌زده",
wet: "مرطوب",
dry: "خشک",
};
const LIGHT_LABELS: Record<string, string> = {
daylight: "روز",
day: "روز",
night: "شب",
low_light: "کم‌نور",
lowlight: "کم‌نور",
};
/** Turn `party.insurance.policyNumber` into a Persian label. */
export function persianFieldPath(path: string): string {
if (!path) return PR.data;
@@ -177,9 +351,39 @@ export function persianFieldPath(path: string): string {
return `بیمه / ${translatedLast}`;
}
if (parts[0] === "claim" && parts[1] === "vehicle") {
const attemptIndex = parts.indexOf("attempts");
if (attemptIndex >= 0) {
const attemptNumber = Number(parts[attemptIndex + 1]);
const attemptLabel = Number.isFinite(attemptNumber)
? `تلاش ${attemptNumber.toLocaleString("fa-IR")}`
: "تلاش استعلام";
const plateLabel = parts.includes("plate") ? " / پلاک" : "";
return `خودرو / سوابق تلاش استعلام / ${attemptLabel}${plateLabel} / ${translatedLast}`;
}
if (parts.includes("currentPlate")) {
return `خودرو / پلاک فعلی / ${translatedLast}`;
}
if (parts.includes("previousPlate")) {
return `خودرو / پلاک قبلی / ${translatedLast}`;
}
return `خودرو / ${translatedLast}`;
}
if (parts[0] === "party" && parts[1] === "vehicle") {
const attemptIndex = parts.indexOf("attempts");
if (attemptIndex >= 0) {
const attemptNumber = Number(parts[attemptIndex + 1]);
const attemptLabel = Number.isFinite(attemptNumber)
? `تلاش ${attemptNumber.toLocaleString("fa-IR")}`
: "تلاش استعلام";
const plateLabel = parts.includes("plate") ? " / پلاک" : "";
return `خودرو / سوابق تلاش استعلام / ${attemptLabel}${plateLabel} / ${translatedLast}`;
}
if (parts.includes("currentPlate")) {
return `خودرو / پلاک فعلی / ${translatedLast}`;
}
if (parts.includes("previousPlate")) {
return `خودرو / پلاک قبلی / ${translatedLast}`;
}
return `خودرو / ${translatedLast}`;
}
@@ -189,8 +393,69 @@ export function persianFieldPath(path: string): string {
return translated.join(" / ");
}
/**
* Localize only known domain tokens. Free-form provider values, names,
* identifiers, plates and VINs are deliberately preserved verbatim.
*/
export function persianReportValue(
path: string,
value: unknown,
): unknown {
if (value === undefined || value === null || value === "") return value;
if (typeof value === "boolean") return value ? "بله" : "خیر";
if (typeof value !== "string" && typeof value !== "number") return value;
const raw = String(value).trim();
const key = raw.toUpperCase().replace(/[\s-]+/g, "_");
const field = path.split(".").filter(Boolean).pop() ?? "";
if (field === "licenseType" || field === "LicenseType") {
return LICENSE_TYPE_LABELS[key] ?? raw;
}
if (field === "registrationState") {
return REGISTRATION_STATE_LABELS[key] ?? raw;
}
if (field === "plateKind") return PLATE_KIND_LABELS[key] ?? raw;
if (field === "role" || field.endsWith("Role")) {
return PARTICIPANT_ROLE_VALUE_LABELS[key] ?? raw;
}
if (field === "blameRequestType" || field === "fileType") {
return CASE_TYPE_LABELS[key] ?? raw;
}
if (
(field === "carType" || field === "type") &&
path.toLowerCase().includes("vehicle")
) {
return VEHICLE_TYPE_LABELS[key] ?? raw;
}
if (field === "typeOfDamage") return DAMAGE_TYPE_LABELS[key] ?? raw;
if (field === "error") return INQUIRY_ERROR_LABELS[key] ?? raw;
if (/status$/i.test(field)) {
return STATUS_LABELS[raw] ?? VALIDITY_LABELS[key] ?? raw;
}
if (raw === "true" || raw === "false") return STATUS_LABELS[raw];
return raw;
}
export function persianStatus(value: unknown): string | undefined {
if (value === undefined || value === null || value === "") return undefined;
const key = String(value);
return STATUS_LABELS[key] ?? key;
}
export function persianAccidentCondition(
kind: "weather" | "road" | "light",
value: unknown,
): string | undefined {
if (value === undefined || value === null || value === "") return undefined;
const raw = String(value).trim();
const normalized = raw.toLowerCase().replace(/[\s-]+/g, "_");
const labels =
kind === "weather"
? WEATHER_LABELS
: kind === "road"
? ROAD_LABELS
: LIGHT_LABELS;
return labels[normalized] ?? raw;
}

View File

@@ -0,0 +1,113 @@
/**
* Regression: concurrent capturePart writes must not clobber sibling slots.
*
* capturePartV2 used to `$set` the entire `media.damagedParts` array from a
* stale read. Parallel uploads (common while Fanavaran attachment submit keeps
* the HTTP request open) made the last writer win — Fanavaran still saw each
* file on disk and could return errors, while Mongo was missing captures.
*
* Required strategy: per-index `$set` (`media.damagedParts.N`), matching
* `media.carAngles.<key>`.
*/
describe("capture-part media.damagedParts write strategies", () => {
type Row = { path?: string; fileName?: string; name?: string };
type Claim = { media: { damagedParts: Row[] } };
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/** Legacy (buggy) strategy: replace the entire array from a stale read. */
async function writeWholeArray(
store: { claim: Claim },
index: number,
capture: Row,
readDelayMs: number,
) {
const snapshot = structuredClone(store.claim);
await sleep(readDelayMs);
const next = snapshot.media.damagedParts.map((row) => ({ ...row }));
while (next.length <= index) next.push({});
next[index] = { ...next[index], ...capture };
store.claim = {
...store.claim,
media: { ...store.claim.media, damagedParts: next },
};
}
/** Required strategy: set only the target index (Mongo $set media.damagedParts.N). */
async function writeSingleIndex(
store: { claim: Claim },
index: number,
capture: Row,
readDelayMs: number,
) {
await sleep(readDelayMs);
const next = store.claim.media.damagedParts.map((row) => ({ ...row }));
while (next.length <= index) next.push({});
next[index] = { ...next[index], ...capture };
store.claim.media.damagedParts[index] = next[index];
}
it("documents that whole-array replace loses a concurrent capture", async () => {
const store: { claim: Claim } = {
claim: {
media: {
damagedParts: [{ name: "hood" }, { name: "front_bumper" }],
},
},
};
await Promise.all([
writeWholeArray(
store,
0,
{ path: "files/claim-captures/hood.jpg", fileName: "hood.jpg" },
30,
),
writeWholeArray(
store,
1,
{
path: "files/claim-captures/bumper.jpg",
fileName: "bumper.jpg",
},
10,
),
]);
expect(store.claim.media.damagedParts.map((r) => r.path).filter(Boolean))
.toHaveLength(1);
});
it("keeps both concurrent captures with per-index writes", async () => {
const store: { claim: Claim } = {
claim: {
media: {
damagedParts: [{ name: "hood" }, { name: "front_bumper" }],
},
},
};
await Promise.all([
writeSingleIndex(
store,
0,
{ path: "files/claim-captures/hood.jpg", fileName: "hood.jpg" },
30,
),
writeSingleIndex(
store,
1,
{
path: "files/claim-captures/bumper.jpg",
fileName: "bumper.jpg",
},
10,
),
]);
expect(store.claim.media.damagedParts.map((r) => r.path)).toEqual([
"files/claim-captures/hood.jpg",
"files/claim-captures/bumper.jpg",
]);
});
});

View File

@@ -0,0 +1,59 @@
import { ForbiddenException } from "@nestjs/common";
import { ClaimRequestManagementService } from "./claim-request-management.service";
import { RoleEnum } from "src/Types&Enums/role.enum";
describe("V2 claim-detail access for split file roles", () => {
const makerId = "maker-id";
const reviewerId = "reviewer-id";
const createService = (blame: Record<string, unknown>) => {
const service = Object.create(
ClaimRequestManagementService.prototype,
) as ClaimRequestManagementService;
(service as any).blameRequestDbService = {
findById: jest.fn().mockResolvedValue(blame),
};
return service;
};
const claim = { blameRequestId: "blame-id" };
it("allows the FileMaker who created a completed V4/V5 file", async () => {
const service = createService({
isMadeByFileMaker: true,
expertInitiated: true,
creationMethod: "IN_PERSON",
initiatedByFieldExpertId: makerId,
});
await expect(
(service as any).assertActorCanViewClaimV2(claim, makerId, {
sub: makerId,
role: RoleEnum.FILE_MAKER,
}),
).resolves.toBeUndefined();
});
it("allows only the assigned FileReviewer", async () => {
const service = createService({
isMadeByFileMaker: true,
expertInitiated: true,
creationMethod: "IN_PERSON",
assignedFileReviewerId: reviewerId,
});
await expect(
(service as any).assertActorCanViewClaimV2(claim, reviewerId, {
sub: reviewerId,
role: RoleEnum.FILE_REVIEWER,
}),
).resolves.toBeUndefined();
await expect(
(service as any).assertActorCanViewClaimV2(claim, "other-reviewer", {
sub: "other-reviewer",
role: RoleEnum.FILE_REVIEWER,
}),
).rejects.toBeInstanceOf(ForbiddenException);
});
});

View File

@@ -37,7 +37,6 @@ import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-managem
import { CarDamagePartDto, OtherCarDamagePartDto } from "./dto/car-part.dto";
import { UserCommentDto } from "./dto/user-comment.dto";
import { UserObjectionDto } from "./dto/user-objection.dto";
import { InPersonVisitDto } from "./dto/in-person-visit.dto";
import { UserRatingDto } from "./dto/user-rating.dto";
@ApiExcludeController()
@@ -464,29 +463,27 @@ export class ClaimRequestManagementController {
);
}
// @ApiBody({ type: InPersonVisitDto })
// @ApiParam({ name: "id" })
// @ApiOperation({ deprecated: true })
@Patch(":id/visit")
async inPersonVisit(
@Param("id") requestId: string,
@Body() body: InPersonVisitDto,
@CurrentUser() actor,
) {
// Pass the branchId from the body to the service
return await this.claimRequestManagementService.inPersonVisit(
requestId,
body.branchId,
actor,
);
return await this.claimRequestManagementService.inPersonVisit(requestId, actor);
}
// @ApiOperation({ deprecated: true })
@Get("branches/:insuranceId")
// @ApiParam({ name: "insuranceId" })
async insuranceBranches(@Param("insuranceId") insuranceId: string) {
return await this.claimRequestManagementService.retrieveInsuranceBranches(insuranceId);
}
// Legacy V1 owner branch picker — intentionally disabled.
// A claim is handled by an expert assigned to the insurer branch, so the
// owner must not choose an insurer branch while signing. This does not apply
// to the separate expert-only `daghi.branchId` choice in pricing flows.
//
// @Get("branches/:insuranceId")
// async insuranceBranches(@Param("insuranceId") insuranceId: string) {
// return await this.claimRequestManagementService.retrieveInsuranceBranches(
// insuranceId,
// );
// }
// @ApiOperation({ deprecated: true })
@Get("fanavaran-submit/:claimRequestId")

View File

@@ -58,7 +58,6 @@ import { MediaPolicyModule } from "src/media-policy/media-policy.module";
import { FanavaranAuditModule } from "src/fanavaran/fanavaran-audit.module";
import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module";
import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normalizer.module";
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
@Module({
imports: [
@@ -77,7 +76,6 @@ import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.
SandHubModule,
ClientModule,
MediaPolicyModule,
SmsOrchestrationModule,
JwtModule.register({}),
MongooseModule.forFeature([
{ name: ClaimCase.name, schema: ClaimCaseSchema },

View File

@@ -83,7 +83,7 @@ export class ClaimRequestManagementV2Controller {
@ApiOperation({
summary: "Get My Claims (V2)",
description:
"Claims for the current user, or claims from blame files initiated by the current FIELD_EXPERT / REGISTRAR (LINK and IN_PERSON). Optional query: `search`, `sortBy`, `sortOrder`, `page`, `limit`.",
"Claims for the current user, or claims from blame files initiated by the current FIELD_EXPERT / REGISTRAR (LINK and IN_PERSON). Optional query: `search`, `sortBy`, `sortOrder`, `page`, `limit`, `unifiedStatus`, `fileType`, `startDate`, `endDate`.",
})
@ApiResponse({
status: 200,
@@ -117,7 +117,7 @@ export class ClaimRequestManagementV2Controller {
@ApiOperation({
summary: "Get Claim Details (V2)",
description:
"Returns the claim snapshot for **USER** (owner), **FIELD_EXPERT**, or **REGISTRAR** when permitted. Initiating experts/registrars see unmasked money fields; owners get `ownerGuidance`.",
"Returns the claim snapshot for an authorized **USER**, **FIELD_EXPERT**, **REGISTRAR**, **FILE_MAKER**, or assigned **FILE_REVIEWER**. Initiating experts/registrars see unmasked money fields; owners get `ownerGuidance`. Completed claims include Fanavaran `claimId` / `claimNo` / `dmgCaseId` / `expertiseId` when available.",
})
@ApiResponse({
status: 200,
@@ -322,7 +322,8 @@ export class ClaimRequestManagementV2Controller {
}
/**
* V2: Owner signature — priced-line gate (mixed factors) or final accept/reject.
* V2–V5: owner signature used only as the priced-line gate for mixed-factor claims.
* The final accept/reject phase is retained for legacy rows only.
*/
@Put("request/:claimRequestId/owner-insurer-approval/sign")
@ApiParam({
@@ -332,18 +333,18 @@ export class ClaimRequestManagementV2Controller {
})
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: "Sign priced lines or final claim pricing (owner)",
summary: "Sign priced lines before factor uploads (owner; final phase is legacy only)",
description:
"Multipart: `sign`, `agree`, `branchId`. Requires `ClaimCaseStatus` **`INSURER_REVIEW_AWAITING_OWNER_SIGN`**, **`INSURER_REVIEW_MIXED_FACTORS_PENDING`**, or legacy **`WAITING_FOR_INSURER_APPROVAL`**, and `workflow.currentStep=INSURER_REVIEW` (not during owner factor upload or `EXPERT_COST_EVALUATION`).\n\n" +
"Multipart: `sign`, `agree`. Requires `ClaimCaseStatus` **`INSURER_REVIEW_AWAITING_OWNER_SIGN`**, **`INSURER_REVIEW_MIXED_FACTORS_PENDING`**, or legacy **`WAITING_FOR_INSURER_APPROVAL`**, and `workflow.currentStep=INSURER_REVIEW` (not during owner factor upload or `EXPERT_COST_EVALUATION`).\n\n" +
"**Phase A — Mixed reply, priced lines only:** `claimStatus=NEEDS_REVISION`, no `evaluation.ownerPricedPartsApproval` yet. `agree=true` records that signature and moves to `OWNER_UPLOAD_FACTOR_DOCUMENTS` for factor uploads; `agree=false` rejects the whole case (`REJECTED`).\n\n" +
"**Phase B — Final:** `claimStatus=APPROVED`, no `evaluation.ownerInsurerApproval` yet. `agree=true` → `COMPLETED`; `agree=false` → `REJECTED`.\n\n" +
"Response may include `phase`: `PRICED_PARTS_FOR_FACTORS` or `FINAL_APPROVAL` for UI state.",
"**Phase B — Legacy final phase only:** pre-existing rows with `claimStatus=APPROVED` may still be accepted or rejected through this endpoint. New V2–V5 claims complete after expert work (and V5 FileMaker approval) without a final owner signature.\n\n" +
"Response may include `phase`: `PRICED_PARTS_FOR_FACTORS` or legacy `FINAL_APPROVAL` for UI state.",
})
@ApiBody({
description: "Signature file, agreement, and branch",
description: "Signature file and agreement",
schema: {
type: "object",
required: ["sign", "agree", "branchId"],
required: ["sign", "agree"],
properties: {
sign: {
type: "string",
@@ -354,12 +355,6 @@ export class ClaimRequestManagementV2Controller {
type: "boolean",
description: "true to accept expert pricing and complete the claim",
},
branchId: {
type: "string",
description:
"Insurer branch id (must belong to the claim owner's insurer; if pricing lists branch options, must match one of them)",
example: "507f1f77bcf86cd799439011",
},
},
},
})
@@ -391,7 +386,6 @@ export class ClaimRequestManagementV2Controller {
async submitOwnerInsurerApprovalSignV2(
@Param("claimRequestId") claimRequestId: string,
@Body("agree") agree: string | boolean,
@Body("branchId") branchId: string,
@CurrentUser() user: any,
@UploadedFile() sign: Express.Multer.File,
) {
@@ -407,7 +401,6 @@ export class ClaimRequestManagementV2Controller {
return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2(
claimRequestId,
agreed,
typeof branchId === "string" ? branchId : "",
sign,
user.sub,
user,
@@ -466,7 +459,7 @@ export class ClaimRequestManagementV2Controller {
@ApiOperation({
summary: "Get insurer branches (V2)",
description:
"Returns branch list for a given insurer/client id so frontend can render branch options (name/code/address/city/state) and submit selected branchId in daghi part options.",
"Returns branch list for a given insurer/client id. Claim pricing and owner approval no longer require branch selection.",
})
@ApiParam({
name: "insuranceId",

View File

@@ -102,7 +102,7 @@ export class ClaimDetailsV2ResponseDto {
@ApiProperty({
description:
"ClaimCaseStatus; see also `ownerGuidance` for UX. Post-expert: INSURER_REVIEW_AWAITING_OWNER_SIGN | INSURER_REVIEW_MIXED_FACTORS_PENDING | OWNER_REPAIR_FACTOR_UPLOAD_PENDING | EXPERT_VALIDATING_REPAIR_FACTORS; legacy WAITING_FOR_INSURER_APPROVAL may still appear.",
"ClaimCaseStatus; see also `ownerGuidance` for UX. New V2–V5 priced-only claims are COMPLETED after expert work; factor claims use INSURER_REVIEW_MIXED_FACTORS_PENDING | OWNER_REPAIR_FACTOR_UPLOAD_PENDING | EXPERT_VALIDATING_REPAIR_FACTORS. V5 then uses WAITING_FOR_FILE_MAKER_APPROVAL. Legacy WAITING_FOR_INSURER_APPROVAL or INSURER_REVIEW_AWAITING_OWNER_SIGN may still appear.",
example: "OWNER_REPAIR_FACTOR_UPLOAD_PENDING",
})
status: string;
@@ -122,6 +122,18 @@ export class ClaimDetailsV2ResponseDto {
@ApiPropertyOptional({ description: 'Blame request number' })
blameRequestNo?: string;
@ApiPropertyOptional({
description: 'Blame file type: THIRD_PARTY or CAR_BODY',
example: 'THIRD_PARTY',
})
blameType?: string;
@ApiPropertyOptional({
description: 'How the blame file was initiated: IN_PERSON or LINK',
example: 'IN_PERSON',
})
creationMethod?: string;
@ApiPropertyOptional({
description: 'Claim owner (damaged party): ids for the user and their insurer client scope',
})
@@ -192,12 +204,56 @@ export class ClaimDetailsV2ResponseDto {
fileName?: string;
}>;
@ApiPropertyOptional({
description:
'Append-only expert damaged-part revisions. Removed rows retain their original capture URL.',
type: 'array',
items: {
type: 'object',
properties: {
revisionId: { type: 'string' },
changedAt: { type: 'string', format: 'date-time' },
changedBy: { type: 'object' },
removedParts: { type: 'array', items: { type: 'object' } },
addedParts: { type: 'array', items: { type: 'object' } },
},
},
})
damagedPartsHistory?: Array<{
revisionId: string;
changedAt: Date | string;
changedBy: {
actorId: string;
actorName?: string;
actorType: string;
};
removedParts: Array<Record<string, unknown>>;
addedParts: Array<Record<string, unknown>>;
}>;
@ApiPropertyOptional({
description:
'Damage expert resend instructions and progress (when status is WAITING_FOR_USER_RESEND).',
})
expertResend?: ExpertResendDetailsV2Dto;
@ApiPropertyOptional({
description:
"Fanavaran claim reference. Returned only after the local claim reaches COMPLETED. Each of claimId, claimNo, dmgCaseId, and expertiseId is included when Fanavaran has supplied it.",
example: {
claimId: 987654,
claimNo: 123456,
dmgCaseId: 11,
expertiseId: 22,
},
})
fanavaran?: {
claimId?: number;
claimNo?: number;
dmgCaseId?: number;
expertiseId?: number;
};
@ApiPropertyOptional({
description: "Damage expert opinion(s): initial and final (after objection).",
type: Object,

View File

@@ -1,12 +0,0 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsMongoId, IsNotEmpty } from "class-validator";
export class InPersonVisitDto {
@ApiProperty({
example: "60d5ec49e7b2f8001c8e4d2a",
description: "The unique ID of the branch the user is being sent to.",
})
@IsNotEmpty()
@IsMongoId()
branchId: string;
}

View File

@@ -12,8 +12,8 @@ export class ClaimListItemV2Dto {
@ApiProperty({
description:
"ClaimCaseStatus. Post-expert owner phase includes: INSURER_REVIEW_AWAITING_OWNER_SIGN (priced lines only → final owner sign); INSURER_REVIEW_MIXED_FACTORS_PENDING (priced + factor lines); OWNER_REPAIR_FACTOR_UPLOAD_PENDING (all lines factor-needed); EXPERT_VALIDATING_REPAIR_FACTORS (all factors uploaded, expert validating). Legacy DB rows may still use WAITING_FOR_INSURER_APPROVAL for those flows.",
example: "INSURER_REVIEW_AWAITING_OWNER_SIGN",
"ClaimCaseStatus. New V2–V5 priced-only claims become COMPLETED after expert work. Factor claims use INSURER_REVIEW_MIXED_FACTORS_PENDING (priced-line acceptance before factor uploads), OWNER_REPAIR_FACTOR_UPLOAD_PENDING, and EXPERT_VALIDATING_REPAIR_FACTORS. V5 then waits at WAITING_FOR_FILE_MAKER_APPROVAL. Legacy DB rows may still use WAITING_FOR_INSURER_APPROVAL or INSURER_REVIEW_AWAITING_OWNER_SIGN.",
example: "COMPLETED",
})
status: string;
@@ -28,6 +28,24 @@ export class ClaimListItemV2Dto {
@ApiProperty({ description: 'Blame request ID this claim originated from' })
blameRequestId?: string;
@ApiPropertyOptional({
description: 'Blame file type: THIRD_PARTY or CAR_BODY',
example: 'THIRD_PARTY',
})
blameType?: string;
@ApiPropertyOptional({
description: 'How the blame file was initiated: IN_PERSON or LINK',
example: 'IN_PERSON',
})
creationMethod?: string;
@ApiPropertyOptional({
description: 'Calculated combined blame and claim lifecycle status',
example: 'WAITING_FOR_DAMAGE_EXPERT',
})
unifiedFileStatus?: string;
}
export class GetMyClaimsV2ResponseDto {

View File

@@ -56,20 +56,21 @@ export class SelectOtherPartsV2Dto {
@IsString()
shebaNumber?: string;
@ApiProperty({
description: 'National code of insurer/owner - 10 digits',
@ApiPropertyOptional({
description:
'Legacy fallback for claims created before participant roles were stored. New claims derive the vehicle owner national code from the linked blame case.',
example: '1234567890',
pattern: '^[0-9]{10}$',
minLength: 10,
maxLength: 10,
})
@IsNotEmpty({ message: 'nationalCodeOfInsurer is required' })
@IsOptional()
@IsString({ message: 'National code must be a string' })
@Length(10, 10, { message: 'National code must be exactly 10 digits' })
@Matches(/^[0-9]{10}$/, {
message: 'National code must contain exactly 10 digits',
})
nationalCodeOfInsurer: string;
nationalCodeOfInsurer?: string;
@ApiPropertyOptional({
description: 'Legacy alias for backward compatibility',

View File

@@ -18,6 +18,18 @@ export class ClaimDamageSelection {
@Prop({ type: [String], default: [] })
otherParts?: string[];
/**
* Append-only expert edit revisions. Each revision preserves the prior
* selection and capture metadata for removed parts before the live arrays
* are replaced. Mixed keeps legacy/free-text part shapes readable.
*/
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
partSelectionHistory?: unknown[];
/** Parts introduced by an expert across revisions (used for origin labels). */
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
expertAddedParts?: unknown[];
/**
* Legacy fields - kept for backward compatibility
*/

View File

@@ -83,10 +83,6 @@ export class ClaimOwnerInsurerApproval {
@Prop({ type: Boolean, required: true })
agree: boolean;
/** Branch the owner is signing for (must belong to their insurer; aligned with expert daghi options when present). */
@Prop({ type: Types.ObjectId })
branchId?: Types.ObjectId;
@Prop({ type: Types.ObjectId })
signDetailId?: Types.ObjectId;
@@ -102,9 +98,6 @@ export class ClaimOwnerPricedPartsApproval {
@Prop({ type: Boolean, required: true })
agree: boolean;
@Prop({ type: Types.ObjectId })
branchId?: Types.ObjectId;
@Prop({ type: Types.ObjectId })
signDetailId?: Types.ObjectId;
@@ -349,4 +342,3 @@ export class ClaimEvaluation {
}
export const ClaimEvaluationSchema =
SchemaFactory.createForClass(ClaimEvaluation);

View File

@@ -36,6 +36,13 @@ export class ClaimVehicleSnapshot {
@Prop({ type: ClaimPlateSchema })
plate?: ClaimPlate;
@Prop({ type: String })
price?: string;
/** Vehicle value in Rial from hull policy VehicleValue or expert assessment. */
@Prop({ type: Number })
carPrice?: number;
}
export const ClaimVehicleSnapshotSchema =
SchemaFactory.createForClass(ClaimVehicleSnapshot);

View File

@@ -89,10 +89,45 @@ export class FanavaranSyncStage {
@Prop({ type: Number })
driverId?: number;
/** Cached GEN.44 other-people Id when the person was created because inquiry missed. */
@Prop({ type: Number })
otherPersonId?: number;
/** Cached Fanavaran VehicleKindId. */
@Prop({ type: Number })
vehicleKindId?: number;
/** How VehicleKindId was resolved: vehicle-inquiry | car-type-lookup */
@Prop({ type: String })
vehicleKindSource?: string;
/** Cached Fanavaran PlaqueKindId from VIN inquiry / vehicle GET. */
@Prop({ type: Number })
plaqueKindId?: number;
/** How PlaqueKindId was resolved: vehicle-inquiry | config-default */
@Prop({ type: String })
plaqueKindSource?: string;
/** Cached Fanavaran PlaqueSampleId from VIN inquiry / vehicle GET. */
@Prop({ type: Number })
plaqueSampleId?: number;
/** How PlaqueSampleId was resolved: vehicle-inquiry | config-default */
@Prop({ type: String })
plaqueSampleSource?: string;
/**
* Cached Fanavaran AccidentVehicleUsedId, resolved from VIN inquiry / vehicle GET
* UsedId (lookup-filter safe). Not the Mongo tenant default.
*/
@Prop({ type: Number })
accidentVehicleUsedId?: number;
/** How AccidentVehicleUsedId was resolved: vin-inquiry | policy-vehicle */
@Prop({ type: String })
accidentVehicleUsedSource?: string;
/** Cached Fanavaran InsuranceCorpId. */
@Prop({ type: Number })
insuranceCorpId?: number;
@@ -107,7 +142,7 @@ export class FanavaranSyncStage {
@Prop({ type: Date })
lastPayloadBuiltAt?: Date;
/** When we SMS'd the claim owner about Fanavaran claimId/ClaimNo (base claim). */
/** Legacy marker from the retired post-expertise Fanavaran SMS flow. */
@Prop({ type: Date })
smsNotifiedAt?: Date;
@@ -204,6 +239,10 @@ export class ClaimCase {
@Prop({ type: Types.ObjectId, index: true })
initiatedByFieldExpertId?: Types.ObjectId;
/** Branch snapshot inherited from the originating expert-created blame. */
@Prop({ type: Types.ObjectId, index: true })
branchId?: Types.ObjectId;
/**
* The damaged party's userId, resolved from the blame at claim-creation time.
* Stored here so view/list access does not require an extra blame lookup.

View File

@@ -527,16 +527,16 @@ Returns status of each item (uploaded/captured or not).
);
}
// ─── Owner signature on expert pricing ───────────────────────────────────────
// ─── Mixed-factor priced-line signature ──────────────────────────────────────
@Put("claim-sign/:claimRequestId")
@ApiParam({ name: "claimRequestId" })
@ApiConsumes("multipart/form-data")
@ApiBody({
description: "Signature file, agreement, and branch",
description: "Signature file and agreement",
schema: {
type: "object",
required: ["sign", "agree", "branchId"],
required: ["sign", "agree"],
properties: {
sign: {
type: "string",
@@ -547,17 +547,17 @@ Returns status of each item (uploaded/captured or not).
type: "boolean",
description: "true to accept, false to reject",
},
branchId: { type: "string", description: "Insurer branch ID" },
},
},
})
@ApiOperation({
summary:
"Owner signature on expert pricing (Flow 3 — expert acts on behalf of user)",
"Priced-line acceptance before factor uploads (Flow 3 — expert acts on behalf of user)",
description:
"Field expert submits the damaged party's signature during the final approval stage. " +
"Delegates to the same service method as the user sign endpoint; the expert's " +
"identity is resolved to the claim owner via `resolveClaimEffectiveUserId`.",
"For mixed priced/factor claims only, the field expert records the damaged party's " +
"acceptance of priced lines before factor uploads. V2–V5 no longer require a final " +
"owner signature: they complete after expert work. The expert's identity is resolved " +
"to the claim owner via `resolveClaimEffectiveUserId`.",
})
@UseInterceptors(
FileInterceptor("sign", {
@@ -576,7 +576,6 @@ Returns status of each item (uploaded/captured or not).
async submitOwnerSign(
@Param("claimRequestId") claimRequestId: string,
@Body("agree") agree: string | boolean,
@Body("branchId") branchId: string,
@CurrentUser() expert: any,
@UploadedFile() sign: Express.Multer.File,
) {
@@ -589,7 +588,6 @@ Returns status of each item (uploaded/captured or not).
return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2(
claimRequestId,
agreed,
typeof branchId === "string" ? branchId : "",
sign,
expert.sub,
expert,

View File

@@ -0,0 +1,36 @@
import {
selectAccidentVehicleUsedId,
vehicleGroupIdForKind,
} from "./fanavaran-accident-vehicle-used";
describe("selectAccidentVehicleUsedId", () => {
const useTypes = [
{ Id: 1, Caption: "شخصي", IsActive: 1, VehicleGroupId: 1 },
{ Id: 3, Caption: "تاکسي درون شهري", IsActive: 1, VehicleGroupId: 1 },
{ Id: 35, Caption: "آمبولانس", IsActive: 1, VehicleGroupId: 2 },
{ Id: 36, Caption: "حمل مواد سريع الاشتعال", IsActive: 1, VehicleGroupId: 3 },
];
const kinds = [
{ Id: 8671, VehicleGroupId: 1, IsActive: 1 },
{ Id: 5904, VehicleGroupId: 3, IsActive: 1 },
];
it("keeps preferred id 1 when the car is passenger group 1", () => {
expect(vehicleGroupIdForKind(kinds, 8671)).toBe(1);
expect(selectAccidentVehicleUsedId(useTypes, 1, 1)).toBe(1);
});
it("keeps the vehicle UsedId when it is valid for that group", () => {
expect(selectAccidentVehicleUsedId(useTypes, 3, 36)).toBe(36);
});
it("picks an active use type for a non-passenger group instead of 1", () => {
expect(vehicleGroupIdForKind(kinds, 5904)).toBe(3);
expect(selectAccidentVehicleUsedId(useTypes, 3, 1)).toBe(36);
});
it("falls back to the vehicle UsedId when group is unknown", () => {
expect(selectAccidentVehicleUsedId(useTypes, null, 84)).toBe(84);
});
});

View File

@@ -0,0 +1,71 @@
export type FanavaranVehicleKindRow = {
Id?: unknown;
VehicleGroupId?: unknown;
IsActive?: unknown;
Caption?: unknown;
};
export type FanavaranVehicleUseTypeRow = {
Id?: unknown;
VehicleGroupId?: unknown;
IsActive?: unknown;
Caption?: unknown;
};
function asPositiveId(value: unknown): number | null {
if (value === null || value === undefined) return null;
const id = Number(value);
return Number.isFinite(id) && id > 0 ? id : null;
}
export function vehicleGroupIdForKind(
kinds: unknown,
vehicleKindId: number,
): number | null {
if (!Array.isArray(kinds)) return null;
const match = kinds.find(
(row) => asPositiveId((row as FanavaranVehicleKindRow)?.Id) === vehicleKindId,
) as FanavaranVehicleKindRow | undefined;
return asPositiveId(match?.VehicleGroupId);
}
/**
* Fanavaran filters AccidentVehicleUsedId by the policy car's VehicleGroupId.
* `preferredId` is the vehicle UsedId from VIN inquiry / vehicle GET — never a Mongo tenant default.
*/
export function selectAccidentVehicleUsedId(
useTypes: unknown,
vehicleGroupId: number | null,
preferredId: number | null,
): number | null {
if (!Array.isArray(useTypes) || vehicleGroupId == null) {
return preferredId;
}
const active = useTypes.filter((row) => {
const item = row as FanavaranVehicleUseTypeRow;
return (
item?.IsActive === 1 &&
asPositiveId(item.VehicleGroupId) === vehicleGroupId &&
asPositiveId(item.Id) != null
);
}) as FanavaranVehicleUseTypeRow[];
if (active.length === 0) {
return preferredId;
}
const preferred = active.find((row) => asPositiveId(row.Id) === preferredId);
if (preferred && preferredId != null) {
return preferredId;
}
const personal = active.find((row) =>
String(row.Caption ?? "").includes("شخص"),
);
if (personal) {
return asPositiveId(personal.Id) ?? preferredId;
}
return asPositiveId(active[0].Id) ?? preferredId;
}

View File

@@ -0,0 +1,157 @@
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
import {
fanavaranClaimProductFromBlameType,
fanavaranClaimsBaseUrl,
fanavaranClaimStageUrl,
fanavaranHasDamageCaseStage,
fanavaranHullImportClaimsUrl,
fanavaranPartyInquirySources,
isFanavaranSubmitSupportedBlameType,
pickCarBodyPolicyNationalCode,
pickStoredCarBodyPolicyId,
} from "./fanavaran-claim-product";
describe("fanavaran claim product", () => {
it("maps CAR_BODY blame files onto the hull claims resource", () => {
expect(fanavaranClaimProductFromBlameType(BlameRequestType.CAR_BODY)).toBe(
"car-body",
);
expect(fanavaranClaimsBaseUrl("car-body")).toBe(
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims",
);
});
it("keeps THIRD_PARTY on the financial third-party claims resource", () => {
expect(
fanavaranClaimProductFromBlameType(BlameRequestType.THIRD_PARTY),
).toBe("third-party");
expect(fanavaranClaimsBaseUrl("third-party")).toBe(
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/third-party-car-financial-claims",
);
});
it("does not POST hull damage to the missing dmg-cases resource", () => {
expect(fanavaranHasDamageCaseStage("car-body")).toBe(false);
expect(fanavaranClaimStageUrl("car-body", "damage-case", 5023617)).toBeNull();
expect(fanavaranClaimStageUrl("car-body", "attachments", 5023617)).toBe(
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims/5023617/files",
);
expect(fanavaranClaimStageUrl("car-body", "expertise", 5023617)).toBe(
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims/5023617/expertise",
);
expect(fanavaranClaimStageUrl("car-body", "culprits", 5023617)).toBe(
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-claims/5023617/culprits",
);
expect(fanavaranClaimStageUrl("third-party", "culprits", 1)).toBeNull();
expect(fanavaranHullImportClaimsUrl()).toBe(
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/vehicle-hull-import-claims",
);
expect(fanavaranClaimsBaseUrl("car-body")).not.toContain(
"vehicle-hull-import-claims",
);
});
it("allows Fanavaran submit for both car products", () => {
expect(isFanavaranSubmitSupportedBlameType("THIRD_PARTY")).toBe(true);
expect(isFanavaranSubmitSupportedBlameType("CAR_BODY")).toBe(true);
expect(isFanavaranSubmitSupportedBlameType("OTHER")).toBe(false);
});
it("reuses the car-body inquiry PolicyId without another policy list call", () => {
const policyId = pickStoredCarBodyPolicyId({
parties: [
{
role: PartyRole.FIRST,
insurance: { carBodyInsurance: { policyId: 15292336 } },
},
],
});
expect(policyId).toBe(15292336);
});
it("reads PolicyId from nested car-body inquiry raw when the flat field is missing", () => {
const policyId = pickStoredCarBodyPolicyId({
parties: [
{
role: PartyRole.FIRST,
vehicle: {
inquiry: {
carBody: {
mapped: {},
raw: { policyId: "15292336", policy: { PolicyId: 99 } },
},
},
},
},
],
});
expect(policyId).toBe(15292336);
});
it("uses the first-party car-body policyholder national code", () => {
const nationalCode = pickCarBodyPolicyNationalCode({
parties: [
{
role: PartyRole.FIRST,
person: { nationalCodeOfInsurer: "0012345678" },
insurance: {
carBodyInsurance: { ownerNationalCode: "0098765432" },
},
},
],
});
expect(nationalCode).toBe("0012345678");
});
it("flattens car-body inquiry fields onto the damage-case aliases", () => {
const { mapped } = fanavaranPartyInquirySources("car-body", {
insurance: {
carBodyInsurance: {
policyNumber: "70019846985",
chassisNumber: "IRNKAEK4150012345",
},
},
vehicle: {
inquiry: {
carBody: {
mapped: {
policyId: 15292336,
policyNumber: "70019846985",
chassisNumber: "IRNKAEK4150012345",
motorNumber: "M123",
vin: "IRNKAEK4150012345",
StartDate: "1405/05/18",
EndDate: "1406/05/18",
builtYear: 1402,
platePartOne: "29",
plateLetterTitle: "د",
platePartThree: "782",
plateSerialNumber: "44",
},
raw: {
policy: { CINumber: "70019846985" },
vehicle: { ChassisNo: "IRNKAEK4150012345" },
},
},
},
},
});
expect(mapped.policyId).toBe(15292336);
expect(mapped.PrntCmpDocNo).toBe("70019846985");
expect(mapped.ShsNum).toBe("IRNKAEK4150012345");
expect(mapped.MtrNum).toBe("M123");
expect(mapped.VIN).toBe("IRNKAEK4150012345");
expect(mapped.HBgnDte).toBe("1405/05/18");
expect(mapped.HEndDte).toBe("1406/05/18");
expect(mapped.PrdDte).toBe(1402);
expect(mapped.plk1).toBe("29");
expect(mapped.plk2).toBe("د");
expect(mapped.plk3).toBe("782");
expect(mapped.plksrl).toBe("44");
});
});

View File

@@ -0,0 +1,271 @@
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
import {
FANAVARAN_CAR_BODY_LINE_ID,
FANAVARAN_THIRD_PARTY_LINE_ID,
asObjectRecord,
parseFanavaranId,
type FanavaranCarPolicyProduct,
} from "src/lookups/fanavaran-last-car-policy";
export type FanavaranClaimProduct = FanavaranCarPolicyProduct;
const FANAVARAN_CLAIMS_HOST =
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car";
export function fanavaranClaimProductFromBlameType(
type?: string | null,
): FanavaranClaimProduct {
return type === BlameRequestType.CAR_BODY || type === "CAR_BODY"
? "car-body"
: "third-party";
}
export function isFanavaranSubmitSupportedBlameType(
type?: string | null,
): boolean {
return (
type === BlameRequestType.THIRD_PARTY ||
type === "THIRD_PARTY" ||
type === BlameRequestType.CAR_BODY ||
type === "CAR_BODY"
);
}
export function fanavaranClaimsResourcePath(
product: FanavaranClaimProduct,
): string {
return product === "car-body"
? "vehicle-hull-claims"
: "third-party-car-financial-claims";
}
export function fanavaranClaimsBaseUrl(
product: FanavaranClaimProduct,
): string {
return `${FANAVARAN_CLAIMS_HOST}/${fanavaranClaimsResourcePath(product)}`;
}
export type FanavaranClaimStage =
| "damage-case"
| "attachments"
| "expertise"
| "culprits";
/** Hull (بدنه) has no GEN.12 dmg-cases resource. Proven 2026-09-15: Feature:url(.../vehicle-hull-claims/{id}/dmg-cases) not found. Files and expertise do exist. */
export const FANAVARAN_HULL_NO_DAMAGE_CASE_REASON =
"Fanavaran vehicle-hull-claims has no dmg-cases resource. Hull is first-party (one damaged vehicle on the base claim). Continue with files and expertise.";
export const FANAVARAN_HULL_IMPORT_CLAIMS_PATH = "vehicle-hull-import-claims";
export function fanavaranHasDamageCaseStage(
product: FanavaranClaimProduct,
): boolean {
return product === "third-party";
}
export function fanavaranHullImportClaimsUrl(): string {
return `${FANAVARAN_CLAIMS_HOST}/${FANAVARAN_HULL_IMPORT_CLAIMS_PATH}`;
}
export function fanavaranClaimStageUrl(
product: FanavaranClaimProduct,
stage: FanavaranClaimStage,
claimId: number | string,
): string | null {
if (stage === "damage-case" && !fanavaranHasDamageCaseStage(product)) {
return null;
}
if (stage === "culprits" && product !== "car-body") {
return null;
}
const suffix =
stage === "damage-case"
? "dmg-cases"
: stage === "attachments"
? "files"
: stage === "culprits"
? "culprits"
: "expertise";
return `${fanavaranClaimsBaseUrl(product)}/${claimId}/${suffix}`;
}
export function fanavaranInsuranceLineIdForProduct(
product: FanavaranClaimProduct,
): number {
return product === "car-body"
? FANAVARAN_CAR_BODY_LINE_ID
: FANAVARAN_THIRD_PARTY_LINE_ID;
}
type BlamePartyForCarBodyPolicy = {
role?: string;
person?: { nationalCodeOfInsurer?: unknown };
insurance?: {
carBodyInsurance?: {
policyId?: unknown;
ownerNationalCode?: unknown;
insurerNationalCode?: unknown;
policyNumber?: unknown;
chassisNumber?: unknown;
motorNumber?: unknown;
vin?: unknown;
startDate?: unknown;
endDate?: unknown;
};
};
vehicle?: {
inquiry?: {
mapped?: Record<string, unknown>;
raw?: Record<string, unknown> | { data?: Record<string, unknown> };
carBody?: {
mapped?: Record<string, unknown>;
raw?: Record<string, unknown> & {
policyId?: unknown;
policy?: Record<string, unknown>;
vehicle?: Record<string, unknown>;
};
};
};
};
};
function firstCarBodyParty(
blame?: { parties?: BlamePartyForCarBodyPolicy[] } | null,
): BlamePartyForCarBodyPolicy | null {
const parties = blame?.parties ?? [];
return (
parties.find((party) => party?.role === PartyRole.FIRST) ??
parties[0] ??
null
);
}
function nonEmptyText(value: unknown): string | null {
if (value == null) return null;
const text = String(value).trim();
return text ? text : null;
}
export function pickStoredCarBodyPolicyId(
blame?: { parties?: BlamePartyForCarBodyPolicy[] } | null,
): number | null {
const party = firstCarBodyParty(blame);
const carBody = party?.vehicle?.inquiry?.carBody;
const raw = asObjectRecord(carBody?.raw) ?? {};
const mapped = asObjectRecord(carBody?.mapped) ?? {};
const rawPolicy = asObjectRecord(raw.policy);
return parseFanavaranId(
party?.insurance?.carBodyInsurance?.policyId ??
mapped.policyId ??
raw.policyId ??
rawPolicy?.PolicyId,
);
}
export function pickCarBodyPolicyNationalCode(
blame?: { parties?: BlamePartyForCarBodyPolicy[] } | null,
): string | null {
const party = firstCarBodyParty(blame);
return (
nonEmptyText(party?.person?.nationalCodeOfInsurer) ??
nonEmptyText(party?.insurance?.carBodyInsurance?.ownerNationalCode) ??
nonEmptyText(party?.insurance?.carBodyInsurance?.insurerNationalCode)
);
}
export function fanavaranPartyInquirySources(
product: FanavaranClaimProduct,
party?: BlamePartyForCarBodyPolicy | null,
): {
mapped: Record<string, any>;
raw: Record<string, any>;
} {
const inquiry = party?.vehicle?.inquiry ?? {};
if (product !== "car-body") {
const mapped = asObjectRecord(inquiry.mapped) ?? {};
const rawValue = inquiry.raw as
| Record<string, unknown>
| { data?: Record<string, unknown> }
| undefined;
const raw =
asObjectRecord(
rawValue &&
typeof rawValue === "object" &&
"data" in rawValue &&
rawValue.data != null &&
typeof rawValue.data === "object" &&
!Array.isArray(rawValue.data)
? rawValue.data
: rawValue,
) ?? {};
return { mapped, raw };
}
const carBody = inquiry.carBody ?? {};
const mapped = asObjectRecord(carBody.mapped) ?? {};
const raw = asObjectRecord(carBody.raw) ?? {};
const policy = asObjectRecord(raw.policy) ?? {};
const vehicle = asObjectRecord(raw.vehicle) ?? {};
const insurance = party?.insurance?.carBodyInsurance ?? {};
const policyNumber =
mapped.policyNumber ??
insurance.policyNumber ??
policy.CINumber ??
policy.PolicyNo;
const chassisNumber =
mapped.chassisNumber ?? insurance.chassisNumber ?? vehicle.ChassisNo;
const motorNumber =
mapped.motorNumber ?? insurance.motorNumber ?? vehicle.MotorNo;
const vin = mapped.vin ?? insurance.vin ?? vehicle.VIN ?? vehicle.ChassisNo;
const beginDate =
mapped.StartDate ?? mapped.startDate ?? insurance.startDate ?? policy.BeginDate;
const endDate =
mapped.EndDate ?? mapped.endDate ?? insurance.endDate ?? policy.EndDate;
const builtYear = mapped.builtYear ?? vehicle.BuiltYear;
const plaque = asObjectRecord(vehicle.plaque) ?? {};
const plk1 =
mapped.platePartOne ?? plaque.leftTwoDigits ?? vehicle.PlaqueLeftNo;
const plk2 =
mapped.plateLetterTitle ?? plaque.serialLetter;
const plk3 =
mapped.platePartThree ?? plaque.threeDigits ?? vehicle.PlaqueRightNo;
const plksrl =
mapped.plateSerialNumber ??
plaque.rightTwoDigits ??
vehicle.PlaqueSerial;
return {
mapped: {
...mapped,
...insurance,
policyId:
mapped.policyId ??
insurance.policyId ??
raw.policyId ??
policy.PolicyId,
PrntCmpDocNo: policyNumber,
PlcyUnqCod: policy.PolicyUnqCod ?? mapped.policyNumber,
ShsNum: chassisNumber,
MtrNum: motorNumber,
VIN: vin,
HBgnDte: beginDate,
HEndDte: endDate,
PrdDte: builtYear,
plk1,
Plk1: plk1,
plk2,
Plk2: plk2,
plk3,
Plk3: plk3,
plksrl,
PlkSrl: plksrl,
},
raw: {
...policy,
...vehicle,
...raw,
},
};
}

View File

@@ -0,0 +1,81 @@
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { fanavaranClaimReferences } from "./fanavaran-claim-references";
describe("fanavaranClaimReferences", () => {
it("returns all four Fanavaran ids when the claim is completed", () => {
expect(
fanavaranClaimReferences({
status: ClaimCaseStatus.COMPLETED,
claimId: 987654,
claimNo: 123456,
dmgCaseId: 11,
expertiseId: 22,
}),
).toEqual({
claimId: 987654,
claimNo: 123456,
dmgCaseId: 11,
expertiseId: 22,
});
});
it("exposes the persisted Fanavaran reference codes to expert detail APIs", () => {
expect(
fanavaranClaimReferences({
status: ClaimCaseStatus.COMPLETED,
claimId: 100,
claimNo: 101,
dmgCaseId: 102,
expertiseId: 103,
fanavaranSync: {
baseClaim: { policyId: 104 },
damageCase: {
driverId: 105,
vehicleKindId: 106,
plaqueKindId: 107,
plaqueSampleId: 108,
accidentVehicleUsedId: 109,
insuranceCorpId: 110,
},
},
}),
).toEqual({
claimId: 100,
claimNo: 101,
dmgCaseId: 102,
expertiseId: 103,
policyId: 104,
driverId: 105,
vehicleKindId: 106,
plaqueKindId: 107,
plaqueSampleId: 108,
accidentVehicleUsedId: 109,
insuranceCorpId: 110,
});
});
it("omits missing ids", () => {
expect(
fanavaranClaimReferences({
status: ClaimCaseStatus.COMPLETED,
claimId: 987654,
claimNo: null,
dmgCaseId: undefined,
expertiseId: 22,
}),
).toEqual({
claimId: 987654,
expertiseId: 22,
});
});
it("returns undefined before the claim is completed", () => {
expect(
fanavaranClaimReferences({
status: ClaimCaseStatus.EXPERT_REVIEWING,
claimId: 987654,
dmgCaseId: 11,
}),
).toBeUndefined();
});
});

View File

@@ -0,0 +1,80 @@
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
export type FanavaranClaimReferences = {
claimId?: number;
claimNo?: number;
dmgCaseId?: number;
expertiseId?: number;
policyId?: number;
driverId?: number;
vehicleKindId?: number;
plaqueKindId?: number;
plaqueSampleId?: number;
accidentVehicleUsedId?: number;
insuranceCorpId?: number;
};
/**
* Fanavaran ids for v2 claim GET. Only on COMPLETED; each field is omitted when missing.
*/
export function fanavaranClaimReferences(claim: {
status?: string;
claimId?: number | null;
claimNo?: number | null;
dmgCaseId?: number | null;
expertiseId?: number | null;
fanavaranSync?: {
baseClaim?: FanavaranReferenceStage | null;
damageCase?: FanavaranReferenceStage | null;
} | null;
}): FanavaranClaimReferences | undefined {
if (claim.status !== ClaimCaseStatus.COMPLETED) {
return undefined;
}
const refs: FanavaranClaimReferences = {};
if (claim.claimId != null) refs.claimId = claim.claimId;
if (claim.claimNo != null) refs.claimNo = claim.claimNo;
if (claim.dmgCaseId != null) refs.dmgCaseId = claim.dmgCaseId;
if (claim.expertiseId != null) refs.expertiseId = claim.expertiseId;
const baseClaim = claim.fanavaranSync?.baseClaim;
const damageCase = claim.fanavaranSync?.damageCase;
if (baseClaim?.policyId != null) refs.policyId = baseClaim.policyId;
if ((damageCase?.driverId ?? baseClaim?.driverId) != null) {
refs.driverId = damageCase?.driverId ?? baseClaim?.driverId;
}
if ((damageCase?.vehicleKindId ?? baseClaim?.vehicleKindId) != null) {
refs.vehicleKindId =
damageCase?.vehicleKindId ?? baseClaim?.vehicleKindId;
}
if (damageCase?.plaqueKindId != null) {
refs.plaqueKindId = damageCase.plaqueKindId;
}
if (damageCase?.plaqueSampleId != null) {
refs.plaqueSampleId = damageCase.plaqueSampleId;
}
if (
(damageCase?.accidentVehicleUsedId ?? baseClaim?.accidentVehicleUsedId) !=
null
) {
refs.accidentVehicleUsedId =
damageCase?.accidentVehicleUsedId ?? baseClaim?.accidentVehicleUsedId;
}
if ((damageCase?.insuranceCorpId ?? baseClaim?.insuranceCorpId) != null) {
refs.insuranceCorpId =
damageCase?.insuranceCorpId ?? baseClaim?.insuranceCorpId;
}
return Object.keys(refs).length > 0 ? refs : undefined;
}
type FanavaranReferenceStage = {
policyId?: number | null;
driverId?: number | null;
vehicleKindId?: number | null;
plaqueKindId?: number | null;
plaqueSampleId?: number | null;
accidentVehicleUsedId?: number | null;
insuranceCorpId?: number | null;
};

View File

@@ -0,0 +1,76 @@
import {
asPartyInquiryRows,
collectPersonNationalCodes,
parseJalaliDateParts,
pickPersonBirthday,
pickPersonNationalCode,
selectFanavaranDriverId,
} from "./fanavaran-driver-inquiry";
describe("selectFanavaranDriverId", () => {
const rows = [
{ Id: 10, RoleId: 161, Name: "insurer" },
{ Id: 20, RoleId: 166, Name: "driver" },
];
it("prefers insurer role 161 when the driver is the owner", () => {
expect(selectFanavaranDriverId(rows, true)).toBe(10);
});
it("prefers driver role 166 when the driver is not the owner", () => {
expect(selectFanavaranDriverId(rows, false)).toBe(20);
});
it("uses the other role when the preferred role is missing", () => {
expect(selectFanavaranDriverId([{ Id: 20, RoleId: 166 }], true)).toBe(20);
});
it("picks 4773521 from a real Fanavaran unique-identifier response", () => {
expect(
selectFanavaranDriverId(
[
{ Id: 4773521, RoleId: 161 },
{ Id: 4773521, RoleId: 163 },
{ Id: 4773521, RoleId: 166 },
],
true,
),
).toBe(4773521);
});
});
describe("pickPersonNationalCode / birthday", () => {
it("uses insurer national code when driverIsInsurer", () => {
expect(
pickPersonNationalCode({
driverIsInsurer: true,
nationalCodeOfInsurer: "0012345678",
nationalCodeOfDriver: "",
}),
).toBe("0012345678");
});
it("falls back across birthday fields", () => {
expect(
pickPersonBirthday({
driverBirthday: null,
birthday: "13480313",
}),
).toBe("13480313");
});
});
describe("asPartyInquiryRows", () => {
it("normalizes Persian digits and compact insurer birthday 13640628", () => {
expect(
collectPersonNationalCodes({
nationalCodeOfInsurer: "۰۰۸۰۰۸۶۵۱۹",
}),
).toEqual(["0080086519"]);
expect(parseJalaliDateParts(13640628)).toEqual({
year: 1364,
month: 6,
day: 28,
});
});
});

View File

@@ -0,0 +1,165 @@
import { toEnglishDigits } from "src/lookups/fanavaran-last-car-policy";
export const FANAVARAN_INSURER_ROLE_ID = 161;
export const FANAVARAN_PLAQUE_OWNER_ROLE_ID = 163;
export const FANAVARAN_DRIVER_ROLE_ID = 166;
export type FanavaranPartyInquiryRow = {
Id?: unknown;
RoleId?: unknown;
NationalCode?: unknown;
Name?: unknown;
LastName?: unknown;
};
function asPositiveId(value: unknown): number | null {
if (value === null || value === undefined) return null;
const id = Number(value);
return Number.isFinite(id) && id > 0 ? id : null;
}
export function asPartyInquiryRows(value: unknown): FanavaranPartyInquiryRow[] {
if (Array.isArray(value)) {
return value.filter(
(row): row is FanavaranPartyInquiryRow =>
!!row && typeof row === "object" && !Array.isArray(row),
);
}
if (!value || typeof value !== "object") return [];
const record = value as Record<string, unknown>;
for (const key of ["value", "Value", "items", "Items", "data", "Data"]) {
const nested = record[key];
if (Array.isArray(nested)) return asPartyInquiryRows(nested);
}
return [];
}
/**
* DriverId for GEN.12 زیان‌دیده. Prefer insurer (161) or driver (166)
* based on driverIsInsurer, then any returned party id.
*/
export function selectFanavaranDriverId(
rows: unknown,
driverIsInsurer: boolean | undefined,
): number | null {
const parties = asPartyInquiryRows(rows);
if (parties.length === 0) return null;
const preferredRoles = driverIsInsurer
? [FANAVARAN_INSURER_ROLE_ID, FANAVARAN_DRIVER_ROLE_ID, FANAVARAN_PLAQUE_OWNER_ROLE_ID]
: [FANAVARAN_DRIVER_ROLE_ID, FANAVARAN_INSURER_ROLE_ID, FANAVARAN_PLAQUE_OWNER_ROLE_ID];
for (const roleId of preferredRoles) {
const match = parties.find((row) => asPositiveId(row.RoleId) === roleId);
if (match) return asPositiveId(match.Id);
}
return asPositiveId(parties[0].Id);
}
export function normalizeNationalCode(value: unknown): string | null {
const digits = toEnglishDigits(value).replace(/\D/g, "");
if (digits.length === 10) return digits;
if (digits.length === 8 || digits.length === 9) return digits.padStart(10, "0");
return digits.length > 0 ? digits : null;
}
export function pickPersonNationalCode(person: {
driverIsInsurer?: boolean;
nationalCodeOfDriver?: unknown;
nationalCodeOfInsurer?: unknown;
nationalCode?: unknown;
} | null | undefined): string | null {
if (!person) return null;
const driver = normalizeNationalCode(person.nationalCodeOfDriver);
const insurer = normalizeNationalCode(person.nationalCodeOfInsurer);
const generic = normalizeNationalCode(person.nationalCode);
if (person.driverIsInsurer) {
return insurer || driver || generic;
}
return driver || insurer || generic;
}
export function pickPersonBirthday(person: {
driverIsInsurer?: boolean;
driverBirthday?: unknown;
birthday?: unknown;
insurerBirthday?: unknown;
} | null | undefined): string | number | null {
if (!person) return null;
const ordered = person.driverIsInsurer
? [person.insurerBirthday, person.birthday, person.driverBirthday]
: [person.driverBirthday, person.birthday, person.insurerBirthday];
for (const value of ordered) {
if (value == null || String(value).trim() === "") continue;
if (parseJalaliDateParts(value)) return value as string | number;
}
return null;
}
export function collectPersonNationalCodes(
person: {
nationalCodeOfDriver?: unknown;
nationalCodeOfInsurer?: unknown;
nationalCode?: unknown;
} | null | undefined,
): string[] {
const codes = [
normalizeNationalCode(person?.nationalCodeOfDriver),
normalizeNationalCode(person?.nationalCodeOfInsurer),
normalizeNationalCode(person?.nationalCode),
].filter((value): value is string => !!value);
return [...new Set(codes)];
}
export function collectPersonBirthdays(person: {
driverBirthday?: unknown;
birthday?: unknown;
insurerBirthday?: unknown;
} | null | undefined): Array<string | number> {
const values = [
person?.insurerBirthday,
person?.birthday,
person?.driverBirthday,
].filter((value) => value != null && String(value).trim() !== "");
const unique: Array<string | number> = [];
const seen = new Set<string>();
for (const value of values) {
const parsed = parseJalaliDateParts(value);
const key = parsed
? `${parsed.year}-${parsed.month}-${parsed.day}`
: String(value);
if (seen.has(key)) continue;
seen.add(key);
unique.push(value as string | number);
}
return unique;
}
export function parseJalaliDateParts(
birthday: unknown,
): { year: number; month: number; day: number } | null {
if (birthday == null) return null;
if (typeof birthday === "object") {
const year = Number(toEnglishDigits((birthday as any).year ?? (birthday as any).BirthYear));
const month = Number(toEnglishDigits((birthday as any).month ?? (birthday as any).BirthMonth));
const day = Number(toEnglishDigits((birthday as any).day ?? (birthday as any).BirthDay));
if (year > 0 && month > 0 && day > 0) return { year, month, day };
return null;
}
const str = toEnglishDigits(birthday).trim();
if (/^\d{8}$/.test(str)) {
const year = parseInt(str.slice(0, 4), 10);
const month = parseInt(str.slice(4, 6), 10);
const day = parseInt(str.slice(6, 8), 10);
if (!year || !month || !day) return null;
return { year, month, day };
}
const parts = str.split(/[/\-.]/);
if (parts.length < 3) return null;
const year = parseInt(parts[0], 10);
const month = parseInt(parts[1], 10);
const day = parseInt(parts[2], 10);
if (!year || !month || !day) return null;
return { year, month, day };
}

View File

@@ -0,0 +1,87 @@
import {
actualPremiumFromHullPolicyRecord,
customerIdFromHullPolicyRecord,
FANAVARAN_DEFAULT_HULL_ACCIDENT_TYPE_ID,
fanavaranHullNestedClaimId,
toFanavaranHullBaseClaimPayload,
} from "./fanavaran-hull-base-claim";
describe("fanavaran hull base claim", () => {
it("reads ActualPremium and CustomerId from hull policy GET", () => {
expect(
actualPremiumFromHullPolicyRecord({ TotalPremium: 21613552 }),
).toBe(21613552);
expect(customerIdFromHullPolicyRecord({ CustomerId: 1230091 })).toBe(
1230091,
);
});
it("defaults CustomerFaultPercent to 100 when CulpritTypeId is 300", () => {
const hull = toFanavaranHullBaseClaimPayload({
PolicyId: 1,
CulpritTypeId: 300,
});
expect(hull.CustomerFaultPercent).toBe(100);
});
it("uses GEN.03 Id for nested files/expertise, not ClaimNo", () => {
expect(
fanavaranHullNestedClaimId({ Id: 5023617, ClaimNo: 2268 }),
).toBe(5023617);
expect(
fanavaranHullNestedClaimId({ claimId: 5023617, claimNo: 2268 }),
).toBe(5023617);
expect(fanavaranHullNestedClaimId({ ClaimNo: 2268 })).toBeNull();
});
it("keeps IsLicenseReplaced null even when ثالث IsLicenseReplacement is 0", () => {
const hull = toFanavaranHullBaseClaimPayload({
PolicyId: 1,
IsLicenseReplacement: 0,
});
expect(hull.IsLicenseReplaced).toBeNull();
});
it("strips ثالث-only fields from a live hull create echo", () => {
const hull = toFanavaranHullBaseClaimPayload({
AccidentCauseId: 6,
AccidentCityId: 701,
AccidentDate: "1405/06/24",
AccidentLocationAddress: "استان تهران شهر تهران",
AccidentReportTypeId: 155,
AccidentTime: "10:33",
AccidentVehicleUsedId: 1,
ActualPremium: 57270462,
AnnouncementDate: "1405/06/24",
ClaimExpertId: 154,
ClaimNo: 2268,
CompensationReferenceId: 167,
CulpritLicenceNo: "9705463515",
CulpritTypeId: 337,
CustomerFaultPercent: 100,
DamagedCount: 1,
EstimateAmount: 16000000,
HasOtherCulprit: 0,
Id: 5023617,
IsFatalAccident: 0,
IsLicenseReplacement: null,
IsPlaqueChanged: 0,
PolicyId: 13764408,
PreviousPolicyEndDate: "1404/10/23",
SanhabVersion: 6,
});
expect(hull.PolicyId).toBe(13764408);
expect(hull.AccidentTypeId).toBe(FANAVARAN_DEFAULT_HULL_ACCIDENT_TYPE_ID);
expect(hull.IsLicenseReplaced).toBeNull();
expect(hull.CostSeparationToDmgSections).toBe(0);
expect(hull.IsOwnerChanged).toBe(0);
expect(hull.CulpritTypeId).toBe(300);
expect(hull.DamagedCount).toBeUndefined();
expect(hull.HasOtherCulprit).toBeUndefined();
expect(hull.CompensationReferenceId).toBeUndefined();
expect(hull.SanhabVersion).toBeUndefined();
expect(hull.Id).toBeUndefined();
expect(hull.ClaimNo).toBeUndefined();
});
});

View File

@@ -0,0 +1,184 @@
/** GEN.03 VHUD input fields. Do not send ثالث-only keys (DamagedCount, HasOtherCulprit, plaques, …). */
export const FANAVARAN_HULL_BASE_CLAIM_KEYS = [
"ArchiveNo",
"AccidentDate",
"AccidentTime",
"AnnouncementDate",
"AccidentLocationAddress",
"ClaimExpertId",
"EntryDate",
"CulpritLicenceNo",
"CulpritLicenceIssuDate",
"CulpritLicenceForeignCityName",
"PoliceReportSeri",
"PoliceReportSerial",
"PoliceReportDesc",
"CustomerFaultPercent",
"CulpritLevelTwoLicenceIssuDate",
"ActualPremium",
"EstimateAmount",
"TrackingCode",
"CourtArchiveNo",
"PlaqueReplacementDate",
"StatusChangeDate",
"ClaimCompletionDate",
"DmgAssessorFirstCreationTime",
"PolicyId",
"IsSurplusArticleEighthLaw",
"AccidentCityId",
"AccidentCauseId",
"AccidentTypeId",
"GlassBreakReasonId",
"CulpritTypeId",
"AuthorityCulpritId",
"AccidentCulpritId",
"CulpritLicenceTypeId",
"IsLicenseReplaced",
"CulpritLicenceCountryId",
"CulpritLicenceCityId",
"AccidentReportTypeId",
"PoliceOfficerId",
"IsAccidentOutOfBorder",
"AccidentVehicleUsedId",
"IsOwnerChanged",
"CostSeparationToDmgSections",
] as const;
/** GEN.03 default: تصادف(حادثه). Lookup: GET /lookups/vehicle-hull-accident-types */
export const FANAVARAN_DEFAULT_HULL_ACCIDENT_TYPE_ID = 2;
/** GEN.03 ans (0=خیر). Used for CostSeparationToDmgSections, IsOwnerChanged, IsLicenseReplaced. */
export const FANAVARAN_HULL_ANS_NO = 0;
/** GEN.03 hull culprit type when third-party is at fault. Lookup: vehicle-hull-accident-culprit-type */
export const FANAVARAN_DEFAULT_HULL_CULPRIT_TYPE_ID = 300;
/** GEN.03 required: درصد تقصير بيمه گذار. Proven Parsian submit uses 100. */
export const FANAVARAN_DEFAULT_HULL_CUSTOMER_FAULT_PERCENT = 100;
/**
* GEN.03 required: مرجع تعيين مقصر.
* Lookup: GET /lookups/detection-accident-culprits. Fallback is بیمه‌گذار.
*/
export const FANAVARAN_DEFAULT_HULL_AUTHORITY_CULPRIT_ID = 1;
export const FANAVARAN_HULL_AUTHORITY_CULPRIT_CAPTION = "بيمه گذار";
/** GEN.03 ActualPremium from GET car/vehicle-hull-policies/{PolicyId} → TotalPremium. */
export function actualPremiumFromHullPolicyRecord(
policy: Record<string, unknown> | null | undefined,
): number | null {
if (!policy) return null;
const raw = policy.TotalPremium ?? policy.totalPremium;
const n = typeof raw === "number" ? raw : Number(raw);
return Number.isFinite(n) && n >= 0 ? n : null;
}
function normalizeHullCaption(value: unknown): string {
return String(value ?? "")
.replace(/ي/g, "ی")
.replace(/ك/g, "ک")
.replace(/\s+/g, " ")
.trim();
}
/** AuthorityCulpritId from GET /lookups/detection-accident-culprits. */
export function authorityCulpritIdFromDetectionRows(
rows: unknown,
): number {
const list = Array.isArray(rows) ? rows : [];
const wanted = normalizeHullCaption(
FANAVARAN_HULL_AUTHORITY_CULPRIT_CAPTION,
);
for (const row of list) {
if (!row || typeof row !== "object") continue;
const caption = normalizeHullCaption(
(row as { Caption?: unknown }).Caption,
);
if (!caption.includes(wanted)) continue;
const raw = (row as { Id?: unknown }).Id;
const n = typeof raw === "number" ? raw : Number(raw);
if (Number.isFinite(n) && n > 0) return n;
}
for (const row of list) {
if (!row || typeof row !== "object") continue;
const raw = (row as { Id?: unknown }).Id;
const n = typeof raw === "number" ? raw : Number(raw);
if (Number.isFinite(n) && n > 0) return n;
}
return FANAVARAN_DEFAULT_HULL_AUTHORITY_CULPRIT_ID;
}
/** Fallback AccidentCulpritId when parties inquiry is unavailable. */
export function customerIdFromHullPolicyRecord(
policy: Record<string, unknown> | null | undefined,
): number | null {
if (!policy) return null;
const raw = policy.CustomerId ?? policy.customerId;
const n = typeof raw === "number" ? raw : Number(raw);
return Number.isFinite(n) && n > 0 ? n : null;
}
export function toFanavaranHullBaseClaimPayload(
built: Record<string, unknown>,
): Record<string, unknown> {
const next: Record<string, unknown> = {};
for (const key of FANAVARAN_HULL_BASE_CLAIM_KEYS) {
if (key === "IsLicenseReplaced") {
// GEN.03: must be empty/null. Do not map ثالث IsLicenseReplacement (default 0).
next[key] = null;
continue;
}
if (key === "AccidentTypeId") {
next[key] =
built.AccidentTypeId ?? FANAVARAN_DEFAULT_HULL_ACCIDENT_TYPE_ID;
continue;
}
if (key === "CostSeparationToDmgSections") {
next[key] =
built.CostSeparationToDmgSections ?? FANAVARAN_HULL_ANS_NO;
continue;
}
if (key === "IsOwnerChanged") {
next[key] = built.IsOwnerChanged ?? FANAVARAN_HULL_ANS_NO;
continue;
}
if (key === "CulpritTypeId") {
const raw = built.CulpritTypeId;
// ثالث profile default 337 is not in vehicle-hull-accident-culprit-type.
if (raw == null || raw === 337) {
next[key] = FANAVARAN_DEFAULT_HULL_CULPRIT_TYPE_ID;
} else {
next[key] = raw;
}
continue;
}
if (key === "CustomerFaultPercent") {
next[key] =
built.CustomerFaultPercent ??
FANAVARAN_DEFAULT_HULL_CUSTOMER_FAULT_PERCENT;
continue;
}
if (key === "AuthorityCulpritId") {
next[key] =
built.AuthorityCulpritId ?? FANAVARAN_DEFAULT_HULL_AUTHORITY_CULPRIT_ID;
continue;
}
next[key] = Object.prototype.hasOwnProperty.call(built, key)
? built[key]
: null;
}
return next;
}
/** Nested hull files/expertise use GEN.03 `Id` (کد رایانه), never `ClaimNo` (شماره پرونده). */
export function fanavaranHullNestedClaimId(input: {
Id?: unknown;
ClaimNo?: unknown;
claimId?: unknown;
claimNo?: unknown;
}): number | null {
const id = input.claimId ?? input.Id;
if (typeof id === "number" && Number.isFinite(id) && id > 0) return id;
if (typeof id === "string" && /^\d+$/.test(id.trim())) return Number(id);
return null;
}

View File

@@ -0,0 +1,78 @@
import { parseFanavaranId } from "src/lookups/fanavaran-last-car-policy";
export const FANAVARAN_HULL_DMG_KIND_CAPTION = "تخریب";
export const FANAVARAN_HULL_DMG_COST_KIND_CAPTION = "مجموع لوازم";
/** Fallback when lookup fetch fails (Parsian `vehicle-hull-dmg-kind`). */
export const FANAVARAN_DEFAULT_HULL_DMG_KIND_ID = 5485;
/** Fallback when lookup fetch fails (`vehicle-hull-dmg-cost-kinds` Id). */
export const FANAVARAN_DEFAULT_HULL_DMG_COST_KIND_ID = 1;
/** GEN.06 factory (فابریک) accessory row. Used for every hull DmgSection. */
export const FANAVARAN_DEFAULT_HULL_VEHICLE_ACCESSORY_ID = 3043330;
export type FanavaranHullDmgAccessoryRow = {
Id?: unknown;
AccessoryKindId?: unknown;
AccessoryDesc?: unknown;
};
export function asLookupRows(value: unknown): Record<string, unknown>[] {
if (!Array.isArray(value)) return [];
return value.filter(
(row): row is Record<string, unknown> =>
!!row && typeof row === "object" && !Array.isArray(row),
);
}
export function asHullDmgAccessoryRows(value: unknown): FanavaranHullDmgAccessoryRow[] {
return asLookupRows(value) as FanavaranHullDmgAccessoryRow[];
}
export function findLookupIdByCaption(
rows: unknown,
caption: string,
): number | null {
const target = caption.trim();
for (const row of asLookupRows(rows)) {
if (String(row.Caption ?? "").trim() !== target) continue;
const id = parseFanavaranId(row.Id);
if (id != null) return id;
}
return null;
}
function isFactoryDefaultAccessoryRow(row: FanavaranHullDmgAccessoryRow): boolean {
const desc = String(row.AccessoryDesc ?? "").trim();
return (
desc.includes("کليه قطعات فابريک") ||
desc.includes("کلیه قطعات فابریک") ||
desc.includes("کليه قطعات") ||
desc.includes("فابريک")
);
}
/**
* GEN.06 VehicleHullAccessoryId from policy dmg-accessories.
* 1) Match AccessoryKindId to car-components part id.
* 2) Else factory bundle row (app default outer parts map here on Parsian).
*/
export function resolveVehicleHullAccessoryId(
accessories: FanavaranHullDmgAccessoryRow[],
accessoryKindId: number | null,
): number | null {
if (accessories.length === 0) return null;
if (accessoryKindId != null) {
const exact = accessories.find(
(row) => parseFanavaranId(row.AccessoryKindId) === accessoryKindId,
);
const exactId = parseFanavaranId(exact?.Id);
if (exactId != null) return exactId;
}
const factory = accessories.find(isFactoryDefaultAccessoryRow);
const factoryId = parseFanavaranId(factory?.Id);
if (factoryId != null) return factoryId;
return parseFanavaranId(accessories[0]?.Id);
}

View File

@@ -0,0 +1,172 @@
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
import {
hullExpertiseAssertFields,
collectFanavaranExpertiseReadinessWarnings,
pickHullVehicleIdentity,
toFanavaranHullExpertiseDmgSection,
toFanavaranHullExpertisePayload,
vehicleCurrentValueFromHullPolicyRecord,
} from "./fanavaran-hull-expertise";
describe("fanavaran hull expertise", () => {
it("reads VehicleCurrentValue from hull policy VehicleValue", () => {
expect(
vehicleCurrentValueFromHullPolicyRecord({ VehicleValue: 11000000000 }),
).toBe(11000000000);
});
it("maps car-body inquiry identity onto GEN.06 vehicle fields", () => {
const vehicle = pickHullVehicleIdentity({
parties: [
{
role: PartyRole.FIRST,
insurance: {
carBodyInsurance: {
chassisNumber: "NAS431100K1050805",
motorNumber: "M136251767",
vin: "VIN123",
},
},
vehicle: {
inquiry: {
carBody: {
mapped: {
platePartOne: "29",
plateLetterTitle: "د",
platePartThree: "782",
plateSerialNumber: "60",
builtYear: 1398,
},
},
},
},
},
],
});
expect(vehicle).toEqual({
motorNo: "M136251767",
chassisNo: "NAS431100K1050805",
vin: "VIN123",
plaqueNo: "29د782",
plaqueSerial: "60",
builtYear: 1398,
});
});
it("builds a GEN.06 payload without ثالث expertise fields", () => {
const payload = toFanavaranHullExpertisePayload({
claimExpertId: 29,
dmgAssessmentDate: "1405/06/24",
inspectionTime: "10:33",
wage: 1000,
componentReplacementCost: 2000,
wasteValue: 0,
dropAmount: 12,
vehicleCurrentValue: 1900000000,
vehicle: {
motorNo: "M1",
chassisNo: "C1",
vin: null,
plaqueNo: "695ط61",
plaqueSerial: "87",
builtYear: 1398,
},
dmgSections: [
toFanavaranHullExpertiseDmgSection({
partId: 9,
desc: "bumper",
wasteValue: 0,
amount: 3000,
dmgKindId: 5485,
dmgCostKindId: 1,
vehicleHullAccessoryId: 3043330,
}),
],
});
expect(payload.Wage).toBe(1000);
expect(payload.RepairWage).toBeUndefined();
expect(payload.DmgCaseId).toBeUndefined();
expect(payload.InspectionPlaceId).toBeUndefined();
expect(payload.DropAmountStatus).toBeUndefined();
expect(payload.DamagedVehicleCurrentPrice).toBeUndefined();
expect(payload.ClaimExpertId).toBe(29);
expect(payload.PlaqueNo).toBe("695ط61");
expect(payload.DmgSections).toEqual([
{
Count: 1,
Desc: "bumper",
WasteValue: 0,
AccessoryKindId: 9,
DmgKindId: 5485,
VehicleHullAccessoryId: 3043330,
DmgSectionCosts: [
{ Caption: "bumper", Amount: 3000, DmgCostKindId: 1 },
],
},
]);
});
it("does not require ثالث expertise fields on a GEN.06 hull payload", () => {
const payload = toFanavaranHullExpertisePayload({
claimExpertId: 29,
dmgAssessmentDate: "1405/06/24",
inspectionTime: "10:41",
wage: 6000000,
componentReplacementCost: 10000000,
wasteValue: 0,
dropAmount: 10000,
vehicleCurrentValue: null,
vehicle: {
motorNo: "13389030167",
chassisNo: "NAAP41FD5BJ301065",
vin: "IRFC891V7D2301065",
plaqueNo: "56د394",
plaqueSerial: "66",
builtYear: 1389,
},
dmgSections: [
toFanavaranHullExpertiseDmgSection({
partId: 9,
desc: "سپر جلو",
wasteValue: 0,
amount: 11000000,
dmgKindId: 5485,
dmgCostKindId: 1,
vehicleHullAccessoryId: 3043330,
}),
toFanavaranHullExpertiseDmgSection({
partId: 11,
desc: "آينه سمت راننده",
wasteValue: 0,
amount: 5000000,
dmgKindId: 5485,
dmgCostKindId: 1,
vehicleHullAccessoryId: 3043330,
}),
],
});
expect(
collectFanavaranExpertiseReadinessWarnings(
payload,
hullExpertiseAssertFields("car-body"),
),
).toEqual([]);
expect(
collectFanavaranExpertiseReadinessWarnings(
payload,
hullExpertiseAssertFields("third-party"),
),
).toEqual([
"DmgCaseId is required.",
"InspectionPlaceId is required.",
"DropAmountStatus is required.",
"DmgSections[0].DmgSectionId is required.",
"DmgSections[0].AccidentLevel is required.",
"DmgSections[1].DmgSectionId is required.",
"DmgSections[1].AccidentLevel is required.",
]);
});
});

View File

@@ -0,0 +1,210 @@
import { parseFanavaranId } from "src/lookups/fanavaran-last-car-policy";
import {
fanavaranPartyInquirySources,
type FanavaranClaimProduct,
} from "./fanavaran-claim-product";
export type FanavaranHullVehicleIdentity = {
motorNo: string | null;
chassisNo: string | null;
vin: string | null;
plaqueNo: string | null;
plaqueSerial: string | null;
builtYear: number | null;
};
export function mergeHullVehicleIdentityFromFanavaranVehicle(
base: FanavaranHullVehicleIdentity,
vehicle: Record<string, unknown> | null | undefined,
): FanavaranHullVehicleIdentity {
if (!vehicle) return base;
const readText = (key: string, fallback: string | null) => {
const raw = vehicle[key];
if (raw == null || String(raw).trim() === "") return fallback;
return String(raw).trim();
};
return {
motorNo: readText("MotorNo", base.motorNo),
chassisNo: readText("ChassisNo", base.chassisNo),
vin: readText("VIN", base.vin),
plaqueNo: readText("PlaqueNo", base.plaqueNo),
plaqueSerial: readText("PlaqueSerial", base.plaqueSerial),
builtYear:
parseFanavaranId(vehicle.BuiltYear) ??
parseFanavaranId(vehicle.builtYear) ??
base.builtYear,
};
}
export function pickHullVehicleIdentity(
blame?: { parties?: unknown[] } | null,
): FanavaranHullVehicleIdentity {
const party = (blame?.parties ?? [])[0] as
| Parameters<typeof fanavaranPartyInquirySources>[1]
| undefined;
const first =
(blame?.parties ?? []).find(
(row) => (row as { role?: string })?.role === "FIRST",
) ?? party;
const { mapped } = fanavaranPartyInquirySources(
"car-body",
first as Parameters<typeof fanavaranPartyInquirySources>[1],
);
const left = text(mapped.plk1 ?? mapped.Plk1);
const letter = text(mapped.plk2 ?? mapped.Plk2);
const right = text(mapped.plk3 ?? mapped.Plk3);
const serial = text(mapped.plksrl ?? mapped.PlkSrl);
const plaqueNo = [left, letter, right].filter(Boolean).join("") || null;
return {
motorNo: text(mapped.MtrNum),
chassisNo: text(mapped.ShsNum),
vin: text(mapped.VIN),
plaqueNo,
plaqueSerial: serial,
builtYear: parseFanavaranId(mapped.PrdDte),
};
}
function text(value: unknown): string | null {
if (value == null) return null;
const next = String(value).trim();
return next ? next : null;
}
export {
FANAVARAN_DEFAULT_HULL_DMG_COST_KIND_ID,
FANAVARAN_DEFAULT_HULL_DMG_KIND_ID,
FANAVARAN_HULL_DMG_COST_KIND_CAPTION,
FANAVARAN_HULL_DMG_KIND_CAPTION,
} from "./fanavaran-hull-expertise-lookups";
/** GEN.06 RepairDuration in days for every hull expertise. */
export const FANAVARAN_DEFAULT_HULL_REPAIR_DURATION = 3;
/** GEN.06 VehicleCurrentValue from GET car/vehicle-hull-policies/{PolicyId} → VehicleValue. */
export function vehicleCurrentValueFromHullPolicyRecord(
policy: Record<string, unknown> | null | undefined,
): number | null {
if (!policy) return null;
const raw =
policy.VehicleValue ?? policy.vehicleValue ?? policy.VehicleCurrentValue;
const n = typeof raw === "number" ? raw : Number(raw);
return Number.isFinite(n) && n > 0 ? n : null;
}
export function toFanavaranHullExpertisePayload(input: {
claimExpertId: number;
dmgAssessmentDate: string;
inspectionTime: string;
wage: number;
componentReplacementCost: number;
wasteValue: number;
dropAmount: number;
vehicleCurrentValue: number | null;
vehicle: FanavaranHullVehicleIdentity;
colorId?: number | null;
repairDuration?: number | null;
dmgSections: Record<string, unknown>[];
}): Record<string, unknown> {
return {
ClaimExpertId: input.claimExpertId,
DmgAssessmentDate: input.dmgAssessmentDate,
InspectionTime: input.inspectionTime,
MotorNo: input.vehicle.motorNo,
ChassisNo: input.vehicle.chassisNo,
BuiltYear: input.vehicle.builtYear,
VIN: input.vehicle.vin,
PlaqueNo: input.vehicle.plaqueNo,
PlaqueSerial: input.vehicle.plaqueSerial,
Wage: input.wage,
ComponentReplacementCost: input.componentReplacementCost,
WasteValue: input.wasteValue,
CarryAndRescueCost: null,
RepairDuration:
input.repairDuration ?? FANAVARAN_DEFAULT_HULL_REPAIR_DURATION,
VehicleCurrentValue: input.vehicleCurrentValue,
WreckHighestValue: null,
InspectionDeduction: null,
DmgAndWasteDesc: null,
WentDistanceByExpert: null,
IsDestruction: null,
DropAmount: input.dropAmount,
ColorId: input.colorId ?? null,
PlaqueDesignId: null,
PlaqueCityId: null,
AccidentPercent: null,
TheftCases: [],
DmgSections: input.dmgSections,
};
}
export function toFanavaranHullExpertiseDmgSection(input: {
partId?: unknown;
desc: string;
wasteValue: number;
amount: number;
dmgKindId: number;
dmgCostKindId: number;
vehicleHullAccessoryId: number | null;
}): Record<string, unknown> {
return {
Count: 1,
Desc: input.desc,
WasteValue: input.wasteValue,
AccessoryKindId: parseFanavaranId(input.partId),
DmgKindId: input.dmgKindId,
VehicleHullAccessoryId: input.vehicleHullAccessoryId,
DmgSectionCosts: [
{
Caption: input.desc,
Amount: input.amount,
DmgCostKindId: input.dmgCostKindId,
},
],
};
}
export function hullExpertiseAssertFields(
product: FanavaranClaimProduct,
): { requireDmgCaseId: boolean; requireThirdPartyLookups: boolean } {
if (product === "car-body") {
return { requireDmgCaseId: false, requireThirdPartyLookups: false };
}
return { requireDmgCaseId: true, requireThirdPartyLookups: true };
}
export function collectFanavaranExpertiseReadinessWarnings(
payload: Record<string, unknown>,
options?: { requireDmgCaseId?: boolean; requireThirdPartyLookups?: boolean },
): string[] {
const warnings: string[] = [];
const sections = Array.isArray(payload.DmgSections)
? payload.DmgSections
: [];
const thirdPartyLookups = options?.requireThirdPartyLookups !== false;
if (options?.requireDmgCaseId !== false && !payload.DmgCaseId) {
warnings.push("DmgCaseId is required.");
}
if (thirdPartyLookups) {
if (!payload.InspectionPlaceId) {
warnings.push("InspectionPlaceId is required.");
}
if (!payload.DropAmountStatus) {
warnings.push("DropAmountStatus is required.");
}
}
if (!sections.length) {
warnings.push("At least one DmgSections row is required.");
}
for (const [index, section] of sections.entries()) {
const row = section as Record<string, unknown>;
if (!thirdPartyLookups) continue;
if (!row.DmgSectionId) {
warnings.push(`DmgSections[${index}].DmgSectionId is required.`);
}
if (!row.AccidentLevel) {
warnings.push(`DmgSections[${index}].AccidentLevel is required.`);
}
}
return Array.from(new Set(warnings));
}

View File

@@ -0,0 +1,80 @@
import {
buildFanavaranOtherPeoplePayload,
pickFanavaranRecordId,
pickLookupIdByCaption,
splitPersianFullName,
} from "./fanavaran-other-people";
describe("fanavaran other people (GEN.44)", () => {
it("builds a civil-registry create payload from local case + user fields", () => {
const payload = buildFanavaranOtherPeoplePayload(
{
nationalCode: "3392645966",
birthday: "1364/09/01",
fullName: "الهام مهدوی نیا",
mobile: "9366666666",
address: "گاندی ک نهم",
cityName: "تهران",
gender: "female",
},
{
cities: [{ Id: 9131, Caption: "تهران", IsActive: 1 }],
gender: [
{ Id: 26, Caption: "مرد", IsActive: 1 },
{ Id: 27, Caption: "زن", IsActive: 1 },
],
ans: [
{ Id: 1, Caption: "بله", IsActive: 1 },
{ Id: 0, Caption: "خیر", IsActive: 1 },
],
personKind: [{ Id: 46, Caption: "حقیقی", IsActive: 1 }],
},
);
expect(payload).toMatchObject({
NationalCode: "3392645966",
Name: "الهام",
LastName: "مهدوی نیا",
BirthYear: 1364,
BirthMonth: 9,
BirthDay: 1,
Mobile: "09366666666",
Address: "گاندی ک نهم",
CityId: 9131,
GenderId: 27,
IsIranian: 1,
PersonKindId: 46,
ADBirthYear: null,
NationalityId: null,
});
});
it("returns null when national code or birthday is missing", () => {
expect(
buildFanavaranOtherPeoplePayload({
nationalCode: "3392645966",
}),
).toBeNull();
});
it("picks the created person Id from a wrapped Fanavaran response", () => {
expect(pickFanavaranRecordId({ data: { Id: 4553876 } })).toBe(4553876);
expect(pickFanavaranRecordId([{ Id: "4553876" }])).toBe(4553876);
});
it("matches lookup captions ignoring yeh/keheh and extra spaces", () => {
expect(
pickLookupIdByCaption(
[{ Id: 701, Caption: "ايران", IsActive: 1 }],
["ایران"],
),
).toBe(701);
});
it("splits a Persian full name into first and last name", () => {
expect(splitPersianFullName("الهام مهدوی نیا")).toEqual({
name: "الهام",
lastName: "مهدوی نیا",
});
});
});

View File

@@ -0,0 +1,252 @@
import { toEnglishDigits } from "src/lookups/fanavaran-last-car-policy";
import {
parseJalaliDateParts,
pickPersonBirthday,
pickPersonNationalCode,
} from "./fanavaran-driver-inquiry";
export type FanavaranLookupRow = {
Id?: unknown;
Caption?: unknown;
Name?: unknown;
IsActive?: unknown;
};
export type FanavaranOtherPeopleLookups = {
cities?: unknown;
gender?: unknown;
maritalStatus?: unknown;
ans?: unknown;
countries?: unknown;
personKind?: unknown;
};
export type FanavaranOtherPeopleSource = {
nationalCode?: unknown;
birthday?: unknown;
fullName?: unknown;
fatherName?: unknown;
mobile?: unknown;
tel?: unknown;
email?: unknown;
address?: unknown;
jobAddress?: unknown;
postalCode?: unknown;
cityName?: unknown;
gender?: unknown;
isIranian?: boolean;
naturalizedCode?: unknown;
identityNo?: unknown;
identityNoIssuPlace?: unknown;
passportNo?: unknown;
companyCode?: unknown;
economicCode?: unknown;
registerNo?: unknown;
};
export type FanavaranOtherPeoplePayload = {
NationalCode: string | null;
Name: string | null;
LastName: string | null;
FatherName: string | null;
BirthYear: number | null;
BirthMonth: number | null;
BirthDay: number | null;
ADBirthYear: null;
ADBirthMonth: null;
ADBirthDay: null;
IdentityNoIssuPlace: string | null;
PassportNo: string | null;
Address: string | null;
PostalCode: string | null;
Tel: string | null;
Mobile: string | null;
Email: string | null;
JobAddress: string | null;
EconomicCode: string | null;
NaturalizedCode: string | null;
CompanyCode: string | null;
IdentityNo: string | null;
RegisterNo: string | null;
CityId: number | null;
GenderId: number | null;
MaritalStatus: number | null;
IsIranian: number | string | null;
NationalityId: number | null;
PersonKindId: number | null;
};
const GENDER_MALE_TEXTS = ["مرد", "آقا", "male", "m"];
const GENDER_FEMALE_TEXTS = ["زن", "خانم", "female", "f"];
const ANS_YES_TEXTS = ["بله", "بلی", "yes", "1"];
const NATURAL_PERSON_TEXTS = ["حقیقی", "شخص حقیقی", "طبيعي", "natural"];
const IRAN_COUNTRY_TEXTS = ["ایران", "ايران", "iran"];
function asLookupRows(value: unknown): FanavaranLookupRow[] {
if (!Array.isArray(value)) return [];
return value.filter(
(row): row is FanavaranLookupRow =>
!!row && typeof row === "object" && !Array.isArray(row),
);
}
function asPositiveId(value: unknown): number | null {
if (value === null || value === undefined) return null;
const id = Number(value);
return Number.isFinite(id) && id > 0 ? id : null;
}
function normalizeLookupText(value: unknown): string {
return toEnglishDigits(value)
.toLowerCase()
.replace(/[ي]/g, "ی")
.replace(/[ك]/g, "ک")
.replace(/[\u200c\s_\-\/]+/g, "")
.replace(/[^\p{L}\p{N}]/gu, "");
}
function lookupRowId(row: FanavaranLookupRow): number | null {
return asPositiveId(row.Id);
}
function lookupRowTexts(row: FanavaranLookupRow): string[] {
return [row.Caption, row.Name]
.filter((value): value is string => typeof value === "string")
.filter(Boolean);
}
export function pickLookupIdByCaption(
rows: unknown,
texts: unknown[],
): number | null {
const wanted = texts
.map((text) => normalizeLookupText(text))
.filter(Boolean);
if (!wanted.length) return null;
for (const row of asLookupRows(rows)) {
if (row.IsActive === 0) continue;
const rowTexts = lookupRowTexts(row).map(normalizeLookupText);
const matched = rowTexts.some((rowText) =>
wanted.some(
(want) =>
rowText === want || rowText.includes(want) || want.includes(rowText),
),
);
if (matched) return lookupRowId(row);
}
return null;
}
export function splitPersianFullName(
fullName: unknown,
): { name: string | null; lastName: string | null } {
const trimmed = String(fullName ?? "").trim().replace(/\s+/g, " ");
if (!trimmed) return { name: null, lastName: null };
const parts = trimmed.split(" ");
if (parts.length === 1) return { name: parts[0], lastName: null };
return { name: parts[0], lastName: parts.slice(1).join(" ") };
}
export function normalizeIranMobile(value: unknown): string | null {
const digits = toEnglishDigits(value).replace(/\D/g, "");
if (!digits) return null;
if (digits.length === 10 && digits.startsWith("9")) return `0${digits}`;
if (digits.length === 11 && digits.startsWith("09")) return digits;
if (digits.length === 12 && digits.startsWith("989")) {
return `0${digits.slice(2)}`;
}
return digits.length >= 8 ? digits : null;
}
export function pickFanavaranRecordId(value: unknown): number | null {
if (value == null) return null;
if (typeof value === "number" || typeof value === "string") {
return asPositiveId(value);
}
if (Array.isArray(value)) {
for (const item of value) {
const id = pickFanavaranRecordId(item);
if (id != null) return id;
}
return null;
}
if (typeof value !== "object") return null;
const record = value as Record<string, unknown>;
const direct = asPositiveId(record.Id ?? record.id);
if (direct != null) return direct;
for (const key of ["value", "Value", "data", "Data", "item", "Item"]) {
const nested = pickFanavaranRecordId(record[key]);
if (nested != null) return nested;
}
return null;
}
function emptyToNull(value: unknown): string | null {
if (value == null) return null;
const text = String(value).trim();
return text ? text : null;
}
export function buildFanavaranOtherPeoplePayload(
source: FanavaranOtherPeopleSource,
lookups: FanavaranOtherPeopleLookups = {},
): FanavaranOtherPeoplePayload | null {
const nationalCode = pickPersonNationalCode({
nationalCode: source.nationalCode,
nationalCodeOfDriver: source.nationalCode,
});
const birthday = parseJalaliDateParts(
source.birthday ?? pickPersonBirthday({ birthday: source.birthday }),
);
if (!nationalCode || !birthday) return null;
const isIranian =
source.isIranian !== false && !emptyToNull(source.naturalizedCode);
const names = splitPersianFullName(source.fullName);
const genderTexts =
source.gender === "male" || source.gender === "m"
? GENDER_MALE_TEXTS
: source.gender === "female" || source.gender === "f"
? GENDER_FEMALE_TEXTS
: [source.gender];
return {
NationalCode: nationalCode,
Name: names.name,
LastName: names.lastName,
FatherName: emptyToNull(source.fatherName),
BirthYear: birthday.year,
BirthMonth: birthday.month,
BirthDay: birthday.day,
ADBirthYear: null,
ADBirthMonth: null,
ADBirthDay: null,
IdentityNoIssuPlace: emptyToNull(source.identityNoIssuPlace),
PassportNo: emptyToNull(source.passportNo),
Address: emptyToNull(source.address),
PostalCode: emptyToNull(source.postalCode),
Tel: normalizeIranMobile(source.tel) ?? emptyToNull(source.tel),
Mobile: normalizeIranMobile(source.mobile),
Email: emptyToNull(source.email),
JobAddress: emptyToNull(source.jobAddress),
EconomicCode: emptyToNull(source.economicCode),
NaturalizedCode: emptyToNull(source.naturalizedCode),
CompanyCode: emptyToNull(source.companyCode),
IdentityNo: emptyToNull(source.identityNo),
RegisterNo: emptyToNull(source.registerNo),
CityId: pickLookupIdByCaption(lookups.cities, [source.cityName]),
GenderId: pickLookupIdByCaption(lookups.gender, genderTexts),
MaritalStatus: pickLookupIdByCaption(lookups.maritalStatus, []),
IsIranian: isIranian
? (pickLookupIdByCaption(lookups.ans, ANS_YES_TEXTS) ?? 1)
: (pickLookupIdByCaption(lookups.ans, ["خیر", "no", "0"]) ?? 0),
NationalityId: isIranian
? null
: pickLookupIdByCaption(lookups.countries, IRAN_COUNTRY_TEXTS),
PersonKindId: pickLookupIdByCaption(
lookups.personKind,
NATURAL_PERSON_TEXTS,
),
};
}

View File

@@ -0,0 +1,71 @@
import { ExpertInitiatedClaimMirrorController } from "./expert-initiated-claim.mirror.controller";
import { FileReviewerBlameV4Controller } from "../request-management/file-reviewer-blame-v4.controller";
import { FileReviewerBlameV5Controller } from "../request-management/file-reviewer-blame-v5.controller";
describe("owner insurer approval mirror endpoints", () => {
const claimRequestId = "507f1f77bcf86cd799439011";
const actor = { sub: "507f1f77bcf86cd799439012" };
const sign = {
filename: "sign.png",
path: "/tmp/sign.png",
} as Express.Multer.File;
const createDependencies = () => {
const claimRequestManagementService = {
submitOwnerInsurerApprovalSignV2: jest
.fn()
.mockResolvedValue({ accepted: true }),
};
const mediaPolicyService = {
assertForClaim: jest.fn().mockResolvedValue(undefined),
};
return { claimRequestManagementService, mediaPolicyService };
};
it.each([
[
"expert-initiated",
(deps: ReturnType<typeof createDependencies>) =>
new ExpertInitiatedClaimMirrorController(
deps.claimRequestManagementService as never,
deps.mediaPolicyService as never,
),
],
[
"V4 file-reviewer",
(deps: ReturnType<typeof createDependencies>) =>
new FileReviewerBlameV4Controller(
{} as never,
deps.claimRequestManagementService as never,
deps.mediaPolicyService as never,
),
],
[
"V5 file-reviewer",
(deps: ReturnType<typeof createDependencies>) =>
new FileReviewerBlameV5Controller(
{} as never,
deps.claimRequestManagementService as never,
deps.mediaPolicyService as never,
),
],
])(
"forwards a signature without branchId through the %s endpoint",
async (_name, createController) => {
const dependencies = createDependencies();
const controller = createController(dependencies) as {
submitOwnerSign: (...args: any[]) => Promise<unknown>;
};
await controller.submitOwnerSign(claimRequestId, "true", actor, sign);
expect(
dependencies.mediaPolicyService.assertForClaim,
).toHaveBeenCalledWith(sign, claimRequestId, "image");
expect(
dependencies.claimRequestManagementService
.submitOwnerInsurerApprovalSignV2,
).toHaveBeenCalledWith(claimRequestId, true, sign, actor.sub, actor);
},
);
});

View File

@@ -0,0 +1,62 @@
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
import { ClaimRequestManagementService } from "./claim-request-management.service";
describe("owner insurer approval without branch selection", () => {
it("records a final rejection when no branchId is supplied", async () => {
const service = Object.create(
ClaimRequestManagementService.prototype,
) as ClaimRequestManagementService;
const findByIdAndUpdate = jest.fn().mockResolvedValue(undefined);
(service as any).claimCaseDbService = {
findById: jest.fn().mockResolvedValue({
_id: "507f1f77bcf86cd799439011",
status: ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN,
claimStatus: ClaimStatus.APPROVED,
workflow: { currentStep: ClaimWorkflowStep.INSURER_REVIEW },
owner: { fullName: "مالک تست", clientId: "client-id" },
evaluation: {
damageExpertReply: { submittedAt: new Date(), parts: [] },
},
}),
findByIdAndUpdate,
};
(service as any).resolveClaimEffectiveUserId = jest
.fn()
.mockResolvedValue("507f1f77bcf86cd799439012");
(service as any).assertEffectiveUserIsDamagedPartyOnClaim = jest
.fn()
.mockResolvedValue(undefined);
(service as any).claimSignDbService = {
create: jest
.fn()
.mockResolvedValue({ _id: "507f1f77bcf86cd799439013" }),
};
await expect(
service.submitOwnerInsurerApprovalSignV2(
"507f1f77bcf86cd799439011",
false,
{ filename: "sign.png", path: "/tmp/sign.png" } as Express.Multer.File,
"507f1f77bcf86cd799439012",
),
).resolves.toMatchObject({
accepted: false,
phase: "FINAL_APPROVAL",
status: ClaimCaseStatus.REJECTED,
});
expect(findByIdAndUpdate).toHaveBeenCalledWith(
"507f1f77bcf86cd799439011",
expect.objectContaining({
$set: expect.objectContaining({
"evaluation.ownerInsurerApproval": expect.not.objectContaining({
branchId: expect.anything(),
}),
}),
}),
);
});
});

View File

@@ -52,7 +52,7 @@ export class ExternalInquiryFlagsDto implements ExternalInquiryFlags {
@ApiProperty({
description:
"ESG VIN/chassis-number inquiry (`/inquiry/policyByChassis`). Required for the VIN initial-form path.",
"ESG two-factor VIN/chassis inquiry (`/inquiry/carByChassis`). Required for the VIN initial-form path.",
example: false,
})
@IsBoolean()

View File

@@ -0,0 +1,200 @@
import {
ApiHideProperty,
ApiProperty,
ApiPropertyOptional,
} from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsBoolean,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
Length,
ValidateNested,
} from "class-validator";
export enum InquiryParticipantRole {
DRIVER = "DRIVER",
VEHICLE_OWNER = "VEHICLE_OWNER",
THIRD_PARTY_POLICYHOLDER = "THIRD_PARTY_POLICYHOLDER",
CAR_BODY_POLICYHOLDER = "CAR_BODY_POLICYHOLDER",
}
export enum VehicleRegistrationState {
CURRENT = "CURRENT",
RECENTLY_TRANSFERRED = "RECENTLY_TRANSFERRED",
}
export class InquiryPlateDto {
@ApiProperty({ example: "44" })
@IsNotEmpty()
leftDigits: string | number;
@ApiProperty({ example: "ب" })
@IsString()
@IsNotEmpty()
centerAlphabet: string;
@ApiProperty({ example: "111" })
@IsNotEmpty()
centerDigits: string | number;
@ApiProperty({ example: "22" })
@IsNotEmpty()
ir: string | number;
}
export class InquiryParticipantInputDto {
@ApiPropertyOptional({ enum: InquiryParticipantRole })
@IsOptional()
@IsEnum(InquiryParticipantRole)
sameAs?: InquiryParticipantRole;
@ApiPropertyOptional({ example: "0012345678" })
@IsOptional()
@IsString()
nationalCode?: string;
@ApiPropertyOptional({ example: "1370/01/01" })
@IsOptional()
birthday?: string | number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
fullName?: string;
@ApiPropertyOptional({
description: "Required for a driver who has a licence.",
})
@IsOptional()
@IsBoolean()
hasDrivingLicense?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsString()
licenseNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
licenseType?: string;
}
export class InquiryVehicleInputDto {
@ApiPropertyOptional({
enum: VehicleRegistrationState,
default: VehicleRegistrationState.CURRENT,
})
@IsOptional()
@IsEnum(VehicleRegistrationState)
registrationState?: VehicleRegistrationState;
@ApiProperty({ type: InquiryPlateDto })
@ValidateNested()
@Type(() => InquiryPlateDto)
currentPlate: InquiryPlateDto;
@ApiPropertyOptional({ type: InquiryPlateDto })
@IsOptional()
@ValidateNested()
@Type(() => InquiryPlateDto)
previousPlate?: InquiryPlateDto;
@ApiPropertyOptional({
description:
"National code of the policyholder associated with previousPlate. Required only for RECENTLY_TRANSFERRED vehicles.",
example: "0012345678",
})
@IsOptional()
@IsString()
previousPolicyholderNationalCode?: string;
@ApiPropertyOptional({
minLength: 17,
maxLength: 17,
example: "NAAM01E15HK123456",
})
@IsOptional()
@IsString()
@Length(17, 17)
vin?: string;
@ApiPropertyOptional({
description: "Whether the vehicle is newly purchased/registered.",
})
@IsOptional()
@IsBoolean()
isNewCar?: boolean;
}
/** Structured role-complete contract mixed into every inquiry DTO. */
export class InquiryParticipantFieldsDto {
@ApiProperty({ type: InquiryParticipantInputDto })
@ValidateNested()
@Type(() => InquiryParticipantInputDto)
driver: InquiryParticipantInputDto;
@ApiProperty({ type: InquiryParticipantInputDto })
@ValidateNested()
@Type(() => InquiryParticipantInputDto)
vehicleOwner: InquiryParticipantInputDto;
@ApiProperty({ type: InquiryParticipantInputDto })
@ValidateNested()
@Type(() => InquiryParticipantInputDto)
thirdPartyPolicyholder: InquiryParticipantInputDto;
@ApiPropertyOptional({ type: InquiryParticipantInputDto })
@IsOptional()
@ValidateNested()
@Type(() => InquiryParticipantInputDto)
carBodyPolicyholder?: InquiryParticipantInputDto;
@ApiProperty({ type: InquiryVehicleInputDto })
@ValidateNested()
@Type(() => InquiryVehicleInputDto)
vehicle: InquiryVehicleInputDto;
/** Internal normalized projections; rejected as request input by the resolver. */
@ApiHideProperty()
nationalCodeOfDriver?: string;
@ApiHideProperty()
driverBirthday?: any;
@ApiHideProperty()
driverLicense?: string;
@ApiHideProperty()
licenseType?: string;
@ApiHideProperty()
nationalCodeOfInsurer?: string;
@ApiHideProperty()
insurerBirthday?: any;
@ApiHideProperty()
insurerLicense?: string;
@ApiHideProperty()
driverIsInsurer?: boolean;
@ApiHideProperty()
userNoCertificate?: boolean;
@ApiHideProperty()
plate?: any;
@ApiHideProperty()
plateId?: string;
@ApiHideProperty()
vin?: string;
@ApiHideProperty()
isNewCar?: boolean;
}

View File

@@ -27,6 +27,15 @@ export class UnifiedFileStatusReportQueryDto {
@IsISO8601({ strict: false })
to?: string;
@ApiPropertyOptional({
enum: [30, 60, 90],
description:
"Preset reporting window in days. Default: 30 when from/to are omitted.",
})
@IsOptional()
@IsIn([30, 60, 90, "30", "60", "90"])
periodDays?: number | string;
@ApiPropertyOptional({
enum: LIST_FILE_TYPE_V2,
description:

View File

@@ -30,5 +30,8 @@ export class CaseInquiries {
@Prop({ type: InquiryStatusSchema })
drivingLicence?: InquiryStatus;
@Prop({ type: InquiryStatusSchema })
ownership?: InquiryStatus;
}
export const CaseInquiriesSchema = SchemaFactory.createForClass(CaseInquiries);

View File

@@ -0,0 +1,101 @@
import {
getInquiryErrorMessage,
isInquiryFailurePayload,
} from "./inquiry-error";
describe("inquiry error messages", () => {
it("preserves a Persian ESG not-found response", () => {
expect(
getInquiryErrorMessage(
{ success: false, message: "موردی یافت نشد" },
"thirdPartyPlate",
),
).toBe("موردی یافت نشد");
});
it.each([
["RECORD_NOT_FOUND", "رکوردی یافت نشد", "Provider request failed"],
[
"INQUIRY_NO_MATCH",
"نتیجه‌ای مطابق با اطلاعات وارد شده یافت نشد",
"Inquiry returned no matching result",
],
])(
"prefers ESG messageFa for %s over the technical message",
(code, messageFa, message) => {
expect(
getInquiryErrorMessage(
{
error: {
code,
message,
messageFa,
providerMessage: message,
providerCode: code,
},
attemptSummary: {
attempts: [{ code, message, messageFa }],
},
},
"thirdPartyPlate",
),
).toBe(messageFa);
},
);
it("finds messageFa inside an HTTP response envelope", () => {
expect(
getInquiryErrorMessage(
{
response: {
status: 404,
data: {
error: {
code: "RECORD_NOT_FOUND",
message: "Provider request failed",
messageFa: "رکوردی یافت نشد",
},
},
},
},
"thirdPartyPlate",
),
).toBe("رکوردی یافت نشد");
});
it("distinguishes VIN and car-body not-found failures", () => {
expect(getInquiryErrorMessage({ status: 404 }, "thirdPartyVin")).toContain(
"شماره شاسی (VIN)",
);
expect(
getInquiryErrorMessage(
new Error("No active policy found"),
"carBodyPlate",
),
).toBe("بیمه‌نامه بدنه فعالی مطابق پلاک و کد ملی واردشده یافت نشد.");
});
it("preserves a specific Persian provider message", () => {
expect(
getInquiryErrorMessage(
{ message: "کد ملی واردشده صحیح نیست" },
"personalIdentity",
),
).toBe("کد ملی واردشده صحیح نیست");
});
it("does not expose transport or authentication details", () => {
expect(
getInquiryErrorMessage(
{ response: { status: 401 }, message: "ESG authentication failed" },
"thirdPartyPlate",
),
).toBe("سرویس استعلام در دسترس نیست. لطفاً کمی بعد دوباره تلاش کنید.");
});
it("recognizes provider failure envelopes", () => {
expect(isInquiryFailurePayload({ success: false })).toBe(true);
expect(isInquiryFailurePayload({ HasError: true })).toBe(true);
expect(isInquiryFailurePayload({ success: true, data: {} })).toBe(false);
});
});

View File

@@ -0,0 +1,247 @@
export type InquiryErrorContext =
| "thirdPartyPlate"
| "thirdPartyVin"
| "carBodyPlate"
| "carBodyVin"
| "personalIdentity"
| "drivingLicense"
| "carOwnership"
| "sheba"
| "generic";
type UnknownRecord = Record<string, unknown>;
const NOT_FOUND_MESSAGES: Record<InquiryErrorContext, string> = {
thirdPartyPlate: "بیمه‌نامه شخص ثالثی مطابق پلاک و کد ملی واردشده یافت نشد.",
thirdPartyVin:
"بیمه‌نامه شخص ثالثی مطابق شماره شاسی (VIN) و کد ملی واردشده یافت نشد.",
carBodyPlate: "بیمه‌نامه بدنه فعالی مطابق پلاک و کد ملی واردشده یافت نشد.",
carBodyVin:
"بیمه‌نامه بدنه فعالی مطابق شماره شاسی (VIN) و کد ملی واردشده یافت نشد.",
personalIdentity: "اطلاعات هویتی مطابق کد ملی و تاریخ تولد واردشده یافت نشد.",
drivingLicense:
"گواهینامه‌ای مطابق کد ملی و شماره گواهینامه واردشده یافت نشد.",
carOwnership: "مالکیتی مطابق پلاک و کد ملی واردشده یافت نشد.",
sheba: "اطلاعاتی مطابق شماره شبا و کد ملی واردشده یافت نشد.",
generic: "موردی مطابق اطلاعات واردشده یافت نشد.",
};
const INVALID_MESSAGES: Record<InquiryErrorContext, string> = {
thirdPartyPlate: "پلاک یا کد ملی واردشده برای استعلام شخص ثالث معتبر نیست.",
thirdPartyVin:
"شماره شاسی (VIN) یا کد ملی واردشده برای استعلام شخص ثالث معتبر نیست.",
carBodyPlate: "پلاک یا کد ملی واردشده برای استعلام بیمه بدنه معتبر نیست.",
carBodyVin:
"شماره شاسی (VIN) یا کد ملی واردشده برای استعلام بیمه بدنه معتبر نیست.",
personalIdentity: "کد ملی یا تاریخ تولد واردشده معتبر نیست.",
drivingLicense: "کد ملی یا شماره گواهینامه واردشده معتبر نیست.",
carOwnership: "پلاک یا کد ملی واردشده برای استعلام مالکیت معتبر نیست.",
sheba: "شماره شبا یا کد ملی واردشده معتبر نیست.",
generic: "اطلاعات ارسال‌شده برای استعلام معتبر نیست.",
};
const asRecord = (value: unknown): UnknownRecord | undefined =>
value && typeof value === "object" && !Array.isArray(value)
? (value as UnknownRecord)
: undefined;
const cleanMessage = (value: unknown): string => {
if (typeof value === "string") return value.trim();
if (Array.isArray(value)) {
return value
.filter((item): item is string => typeof item === "string")
.map((item) => item.trim())
.filter(Boolean)
.join("، ");
}
return "";
};
function errorRecords(error: unknown): UnknownRecord[] {
const root = asRecord(error);
if (!root) return [];
const response = asRecord(root.response);
const responseData = asRecord(response?.data);
const data = asRecord(root.data);
const nestedError = asRecord(root.Error) ?? asRecord(root.error);
const responseError =
asRecord(responseData?.Error) ?? asRecord(responseData?.error);
return [root, responseData, data, nestedError, responseError].filter(
(item): item is UnknownRecord => !!item,
);
}
export function inquiryErrorStatus(error: unknown): number | undefined {
for (const record of errorRecords(error)) {
const response = asRecord(record.response);
const value =
record.statusCode ?? record.status ?? response?.status ?? record.code;
const parsed = Number(value);
if (Number.isFinite(parsed) && parsed >= 100 && parsed <= 599) {
return parsed;
}
}
return undefined;
}
export function extractInquiryProviderMessage(error: unknown): string {
const records = errorRecords(error);
// The normalized ESG/Parsian envelope carries the safe user-facing text in
// messageFa while `message` and `providerMessage` may remain technical.
// Search every envelope level for that explicit Persian field before
// considering generic message fields on an outer object.
for (const record of records) {
for (const key of [
"messageFa",
"MessageFa",
"messageFA",
"persianMessage",
] as const) {
const message = cleanMessage(record[key]);
if (message) return message;
}
}
for (const record of records) {
for (const key of ["message", "Message", "detail", "title"] as const) {
const message = cleanMessage(record[key]);
if (message) return message;
}
const scalarData = cleanMessage(record.data);
if (scalarData) return scalarData;
}
return error instanceof Error ? error.message.trim() : cleanMessage(error);
}
export function isInquiryFailurePayload(value: unknown): boolean {
const root = asRecord(value);
if (!root) return false;
return (
root.success === false ||
root.isSuccess === false ||
root.IsSuccess === false ||
root.IsSucceed === false ||
root.ReturnValue === false ||
root.HasError === true ||
root.hasError === true ||
root.Error != null ||
root.error != null
);
}
export function isInquiryTimeout(error: unknown): boolean {
const root = asRecord(error);
const code = String(root?.code ?? "").toUpperCase();
const message = extractInquiryProviderMessage(error);
return (
["ECONNABORTED", "ETIMEDOUT", "ESOCKETTIMEDOUT"].includes(code) ||
/timeout|timed out|مهلت|زمان.*پایان/i.test(message)
);
}
const hasPersian = (value: string): boolean => /[\u0600-\u06ff]/.test(value);
const isNotFound = (error: unknown, message: string): boolean => {
const codes = errorRecords(error).flatMap((record) =>
[record.code, record.providerCode]
.map((code) => String(code ?? "").toUpperCase())
.filter(Boolean),
);
return (
inquiryErrorStatus(error) === 404 ||
codes.some((code) =>
[
"NOT_FOUND",
"POLICY_NOT_FOUND",
"NO_POLICY",
"RECORD_NOT_FOUND",
"INQUIRY_NO_MATCH",
].includes(code),
) ||
/\bnot[ -]?found\b|\bno (?:active |relevant )?(?:record|policy|item)\b|record\.not\.found|موردی یافت نشد|یافت نشد|پیدا نشد|فاقد بیمه(?:‌| )?نامه/i.test(
message,
)
);
};
const isInvalidInput = (message: string): boolean =>
/invalid|malformed|required|must contain|bad request|نامعتبر|الزامی|وارد نشده|صحیح نیست/i.test(
message,
);
const isUnavailable = (error: unknown, message: string): boolean => {
const root = asRecord(error);
const code = String(root?.code ?? "").toUpperCase();
const status = inquiryErrorStatus(error);
return (
isInquiryTimeout(error) ||
["ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "ERR_NETWORK"].includes(code) ||
status === 401 ||
status === 502 ||
status === 503 ||
status === 504 ||
/offline|unavailable|connection|socket|network|authentication|credentials|empty response|در دسترس نیست|عدم دسترسی/i.test(
message,
)
);
};
/**
* Converts provider and transport failures into a stable, user-facing Persian
* message. Provider details remain in server logs; raw English or technical
* messages are never returned to clients.
*/
export function getInquiryErrorMessage(
error: unknown,
context: InquiryErrorContext = "generic",
): string {
const providerMessage = extractInquiryProviderMessage(error);
// Persian text supplied by the provider is already the intended client
// message. Preserve it verbatim instead of replacing it with a local
// contextual fallback such as "inquiry not found".
if (providerMessage && hasPersian(providerMessage)) return providerMessage;
if (isNotFound(error, providerMessage)) return NOT_FOUND_MESSAGES[context];
if (
context === "carOwnership" &&
/not the owner|مالک.*نیست/i.test(providerMessage)
) {
return "پلاک واردشده متعلق به کد ملی واردشده نیست.";
}
if (
context === "sheba" &&
/does not match|تطابق ندارد/i.test(providerMessage)
) {
return "شماره شبا متعلق به کد ملی واردشده نیست.";
}
if (
context === "drivingLicense" &&
/not valid|نامعتبر/i.test(providerMessage)
) {
return "گواهینامه واردشده معتبر نیست.";
}
if (
/policy insurer does not match|بیمه.*متعلق.*نیست/i.test(providerMessage)
) {
return "بیمه‌نامه یافت‌شده متعلق به شرکت بیمه این سامانه نیست.";
}
if (isInquiryTimeout(error)) {
return "زمان پاسخ‌گویی سرویس استعلام به پایان رسید. لطفاً دوباره تلاش کنید.";
}
if (isUnavailable(error, providerMessage)) {
return "سرویس استعلام در دسترس نیست. لطفاً کمی بعد دوباره تلاش کنید.";
}
if (isInvalidInput(providerMessage) || inquiryErrorStatus(error) === 422) {
return INVALID_MESSAGES[context];
}
return "انجام استعلام با خطا مواجه شد. لطفاً دوباره تلاش کنید.";
}

View File

@@ -5,22 +5,18 @@ import {
ValidatorConstraint,
ValidatorConstraintInterface,
} from "class-validator";
import { normalizeMoneyAmountString } from "src/utils/unicode-digits";
import { parseMoneyAmountToman } from "src/utils/unicode-digits";
@ValidatorConstraint({ name: "isMoneyAmountString", async: false })
export class IsMoneyAmountStringConstraint
implements ValidatorConstraintInterface
{
export class IsMoneyAmountStringConstraint implements ValidatorConstraintInterface {
validate(value: unknown): boolean {
if (value == null || value === "") return true;
if (typeof value !== "string") return false;
const n = normalizeMoneyAmountString(value);
if (!n) return false;
return /^\d+(\.\d+)?$/.test(n);
return parseMoneyAmountToman(value) !== null;
}
defaultMessage(): string {
return "Must be a non-negative amount (digits only, optional decimal).";
return "Must be a non-negative whole-Toman amount.";
}
}

View File

@@ -25,7 +25,12 @@ export class IsRepairLineAmountTomanConstraint
number,
boolean | undefined,
];
if (value == null || value === "") return true;
// Optional fields are skipped by @IsOptional. A required amount must not
// accept an omitted or blank value, otherwise an empty expert pricing line
// can be submitted as a zero-cost repair.
if (value == null || (typeof value === "string" && value.trim() === "")) {
return false;
}
const amount = parseMoneyAmountToman(value);
if (amount == null) return false;
if (allowZero && amount === 0) return true;

View File

@@ -1,20 +1,20 @@
/**
* Per-line and total caps for repair money. All values are **Toman** (no unit conversion in the API).
*/
export const REPAIR_LINE_AMOUNT_TOMAN = {
export const REPAIR_LINE_AMOUNT_TOMAN = { // IT IS RIAL FROM NOW ON
/** Below this is not credible for a priced repair line (e.g. 1,000 Toman). */
MIN: 10_000,
MIN: 100_000,
/** Aligns with the total assessment cap; rejects absurd values (e.g. 100bn). */
MAX: 53_000_000,
MAX: 530_000_000,
} as const;
/** Max sum of all priced + factor lines in one expert reply / validation (Toman). */
/** Max sum of all priced + factor lines in one V1 expert reply / validation (Rial). */
export const CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN = REPAIR_LINE_AMOUNT_TOMAN.MAX;
const ENABLED_VALUES = new Set(["1", "true", "yes", "on", "enabled"]);
/**
* Returns null when the claim v2 total cap is disabled.
* Returns null when the V1 claim total cap is disabled.
*
* Set CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED=true to enforce the cap again.
* Optionally set CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN to override the amount.

View File

@@ -35,6 +35,11 @@ export interface FanavaranAuthConfig {
password: string;
corpId: string;
contractId: string;
/**
* Vehicle-hull (بدنه) ContractId. Must not be the ثالث contract.
* When unset, body lookups fall back to `contractId`.
*/
hullContractId?: string;
location: string;
}
@@ -52,6 +57,8 @@ export interface FanavaranPayloadDefaults {
* (Parsian: 29; Tejaratno proven: 2709).
*/
ExpertiseClaimExpertId: number;
/** GEN.06 hull RepairDuration when configured per tenant (optional). */
HullExpertiseRepairDuration?: number | null;
CompensationReferenceId: number;
CulpritLicenceTypeId: number;
CulpritTypeId: number;
@@ -115,7 +122,7 @@ export const SEED_FANAVARAN_CLIENT_PROFILES: Record<
defaults: {
...SHARED_FANAVARAN_DEFAULTS,
// GEN.03 — کارشناس مسئول پرونده مالی
ClaimExpertId: 4543092,
ClaimExpertId: 2721,
// GEN.08 — ارزیاب (proven successful submit 2026-07-18 ClaimExpertId: 2709)
ExpertiseClaimExpertId: 2709,
ClaimFileTypeId: 23,
@@ -152,7 +159,7 @@ export const SEED_FANAVARAN_CLIENT_PROFILES: Record<
password: "itT@l!3@user",
corpId: "5650",
contractId: "304",
location: "30900",
location: "1",
},
// Expert role ids not confirmed for Moallem yet — start from shared shape.
defaults: {
@@ -209,6 +216,19 @@ export function getFanavaranClientProfile(
);
}
/** ثالث uses `contractId`. بدنه uses `hullContractId` when configured. */
export function resolveFanavaranProductContractId(
clientKey: FanavaranClientKey,
product: "third-party" | "car-body",
): string {
const profile = getFanavaranClientProfile(clientKey);
if (product === "car-body") {
const hull = profile.auth.hullContractId?.trim();
if (hull) return hull;
}
return profile.auth.contractId;
}
export function listFanavaranClientProfiles(): FanavaranClientProfile[] {
return FANAVARAN_CLIENT_KEYS.map((key) => getFanavaranClientProfile(key));
}

View File

@@ -116,6 +116,8 @@ export class AllRequestDtoV2 {
lockFile: boolean;
lockTime: string | null;
type: string;
/** IN_PERSON or LINK — how the blame file was created */
creationMethod?: string;
blameStatus: string;
/** Calculated blame + linked claim lifecycle status */
unifiedFileStatus?: string;

View File

@@ -0,0 +1,66 @@
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
import { ExpertBlameService } from "./expert-blame.service";
describe("ExpertBlameService participant detail contract", () => {
it("returns normalized participants and their role assignments from the blame party", async () => {
const service = new (ExpertBlameService as any)(
...new Array(13).fill(undefined),
) as any;
const expertId = "66ec0e480e321873c0900001";
service.expireBlameCaseWorkflowLockV2IfStale = jest
.fn()
.mockResolvedValue(undefined);
service.blameRequestDbService = {
findByIdWithoutHistory: jest.fn().mockResolvedValue({
_id: "66ec0e480e321873c0900002",
type: BlameRequestType.THIRD_PARTY,
status: CaseStatus.WAITING_FOR_EXPERT,
expertInitiated: true,
initiatedByFieldExpertId: expertId,
workflow: {},
parties: [
{
role: "FIRST",
person: { fullName: "Legacy Party Name" },
participants: [
{
participantId: "PERSON_1",
nationalCode: "0012345678",
birthday: "1370/01/01",
unknown: true,
},
],
participantRoles: {
driver: "PERSON_1",
vehicleOwner: "PERSON_1",
thirdPartyPolicyholder: "PERSON_1",
},
vehicle: { inquiry: { raw: { large: true } } },
},
],
createdAt: new Date("2026-09-19T00:00:00.000Z"),
updatedAt: new Date("2026-09-19T00:00:00.000Z"),
}),
};
const result = await service.findOneV2("blame-1", { sub: expertId });
const party = (result.parties as any[])[0];
expect(party.participants).toEqual([
{
participantId: "PERSON_1",
nationalCode: "0012345678",
birthday: "1370/01/01",
},
]);
expect(party.participantRoles).toEqual({
driver: "PERSON_1",
vehicleOwner: "PERSON_1",
thirdPartyPolicyholder: "PERSON_1",
});
expect(party.person.fullName).toBe("Legacy Party Name");
expect(party.vehicle.inquiry).toBeUndefined();
});
});

View File

@@ -91,6 +91,7 @@ import {
ExpertFileActivityType,
ExpertFileKind,
} from "src/users/entities/schema/expert-file-activity.schema";
import { sanitizeStoredInquiryParticipants } from "src/request-management/inquiry-participant-resolver";
interface CheckedRequestEntry {
CheckedRequest?: {
@@ -613,9 +614,24 @@ export class ExpertBlameService {
)) as Record<string, unknown>[];
// Scope to this reviewer's insurance company via blame party clientId
const visibleCases = allSealed.filter((doc) =>
const tenantVisibleCases = allSealed.filter((doc) =>
blameCaseTouchesClient(doc, clientKey),
);
const visibleCases = (
await Promise.all(
tenantVisibleCases.map(async (doc) => {
try {
await this.requestManagementService.assertFileReviewerBranchAccess(
doc,
actor.sub,
);
return doc;
} catch {
return null;
}
}),
)
).filter((doc): doc is Record<string, unknown> => doc != null);
const pagedResult = await this.paginateBlameListV2(visibleCases, query);
return pagedResult;
@@ -762,6 +778,7 @@ export class ExpertBlameService {
? new Date(workflow.lockedAt as string).toISOString()
: null,
type: String(doc.type ?? "THIRD_PARTY"),
creationMethod: doc.creationMethod ? String(doc.creationMethod) : undefined,
blameStatus: String(doc.blameStatus ?? ""),
partiesInitialForms: {
firstParty: statementToFormKey(firstParty?.statement) ?? "",
@@ -1249,8 +1266,17 @@ export class ExpertBlameService {
doc.createdAtFormatted = `${createdDate} ${createdTime}`;
doc.updatedAtFormatted = `${updatedDate} ${updatedTime}`;
// Keep the normalized blame-party participant contract authoritative for
// every expert detail response. Legacy person fields remain for old files,
// but new UIs must read participants + participantRoles from the same party.
doc.parties = parties.map((party: Record<string, unknown>) => ({
...party,
participants: sanitizeStoredInquiryParticipants(party.participants),
participantRoles: party.participantRoles,
}));
// Strip heavy SandHub inquiry blob
for (const party of parties as Array<{
for (const party of doc.parties as Array<{
vehicle?: Record<string, unknown>;
}>) {
if (

View File

@@ -52,6 +52,20 @@ export class ClaimDetailV2ResponseDto {
})
blameRequestType?: BlameRequestType;
@ApiPropertyOptional({
description: "How the blame file was initiated: NORMAL, IN_PERSON, or LINK",
example: "IN_PERSON",
})
creationMethod?: string;
@ApiProperty({
nullable: true,
description:
"Maximum expert-reply total for V1 user-created files. Null for V2-V6 flows or when the cap is disabled.",
example: 530000000,
})
priceCap: number | null;
@ApiPropertyOptional({
description:
"CAR_BODY only: first-step flags — another car (`car`) and/or object (`object`)",
@@ -125,6 +139,33 @@ export class ClaimDetailV2ResponseDto {
url?: string;
}>;
@ApiPropertyOptional({
description:
"Append-only expert damaged-part revisions. Removed rows retain their original capture URL even after the authoritative selection changes.",
type: "array",
items: {
type: "object",
properties: {
revisionId: { type: "string" },
changedAt: { type: "string", format: "date-time" },
changedBy: { type: "object" },
removedParts: { type: "array", items: { type: "object" } },
addedParts: { type: "array", items: { type: "object" } },
},
},
})
damagedPartsHistory?: Array<{
revisionId: string;
changedAt: Date | string;
changedBy: {
actorId: string;
actorName?: string;
actorType: string;
};
removedParts: Array<Record<string, unknown>>;
addedParts: Array<Record<string, unknown>>;
}>;
@ApiPropertyOptional({
description:
"True when user uploaded all required factors and the case awaits expert approve/reject.",
@@ -157,6 +198,30 @@ export class ClaimDetailV2ResponseDto {
})
fileMakerApprovalActorId?: string;
@ApiPropertyOptional({
description:
"Fanavaran claim reference. Returned only after the local claim reaches COMPLETED. Each of claimId, claimNo, dmgCaseId, and expertiseId is included when Fanavaran has supplied it.",
example: {
claimId: 987654,
claimNo: 123456,
dmgCaseId: 11,
expertiseId: 22,
},
})
fanavaran?: {
claimId?: number;
claimNo?: number;
dmgCaseId?: number;
expertiseId?: number;
policyId?: number;
driverId?: number;
vehicleKindId?: number;
plaqueKindId?: number;
plaqueSampleId?: number;
accidentVehicleUsedId?: number;
insuranceCorpId?: number;
};
@ApiPropertyOptional({
description:
"Slice of `claim.evaluation` exposed to the damage expert. " +
@@ -177,14 +242,12 @@ export class ClaimDetailV2ResponseDto {
damageExpertResend: unknown;
ownerInsurerApproval?: {
agree: boolean;
branchId?: string;
signDetailId?: string;
signLink?: string;
signedAt?: Date | string;
};
ownerPricedPartsApproval?: {
agree: boolean;
branchId?: string;
signDetailId?: string;
signLink?: string;
signedAt?: Date | string;
@@ -223,7 +286,7 @@ export class ClaimDetailV2ResponseDto {
@ApiPropertyOptional({
description:
"Linked blame case (`blameCases`), same shape as expert-blame detail: parties with video/voice URLs, workflow, expert, formatted dates.",
"Linked blame case (`blameCases`), same shape as expert-blame detail. Each party exposes its authoritative normalized `participants` and `participantRoles`, plus video/voice URLs, workflow, expert, and formatted dates.",
})
blameCase?: Record<string, unknown>;

View File

@@ -49,6 +49,12 @@ export class ClaimListItemV2Dto {
})
blameRequestType?: BlameRequestType;
@ApiPropertyOptional({
description: 'How the blame file was initiated: IN_PERSON or LINK',
example: 'IN_PERSON',
})
creationMethod?: string;
@ApiPropertyOptional({
description:
'CAR_BODY only: first-step flags — accident involved another car (`car`) and/or fixed object (`object`)',

View File

@@ -0,0 +1,110 @@
import { plainToInstance } from "class-transformer";
import { validate } from "class-validator";
import { DaghiOption } from "src/Types&Enums/claim-request-management/daghi-option.enum";
import { TypeOfDamage } from "src/Types&Enums/claim-request-management/type-of-damage.enum";
import { SubmitExpertReplyV2Dto } from "./expert-claim-v2.dto";
describe("SubmitExpertReplyV2Dto", () => {
it("rejects a selected damaged part with blank pricing", async () => {
const dto = plainToInstance(SubmitExpertReplyV2Dto, {
description: "Damage assessment",
parts: [
{
partId: 201,
typeOfDamage: TypeOfDamage.Repair,
price: "",
salary: "",
totalPayment: "",
factorNeeded: false,
daghi: { option: DaghiOption.NO_VALUE },
},
],
});
const errors = await validate(dto);
expect(errors).not.toHaveLength(0);
});
it("rejects a replacement part without a price", async () => {
const dto = plainToInstance(SubmitExpertReplyV2Dto, {
description: "Damage assessment",
parts: [
{
partId: 201,
typeOfDamage: TypeOfDamage.Change,
salary: "0",
totalPayment: "0",
factorNeeded: false,
daghi: { option: DaghiOption.NO_VALUE },
},
],
});
expect(await validate(dto)).not.toHaveLength(0);
});
it("accepts a repair part without daghi", async () => {
const dto = plainToInstance(SubmitExpertReplyV2Dto, {
description: "Damage assessment",
parts: [
{
partId: 201,
typeOfDamage: TypeOfDamage.Repair,
price: "100000",
salary: "100000",
totalPayment: "200000",
factorNeeded: false,
},
],
});
expect(await validate(dto)).toHaveLength(0);
});
it("accepts the expert's repair and replacement pricing rules", async () => {
const dto = plainToInstance(SubmitExpertReplyV2Dto, {
description: "Damage assessment",
parts: [
{
partId: 11,
typeOfDamage: TypeOfDamage.Repair,
salary: "۱۰۰,۰۰۰",
totalPayment: "100000",
factorNeeded: false,
},
{
partId: 23,
typeOfDamage: TypeOfDamage.Change,
price: "۱۰۰,۰۰۰",
salary: "۱۰۰,۰۰۰",
totalPayment: "150000",
factorNeeded: false,
daghi: {
option: DaghiOption.RECYCLED_PARTS_VALUE,
price: "۱۰۰,۵۰۰,۰۰۰",
},
},
],
});
expect(await validate(dto)).toHaveLength(0);
});
it("accepts a grouped Persian current car price as a string", async () => {
const dto = plainToInstance(SubmitExpertReplyV2Dto, {
carPrice: "۱٬۲۵۰٬۰۰۰٬۰۰۰",
parts: [
{
partId: 201,
typeOfDamage: TypeOfDamage.Repair,
salary: "1000000",
totalPayment: "1000000",
factorNeeded: false,
},
],
});
expect(await validate(dto)).toHaveLength(0);
});
});

View File

@@ -3,17 +3,20 @@ import {
IsString,
IsNotEmpty,
IsArray,
ArrayMinSize,
IsBoolean,
IsOptional,
ValidateIf,
ValidateNested,
IsEnum,
IsInt,
} from 'class-validator';
import { Type } from 'class-transformer';
import { IsRepairLineAmountToman } from 'src/common/validators/repair-line-amount-toman.validator';
import { IsMoneyAmountString } from 'src/common/validators/money-amount-string.validator';
import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum';
import { DamagedPartItem } from 'src/claim-request-management/dto/capture-requirements-v2.dto';
import { DaghiOption } from 'src/Types&Enums/claim-request-management/daghi-option.enum';
import { TypeOfDamage } from 'src/Types&Enums/claim-request-management/type-of-damage.enum';
export class DaghiDetailsV2Dto {
@ApiProperty({
enum: DaghiOption,
@@ -24,19 +27,17 @@ export class DaghiDetailsV2Dto {
option: DaghiOption;
@ApiPropertyOptional({
description: `Required when option is '${DaghiOption.RECYCLED_PARTS_VALUE}' (Toman)`,
description: `Required when option is '${DaghiOption.RECYCLED_PARTS_VALUE}' (Toman; 100,000 to 10,000,000,000).`,
})
@IsOptional()
@ValidateIf(
(daghi: DaghiDetailsV2Dto) =>
daghi.option === DaghiOption.RECYCLED_PARTS_VALUE,
)
@IsString()
@IsRepairLineAmountToman()
@IsNotEmpty()
@IsMoneyAmountString()
price?: string;
@ApiPropertyOptional({
description: `Required when option is '${DaghiOption.DELIVER_DAMAGED_PART}' (Mongo ObjectId string)`,
})
@IsOptional()
@IsString()
branchId?: string;
}
export class PartPricingV2Dto {
@@ -47,38 +48,60 @@ export class PartPricingV2Dto {
@IsInt()
partId: number;
@ApiProperty({ example: 'Minor' })
@IsString()
typeOfDamage: string;
@ApiProperty({
example: "5000000",
description: "Part price in Toman (integer string). Use 0 if the full amount is in salary.",
enum: TypeOfDamage,
description: "'change' requires price; 'repair' may omit it.",
})
@IsEnum(TypeOfDamage)
typeOfDamage: TypeOfDamage;
@ApiPropertyOptional({
example: "5000000",
description:
"Required for change lines; omitted for repair lines. Every supplied amount must be between 1,000,000 and 100,000,000,000 Rial.",
})
@ValidateIf(
(part: PartPricingV2Dto) =>
part.typeOfDamage === TypeOfDamage.Change ||
(part.price != null &&
(typeof part.price !== 'string' || part.price.trim() !== '')),
)
@IsString()
@IsRepairLineAmountToman({ allowZero: true })
@IsNotEmpty()
@IsMoneyAmountString()
price: string;
@ApiProperty({
example: "2000000",
description: "Labor in Toman (integer string). Use 0 if the full amount is in price.",
description:
"Labor in Rial (integer string; 1,000,000 to 100,000,000,000).",
})
@IsString()
@IsRepairLineAmountToman({ allowZero: true })
@IsNotEmpty()
@IsMoneyAmountString()
salary: string;
@ApiProperty({
example: "7000000",
description: "Line total in Toman (integer string).",
description:
"Line total in Rial (integer string; 1,000,000 to 100,000,000,000).",
})
@IsString()
@IsRepairLineAmountToman()
@IsNotEmpty()
@IsMoneyAmountString()
totalPayment: string;
@ApiProperty({ type: DaghiDetailsV2Dto })
@ApiPropertyOptional({
type: DaghiDetailsV2Dto,
description: "Required for change lines; omit for repair lines.",
})
@ValidateIf(
(part: PartPricingV2Dto) => part.typeOfDamage === TypeOfDamage.Change,
)
@IsNotEmpty()
@ValidateNested()
@Type(() => DaghiDetailsV2Dto)
daghi: DaghiDetailsV2Dto;
daghi?: DaghiDetailsV2Dto;
@ApiProperty({
example: false,
@@ -90,10 +113,22 @@ export class PartPricingV2Dto {
}
export class SubmitExpertReplyV2Dto {
@ApiProperty({ example: 'Front door and hood have severe paint damage' })
@ApiPropertyOptional({
example: 'Front door and hood have severe paint damage',
description: "Optional expert note about the damage assessment.",
})
@IsOptional()
@IsString()
@IsNotEmpty()
description: string;
description?: string;
@ApiPropertyOptional({
example: '1000000000',
description:
"Current vehicle price in Rial. Required for car-body claims and forbidden for third-party claims. This is independent from evaluation.priceDrop.carPrice.",
})
@IsOptional()
@IsMoneyAmountString()
carPrice?: string;
@ApiProperty({
type: [PartPricingV2Dto],
@@ -101,6 +136,7 @@ export class SubmitExpertReplyV2Dto {
"One line per damaged part (`partId` from claim detail `damagedParts[]`), plus pricing, `daghi`, and `factorNeeded`.",
})
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => PartPricingV2Dto)
parts: PartPricingV2Dto[];

View File

@@ -25,12 +25,6 @@ export class DaghiDetailsDto {
})
price?: string;
@ApiProperty({
required: false,
type: String,
description: "Branch ID required when option is 'تحویل داغی'",
})
branchId?: string;
}
export class PartsList {

View File

@@ -17,6 +17,7 @@ import { RequestManagementModule } from "src/request-management/request-manageme
import { SandHubModule } from "src/sand-hub/sand-hub.module";
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
import { UsersModule } from "src/users/users.module";
import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module";
import { ExpertClaimController } from "./expert-claim.controller";
import { ExpertClaimV2Controller } from "./expert-claim.v2.controller";
import { ExpertClaimService } from "./expert-claim.service";
@@ -41,6 +42,7 @@ import { ExpertClaimService } from "./expert-claim.service";
SmsOrchestrationModule,
UsersModule,
ClientModule,
FanavaranLookupModule,
],
controllers: [ExpertClaimController, ExpertClaimV2Controller],
providers: [ExpertClaimService, ClaimFactorsImageDbService],

View File

@@ -0,0 +1,561 @@
import { BadRequestException } from "@nestjs/common";
import { Types } from "mongoose";
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { DaghiOption } from "src/Types&Enums/claim-request-management/daghi-option.enum";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { TypeOfDamage } from "src/Types&Enums/claim-request-management/type-of-damage.enum";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { ExpertClaimService } from "./expert-claim.service";
const V2_EXPERT_ID = "66ec0e480e321873c0900001";
const blankPricingReply = {
description: "Damage assessment",
parts: [
{
partId: 201,
typeOfDamage: TypeOfDamage.Repair,
price: "",
salary: "",
totalPayment: "",
factorNeeded: false,
daghi: { option: DaghiOption.NO_VALUE },
},
],
};
function createService() {
return new (ExpertClaimService as any)(
...new Array(21).fill(undefined),
) as ExpertClaimService;
}
function setupSuccessfulV2Submit(blameType: BlameRequestType) {
const service = createService() as any;
const findByIdAndUpdate = jest.fn().mockResolvedValue(undefined);
const claim = {
_id: "66ec0e480e321873c0900002",
publicId: "CLM-1",
blameRequestId: "66ec0e480e321873c0900003",
status: ClaimCaseStatus.EXPERT_REVIEWING,
workflow: { locked: true, lockedBy: { actorId: V2_EXPERT_ID } },
vehicle: { carType: "sedan" },
damage: {
selectedParts: [
{
id: 1,
name: "front",
side: "",
label_fa: "جلو کامل",
catalogKey: "1",
},
],
},
};
service.claimCaseDbService = {
findById: jest.fn().mockResolvedValue(claim),
findByIdAndUpdate,
};
service.blameRequestDbService = {
findById: jest.fn().mockResolvedValue({
type: blameType,
expertInitiated: true,
creationMethod: "IN_PERSON",
}),
};
service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined);
service.snapshotDamageExpert = jest.fn().mockResolvedValue(undefined);
service.recordClaimExpertActivity = jest.fn().mockResolvedValue(undefined);
return { service, findByIdAndUpdate };
}
const validV2Reply = {
parts: [
{
partId: 1,
typeOfDamage: TypeOfDamage.Repair,
salary: "1000000",
totalPayment: "1000000",
factorNeeded: false,
},
],
};
describe("ExpertClaimService expert-reply pricing", () => {
it("requires the current car price for a car-body expert reply", async () => {
const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
BlameRequestType.CAR_BODY,
);
await expect(
service.submitExpertReplyV2("v2-claim", validV2Reply, {
sub: V2_EXPERT_ID,
fullName: "Expert One",
role: RoleEnum.FIELD_EXPERT,
}),
).rejects.toMatchObject({
response: {
code: "CAR_PRICE_REQUIRED",
field: "carPrice",
},
});
expect(findByIdAndUpdate).not.toHaveBeenCalled();
});
it("normalizes and persists the current car price on car-body replies", async () => {
const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
BlameRequestType.CAR_BODY,
);
await service.submitExpertReplyV2(
"v2-claim",
{ ...validV2Reply, carPrice: "۱٬۲۵۰٬۰۰۰٬۰۰۰" },
{
sub: V2_EXPERT_ID,
fullName: "Expert One",
role: RoleEnum.FIELD_EXPERT,
},
);
expect(findByIdAndUpdate).toHaveBeenCalledTimes(1);
expect(findByIdAndUpdate.mock.calls[0][1]).toMatchObject({
"vehicle.price": "1250000000",
"vehicle.carPrice": 1250000000,
});
});
it("rejects the current car price on third-party replies", async () => {
const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
BlameRequestType.THIRD_PARTY,
);
await expect(
service.submitExpertReplyV2(
"v2-claim",
{ ...validV2Reply, carPrice: "1250000000" },
{
sub: V2_EXPERT_ID,
fullName: "Expert One",
role: RoleEnum.FIELD_EXPERT,
},
),
).rejects.toMatchObject({
response: {
code: "CAR_PRICE_NOT_ALLOWED",
field: "carPrice",
},
});
expect(findByIdAndUpdate).not.toHaveBeenCalled();
});
it("does not overwrite vehicle.price on third-party replies", async () => {
const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
BlameRequestType.THIRD_PARTY,
);
await service.submitExpertReplyV2("v2-claim", validV2Reply, {
sub: V2_EXPERT_ID,
fullName: "Expert One",
role: RoleEnum.FIELD_EXPERT,
});
expect(findByIdAndUpdate).toHaveBeenCalledTimes(1);
expect(findByIdAndUpdate.mock.calls[0][1]).not.toHaveProperty(
"vehicle.price",
);
});
it("enforces the configured total-payment cap for a V1 user-created file", async () => {
const previousEnabled = process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED;
process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED = "true";
try {
const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
BlameRequestType.THIRD_PARTY,
);
(service as any).blameRequestDbService.findById.mockResolvedValue({
type: BlameRequestType.THIRD_PARTY,
creationMethod: "NORMAL",
});
await expect(
service.submitExpertReplyV2(
"v1-claim",
{
...validV2Reply,
parts: [
{
...validV2Reply.parts[0],
salary: "1000000",
totalPayment: "600000000",
},
],
},
{
sub: V2_EXPERT_ID,
fullName: "Expert One",
role: RoleEnum.FIELD_EXPERT,
},
),
).rejects.toMatchObject({ response: { code: "PRICE_CAP_ERROR" } });
expect(findByIdAndUpdate).not.toHaveBeenCalled();
} finally {
if (previousEnabled === undefined) {
delete process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED;
} else {
process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED = previousEnabled;
}
}
});
it("does not enforce the V1 cap for an expert-initiated flow", async () => {
const previousEnabled = process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED;
process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED = "true";
try {
const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
BlameRequestType.THIRD_PARTY,
);
await service.submitExpertReplyV2(
"v3-claim",
{
...validV2Reply,
parts: [
{
...validV2Reply.parts[0],
salary: "1000000",
totalPayment: "600000000",
},
],
},
{
sub: V2_EXPERT_ID,
fullName: "Expert One",
role: RoleEnum.FIELD_EXPERT,
},
);
expect(findByIdAndUpdate).toHaveBeenCalledTimes(1);
} finally {
if (previousEnabled === undefined) {
delete process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED;
} else {
process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED = previousEnabled;
}
}
});
it("allows a repair line without daghi and removes a stray daghi payload", () => {
const service = createService() as any;
expect(
service.validateAndNormalizeDaghiForExpertReplyV2([
{
partId: 201,
typeOfDamage: TypeOfDamage.Repair,
price: "10000",
salary: "0",
totalPayment: "10000",
daghi: { option: DaghiOption.NO_VALUE },
},
]),
).toEqual([
{
partId: 201,
typeOfDamage: TypeOfDamage.Repair,
price: "10000",
salary: "0",
totalPayment: "10000",
},
]);
});
it("allows a deliver-damaged-part line without a branch", () => {
const service = createService() as any;
expect(
service.validateAndNormalizeDaghiForExpertReplyV2([
{
partId: 201,
typeOfDamage: TypeOfDamage.Change,
price: "100000",
salary: "100000",
totalPayment: "200000",
daghi: { option: DaghiOption.DELIVER_DAMAGED_PART },
},
]),
).toEqual([
{
partId: 201,
typeOfDamage: TypeOfDamage.Change,
price: "100000",
salary: "100000",
totalPayment: "200000",
daghi: { option: DaghiOption.DELIVER_DAMAGED_PART },
},
]);
});
it("rejects a blank pricing line on the legacy submit endpoint before changing status", async () => {
const service = createService() as any;
const findAndUpdate = jest.fn();
service.claimRequestManagementDbService = {
findOne: jest.fn().mockResolvedValue({
_id: "legacy-claim",
actorLocked: { actorId: "expert-1" },
unlockTime: new Date(Date.now() + 60_000),
lockFile: true,
}),
findAndUpdate,
};
await expect(
service.submitReplyRequest("legacy-claim", blankPricingReply, {
sub: "expert-1",
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(findAndUpdate).not.toHaveBeenCalled();
});
it("rejects a blank pricing line on the V2 submit endpoint before completion", async () => {
const service = createService() as any;
const findByIdAndUpdate = jest.fn();
service.claimCaseDbService = {
findById: jest.fn().mockResolvedValue({
blameRequestId: "blame-1",
status: ClaimCaseStatus.EXPERT_REVIEWING,
workflow: { locked: true, lockedBy: { actorId: "expert-1" } },
}),
findByIdAndUpdate,
};
service.blameRequestDbService = {
findById: jest.fn().mockResolvedValue({ type: "CAR_BODY" }),
};
service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined);
service.snapshotDamageExpert = jest.fn().mockResolvedValue(undefined);
await expect(
service.submitExpertReplyV2(
"v2-claim",
{ ...blankPricingReply, carPrice: "1000000" },
{
sub: "expert-1",
role: RoleEnum.FIELD_EXPERT,
},
),
).rejects.toBeInstanceOf(BadRequestException);
expect(findByIdAndUpdate).not.toHaveBeenCalled();
});
it("returns a Persian, field-specific error when a V2 price is below the minimum", async () => {
const service = createService() as any;
const findByIdAndUpdate = jest.fn();
service.claimCaseDbService = {
findById: jest.fn().mockResolvedValue({
blameRequestId: "blame-1",
status: ClaimCaseStatus.EXPERT_REVIEWING,
workflow: { locked: true, lockedBy: { actorId: "expert-1" } },
}),
findByIdAndUpdate,
};
service.blameRequestDbService = {
findById: jest.fn().mockResolvedValue({ type: "CAR_BODY" }),
};
service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined);
service.snapshotDamageExpert = jest.fn().mockResolvedValue(undefined);
await expect(
service.submitExpertReplyV2(
"v2-claim",
{
description: "Damage assessment",
carPrice: "1000000",
parts: [
{
partId: 201,
typeOfDamage: TypeOfDamage.Repair,
price: "99,999",
salary: "100000",
totalPayment: "100000",
factorNeeded: false,
},
],
},
{ sub: "expert-1", role: RoleEnum.FIELD_EXPERT },
),
).rejects.toMatchObject({
response: {
code: "EXPERT_REPLY_VALIDATION_ERROR",
field: "parts[0].price",
message: expect.stringContaining("حداقل"),
},
});
expect(findByIdAndUpdate).not.toHaveBeenCalled();
});
});
describe("ExpertClaimService damaged-part audit", () => {
it("archives removed capture evidence before replacing the live arrays", async () => {
const service = createService() as any;
const expertId = "66ec0e480e321873c0900001";
const findByIdAndUpdate = jest.fn().mockResolvedValue(undefined);
service.claimCaseDbService = {
findById: jest.fn().mockResolvedValue({
_id: "66ec0e480e321873c0900002",
status: ClaimCaseStatus.EXPERT_REVIEWING,
workflow: { locked: true, lockedBy: { actorId: expertId } },
vehicle: { carType: "SEDAN" },
damage: {
selectedParts: [
{
id: 101,
name: "hood",
side: "front",
label_fa: "کاپوت",
catalogKey: "front_hood",
},
{
id: 202,
name: "door",
side: "left",
label_fa: "درب چپ",
catalogKey: "left_door",
},
],
},
media: {
damagedParts: [
{ path: "claims/hood.jpg", fileName: "hood.jpg" },
{ path: "claims/door.jpg", fileName: "door.jpg" },
],
},
}),
findByIdAndUpdate,
};
service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined);
service.snapshotDamageExpert = jest.fn().mockResolvedValue({
firstName: "Expert",
});
await service.updateClaimDamagedPartsV2(
"66ec0e480e321873c0900002",
{
selectedParts: [
{
id: 202,
name: "door",
side: "left",
label_fa: "درب چپ",
catalogKey: "left_door",
},
],
},
{
sub: expertId,
fullName: "Expert One",
role: RoleEnum.FIELD_EXPERT,
},
);
expect(findByIdAndUpdate).toHaveBeenCalledTimes(1);
const update = findByIdAndUpdate.mock.calls[0][1];
expect(update.$set["media.damagedParts"]).toEqual([
expect.objectContaining({ path: "claims/door.jpg" }),
]);
expect(update.$push["damage.partSelectionHistory"]).toEqual(
expect.objectContaining({
changedBy: expect.objectContaining({ actorId: expertId }),
removedParts: [
expect.objectContaining({
id: 101,
capture: expect.objectContaining({ path: "claims/hood.jpg" }),
}),
],
}),
);
});
});
describe("ExpertClaimService FileReviewer assignment", () => {
it("lets the assigned reviewer reopen a file while it is still awaiting FileReviewer work", async () => {
const service = createService() as any;
const reviewerId = new Types.ObjectId();
const clientId = new Types.ObjectId();
const blameId = new Types.ObjectId();
const claimId = new Types.ObjectId();
service.expireClaimWorkflowLockV2IfStale = jest.fn().mockResolvedValue(undefined);
service.claimCaseDbService = {
findById: jest.fn().mockResolvedValue({
_id: claimId,
blameRequestId: blameId,
status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
}),
};
service.blameRequestDbService = {
findById: jest.fn().mockResolvedValue({
_id: blameId,
type: BlameRequestType.CAR_BODY,
status: "WAITING_FOR_FILE_REVIEWER",
assignedFileReviewerId: reviewerId,
parties: [
{
role: "FIRST",
person: { clientId },
},
],
}),
};
service.fanavaranLocationService = {
assertMakerReviewerBranchCompatible: jest.fn().mockResolvedValue(undefined),
};
service.assertExpertActorOnClaim = jest.fn();
await expect(
service.assignClaimForReviewV2(String(claimId), {
sub: String(reviewerId),
role: RoleEnum.FILE_REVIEWER,
clientKey: String(clientId),
}),
).resolves.toMatchObject({
success: true,
status: "already_assigned_to_you",
});
expect(service.assertExpertActorOnClaim).not.toHaveBeenCalled();
});
});
describe("ExpertClaimService branch-scoped claim views", () => {
it("shows branch-scoped claims only to damage experts in that branch", async () => {
const service = createService() as any;
const branchId = new Types.ObjectId();
const otherBranchId = new Types.ObjectId();
service.damageExpertDbService = {
findById: jest.fn().mockResolvedValue({ branchId }),
};
service.blameRequestDbService = {
find: jest.fn().mockResolvedValue([]),
};
const inBranch = { _id: "in-branch", branchId };
const otherBranch = { _id: "other-branch", branchId: otherBranchId };
const legacyUnscoped = { _id: "legacy-unscoped" };
await expect(
service.filterDamageExpertClaimsByBranch(
[inBranch, otherBranch, legacyUnscoped],
{ sub: V2_EXPERT_ID, role: RoleEnum.DAMAGE_EXPERT },
),
).resolves.toEqual([inBranch, legacyUnscoped]);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@ import {
Post,
Put,
UseGuards,
UsePipes,
Query,
Res,
} from "@nestjs/common";
@@ -50,6 +51,7 @@ import {
import { FactorValidationV2Dto } from "./dto/factor-validation.dto";
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
import { OuterPartCatalogItemDto } from "src/claim-request-management/dto/select-outer-parts-v2.dto";
import { submitExpertReplyValidationPipe } from "./pipes/submit-expert-reply-validation.pipe";
class InPersonVisitV2Dto {
@ApiPropertyOptional({ example: "Paint damage requires physical inspection" })
@@ -166,7 +168,7 @@ export class ExpertClaimV2Controller {
@ApiOperation({
summary: "Get claim request detail for damage expert",
description:
"Returns full claim details including captured images, required documents, damage selections, `evaluation.priceDrop` (included whenever saved, regardless of current status), `videoCapture` (from claim-video-capture via media.videoCaptureId), and `blameCase` (linked blameCases document with party video/voice URLs like expert-blame detail). `evaluation.damageExpertReply` / `damageExpertReplyFinal` are always returned once submitted. Allowed when status is WAITING_FOR_DAMAGE_EXPERT (if locked, only the locking expert) or when awaiting factor validation.",
"Returns full claim details including captured images, required documents, damage selections, `evaluation.priceDrop` (included whenever saved, regardless of current status), `videoCapture` (from claim-video-capture via media.videoCaptureId), and `blameCase` (linked blameCases document with party video/voice URLs like expert-blame detail). `evaluation.damageExpertReply` / `damageExpertReplyFinal` are always returned once submitted. Completed claims include Fanavaran `claimId` / `claimNo` / `dmgCaseId` / `expertiseId` when available. Allowed when status is WAITING_FOR_DAMAGE_EXPERT (if locked, only the locking expert) or when awaiting factor validation.",
})
@ApiParam({ name: "claimRequestId" })
async getClaimDetailV2(
@@ -266,16 +268,17 @@ export class ExpertClaimV2Controller {
@ApiOperation({
summary: "Submit expert damage assessment reply",
description:
"**Preconditions:** claim locked by this expert (`EXPERT_REVIEWING`). **Unlocks** the claim. Each `parts[]` line needs `partId` (from GET claim detail `damagedParts[].partId`), plus pricing, `daghi`, and optional `factorNeeded`. **Cap:** sum of line `totalPayment` values ≤ 53,000,000 **Toman** (same limit as factor-validation totals across priced + factor lines). Clears any prior `evaluation.ownerInsurerApproval` / `ownerPricedPartsApproval`.\n\n" +
"**Preconditions:** claim locked by this expert (`EXPERT_REVIEWING`). **Unlocks** the claim. Each `parts[]` line needs `partId` (from GET claim detail `damagedParts[].partId`), plus pricing, `daghi`, and optional `factorNeeded`. Every supplied monetary field (`price`, `salary`, `totalPayment`, and `daghi.price`) must be 1,000,000–100,000,000,000 **Rial**. **V1-only cap:** for user-created V1 files, sum of line `totalPayment` values ≤ 530,000,000 **Rial** (same limit as factor-validation totals across priced + factor lines). V2–V6 files are uncapped. Claim detail exposes the effective `priceCap` (`null` when uncapped). Clears any prior `evaluation.ownerInsurerApproval` / `ownerPricedPartsApproval`.\n\n" +
"**Frontend routing by `ClaimCaseStatus` (`status`):**\n" +
"- **All parts `factorNeeded`:** `OWNER_REPAIR_FACTOR_UPLOAD_PENDING`, `claimStatus=NEEDS_REVISION`, `workflow.currentStep=OWNER_UPLOAD_FACTOR_DOCUMENTS`, `workflow.nextStep=EXPERT_COST_EVALUATION` → owner uploads all factors; then `status` becomes **`EXPERT_VALIDATING_REPAIR_FACTORS`**, `claimStatus=UNDER_REVIEW`, `currentStep=EXPERT_COST_EVALUATION` for expert **validate-factors**.\n" +
"- **Mixed (some priced, some factorNeeded):** `INSURER_REVIEW_MIXED_FACTORS_PENDING`, `claimStatus=NEEDS_REVISION`, `currentStep=INSURER_REVIEW`, `nextStep=OWNER_UPLOAD_FACTOR_DOCUMENTS` → owner must call **owner-insurer-approval/sign** first (priced-line acceptance); `currentStep` then moves to `OWNER_UPLOAD_FACTOR_DOCUMENTS` (same case `status` until factors are done).\n" +
"- **No factors:** **`INSURER_REVIEW_AWAITING_OWNER_SIGN`**, `claimStatus=APPROVED`, `currentStep=INSURER_REVIEW`, `nextStep=CLAIM_COMPLETED` → owner final sign/reject only.\n\n" +
"- **No factors (V2–V5):** **`COMPLETED`**, `claimStatus=APPROVED`, `workflow.currentStep=CLAIM_COMPLETED` → no final owner signature. The expert can submit to Fanavaran manually when ready. For V5, the case instead waits for FileMaker approval before becoming `COMPLETED`.\n\n" +
"**Legacy rows** may still use `WAITING_FOR_INSURER_APPROVAL` instead of the specific values above.\n\n" +
"**After the owner fulfilled an expert resend** (`damageExpertResend.fulfilledAt`), this expert **cannot** initiate **another resend**—use **reply/submit**, in-person visit, or **validate-factors** as appropriate.",
})
@ApiParam({ name: "claimRequestId" })
@ApiBody({ type: SubmitExpertReplyV2Dto })
@UsePipes(submitExpertReplyValidationPipe)
async submitExpertReplyV2(
@Param("claimRequestId") claimRequestId: string,
@Body() body: SubmitExpertReplyV2Dto,
@@ -350,10 +353,10 @@ export class ExpertClaimV2Controller {
"**Response:** `claimStatus` = `ClaimStatus` (e.g. APPROVED). `caseStatus` = `ClaimCaseStatus` (e.g. COMPLETED vs insurer-review) — they are not interchangeable.\n\n" +
"**Preconditions:** `status=EXPERT_VALIDATING_REPAIR_FACTORS` (or legacy `WAITING_FOR_INSURER_APPROVAL`), `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_COST_EVALUATION`, every `factorNeeded` line has `factorLink`.\n\n" +
"**Decisions:** each factor line gets `APPROVED` or `REJECTED`. **Every** decided line must include expert-entered `totalPayment` **or** both `price` and `salary` (factor photos are not read for amounts).\n\n" +
"**Cap (when every factor line is decided):** sum of **all** reply lines (priced parts + factor lines) must be ≤ **53,000,000 Toman**; otherwise `PRICE_CAP_ERROR` with message that the maximum acceptable total was exceeded.\n\n" +
"**V1-only cap (when every factor line is decided):** for user-created V1 files, sum of **all** reply lines (priced parts + factor lines) must be ≤ **530,000,000 Rial**; otherwise `PRICE_CAP_ERROR` is returned. V2–V6 files are uncapped.\n\n" +
"**Outcomes:**\n" +
"- **All approved:** `caseStatus=COMPLETED`, `claimStatus=APPROVED`, workflow `CLAIM_COMPLETED` — no owner signature.\n" +
"- **Any rejected (repriced):** same auto-complete for now (owner acceptance may be added later).\n" +
"- **All approved:** `caseStatus=COMPLETED`, `claimStatus=APPROVED`, workflow `CLAIM_COMPLETED` — no owner signature. V5 instead waits for FileMaker approval.\n" +
"- **Any rejected (repriced):** same completion behavior for now (V5 waits for FileMaker approval).\n" +
"- **Partial batch:** returns pending until every factor line has a non-pending decision (no cap error until the batch is complete).",
})
@ApiParam({ name: "claimRequestId" })

Some files were not shown because too many files have changed in this diff Show More