Compare commits

..

14 Commits

Author SHA1 Message Date
SepehrYahyaee
8ac185c861 feat(claims): support optional accident sketch 2026-09-22 11:37:21 +03:30
f07e510c5f Merge pull request 'Fixed unstable parts fetching from fanavaran' (#336) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#336
2026-09-21 10:44:24 +03:30
SepehrYahyaee
8b65e43c60 Fixed unstable parts fetching from fanavaran 2026-09-21 10:43:30 +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
32 changed files with 875 additions and 92 deletions

1
.gitignore vendored
View File

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

View File

@@ -461,6 +461,16 @@
</div> </div>
</div> </div>
<div class="card">
<div class="card-title">Optional document shared by every claim flow</div>
<p class="step-note">
Every V1–V6 document-upload step also offers
<code>accident_sketch</code> (کروکی). It is optional for both
<code>THIRD_PARTY</code> and <code>CAR_BODY</code>, may be omitted without
blocking progression, and is returned in expert and insurer case details when uploaded.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════════ --> <!-- ═══════════════════════════════════════════════════════════════ -->
<h2>Flow V1 / V2 — User-Initiated Blame &amp; Claim</h2> <h2>Flow V1 / V2 — User-Initiated Blame &amp; Claim</h2>
<p class="section-intro"> <p class="section-intro">

View File

@@ -446,7 +446,7 @@ curl -X POST "$ESG_URL/inquiry/carByChassis" \
-H "Accept: application/json" \ -H "Accept: application/json" \
--data '{ --data '{
"nationalCode": "0012345678", "nationalCode": "0012345678",
"chassis": "NAAR03HFFRDE07024" "chassisNo": "NAAR03HFFRDE07024"
}' }'
``` ```

View File

@@ -359,7 +359,7 @@
<table> <table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr> <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/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>chassis</code>. مسیر تک‌عاملی <code>policyByChassis</code> استفاده نمی‌شود، چون فیلد <code>nationalCode</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/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> <tr><td><span class="method post">POST</span></td><td><code>/inquiry/sheba</code></td><td>اعتبارسنجی شبا / حساب بانکی.</td></tr>
</table> </table>

View File

@@ -356,7 +356,7 @@
<table> <table>
<tr><th style="width:70px">Method</th><th>Path</th><th>What it does</th></tr> <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/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>chassis</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/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/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> <tr><td><span class="method post">POST</span></td><td><code>/inquiry/sheba</code></td><td>Sheba / bank account validation.</td></tr>
</table> </table>

View File

@@ -462,7 +462,7 @@
<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>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 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 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 post">POST</span></td><td><code>upload-document/:claimId</code></td><td>Upload licences / car cards and the optional <code>accident_sketch</code> (کروکی) against the auto-created claim. The sketch never gates completion.</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> <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> </table>
</div> </div>
@@ -498,7 +498,7 @@
<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 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 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 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 post">POST</span></td><td><code>upload-document/:claimId</code></td><td>Upload chassis / engine / metal-plate documents; <code>accident_sketch</code> (کروکی) remains optional and does not affect completion.</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-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 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 post">POST</span></td><td><code>capture-part/:claimId</code></td><td>Capture part photos + angles for each selected damaged part.</td></tr>

View File

@@ -1,4 +1,7 @@
export enum ClaimRequiredDocumentType { export enum ClaimRequiredDocumentType {
/** Optional police accident sketch (Persian: کروکی). Never gates claim progress. */
ACCIDENT_SKETCH = "accident_sketch",
// Car green card // Car green card
CAR_GREEN_CARD = "car_green_card", CAR_GREEN_CARD = "car_green_card",
CAR_CERTIFICATE = "car_certificate", CAR_CERTIFICATE = "car_certificate",
@@ -34,4 +37,3 @@ export enum CarAngle {
LEFT = "left", LEFT = "left",
RIGHT = "right", RIGHT = "right",
} }

View File

@@ -0,0 +1,45 @@
import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
import { ClaimRequestManagementService } from "./claim-request-management.service";
describe("optional accident sketch", () => {
const service = Object.create(
ClaimRequestManagementService.prototype,
) as ClaimRequestManagementService;
it("is excluded from legacy required-document completion", () => {
const requiredTypes = (service as any).getRequiredDocumentTypes({
blameFile: { type: "THIRD_PARTY" },
});
expect(requiredTypes).not.toContain(
ClaimRequiredDocumentType.ACCIDENT_SKETCH,
);
});
it("does not gate V2 owner-document completion", () => {
const mandatoryKeys = (service as any).requiredDocumentKeysV2(false);
const requiredDocuments = Object.fromEntries(
[...mandatoryKeys, ClaimRequiredDocumentType.CAR_GREEN_CARD].map(
(key) => [key, { uploaded: true }],
),
);
expect(
(service as any).allV2OwnerDocumentsComplete(
{ requiredDocuments },
false,
),
).toBe(true);
requiredDocuments[ClaimRequiredDocumentType.ACCIDENT_SKETCH] = {
uploaded: false,
};
expect(
(service as any).allV2OwnerDocumentsComplete(
{ requiredDocuments },
false,
),
).toBe(true);
});
});

View File

@@ -66,15 +66,15 @@ import {
toFanavaranHullExpertiseDmgSection, toFanavaranHullExpertiseDmgSection,
toFanavaranHullExpertisePayload, toFanavaranHullExpertisePayload,
vehicleCurrentValueFromHullPolicyRecord, vehicleCurrentValueFromHullPolicyRecord,
FANAVARAN_DEFAULT_HULL_REPAIR_DURATION,
} from "./fanavaran-hull-expertise"; } from "./fanavaran-hull-expertise";
import { import {
asHullDmgAccessoryRows,
FANAVARAN_DEFAULT_HULL_DMG_COST_KIND_ID, FANAVARAN_DEFAULT_HULL_DMG_COST_KIND_ID,
FANAVARAN_DEFAULT_HULL_DMG_KIND_ID, FANAVARAN_DEFAULT_HULL_DMG_KIND_ID,
FANAVARAN_DEFAULT_HULL_VEHICLE_ACCESSORY_ID,
FANAVARAN_HULL_DMG_COST_KIND_CAPTION, FANAVARAN_HULL_DMG_COST_KIND_CAPTION,
FANAVARAN_HULL_DMG_KIND_CAPTION, FANAVARAN_HULL_DMG_KIND_CAPTION,
findLookupIdByCaption, findLookupIdByCaption,
resolveVehicleHullAccessoryId,
} from "./fanavaran-hull-expertise-lookups"; } from "./fanavaran-hull-expertise-lookups";
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service"; import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
import { CreateClaimFromBlameResponseDto } from "./dto/create-claim-v2.dto"; import { CreateClaimFromBlameResponseDto } from "./dto/create-claim-v2.dto";
@@ -1226,6 +1226,7 @@ export class ClaimRequestManagementService {
claimRequestId: string, claimRequestId: string,
expert: any, expert: any,
dto: { dto: {
selectedPartIds?: number[];
carPartDamage?: CarDamagePartDto; carPartDamage?: CarDamagePartDto;
sheba?: string; sheba?: string;
nationalCodeOfInsurer?: string; nationalCodeOfInsurer?: string;
@@ -1262,12 +1263,34 @@ export class ClaimRequestManagementService {
); );
} }
if (dto.carPartDamage) { if (dto.selectedPartIds?.length || dto.carPartDamage) {
if (claim.carPartDamage && (claim.carPartDamage as any[]).length > 0) { if (claim.carPartDamage && (claim.carPartDamage as any[]).length > 0) {
throw new BadRequestException("Car part damage is already set."); throw new BadRequestException("Car part damage is already set.");
} }
this.assertCarPartDamageAtMostTwoOfFourSides(dto.carPartDamage); let trueItems: Array<Record<string, unknown>>;
const trueItems = this.findTrueItems(dto.carPartDamage); if (dto.selectedPartIds?.length) {
const liveCatalog = await this.getLiveFanavaranCatalogItems();
const byId = new Map<number, OuterPartCatalogItem>(
liveCatalog.map((part) => [part.id, part]),
);
trueItems = dto.selectedPartIds.map((id) => {
const item = byId.get(id);
if (!item) {
throw new BadRequestException(
`Invalid outer part id: ${id}. Use GET outer-parts-catalog to see valid IDs.`,
);
}
return {
side: item.side || "carDamage",
part: item.titleFa,
id: item.id,
label_fa: item.titleFa,
};
});
} else {
this.assertCarPartDamageAtMostTwoOfFourSides(dto.carPartDamage!);
trueItems = this.findTrueItems(dto.carPartDamage!);
}
const claimDoc = (await this.claimDbService.findOne( const claimDoc = (await this.claimDbService.findOne(
claimRequestId, claimRequestId,
)) as any; )) as any;
@@ -1302,6 +1325,7 @@ export class ClaimRequestManagementService {
claimRequestId: string, claimRequestId: string,
expert: any, expert: any,
dto: { dto: {
selectedPartIds?: number[];
carPartDamage?: CarDamagePartDto; carPartDamage?: CarDamagePartDto;
sheba?: string; sheba?: string;
nationalCodeOfInsurer?: string; nationalCodeOfInsurer?: string;
@@ -1313,7 +1337,7 @@ export class ClaimRequestManagementService {
let working = claimCase; let working = claimCase;
if (dto.carPartDamage) { if (dto.selectedPartIds?.length || dto.carPartDamage) {
if ( if (
working.workflow?.currentStep !== ClaimWorkflowStep.SELECT_OUTER_PARTS working.workflow?.currentStep !== ClaimWorkflowStep.SELECT_OUTER_PARTS
) { ) {
@@ -1327,20 +1351,41 @@ export class ClaimRequestManagementService {
) { ) {
throw new BadRequestException("Car part damage is already set."); throw new BadRequestException("Car part damage is already set.");
} }
this.assertCarPartDamageAtMostTwoOfFourSides(dto.carPartDamage); let selectedPartObjs: DamageSelectedPartV2[];
const selectedSlugStrings = this.carDamagePartDtoToOuterPartSlugs( if (dto.selectedPartIds?.length) {
dto.carPartDamage, const liveCatalog = await this.getLiveFanavaranCatalogItems();
); const byId = new Map<number, OuterPartCatalogItem>(
if (!selectedSlugStrings.length) { liveCatalog.map((part) => [part.id, part]),
throw new BadRequestException( );
"No outer damaged parts could be derived from carPartDamage. Check part selections.", const selectedItems = dto.selectedPartIds.map((id) => {
const item = byId.get(id);
if (!item) {
throw new BadRequestException(
`Invalid outer part id: ${id}. Use GET outer-parts-catalog to see valid IDs.`,
);
}
return item;
});
selectedPartObjs = selectedItems.map((part) =>
catalogItemToSelectedPart(part, liveCatalog),
);
} else {
// Backward compatibility for older expert-panel clients.
this.assertCarPartDamageAtMostTwoOfFourSides(dto.carPartDamage!);
const selectedSlugStrings = this.carDamagePartDtoToOuterPartSlugs(
dto.carPartDamage!,
);
if (!selectedSlugStrings.length) {
throw new BadRequestException(
"No outer damaged parts could be derived from carPartDamage. Check part selections.",
);
}
selectedPartObjs = normalizeDamageSelectedParts(
selectedSlugStrings,
working.vehicle?.carType as ClaimVehicleTypeV2 | undefined,
undefined,
); );
} }
const selectedPartObjs = normalizeDamageSelectedParts(
selectedSlugStrings,
working.vehicle?.carType as ClaimVehicleTypeV2 | undefined,
undefined,
);
const damagedPartsStubs = selectedPartObjs.map((p) => ({ const damagedPartsStubs = selectedPartObjs.map((p) => ({
id: p.id ?? undefined, id: p.id ?? undefined,
name: p.name, name: p.name,
@@ -1594,7 +1639,9 @@ export class ClaimRequestManagementService {
private getRequiredDocumentTypes( private getRequiredDocumentTypes(
claimRequest: any, claimRequest: any,
): ClaimRequiredDocumentType[] { ): ClaimRequiredDocumentType[] {
const allTypes = Object.values(ClaimRequiredDocumentType); const allTypes = Object.values(ClaimRequiredDocumentType).filter(
(type) => type !== ClaimRequiredDocumentType.ACCIDENT_SKETCH,
);
// Check if it's CAR_BODY type // Check if it's CAR_BODY type
const isCarBody = claimRequest.blameFile?.type === "CAR_BODY"; const isCarBody = claimRequest.blameFile?.type === "CAR_BODY";
@@ -4435,7 +4482,7 @@ export class ClaimRequestManagementService {
), ),
}; };
const [dmgKindRows, dmgCostKindRows, policyRecord, accessoriesRaw] = const [dmgKindRows, dmgCostKindRows, policyRecord] =
await Promise.all([ await Promise.all([
this.getFanavaranLookupRows(input.clientKey, "vehicle-hull-dmg-kind"), this.getFanavaranLookupRows(input.clientKey, "vehicle-hull-dmg-kind"),
this.getFanavaranLookupRows( this.getFanavaranLookupRows(
@@ -4449,13 +4496,6 @@ export class ClaimRequestManagementService {
contractOptions, contractOptions,
) )
: Promise.resolve(null), : Promise.resolve(null),
policyId != null
? this.fanavaranLookupService.vehicleHullDmgAccessoriesByPolicyId(
input.clientKey,
policyId,
contractOptions,
)
: Promise.resolve([]),
]); ]);
const dmgKindId = const dmgKindId =
@@ -4467,13 +4507,6 @@ export class ClaimRequestManagementService {
FANAVARAN_HULL_DMG_COST_KIND_CAPTION, FANAVARAN_HULL_DMG_COST_KIND_CAPTION,
) ?? FANAVARAN_DEFAULT_HULL_DMG_COST_KIND_ID; ) ?? FANAVARAN_DEFAULT_HULL_DMG_COST_KIND_ID;
const accessories = asHullDmgAccessoryRows(accessoriesRaw);
if (policyId != null && accessories.length === 0) {
input.warnings.push(
`No hull dmg-accessories for policy ${policyId}; VehicleHullAccessoryId may be missing.`,
);
}
let vehicle = pickHullVehicleIdentity(input.blame); let vehicle = pickHullVehicleIdentity(input.blame);
let colorId: number | null = null; let colorId: number | null = null;
const policy = asObjectRecord(policyRecord); const policy = asObjectRecord(policyRecord);
@@ -4517,16 +4550,6 @@ export class ClaimRequestManagementService {
); );
const hullSections = input.parts.map((part: any) => { const hullSections = input.parts.map((part: any) => {
const accessoryKindId = parseCatalogPartIdInput(part?.partId);
const vehicleHullAccessoryId = resolveVehicleHullAccessoryId(
accessories,
accessoryKindId,
);
if (vehicleHullAccessoryId == null) {
input.warnings.push(
`No VehicleHullAccessoryId for part "${this.fanavaranPartLabel(part)}" (AccessoryKindId=${accessoryKindId ?? "none"}).`,
);
}
return toFanavaranHullExpertiseDmgSection({ return toFanavaranHullExpertiseDmgSection({
partId: part?.partId, partId: part?.partId,
desc: this.fanavaranPartLabel(part), desc: this.fanavaranPartLabel(part),
@@ -4536,7 +4559,7 @@ export class ClaimRequestManagementService {
this.parseFanavaranMoney(part?.salary), this.parseFanavaranMoney(part?.salary),
dmgKindId, dmgKindId,
dmgCostKindId, dmgCostKindId,
vehicleHullAccessoryId, vehicleHullAccessoryId: FANAVARAN_DEFAULT_HULL_VEHICLE_ACCESSORY_ID,
}); });
}); });
@@ -4549,10 +4572,7 @@ export class ClaimRequestManagementService {
vehicleCurrentValue = vehicleCurrentValueFromHullPolicyRecord(policy); vehicleCurrentValue = vehicleCurrentValueFromHullPolicyRecord(policy);
} }
const repairDuration = const repairDuration = FANAVARAN_DEFAULT_HULL_REPAIR_DURATION;
typeof input.profile.defaults.HullExpertiseRepairDuration === "number"
? input.profile.defaults.HullExpertiseRepairDuration
: null;
return { return {
replyKey: input.replyKey, replyKey: input.replyKey,
@@ -7853,7 +7873,18 @@ export class ClaimRequestManagementService {
this.logger.log( this.logger.log(
`[Fanavaran hull sync] PUT ${url} keys=${Object.keys(hull).join(",")}`, `[Fanavaran hull sync] PUT ${url} keys=${Object.keys(hull).join(",")}`,
); );
await this.putFanavaranJson(url, hull, clientKey, undefined, claimCaseId); try {
await this.putFanavaranJson(url, hull, clientKey, undefined, claimCaseId);
} catch (error) {
// Parsian bonyan often has no PUT route on vehicle-hull-claims/{Id}
// ("No HTTP resource was found.../vehicle-hull-claims/{Id}"). Do not
// block GEN.05 files / GEN.06 expertise on that.
this.logger.warn(
`[Fanavaran hull sync] PUT ${url} failed; continuing with nested stages: ${
error instanceof Error ? error.message : error
}`,
);
}
} }
private async postFanavaranMultipart( private async postFanavaranMultipart(
@@ -9318,6 +9349,9 @@ export class ClaimRequestManagementService {
blameRequestId: new Types.ObjectId(blameRequestId), blameRequestId: new Types.ObjectId(blameRequestId),
blameRequestNo: blameRequest.requestNo, blameRequestNo: blameRequest.requestNo,
initiatedByFieldExpertId: new Types.ObjectId(expert.sub), initiatedByFieldExpertId: new Types.ObjectId(expert.sub),
...((blameRequest as any).branchId
? { branchId: new Types.ObjectId(String((blameRequest as any).branchId)) }
: {}),
status: ClaimCaseStatus.SELECTING_OUTER_PARTS, status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
claimStatus: ClaimStatus.PENDING, claimStatus: ClaimStatus.PENDING,
inquiries: (blameRequest as any).inquiries ?? {}, inquiries: (blameRequest as any).inquiries ?? {},
@@ -10300,7 +10334,8 @@ export class ClaimRequestManagementService {
if (!catalogByKey.has(item.key)) catalogByKey.set(item.key, item); if (!catalogByKey.has(item.key)) catalogByKey.set(item.key, item);
} }
// Required documents: CAR_BODY needs 7 (damaged + green card); THIRD_PARTY needs 13 (add guilty_*) // Required documents vary by claim type. The accident sketch is offered
// in every flow, but is excluded from every completion calculation.
const blameRequest = claimCase.blameRequestId const blameRequest = claimCase.blameRequestId
? await this.blameRequestDbService.findById( ? await this.blameRequestDbService.findById(
claimCase.blameRequestId.toString(), claimCase.blameRequestId.toString(),
@@ -10308,53 +10343,68 @@ export class ClaimRequestManagementService {
: null; : null;
const isCarBody = blameRequest?.type === BlameRequestType.CAR_BODY; const isCarBody = blameRequest?.type === BlameRequestType.CAR_BODY;
const requiredDocsDefinition = [ const requiredDocsDefinition = [
{
key: ClaimRequiredDocumentType.ACCIDENT_SKETCH,
label_fa: "کروکی",
label_en: "Accident Sketch",
category: "general",
required: false,
},
{ {
key: "damaged_driving_license_front", key: "damaged_driving_license_front",
label_fa: "گواهینامه طرف آسیب‌دیده - جلو", label_fa: "گواهینامه طرف آسیب‌دیده - جلو",
label_en: "Damaged Party License - Front", label_en: "Damaged Party License - Front",
category: "damaged_party", category: "damaged_party",
required: true,
}, },
{ {
key: "damaged_driving_license_back", key: "damaged_driving_license_back",
label_fa: "گواهینامه طرف آسیب‌دیده - پشت", label_fa: "گواهینامه طرف آسیب‌دیده - پشت",
label_en: "Damaged Party License - Back", label_en: "Damaged Party License - Back",
category: "damaged_party", category: "damaged_party",
required: true,
}, },
{ {
key: "damaged_chassis_number", key: "damaged_chassis_number",
label_fa: "شماره شاسی", label_fa: "شماره شاسی",
label_en: "Chassis Number", label_en: "Chassis Number",
category: "damaged_party", category: "damaged_party",
required: true,
}, },
{ {
key: "damaged_engine_photo", key: "damaged_engine_photo",
label_fa: "عکس موتور", label_fa: "عکس موتور",
label_en: "Engine Photo", label_en: "Engine Photo",
category: "damaged_party", category: "damaged_party",
required: true,
}, },
{ {
key: "damaged_car_card_front", key: "damaged_car_card_front",
label_fa: "کارت خودرو آسیب‌دیده - جلو", label_fa: "کارت خودرو آسیب‌دیده - جلو",
label_en: "Damaged Car Card - Front", label_en: "Damaged Car Card - Front",
category: "damaged_party", category: "damaged_party",
required: true,
}, },
{ {
key: "damaged_car_card_back", key: "damaged_car_card_back",
label_fa: "کارت خودرو آسیب‌دیده - پشت", label_fa: "کارت خودرو آسیب‌دیده - پشت",
label_en: "Damaged Car Card - Back", label_en: "Damaged Car Card - Back",
category: "damaged_party", category: "damaged_party",
required: true,
}, },
{ {
key: "damaged_metal_plate", key: "damaged_metal_plate",
label_fa: "پلاک فلزی آسیب‌دیده", label_fa: "پلاک فلزی آسیب‌دیده",
label_en: "Damaged Metal Plate", label_en: "Damaged Metal Plate",
category: "damaged_party", category: "damaged_party",
required: true,
}, },
{ {
key: "car_green_card", key: "car_green_card",
label_fa: "کارت سبز خودرو", label_fa: "کارت سبز خودرو",
label_en: "Car Green Card", label_en: "Car Green Card",
category: "damaged_party", category: "damaged_party",
required: true,
}, },
...(isCarBody ...(isCarBody
? [] ? []
@@ -10364,30 +10414,35 @@ export class ClaimRequestManagementService {
label_fa: "گواهینامه طرف مقصر - جلو", label_fa: "گواهینامه طرف مقصر - جلو",
label_en: "Guilty Party License - Front", label_en: "Guilty Party License - Front",
category: "guilty_party", category: "guilty_party",
required: true,
}, },
{ {
key: "guilty_driving_license_back", key: "guilty_driving_license_back",
label_fa: "گواهینامه طرف مقصر - پشت", label_fa: "گواهینامه طرف مقصر - پشت",
label_en: "Guilty Party License - Back", label_en: "Guilty Party License - Back",
category: "guilty_party", category: "guilty_party",
required: true,
}, },
{ {
key: "guilty_car_card_front", key: "guilty_car_card_front",
label_fa: "کارت خودرو مقصر - جلو", label_fa: "کارت خودرو مقصر - جلو",
label_en: "Guilty Car Card - Front", label_en: "Guilty Car Card - Front",
category: "guilty_party", category: "guilty_party",
required: true,
}, },
{ {
key: "guilty_car_card_back", key: "guilty_car_card_back",
label_fa: "کارت خودرو مقصر - پشت", label_fa: "کارت خودرو مقصر - پشت",
label_en: "Guilty Car Card - Back", label_en: "Guilty Car Card - Back",
category: "guilty_party", category: "guilty_party",
required: true,
}, },
{ {
key: "guilty_metal_plate", key: "guilty_metal_plate",
label_fa: "پلاک فلزی مقصر", label_fa: "پلاک فلزی مقصر",
label_en: "Guilty Metal Plate", label_en: "Guilty Metal Plate",
category: "guilty_party", category: "guilty_party",
required: true,
}, },
]), ]),
]; ];
@@ -10399,6 +10454,7 @@ export class ClaimRequestManagementService {
label_fa: doc.label_fa, label_fa: doc.label_fa,
label_en: doc.label_en, label_en: doc.label_en,
category: doc.category, category: doc.category,
required: doc.required,
uploaded: docData?.uploaded || false, uploaded: docData?.uploaded || false,
preferUploadDuringCapture: isCapturePhaseDamagedPartyDocKey(doc.key), preferUploadDuringCapture: isCapturePhaseDamagedPartyDocKey(doc.key),
}; };
@@ -10461,7 +10517,10 @@ export class ClaimRequestManagementService {
}); });
// Calculate progress // Calculate progress
const documentsUploaded = requiredDocuments.filter( const requiredDocumentItems = requiredDocuments.filter(
(d) => d.required,
);
const documentsUploaded = requiredDocumentItems.filter(
(d) => d.uploaded, (d) => d.uploaded,
).length; ).length;
const anglesCaptured = carAngles.filter((a) => a.captured).length; const anglesCaptured = carAngles.filter((a) => a.captured).length;
@@ -10479,7 +10538,7 @@ export class ClaimRequestManagementService {
captureProgress.anglesTotal - captureProgress.anglesCaptured, captureProgress.anglesTotal - captureProgress.anglesCaptured,
); );
const postCaptureDocsRemaining = requiredDocuments.filter( const postCaptureDocsRemaining = requiredDocuments.filter(
(d) => !d.preferUploadDuringCapture && !d.uploaded, (d) => d.required && !d.preferUploadDuringCapture && !d.uploaded,
).length; ).length;
const totalRemaining = const totalRemaining =
@@ -10499,7 +10558,7 @@ export class ClaimRequestManagementService {
totalRemaining, totalRemaining,
progress: { progress: {
documentsUploaded, documentsUploaded,
documentsTotal: requiredDocuments.length, documentsTotal: requiredDocumentItems.length,
anglesCaptured, anglesCaptured,
anglesTotal: carAngles.length, anglesTotal: carAngles.length,
partsCaptured, partsCaptured,
@@ -10662,6 +10721,8 @@ export class ClaimRequestManagementService {
const step = claimCase.workflow?.currentStep; const step = claimCase.workflow?.currentStep;
const isResendUpload = step === ClaimWorkflowStep.USER_EXPERT_RESEND; const isResendUpload = step === ClaimWorkflowStep.USER_EXPERT_RESEND;
const isOptionalDocument =
body.documentKey === ClaimRequiredDocumentType.ACCIDENT_SKETCH;
const isCapturePhaseDocUpload = const isCapturePhaseDocUpload =
step === ClaimWorkflowStep.CAPTURE_PART_DAMAGES && step === ClaimWorkflowStep.CAPTURE_PART_DAMAGES &&
(isCapturePhaseDamagedPartyDocKey(body.documentKey) || (isCapturePhaseDamagedPartyDocKey(body.documentKey) ||
@@ -10692,7 +10753,9 @@ export class ClaimRequestManagementService {
} }
const allowedInitialUploadStep = const allowedInitialUploadStep =
step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS || step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS ||
isCapturePhaseDocUpload; isCapturePhaseDocUpload ||
(isOptionalDocument &&
step === ClaimWorkflowStep.USER_SUBMISSION_COMPLETE);
if (!allowedInitialUploadStep) { if (!allowedInitialUploadStep) {
throw new BadRequestException( throw new BadRequestException(
`Invalid workflow step. Expected ${ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS}, ${ClaimWorkflowStep.CAPTURE_PART_DAMAGES} (capture-phase documents only), or ${ClaimWorkflowStep.USER_EXPERT_RESEND}, but current step is ${claimCase.workflow?.currentStep}`, `Invalid workflow step. Expected ${ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS}, ${ClaimWorkflowStep.CAPTURE_PART_DAMAGES} (capture-phase documents only), or ${ClaimWorkflowStep.USER_EXPERT_RESEND}, but current step is ${claimCase.workflow?.currentStep}`,
@@ -10811,7 +10874,7 @@ export class ClaimRequestManagementService {
let remaining = 0; let remaining = 0;
let captureStepCompletedOnThisUpload = false; let captureStepCompletedOnThisUpload = false;
if (!isResendUpload) { if (!isResendUpload && !isOptionalDocument) {
if (isCapturePhaseDocUpload) { if (isCapturePhaseDocUpload) {
const afterThis = (k: string) => const afterThis = (k: string) =>
k === body.documentKey || k === body.documentKey ||
@@ -10949,6 +11012,28 @@ export class ClaimRequestManagementService {
} }
} }
if (!isResendUpload && isOptionalDocument) {
allDocumentsUploaded = options?.v3InPersonFlow
? this.allV3PreCaptureDocumentsComplete(
claimCase,
isCarBodyUpload,
undefined,
options?.skipMetalPlate,
)
: this.allV2OwnerDocumentsComplete(claimCase, isCarBodyUpload);
remaining = options?.v3InPersonFlow
? this.countRemainingV3PreCaptureDocuments(
claimCase,
isCarBodyUpload,
undefined,
options?.skipMetalPlate,
)
: this.countRemainingV2OwnerDocuments(
claimCase,
isCarBodyUpload,
);
}
// Build the final MongoDB update — all operators explicit and clean // Build the final MongoDB update — all operators explicit and clean
const updatePayload: Record<string, unknown> = { $set }; const updatePayload: Record<string, unknown> = { $set };
@@ -11053,7 +11138,12 @@ export class ClaimRequestManagementService {
} }
// Auto-submit Fanavaran attachments when all documents are uploaded // Auto-submit Fanavaran attachments when all documents are uploaded
if (allDocumentsUploaded && !isResendUpload && !options?.v3InPersonFlow) { if (
allDocumentsUploaded &&
!isOptionalDocument &&
!isResendUpload &&
!options?.v3InPersonFlow
) {
this.submitFanavaranAttachmentsV2(claimRequestId, resolveFanavaranClientKey()).catch( this.submitFanavaranAttachmentsV2(claimRequestId, resolveFanavaranClientKey()).catch(
(err) => this.logger.warn(`Fanavaran attachments auto-submit failed: ${err?.message}`), (err) => this.logger.warn(`Fanavaran attachments auto-submit failed: ${err?.message}`),
); );
@@ -11093,7 +11183,9 @@ export class ClaimRequestManagementService {
}; };
} }
const message = isCapturePhaseDocUpload const message = isOptionalDocument
? "Optional accident sketch uploaded successfully."
: isCapturePhaseDocUpload
? captureStepCompletedOnThisUpload ? captureStepCompletedOnThisUpload
? options?.v3InPersonFlow ? options?.v3InPersonFlow
? "Capture-phase documents and all damage captures are complete. Upload the walk-around video (car-capture) to finalise." ? "Capture-phase documents and all damage captures are complete. Upload the walk-around video (car-capture) to finalise."
@@ -11116,7 +11208,9 @@ export class ClaimRequestManagementService {
documentKey: body.documentKey, documentKey: body.documentKey,
fileUrl, fileUrl,
allDocumentsUploaded, allDocumentsUploaded,
currentStep: isCapturePhaseDocUpload currentStep: isOptionalDocument
? step || ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS
: isCapturePhaseDocUpload
? captureStepCompletedOnThisUpload ? captureStepCompletedOnThisUpload
? ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS ? ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS
: ClaimWorkflowStep.CAPTURE_PART_DAMAGES : ClaimWorkflowStep.CAPTURE_PART_DAMAGES
@@ -12873,7 +12967,10 @@ export class ClaimRequestManagementService {
const preCaptureDocs = base.requiredDocuments.filter( const preCaptureDocs = base.requiredDocuments.filter(
(d) => !d.preferUploadDuringCapture, (d) => !d.preferUploadDuringCapture,
); );
const remaining = preCaptureDocs.filter((d) => !d.uploaded).length; const requiredPreCaptureDocs = preCaptureDocs.filter((d) => d.required);
const remaining = requiredPreCaptureDocs.filter(
(d) => !d.uploaded,
).length;
return { return {
...base, ...base,
requiredDocuments: preCaptureDocs, requiredDocuments: preCaptureDocs,
@@ -12885,8 +12982,9 @@ export class ClaimRequestManagementService {
totalRemaining: remaining, totalRemaining: remaining,
progress: { progress: {
...base.progress, ...base.progress,
documentsTotal: preCaptureDocs.length, documentsTotal: requiredPreCaptureDocs.length,
documentsUploaded: preCaptureDocs.filter((d) => d.uploaded).length, documentsUploaded: requiredPreCaptureDocs.filter((d) => d.uploaded)
.length,
partsCaptured: 0, partsCaptured: 0,
partsTotal: 0, partsTotal: 0,
anglesCaptured: 0, anglesCaptured: 0,
@@ -12941,6 +13039,7 @@ export class ClaimRequestManagementService {
label_fa: "تصویر نقطه آسیب‌دیده مقصر", label_fa: "تصویر نقطه آسیب‌دیده مقصر",
label_en: "Guilty Car Damaged Area", label_en: "Guilty Car Damaged Area",
category: "guilty_party", category: "guilty_party",
required: true,
uploaded: guiltyAreaUploaded, uploaded: guiltyAreaUploaded,
preferUploadDuringCapture: true, preferUploadDuringCapture: true,
}; };

View File

@@ -801,6 +801,7 @@ Returns status of each item (uploaded/captured or not).
**Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 5 of Claim) **Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 5 of Claim)
**Upload one of the required documents** (12 for THIRD_PARTY; CAR_BODY may require fewer — see capture-requirements): **Upload one of the required documents** (12 for THIRD_PARTY; CAR_BODY may require fewer — see capture-requirements):
- accident_sketch (optional; never blocks workflow completion)
- damaged_driving_license_front/back - damaged_driving_license_front/back
- damaged_chassis_number, damaged_engine_photo - damaged_chassis_number, damaged_engine_photo
- damaged_car_card_front/back, damaged_metal_plate - damaged_car_card_front/back, damaged_metal_plate
@@ -836,6 +837,7 @@ Returns status of each item (uploaded/captured or not).
"guilty_car_card_front", "guilty_car_card_front",
"guilty_car_card_back", "guilty_car_card_back",
"guilty_metal_plate", "guilty_metal_plate",
"accident_sketch",
], ],
example: "damaged_driving_license_front", example: "damaged_driving_license_front",
}, },

View File

@@ -42,6 +42,13 @@ export class RequiredDocumentItem {
example: true, example: true,
}) })
preferUploadDuringCapture?: boolean; preferUploadDuringCapture?: boolean;
@ApiProperty({
description:
'Whether this document is mandatory for workflow completion. Optional documents are uploadable but never included in remaining/progress counts.',
example: true,
})
required: boolean;
} }
/** /**

View File

@@ -239,6 +239,10 @@ export class ClaimCase {
@Prop({ type: Types.ObjectId, index: true }) @Prop({ type: Types.ObjectId, index: true })
initiatedByFieldExpertId?: Types.ObjectId; 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. * 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. * Stored here so view/list access does not require an extra blame lookup.

View File

@@ -416,6 +416,7 @@ Returns status of each item (uploaded/captured or not).
"guilty_car_card_front", "guilty_car_card_front",
"guilty_car_card_back", "guilty_car_card_back",
"guilty_metal_plate", "guilty_metal_plate",
"accident_sketch",
], ],
example: "damaged_driving_license_front", example: "damaged_driving_license_front",
}, },

View File

@@ -7,6 +7,8 @@ export const FANAVARAN_HULL_DMG_COST_KIND_CAPTION = "مجموع لوازم";
export const FANAVARAN_DEFAULT_HULL_DMG_KIND_ID = 5485; export const FANAVARAN_DEFAULT_HULL_DMG_KIND_ID = 5485;
/** Fallback when lookup fetch fails (`vehicle-hull-dmg-cost-kinds` Id). */ /** Fallback when lookup fetch fails (`vehicle-hull-dmg-cost-kinds` Id). */
export const FANAVARAN_DEFAULT_HULL_DMG_COST_KIND_ID = 1; 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 = { export type FanavaranHullDmgAccessoryRow = {
Id?: unknown; Id?: unknown;

View File

@@ -78,6 +78,9 @@ export {
FANAVARAN_HULL_DMG_KIND_CAPTION, FANAVARAN_HULL_DMG_KIND_CAPTION,
} from "./fanavaran-hull-expertise-lookups"; } 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. */ /** GEN.06 VehicleCurrentValue from GET car/vehicle-hull-policies/{PolicyId} → VehicleValue. */
export function vehicleCurrentValueFromHullPolicyRecord( export function vehicleCurrentValueFromHullPolicyRecord(
policy: Record<string, unknown> | null | undefined, policy: Record<string, unknown> | null | undefined,
@@ -117,7 +120,8 @@ export function toFanavaranHullExpertisePayload(input: {
ComponentReplacementCost: input.componentReplacementCost, ComponentReplacementCost: input.componentReplacementCost,
WasteValue: input.wasteValue, WasteValue: input.wasteValue,
CarryAndRescueCost: null, CarryAndRescueCost: null,
RepairDuration: input.repairDuration ?? null, RepairDuration:
input.repairDuration ?? FANAVARAN_DEFAULT_HULL_REPAIR_DURATION,
VehicleCurrentValue: input.vehicleCurrentValue, VehicleCurrentValue: input.vehicleCurrentValue,
WreckHighestValue: null, WreckHighestValue: null,
InspectionDeduction: null, InspectionDeduction: null,

View File

@@ -373,6 +373,7 @@ Returns status of each item (uploaded/captured or not).
"guilty_car_card_front", "guilty_car_card_front",
"guilty_car_card_back", "guilty_car_card_back",
"guilty_metal_plate", "guilty_metal_plate",
"accident_sketch",
], ],
example: "damaged_driving_license_front", example: "damaged_driving_license_front",
}, },

View File

@@ -614,9 +614,24 @@ export class ExpertBlameService {
)) as Record<string, unknown>[]; )) as Record<string, unknown>[];
// Scope to this reviewer's insurance company via blame party clientId // Scope to this reviewer's insurance company via blame party clientId
const visibleCases = allSealed.filter((doc) => const tenantVisibleCases = allSealed.filter((doc) =>
blameCaseTouchesClient(doc, clientKey), 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); const pagedResult = await this.paginateBlameListV2(visibleCases, query);
return pagedResult; return pagedResult;

View File

@@ -515,6 +515,9 @@ describe("ExpertClaimService FileReviewer assignment", () => {
], ],
}), }),
}; };
service.fanavaranLocationService = {
assertMakerReviewerBranchCompatible: jest.fn().mockResolvedValue(undefined),
};
service.assertExpertActorOnClaim = jest.fn(); service.assertExpertActorOnClaim = jest.fn();
await expect( await expect(
@@ -531,3 +534,28 @@ describe("ExpertClaimService FileReviewer assignment", () => {
expect(service.assertExpertActorOnClaim).not.toHaveBeenCalled(); 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]);
});
});

View File

@@ -418,14 +418,145 @@ export class ExpertClaimService {
claim: any, claim: any,
actor: any, actor: any,
): Promise<void> { ): Promise<void> {
const blame = await this.loadBlameForClaim(claim);
// FILE_REVIEWER: by the time we reach this method the caller has already // FILE_REVIEWER: by the time we reach this method the caller has already
// verified that actor.sub === blame.assignedFileReviewerId (Phase 2 of // verified that actor.sub === blame.assignedFileReviewerId (Phase 2 of
// assignClaimForReviewV2). The generic tenant-scope check would incorrectly // assignClaimForReviewV2). The generic tenant-scope check would incorrectly
// reject them because initiatedByFieldExpertId belongs to the FileMaker, not // reject them because initiatedByFieldExpertId belongs to the FileMaker, not
// the reviewer. Skip it — the assignment check is the access proof. // the reviewer. Skip it — the assignment check is the access proof.
if ((actor as any).role === RoleEnum.FILE_REVIEWER) return; if ((actor as any).role === RoleEnum.FILE_REVIEWER) {
const blame = await this.loadBlameForClaim(claim); await this.assertActorBranchAccess(claim, actor, blame);
return;
}
assertClaimCaseForExpertActor(claim, actor, blame); assertClaimCaseForExpertActor(claim, actor, blame);
await this.assertActorBranchAccess(claim, actor, blame);
}
private async assertActorBranchAccess(
claim: any,
actor: any,
blame?: any,
): Promise<void> {
const caseBranchId = claim?.branchId
? String(claim.branchId)
: blame?.branchId
? String(blame.branchId)
: undefined;
if (actor?.role === RoleEnum.FILE_REVIEWER) {
await this.fanavaranLocationService.assertMakerReviewerBranchCompatible({
fileMakerId: blame?.initiatedByFieldExpertId
? String(blame.initiatedByFieldExpertId)
: null,
fileReviewerId: String(actor.sub),
caseBranchId,
});
return;
}
if (actor?.role === RoleEnum.FILE_MAKER) {
const actorBranchId = await this.fanavaranLocationService.fileMakerBranchId(
String(actor.sub),
);
if (!actorBranchId || (caseBranchId && caseBranchId !== actorBranchId)) {
throw new ForbiddenException("This file belongs to another branch.");
}
return;
}
if (actor?.role === RoleEnum.FIELD_EXPERT) return;
const damageExpert = await this.damageExpertDbService.findById(
String(actor.sub),
);
const actorBranchId = (damageExpert as any)?.branchId
? String((damageExpert as any).branchId)
: undefined;
if (!actorBranchId) {
throw new ForbiddenException(
"DamageExpert account is not assigned to a branch.",
);
}
// Ordinary user-created legacy claims have no branch snapshot. Preserve
// their existing tenant queue; branch-created claims must match exactly.
if (caseBranchId && caseBranchId !== actorBranchId) {
throw new ForbiddenException("This file belongs to another branch.");
}
}
private async filterDamageExpertClaimsByBranch(
claims: any[],
actor: any,
): Promise<any[]> {
const damageExpert = await this.damageExpertDbService.findById(
String(actor.sub),
);
const actorBranchId = (damageExpert as any)?.branchId
? String((damageExpert as any).branchId)
: undefined;
if (!actorBranchId) {
throw new ForbiddenException(
"DamageExpert account is not assigned to a branch.",
);
}
const missingBranchBlameIds = [
...new Set(
claims
.filter((claim) => !claim?.branchId && claim?.blameRequestId)
.map((claim) => String(claim.blameRequestId)),
),
];
const blames = missingBranchBlameIds.length
? ((await this.blameRequestDbService.find(
{
_id: {
$in: missingBranchBlameIds.map((id) => new Types.ObjectId(id)),
},
},
{
lean: true,
select:
"_id branchId isMadeByFileMaker initiatedByFieldExpertId",
},
)) as any[])
: [];
const blameById = new Map(
blames.map((blame) => [String(blame._id), blame] as const),
);
const makerBranchById = new Map<string, string | undefined>();
await Promise.all(
[
...new Set(
blames
.filter(
(blame) =>
blame.isMadeByFileMaker &&
!blame.branchId &&
blame.initiatedByFieldExpertId,
)
.map((blame) => String(blame.initiatedByFieldExpertId)),
),
].map(async (makerId) => {
makerBranchById.set(
makerId,
await this.fanavaranLocationService.fileMakerBranchId(makerId),
);
}),
);
return claims.filter((claim) => {
let caseBranchId = claim?.branchId ? String(claim.branchId) : undefined;
if (!caseBranchId && claim?.blameRequestId) {
const blame = blameById.get(String(claim.blameRequestId));
caseBranchId = blame?.branchId
? String(blame.branchId)
: blame?.isMadeByFileMaker && blame?.initiatedByFieldExpertId
? makerBranchById.get(String(blame.initiatedByFieldExpertId))
: undefined;
}
return !caseBranchId || caseBranchId === actorBranchId;
});
} }
private async fieldExpertOwnsClaim( private async fieldExpertOwnsClaim(
@@ -2600,6 +2731,15 @@ export class ExpertClaimService {
"This file does not belong to your organization.", "This file does not belong to your organization.",
); );
} }
await this.fanavaranLocationService.assertMakerReviewerBranchCompatible({
fileMakerId: (blame as any)?.initiatedByFieldExpertId
? String((blame as any).initiatedByFieldExpertId)
: null,
fileReviewerId: actor.sub,
caseBranchId: (blame as any)?.branchId
? String((blame as any).branchId)
: null,
});
if ( if (
!(blame as any).isMadeByFileMaker || !(blame as any).isMadeByFileMaker ||
!(blame as any).expertInitiated || !(blame as any).expertInitiated ||
@@ -2732,6 +2872,15 @@ export class ExpertClaimService {
"This file does not belong to your organization.", "This file does not belong to your organization.",
); );
} }
await this.fanavaranLocationService.assertMakerReviewerBranchCompatible({
fileMakerId: (reviewerBlame as any).initiatedByFieldExpertId
? String((reviewerBlame as any).initiatedByFieldExpertId)
: null,
fileReviewerId: actor.sub,
caseBranchId: (reviewerBlame as any).branchId
? String((reviewerBlame as any).branchId)
: null,
});
const assignedReviewerId = (reviewerBlame as any).assignedFileReviewerId const assignedReviewerId = (reviewerBlame as any).assignedFileReviewerId
? String((reviewerBlame as any).assignedFileReviewerId) ? String((reviewerBlame as any).assignedFileReviewerId)
: null; : null;
@@ -4121,9 +4270,13 @@ export class ExpertClaimService {
], ],
}); });
const filtered = (claims as any[]).filter((c) => const tenantFiltered = (claims as any[]).filter((c) =>
claimCaseTouchesClient(c, clientKey), claimCaseTouchesClient(c, clientKey),
); );
const filtered = await this.filterDamageExpertClaimsByBranch(
tenantFiltered,
actor,
);
// Reconcile stale locks — if no decision was made, also clear assignedForReviewBy // Reconcile stale locks — if no decision was made, also clear assignedForReviewBy
const staleLockToReconcile = filtered.filter( const staleLockToReconcile = filtered.filter(
@@ -4389,7 +4542,7 @@ export class ExpertClaimService {
{ {
lean: true, lean: true,
select: select:
"_id type creationMethod parties blameStatus status expert.decision assignedFileReviewerId requiresFileMakerApproval", "_id branchId initiatedByFieldExpertId type creationMethod parties blameStatus status expert.decision assignedFileReviewerId requiresFileMakerApproval",
}, },
)) as any[]; )) as any[];
@@ -4398,9 +4551,31 @@ export class ExpertClaimService {
} }
// Scope to this reviewer's insurer via blame party clientId // Scope to this reviewer's insurer via blame party clientId
const scopedBlames = blames.filter((b) => const tenantScopedBlames = blames.filter((b) =>
blameCaseTouchesClient(b, clientKey), blameCaseTouchesClient(b, clientKey),
); );
const scopedBlames = (
await Promise.all(
tenantScopedBlames.map(async (blame) => {
try {
await this.fanavaranLocationService.assertMakerReviewerBranchCompatible(
{
fileMakerId: blame.initiatedByFieldExpertId
? String(blame.initiatedByFieldExpertId)
: null,
fileReviewerId: actor.sub,
caseBranchId: blame.branchId
? String(blame.branchId)
: null,
},
);
return blame;
} catch {
return null;
}
}),
)
).filter((blame): blame is any => blame != null);
if (scopedBlames.length === 0) { if (scopedBlames.length === 0) {
return this.paginateClaimListV2([], query); return this.paginateClaimListV2([], query);
@@ -4478,10 +4653,25 @@ export class ExpertClaimService {
): Promise<GetClaimListV2ResponseDto> { ): Promise<GetClaimListV2ResponseDto> {
const makerOid = new Types.ObjectId(actor.sub); const makerOid = new Types.ObjectId(actor.sub);
const makerBlames = (await this.blameRequestDbService.find( const ownMakerBlames = (await this.blameRequestDbService.find(
{ isMadeByFileMaker: true, initiatedByFieldExpertId: makerOid }, { isMadeByFileMaker: true, initiatedByFieldExpertId: makerOid },
{ lean: true, select: "_id type creationMethod parties blameStatus status expert.decision assignedFileReviewerId requiresFileMakerApproval" }, { lean: true, select: "_id branchId type creationMethod parties blameStatus status expert.decision assignedFileReviewerId requiresFileMakerApproval" },
)) as any[]; )) as any[];
const hasBranchScopedFiles = ownMakerBlames.some(
(blame) => !!blame.branchId,
);
const makerBranchId = hasBranchScopedFiles
? await this.fanavaranLocationService.fileMakerBranchId(actor.sub)
: undefined;
if (hasBranchScopedFiles && !makerBranchId) {
throw new ForbiddenException(
"FileMaker account is not assigned to a branch.",
);
}
const makerBlames = ownMakerBlames.filter(
(blame) =>
!blame.branchId || String(blame.branchId) === makerBranchId,
);
if (makerBlames.length === 0) { if (makerBlames.length === 0) {
return this.paginateClaimListV2([], query); return this.paginateClaimListV2([], query);
@@ -4920,6 +5110,7 @@ export class ExpertClaimService {
if (actor.role !== RoleEnum.FILE_REVIEWER) { if (actor.role !== RoleEnum.FILE_REVIEWER) {
assertClaimCaseForExpertActor(claim, actor, linkedBlame); assertClaimCaseForExpertActor(claim, actor, linkedBlame);
} }
await this.assertActorBranchAccess(claim, actor, linkedBlame);
// Variables used both in the gate block and in the detail-build section below // Variables used both in the gate block and in the detail-build section below
const isDamageExpertPhase = const isDamageExpertPhase =

View File

@@ -0,0 +1,55 @@
import { ForbiddenException } from "@nestjs/common";
import { FanavaranLocationService } from "./fanavaran-location.service";
describe("FanavaranLocationService branch scope", () => {
const makerId = "maker-1";
const reviewerId = "reviewer-1";
const createService = (makerBranch?: string, reviewerBranch?: string) =>
new FanavaranLocationService(
{
findById: jest.fn().mockResolvedValue(
makerBranch ? { branchId: makerBranch } : {},
),
} as any,
{
findById: jest.fn().mockResolvedValue(
reviewerBranch ? { branchId: reviewerBranch } : {},
),
} as any,
);
it("allows a reviewer to view a file from the same branch", async () => {
const service = createService("branch-1", "branch-1");
await expect(
service.assertMakerReviewerBranchCompatible({
fileMakerId: makerId,
fileReviewerId: reviewerId,
}),
).resolves.toBeUndefined();
});
it("rejects a reviewer from another branch", async () => {
const service = createService("branch-1", "branch-2");
await expect(
service.assertMakerReviewerBranchCompatible({
fileMakerId: makerId,
fileReviewerId: reviewerId,
}),
).rejects.toBeInstanceOf(ForbiddenException);
});
it("prefers the immutable case branch snapshot", async () => {
const service = createService("old-branch", "branch-2");
await expect(
service.assertMakerReviewerBranchCompatible({
fileMakerId: makerId,
fileReviewerId: reviewerId,
caseBranchId: "branch-2",
}),
).resolves.toBeUndefined();
});
});

View File

@@ -113,6 +113,54 @@ export class FanavaranLocationService {
} }
} }
async fileMakerBranchId(
fileMakerId: string | null | undefined,
): Promise<string | undefined> {
if (!fileMakerId) return undefined;
const doc = await this.fileMakerDbService.findById(String(fileMakerId));
const branchId = (doc as any)?.branchId;
return branchId ? String(branchId) : undefined;
}
async fileReviewerBranchId(
fileReviewerId: string | null | undefined,
): Promise<string | undefined> {
if (!fileReviewerId) return undefined;
const doc = await this.fileReviewerDbService.findById(
String(fileReviewerId),
);
const branchId = (doc as any)?.branchId;
return branchId ? String(branchId) : undefined;
}
/**
* V4/V5 case visibility is branch-scoped by the FileMaker who created it.
* Missing branch assignments are denied instead of widening visibility.
*/
async assertMakerReviewerBranchCompatible(input: {
fileMakerId?: string | null;
fileReviewerId?: string | null;
caseBranchId?: string | null;
}): Promise<void> {
const reviewerBranchId = await this.fileReviewerBranchId(
input.fileReviewerId,
);
const caseBranchId =
(input.caseBranchId ? String(input.caseBranchId) : undefined) ??
(await this.fileMakerBranchId(input.fileMakerId));
if (!reviewerBranchId) {
throw new ForbiddenException(
"FileReviewer account is not assigned to a branch.",
);
}
if (!caseBranchId || caseBranchId !== reviewerBranchId) {
throw new ForbiddenException(
"This file belongs to another branch.",
);
}
}
private async loadPrimaryLocationId( private async loadPrimaryLocationId(
userId: string | null | undefined, userId: string | null | undefined,
kind: "maker" | "reviewer", kind: "maker" | "reviewer",

View File

@@ -18,9 +18,22 @@ describe("parseIranLocalDateTime", () => {
expect(instant!.toISOString()).toBe("2025-05-16T14:58:00.000Z"); expect(instant!.toISOString()).toBe("2025-05-16T14:58:00.000Z");
}); });
it("applies a separately supplied time to an explicit-offset date", () => {
const instant = parseIranLocalDateTime(
"2025-05-16T00:00:00.000Z",
"18:28",
);
expect(instant!.toISOString()).toBe("2025-05-16T14:58:00.000Z");
});
it("combines Date calendar day in Iran with accidentTime", () => { it("combines Date calendar day in Iran with accidentTime", () => {
const dateOnly = new Date("2025-05-16T00:00:00.000Z"); const dateOnly = new Date("2025-05-16T00:00:00.000Z");
const instant = parseIranLocalDateTime(dateOnly, "18:28"); const instant = parseIranLocalDateTime(dateOnly, "18:28");
expect(instant!.toISOString()).toBe("2025-05-16T14:58:00.000Z"); expect(instant!.toISOString()).toBe("2025-05-16T14:58:00.000Z");
}); });
it("rejects out-of-range hours and minutes", () => {
expect(parseIranLocalDateTime("2025-05-16", "24:00")).toBeNull();
expect(parseIranLocalDateTime("2025-05-16", "12:60")).toBeNull();
});
}); });

View File

@@ -26,6 +26,7 @@ function normalizeTimeToHms(time: string): string | null {
const hh = m[1].padStart(2, "0"); const hh = m[1].padStart(2, "0");
const mm = m[2]; const mm = m[2];
const ss = m[3] ?? "00"; const ss = m[3] ?? "00";
if (Number(hh) > 23 || Number(mm) > 59 || Number(ss) > 59) return null;
return `${hh}:${mm}:${ss}`; return `${hh}:${mm}:${ss}`;
} }
@@ -56,7 +57,12 @@ export function parseIranLocalDateTime(
if (/[zZ]|[+-]\d{2}:?\d{2}$/.test(raw)) { if (/[zZ]|[+-]\d{2}:?\d{2}$/.test(raw)) {
const d = new Date(raw); const d = new Date(raw);
return Number.isNaN(d.getTime()) ? null : d; if (Number.isNaN(d.getTime())) return null;
const cleanTime = (time ?? "").trim();
if (!cleanTime) return d;
const timeHms = normalizeTimeToHms(cleanTime);
if (!timeHms) return null;
return parseIranLocalIso(gregorianDateInIran(d), timeHms);
} }
const naive = raw.match(NAIVE_ISO_DATETIME_RE); const naive = raw.match(NAIVE_ISO_DATETIME_RE);

View File

@@ -2,6 +2,7 @@ import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
import { buildCoefficientsFromPartSeverities } from "./claim-price-drop"; import { buildCoefficientsFromPartSeverities } from "./claim-price-drop";
import { import {
catalogPartIdFromSelectedPart, catalogPartIdFromSelectedPart,
normalizeDamageSelectedParts,
resolveCatalogPartByPartId, resolveCatalogPartByPartId,
resolvePartForExpertReply, resolvePartForExpertReply,
resolveSelectedPartByPartId, resolveSelectedPartByPartId,
@@ -86,6 +87,31 @@ describe("resolveSelectedPartByPartId", () => {
expect(catalogPartIdFromSelectedPart(row)).toBeNull(); expect(catalogPartIdFromSelectedPart(row)).toBeNull();
}); });
it("preserves a live Fanavaran id that is newer than the static snapshot", () => {
const [row] = normalizeDamageSelectedParts(
[{
id: 96,
name: "96",
side: "",
label_fa: "تیوتر",
catalogKey: "96",
}],
ClaimVehicleTypeV2.SEDAN,
);
expect(row).toEqual({
id: 96,
name: "96",
side: "",
label_fa: "تیوتر",
catalogKey: "96",
});
expect(catalogPartIdFromSelectedPart(row)).toBe(96);
expect(resolvePartForExpertReply(96, [row], ClaimVehicleTypeV2.SEDAN)).toBe(
row,
);
});
it("recovers outer part wrongly stored as internal with Persian label", () => { it("recovers outer part wrongly stored as internal with Persian label", () => {
const fixed = sanitizeDamageSelectedPartV2( const fixed = sanitizeDamageSelectedPartV2(
{ {

View File

@@ -466,6 +466,28 @@ export function sanitizeDamageSelectedPartV2(
} }
} }
// Fanavaran serves this catalog live and can add ids that do not exist in
// our fallback snapshot yet. Live rows are persisted with their numeric id
// repeated as `name`/`catalogKey` and an empty side. Preserve that identity
// instead of downgrading the row to an internal free-text part. Otherwise a
// newly-added Fanavaran part (for example id 96) disappears from the expert
// reply payload and later fails with PART_NOT_ON_CLAIM.
const numericCatalogIdentity =
id != null &&
Number.isFinite(id) &&
Number(id) > 0 &&
sideTrim === "" &&
(nameTrim === String(id) || String(catalogKey ?? "").trim() === String(id));
if (numericCatalogIdentity) {
return {
id: Number(id),
name: String(id),
side: "",
label_fa: labelTrim || nameTrim || String(id),
catalogKey: String(id),
};
}
if (isInternalDamageSide(side)) { if (isInternalDamageSide(side)) {
return { return {
id: null, id: null,

View File

@@ -1,4 +1,11 @@
import { ApiPropertyOptional } from "@nestjs/swagger"; import { ApiPropertyOptional } from "@nestjs/swagger";
import {
ArrayMinSize,
ArrayUnique,
IsArray,
IsInt,
IsOptional,
} from "class-validator";
import { CarDamagePartDto } from "src/claim-request-management/dto/car-part.dto"; import { CarDamagePartDto } from "src/claim-request-management/dto/car-part.dto";
/** /**
@@ -6,6 +13,19 @@ import { CarDamagePartDto } from "src/claim-request-management/dto/car-part.dto"
* All fields optional so expert can submit in one or two steps (e.g. car parts first, then sheba/other). * All fields optional so expert can submit in one or two steps (e.g. car parts first, then sheba/other).
*/ */
export class ExpertCompleteClaimDataDto { export class ExpertCompleteClaimDataDto {
@ApiPropertyOptional({
description:
"Selected damaged-part IDs from the live Fanavaran car-components catalog.",
example: [9, 10, 30],
type: [Number],
})
@IsOptional()
@IsArray({ message: "selectedPartIds must be an array" })
@ArrayMinSize(1, { message: "At least one part ID must be selected" })
@ArrayUnique({ message: "Duplicate part IDs are not allowed" })
@IsInt({ each: true, message: "Each selected part ID must be an integer" })
selectedPartIds?: number[];
@ApiPropertyOptional({ @ApiPropertyOptional({
description: "Car part damage selection (same as selectCarPartDamage). Required for first claim data step.", description: "Car part damage selection (same as selectCarPartDamage). Required for first claim data step.",
type: CarDamagePartDto, type: CarDamagePartDto,

View File

@@ -94,6 +94,10 @@ export class BlameRequest {
@Prop({ type: Types.ObjectId }) @Prop({ type: Types.ObjectId })
initiatedByFieldExpertId?: Types.ObjectId; initiatedByFieldExpertId?: Types.ObjectId;
/** Branch snapshot of the expert/FileMaker who created this file. */
@Prop({ type: Types.ObjectId, index: true })
branchId?: Types.ObjectId;
/** True when this blame was created by a registrar. */ /** True when this blame was created by a registrar. */
@Prop({ default: false }) @Prop({ default: false })
registrarInitiated?: boolean; registrarInitiated?: boolean;

View File

@@ -38,6 +38,9 @@ describe("RequestManagementService FileReviewer inbox", () => {
(service as any).claimCaseDbService = { (service as any).claimCaseDbService = {
find: jest.fn().mockResolvedValue([]), find: jest.fn().mockResolvedValue([]),
}; };
(service as any).fanavaranLocationService = {
assertMakerReviewerBranchCompatible: jest.fn().mockResolvedValue(undefined),
};
return { service, blameRequestDbService }; return { service, blameRequestDbService };
} }
@@ -89,6 +92,22 @@ describe("RequestManagementService FileReviewer inbox", () => {
expect(result.list).toEqual([]); expect(result.list).toEqual([]);
}); });
it("does not list an open file from another branch", async () => {
const { service } = createService([sealedFile]);
(service as any).fanavaranLocationService
.assertMakerReviewerBranchCompatible.mockRejectedValue(
new Error("This file belongs to another branch."),
);
const result = await service.getMyFileReviewerFiles({
sub: String(reviewerId),
role: RoleEnum.FILE_REVIEWER,
clientKey: String(clientId),
});
expect(result.list).toEqual([]);
});
it("sorts and paginates the reviewer inbox with the shared list contract", async () => { it("sorts and paginates the reviewer inbox with the shared list contract", async () => {
const olderFile = { const olderFile = {
...sealedFile, ...sealedFile,

View File

@@ -159,7 +159,7 @@ describe("RequestManagementService policyholder inquiry routing", () => {
).toHaveBeenCalledWith( ).toHaveBeenCalledWith(
{ {
nationalCode: "0022222222", nationalCode: "0022222222",
chassis: vehicle.vin, chassisNo: vehicle.vin,
}, },
undefined, undefined,
); );

View File

@@ -60,6 +60,7 @@ import { FileMakerDbService } from "src/users/entities/db-service/file-maker.db.
import { FileReviewerDbService } from "src/users/entities/db-service/file-reviewer.db.service"; import { FileReviewerDbService } from "src/users/entities/db-service/file-reviewer.db.service";
import { isOtpExpiryActive } from "src/helpers/user-otp-expiry"; import { isOtpExpiryActive } from "src/helpers/user-otp-expiry";
import { parseIranLocalDateTime } from "src/helpers/iran-datetime"; import { parseIranLocalDateTime } from "src/helpers/iran-datetime";
import { jalaliToGregorianDate } from "src/helpers/date-jalali";
import { import {
applyListQueryV2, applyListQueryV2,
isInListDateRange, isInListDateRange,
@@ -468,7 +469,7 @@ export class RequestManagementService {
const result = await this.sandHubService.getCarByChassisInquiry( const result = await this.sandHubService.getCarByChassisInquiry(
{ {
nationalCode: subjects.thirdPartyPolicyNationalCode, nationalCode: subjects.thirdPartyPolicyNationalCode,
chassis, chassisNo: chassis,
}, },
options, options,
); );
@@ -1125,6 +1126,22 @@ export class RequestManagementService {
private readonly fileReviewerDbService: FileReviewerDbService, private readonly fileReviewerDbService: FileReviewerDbService,
) {} ) {}
async assertFileReviewerBranchAccess(
file: {
initiatedByFieldExpertId?: unknown;
branchId?: unknown;
},
fileReviewerId: string,
): Promise<void> {
await this.fanavaranLocationService.assertMakerReviewerBranchCompatible({
fileMakerId: file?.initiatedByFieldExpertId
? String(file.initiatedByFieldExpertId)
: null,
fileReviewerId,
caseBranchId: file?.branchId ? String(file.branchId) : null,
});
}
/** /**
* Reject CAR_BODY submissions whose accident is older than the per-client * Reject CAR_BODY submissions whose accident is older than the per-client
* window (see `ClientService.getCarBodyAccidentMaxAgeDays`). The check is * window (see `ClientService.getCarBodyAccidentMaxAgeDays`). The check is
@@ -1171,7 +1188,8 @@ export class RequestManagementService {
if (ageMs < 0) { if (ageMs < 0) {
throw new BadRequestException({ throw new BadRequestException({
code: "CAR_BODY_ACCIDENT_DATE_IN_FUTURE", code: "CAR_BODY_ACCIDENT_DATE_IN_FUTURE",
message: "Accident date cannot be in the future.", message: "Accident date and time cannot be in the future.",
messageFa: "تاریخ و ساعت حادثه نمی‌تواند در آینده باشد.",
}); });
} }
@@ -1202,7 +1220,47 @@ export class RequestManagementService {
date: Date | string, date: Date | string,
time?: string, time?: string,
): Date | null { ): Date | null {
return parseIranLocalDateTime(date, time); const normalizedDate =
typeof date === "string" ? jalaliToGregorianDate(date) ?? date : date;
return parseIranLocalDateTime(normalizedDate, time);
}
/**
* Universal write guard for accident timestamps. UI restrictions are only a
* convenience; every endpoint that persists accidentDate/accidentTime must
* reject an invalid or future Iran-local timestamp as well.
*/
private assertAccidentDateTimeNotInFuture(params: {
accidentDate: Date | string | null | undefined;
accidentTime?: string | null;
}): void {
const { accidentDate, accidentTime } = params;
if (accidentDate == null && !accidentTime) return;
if (accidentDate == null || !String(accidentTime ?? "").trim()) {
throw new BadRequestException({
code: "ACCIDENT_DATE_TIME_INVALID",
message: "A valid accident date and time are required together.",
messageFa: "تاریخ و ساعت معتبر حادثه باید با هم وارد شوند.",
});
}
const instant = this.parseAccidentInstant(accidentDate, accidentTime!);
if (!instant || Number.isNaN(instant.getTime())) {
throw new BadRequestException({
code: "ACCIDENT_DATE_TIME_INVALID",
message: "Invalid accident date or time.",
messageFa: "تاریخ یا ساعت حادثه معتبر نیست.",
});
}
if (instant.getTime() > Date.now()) {
throw new BadRequestException({
code: "ACCIDENT_DATE_TIME_IN_FUTURE",
message: "Accident date and time cannot be in the future.",
messageFa: "تاریخ و ساعت حادثه نمی‌تواند در آینده باشد.",
});
}
} }
/** /**
@@ -4106,6 +4164,10 @@ export class RequestManagementService {
// Add CAR_BODY specific fields if type is CAR_BODY // Add CAR_BODY specific fields if type is CAR_BODY
if (request.type === "CAR_BODY") { if (request.type === "CAR_BODY") {
this.assertAccidentDateTimeNotInFuture({
accidentDate: body.accidentDate,
accidentTime: body.accidentTime,
});
if (body.accidentDate) { if (body.accidentDate) {
updatePayload.$set["firstPartyDetails.firstPartyFile.accidentDate"] = updatePayload.$set["firstPartyDetails.firstPartyFile.accidentDate"] =
body.accidentDate; body.accidentDate;
@@ -5378,6 +5440,15 @@ export class RequestManagementService {
throw new ForbiddenException("FileReviewer account not found."); throw new ForbiddenException("FileReviewer account not found.");
} }
assertFileReviewerCanReviewBlameType(fileReviewer, req.type); assertFileReviewerCanReviewBlameType(fileReviewer, req.type);
await this.fanavaranLocationService.assertMakerReviewerBranchCompatible({
fileMakerId: req?.initiatedByFieldExpertId
? String(req.initiatedByFieldExpertId)
: null,
fileReviewerId: String(expert.sub),
caseBranchId: (req as any)?.branchId
? String((req as any).branchId)
: null,
});
if (!assignedId) { if (!assignedId) {
// Atomically claim — ignore if another reviewer won the race (they would have // Atomically claim — ignore if another reviewer won the race (they would have
@@ -5687,12 +5758,19 @@ export class RequestManagementService {
} }
const isFileMakerRole = (expert as any)?.role === RoleEnum.FILE_MAKER; const isFileMakerRole = (expert as any)?.role === RoleEnum.FILE_MAKER;
let expertBranchId: Types.ObjectId | undefined;
if (isFileMakerRole) { if (isFileMakerRole) {
const fileMaker = await this.fileMakerDbService.findById(String(expert.sub)); const fileMaker = await this.fileMakerDbService.findById(String(expert.sub));
if (!fileMaker) { if (!fileMaker) {
throw new ForbiddenException("FileMaker account not found."); throw new ForbiddenException("FileMaker account not found.");
} }
assertFileMakerCanCreateBlameType(fileMaker, type); assertFileMakerCanCreateBlameType(fileMaker, type);
if (!fileMaker.branchId) {
throw new ForbiddenException(
"FileMaker account is not assigned to a branch.",
);
}
expertBranchId = new Types.ObjectId(String(fileMaker.branchId));
} }
const created = await this.blameRequestDbService.create({ const created = await this.blameRequestDbService.create({
publicId, publicId,
@@ -5708,6 +5786,7 @@ export class RequestManagementService {
history: [], history: [],
expertInitiated: true, expertInitiated: true,
initiatedByFieldExpertId: expertId, initiatedByFieldExpertId: expertId,
...(expertBranchId ? { branchId: expertBranchId } : {}),
creationMethod: dto.creationMethod, creationMethod: dto.creationMethod,
filledBy: filledBy:
dto.creationMethod === CreationMethod.IN_PERSON dto.creationMethod === CreationMethod.IN_PERSON
@@ -8553,6 +8632,11 @@ export class RequestManagementService {
); );
} }
this.assertAccidentDateTimeNotInFuture({
accidentDate: formData.firstPartyDescription?.accidentDate,
accidentTime: formData.firstPartyDescription?.accidentTime,
});
try { try {
// Get or create user for first party phone number // Get or create user for first party phone number
const firstPartyUserId = await this.getOrCreateUserByPhoneNumber( const firstPartyUserId = await this.getOrCreateUserByPhoneNumber(
@@ -11693,6 +11777,16 @@ export class RequestManagementService {
const role = this.resolvePartyRoleV3(req, partyRole); const role = this.resolvePartyRoleV3(req, partyRole);
this.assertBlameV3PartyDetailPhase(req, role); this.assertBlameV3PartyDetailPhase(req, role);
// V3/V5 clients can include CAR_BODY accident fields even though this
// endpoint only persists the description. Never accept a future timestamp
// merely because those extra fields are not part of the V3 statement step.
if (body.accidentDate != null || body.accidentTime != null) {
this.assertAccidentDateTimeNotInFuture({
accidentDate: body.accidentDate,
accidentTime: body.accidentTime,
});
}
const idx = this.getPartyIndex(req, role); const idx = this.getPartyIndex(req, role);
if (idx === -1) throw new BadRequestException(`${role} party not found`); if (idx === -1) throw new BadRequestException(`${role} party not found`);
@@ -11751,6 +11845,11 @@ export class RequestManagementService {
} }
} }
this.assertAccidentDateTimeNotInFuture({
accidentDate: body.accidentDate,
accidentTime: body.accidentTime,
});
const idx = this.getPartyIndex(req, role); const idx = this.getPartyIndex(req, role);
if (idx === -1) throw new BadRequestException(`${role} party not found`); if (idx === -1) throw new BadRequestException(`${role} party not found`);
@@ -12958,10 +13057,31 @@ export class RequestManagementService {
throw new ForbiddenException("Only FileMakers can use this endpoint."); throw new ForbiddenException("Only FileMakers can use this endpoint.");
} }
const makerId = new Types.ObjectId(fileMaker.sub); const makerId = new Types.ObjectId(fileMaker.sub);
const files = await this.blameRequestDbService.find({ const ownFiles = await this.blameRequestDbService.find({
isMadeByFileMaker: true, isMadeByFileMaker: true,
initiatedByFieldExpertId: makerId, initiatedByFieldExpertId: makerId,
}); });
const hasBranchScopedFiles = (ownFiles || []).some(
(file: any) => !!file.branchId,
);
let makerBranchId: string | undefined;
if (hasBranchScopedFiles) {
const fileMakerProfile = await this.fileMakerDbService.findById(
String(fileMaker.sub),
);
makerBranchId = fileMakerProfile?.branchId
? String(fileMakerProfile.branchId)
: undefined;
if (!makerBranchId) {
throw new ForbiddenException(
"FileMaker account is not assigned to a branch.",
);
}
}
const files = (ownFiles || []).filter(
(file: any) =>
!file.branchId || String(file.branchId) === makerBranchId,
);
const blameIds = (files || []).map((f: any) => f._id).filter(Boolean); const blameIds = (files || []).map((f: any) => f._id).filter(Boolean);
const claims = const claims =
blameIds.length > 0 blameIds.length > 0
@@ -13021,6 +13141,17 @@ export class RequestManagementService {
} }
const req = await this.blameRequestDbService.findById(requestId); const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Blame request not found"); if (!req) throw new NotFoundException("Blame request not found");
if ((req as any).branchId) {
const fileMakerProfile = await this.fileMakerDbService.findById(
String(fileMaker.sub),
);
const makerBranchId = fileMakerProfile?.branchId
? String(fileMakerProfile.branchId)
: undefined;
if (!makerBranchId || String((req as any).branchId) !== makerBranchId) {
throw new ForbiddenException("This file belongs to another branch.");
}
}
if ( if (
!req.isMadeByFileMaker || !req.isMadeByFileMaker ||
String((req as any).initiatedByFieldExpertId) !== String(fileMaker.sub) String((req as any).initiatedByFieldExpertId) !== String(fileMaker.sub)
@@ -13149,7 +13280,7 @@ export class RequestManagementService {
{ assignedFileReviewerId: reviewerId }, { assignedFileReviewerId: reviewerId },
], ],
}); });
const visibleFiles = (files || []).filter((file: any) => { const tenantAndAssignmentVisible = (files || []).filter((file: any) => {
const assignedReviewerId = file.assignedFileReviewerId const assignedReviewerId = file.assignedFileReviewerId
? String(file.assignedFileReviewerId) ? String(file.assignedFileReviewerId)
: null; : null;
@@ -13163,6 +13294,26 @@ export class RequestManagementService {
(isOpen || isAssignedToReviewer) (isOpen || isAssignedToReviewer)
); );
}); });
const visibleFiles = (
await Promise.all(
tenantAndAssignmentVisible.map(async (file: any) => {
try {
await this.fanavaranLocationService.assertMakerReviewerBranchCompatible(
{
fileMakerId: file.initiatedByFieldExpertId
? String(file.initiatedByFieldExpertId)
: null,
fileReviewerId: String(fileReviewer.sub),
caseBranchId: file.branchId ? String(file.branchId) : null,
},
);
return file;
} catch {
return null;
}
}),
)
).filter((file): file is any => file != null);
const visibleBlameIds = visibleFiles const visibleBlameIds = visibleFiles
.map((f: any) => f._id) .map((f: any) => f._id)
@@ -13230,6 +13381,15 @@ export class RequestManagementService {
"This file does not belong to your organization.", "This file does not belong to your organization.",
); );
} }
await this.fanavaranLocationService.assertMakerReviewerBranchCompatible({
fileMakerId: (req as any).initiatedByFieldExpertId
? String((req as any).initiatedByFieldExpertId)
: null,
fileReviewerId: String(fileReviewer.sub),
caseBranchId: (req as any).branchId
? String((req as any).branchId)
: null,
});
const assignedId = (req as any).assignedFileReviewerId const assignedId = (req as any).assignedFileReviewerId
? String((req as any).assignedFileReviewerId) ? String((req as any).assignedFileReviewerId)
: null; : null;

View File

@@ -191,7 +191,7 @@ describe("SandHubService inquiry mocks", () => {
const result = await service.getCarByChassisInquiry({ const result = await service.getCarByChassisInquiry({
nationalCode: "0012345678", nationalCode: "0012345678",
chassis: "NAAR03HFFRDE07024", chassisNo: "NAAR03HFFRDE07024",
}); });
expect(result.mapped.Error.Message).toBe( expect(result.mapped.Error.Message).toBe(
@@ -242,7 +242,7 @@ describe("SandHubService inquiry mocks", () => {
service.getCarByChassisInquiry( service.getCarByChassisInquiry(
{ {
nationalCode: "1234567890", nationalCode: "1234567890",
chassis: "NAAR03HFFRDE07024", chassisNo: "NAAR03HFFRDE07024",
}, },
{ enforceDeploymentClientMatch: true }, { enforceDeploymentClientMatch: true },
), ),
@@ -258,14 +258,14 @@ describe("SandHubService inquiry mocks", () => {
await service.getCarByChassisInquiry({ await service.getCarByChassisInquiry({
nationalCode: "0012345678", nationalCode: "0012345678",
chassis: "NAAR03HFFRDE07024", chassisNo: "NAAR03HFFRDE07024",
}); });
expect(esg).toHaveBeenCalledWith( expect(esg).toHaveBeenCalledWith(
expect.stringContaining("/inquiry/carByChassis"), expect.stringContaining("/inquiry/carByChassis"),
{ {
nationalCode: "0012345678", nationalCode: "0012345678",
chassis: "NAAR03HFFRDE07024", chassisNo: "NAAR03HFFRDE07024",
}, },
"vinChassis", "vinChassis",
undefined, undefined,

View File

@@ -1197,14 +1197,14 @@ export class SandHubService {
* @param options - optional per-tenant client scope * @param options - optional per-tenant client scope
*/ */
async getCarByChassisInquiry( async getCarByChassisInquiry(
identity: { nationalCode: string; chassis: string }, identity: { nationalCode: string; chassisNo: string },
options?: SandHubInquiryOptions, options?: SandHubInquiryOptions,
): Promise<{ raw: any; mapped: any }> { ): Promise<{ raw: any; mapped: any }> {
const baseUrl = process.env.ESG_URL ?? "http://192.168.20.22:8085"; const baseUrl = process.env.ESG_URL ?? "http://192.168.20.22:8085";
const requestUrl = `${baseUrl}/inquiry/carByChassis`; const requestUrl = `${baseUrl}/inquiry/carByChassis`;
const requestPayload = { const requestPayload = {
nationalCode: String(identity.nationalCode), nationalCode: String(identity.nationalCode),
chassis: String(identity.chassis), chassisNo: String(identity.chassisNo),
}; };
const live = await this.isInquiryLive("vinChassis", options); const live = await this.isInquiryLive("vinChassis", options);
@@ -1213,7 +1213,7 @@ export class SandHubService {
const ctx = await this.mockCompanyContext(options); const ctx = await this.mockCompanyContext(options);
const raw = this.buildMockPlateInquiryRaw(ctx); const raw = this.buildMockPlateInquiryRaw(ctx);
this.logger.debug( this.logger.debug(
`[MOCK] getCarByChassisInquiry nationalCode=${identity.nationalCode} chassis=${identity.chassis}`, `[MOCK] getCarByChassisInquiry nationalCode=${identity.nationalCode} chassisNo=${identity.chassisNo}`,
); );
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw, "thirdPartyVin"); const mapped = this.mapEsgPolicyByPlateToOldFormat(raw, "thirdPartyVin");
if (!mapped?.Error) { if (!mapped?.Error) {