Merge pull request 'YARA-1177' (#235) from s.yahyaee/yara724-api:main into main

Reviewed-on: Yara724/api#235
This commit is contained in:
2026-07-31 19:50:03 +03:30
9 changed files with 1100 additions and 303 deletions

View File

@@ -38,6 +38,7 @@ import { ClaimCaseDbService } from "./entites/db-service/claim-case.db.service";
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
import { CreateClaimFromBlameResponseDto } from "./dto/create-claim-v2.dto";
import {
OuterPartCatalogItemDto,
SelectOuterPartsV2Dto,
SelectOuterPartsV2ResponseDto,
} from "./dto/select-outer-parts-v2.dto";
@@ -140,7 +141,7 @@ import {
} from "src/helpers/claim-car-angle-media";
import {
ClaimVehicleTypeV2,
OUTER_PARTS_BY_CAR_TYPE,
FANAVARAN_CAR_PARTS_CATALOG,
OuterPartCatalogItem,
} from "src/static/outer-car-parts-catalog";
import {
@@ -563,67 +564,53 @@ export class ClaimRequestManagementService {
/**
* Outer-parts catalog returned to clients (user app + expert panel).
*
* The shape intentionally mirrors how items are persisted under
* `damage.selectedParts` so the front-end can match catalog rows to the
* stored selection by `id` / `catalogKey` without renaming fields:
*
* { id, name, side, label_fa, catalogKey, carType }
*
* - `name` is side-agnostic (`backWheel`, not `left_backWheel`).
* - `label_fa` is disambiguated with the side in parentheses when multiple
* catalog rows share the same Farsi title (e.g. `چرخ عقب (چپ)`).
* - `catalogKey` keeps the original full key (`left_backWheel`) for clients
* that already address parts by it.
* Fetches the live `car/base-info/car-components` lookup from Fanavaran so
* the list always matches what Fanavaran accepts in DmgSections[].DmgSectionId.
* The lookup service handles caching to disk; `carType` is ignored because
* Fanavaran uses a single flat catalog across all vehicle types.
*/
getOuterPartsCatalogV2(
carType?: ClaimVehicleTypeV2,
): (DamageSelectedPartV2 & { carType: ClaimVehicleTypeV2 })[] {
const buildForType = (
type: ClaimVehicleTypeV2,
items: OuterPartCatalogItem[],
): (DamageSelectedPartV2 & { carType: ClaimVehicleTypeV2 })[] =>
items.map((p) => ({
...catalogItemToSelectedPart(p, items),
carType: type,
}));
if (carType) {
const items = OUTER_PARTS_BY_CAR_TYPE[carType] || [];
return buildForType(carType, items);
}
const out: (DamageSelectedPartV2 & { carType: ClaimVehicleTypeV2 })[] = [];
for (const [type, items] of Object.entries(OUTER_PARTS_BY_CAR_TYPE)) {
out.push(...buildForType(type as ClaimVehicleTypeV2, items));
}
return out.sort((a, b) => {
if (a.carType === b.carType) return (a.id ?? 0) - (b.id ?? 0);
return String(a.carType).localeCompare(String(b.carType));
});
async getOuterPartsCatalogV2(): Promise<OuterPartCatalogItemDto[]> {
const clientKey = resolveFanavaranClientKey();
const rows = await this.getFanavaranLookupRows(clientKey, "car-components");
return rows
.filter(
(r): r is { Id: number; Caption: string } =>
!!r &&
typeof r === "object" &&
typeof (r as any).Id === "number" &&
typeof (r as any).Caption === "string",
)
.map((r) => ({ id: r.Id, label_fa: r.Caption }));
}
/**
* Internal raw catalog (kept in the original `OuterPartCatalogItem` shape so
* existing internal code that indexes by `key`/`titleFa` keeps working).
* Public API consumers should use `getOuterPartsCatalogV2` instead.
* Resolve the live Fanavaran car-components catalog as `OuterPartCatalogItem`
* rows for internal use (e.g. validating selectedPartIds).
* Falls back to the static snapshot when the remote lookup is unavailable.
*/
private getOuterPartsRawCatalog(
carType?: ClaimVehicleTypeV2,
): OuterPartCatalogItem[] {
if (carType) {
return (OUTER_PARTS_BY_CAR_TYPE[carType] || []).map((p) => ({
...p,
carType,
private async getLiveFanavaranCatalogItems(): Promise<OuterPartCatalogItem[]> {
const clientKey = resolveFanavaranClientKey();
const rows = await this.getFanavaranLookupRows(clientKey, "car-components");
if (!rows.length) {
this.logger.warn(
"getLiveFanavaranCatalogItems: remote lookup returned empty, falling back to snapshot",
);
return FANAVARAN_CAR_PARTS_CATALOG;
}
return rows
.filter(
(r): r is { Id: number; Caption: string } =>
!!r &&
typeof r === "object" &&
typeof (r as any).Id === "number" &&
typeof (r as any).Caption === "string",
)
.map((r): OuterPartCatalogItem => ({
id: r.Id,
key: String(r.Id),
titleFa: r.Caption,
side: "",
}));
}
const out: OuterPartCatalogItem[] = [];
for (const [type, items] of Object.entries(OUTER_PARTS_BY_CAR_TYPE)) {
const t = type as ClaimVehicleTypeV2;
for (const p of items) {
out.push({ ...p, carType: t });
}
}
return out;
}
private userDamageDetail(blRequest: RequestManagementModel) {
@@ -7628,22 +7615,14 @@ export class ClaimRequestManagementService {
);
}
// 5. Validate by selected car type and resolve selected parts by ids/keys
// 5. Fetch the live Fanavaran catalog and resolve selected parts by ids
const selectedCarType = (body as any)?.carType as
| ClaimVehicleTypeV2
| undefined;
if (!selectedCarType || !OUTER_PARTS_BY_CAR_TYPE[selectedCarType]) {
throw new BadRequestException(
"Vehicle type is required before selecting outer parts.",
);
}
const catalog = OUTER_PARTS_BY_CAR_TYPE[selectedCarType];
const liveCatalog = await this.getLiveFanavaranCatalogItems();
const byId = new Map<number, OuterPartCatalogItem>(
catalog.map((p) => [p.id, p]),
);
const byKey = new Map<string, OuterPartCatalogItem>(
catalog.map((p) => [p.key, p]),
liveCatalog.map((p) => [p.id, p]),
);
const selectedFromIds: OuterPartCatalogItem[] = [];
@@ -7655,43 +7634,20 @@ export class ClaimRequestManagementService {
const item = byId.get(id);
if (!item) {
throw new BadRequestException(
`Invalid outer part id for ${selectedCarType}: ${id}`,
`Invalid outer part id: ${id}. Use GET outer-parts-catalog to see valid IDs.`,
);
}
selectedFromIds.push(item);
}
}
// Backward compatibility with selectedParts + legacy carPartDamage
let selectedParts = Array.isArray((body as any)?.selectedParts)
? ((body as any).selectedParts as string[])
: [];
if (selectedParts.length === 0 && (body as any)?.carPartDamage) {
this.assertCarPartDamageAtMostTwoOfFourSides(
(body as any).carPartDamage as CarDamagePartDto,
if (selectedFromIds.length === 0) {
throw new BadRequestException(
"selectedPartIds is required and must contain at least one valid Fanavaran part ID",
);
selectedParts = this.carDamagePartDtoToOuterPartSlugs(
(body as any).carPartDamage as CarDamagePartDto,
);
}
const selectedFromKeys: OuterPartCatalogItem[] = [];
for (const key of selectedParts) {
const item = byKey.get(key);
if (!item) {
throw new BadRequestException(
`Invalid outer part key for ${selectedCarType}: ${key}`,
);
}
selectedFromKeys.push(item);
}
const selectedItems =
selectedFromIds.length > 0 ? selectedFromIds : selectedFromKeys;
if (selectedItems.length === 0) {
throw new BadRequestException(
"selectedPartIds or selectedParts is required and must contain at least one item",
);
}
const selectedItems = selectedFromIds;
// DISABLED: At most two non-top sides allowed
// const sideSet = new Set(
@@ -7706,7 +7662,7 @@ export class ClaimRequestManagementService {
// }
const selectedPartDocs = selectedItems.map((p) =>
catalogItemToSelectedPart(p, catalog),
catalogItemToSelectedPart(p, liveCatalog),
);
const damagedPartsInitial = selectedPartDocs.map((p) => ({
id: p.id ?? undefined,
@@ -8061,18 +8017,12 @@ export class ClaimRequestManagementService {
"Only the claim owner can view capture requirements",
);
// Build car-type aware outer-parts lookup (complete source: static catalog).
// Uses the raw catalog shape (`{id, key, titleFa, side, ...}`) because the
// map below is keyed on the full `key` (e.g. `left_backfender`).
const selectedCarType = claimCase.vehicle?.carType as
| ClaimVehicleTypeV2
| undefined;
const catalogForType =
selectedCarType && OUTER_PARTS_BY_CAR_TYPE[selectedCarType]
? OUTER_PARTS_BY_CAR_TYPE[selectedCarType]
: this.getOuterPartsRawCatalog();
// Build outer-parts lookup for resolving stored DB parts.
// Uses the static snapshot (FANAVARAN_CAR_PARTS_CATALOG) which is keyed
// by numeric id (key === String(id)). This is only needed for legacy
// migration of parts stored before the Fanavaran catalog change.
const catalogByKey = new Map<string, OuterPartCatalogItem>();
for (const item of catalogForType) {
for (const item of FANAVARAN_CAR_PARTS_CATALOG) {
if (!catalogByKey.has(item.key)) catalogByKey.set(item.key, item);
}

View File

@@ -61,7 +61,6 @@ import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto";
import { UserObjectionV2Dto } from "./dto/user-objection-v2.dto";
import { UserRatingDto } from "./dto/user-rating.dto";
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
@ApiTags("claim-request-management (v2)")
@Controller("v2/claim-request-management")
@@ -452,17 +451,15 @@ export class ClaimRequestManagementV2Controller {
@ApiOperation({
summary: "Get outer parts catalog (V2)",
description:
"Returns outer-damage parts with id/key/side. Optional `carType` filter returns only that type catalog.",
"Returns the Fanavaran car-components list. All vehicle types share the same catalog.",
})
@ApiResponse({
status: 200,
description: "Outer parts catalog",
type: [OuterPartCatalogItemDto],
})
async getOuterPartsCatalog(
@Query("carType") carType?: ClaimVehicleTypeV2,
): Promise<OuterPartCatalogItemDto[]> {
return this.claimRequestManagementService.getOuterPartsCatalogV2(carType);
async getOuterPartsCatalog(): Promise<OuterPartCatalogItemDto[]> {
return await this.claimRequestManagementService.getOuterPartsCatalogV2();
}
@Get("branches/:insuranceId")

View File

@@ -8,69 +8,20 @@ import {
IsOptional,
IsInt,
} from "class-validator";
import {
ClaimVehicleTypeV2,
OuterPartSideV2,
} from "src/static/outer-car-parts-catalog";
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
import { DamageSelectedPartV2BodyDto } from "./damage-selected-part-v2.dto";
/**
* Enum for valid outer car parts that can be damaged
* Follows the naming convention from workflow step definition
*/
export enum OuterCarPart {
// Hood
HOOD = "hood",
// Doors
FRONT_RIGHT_DOOR = "front_right_door",
FRONT_LEFT_DOOR = "front_left_door",
REAR_RIGHT_DOOR = "rear_right_door",
REAR_LEFT_DOOR = "rear_left_door",
// Bumpers
FRONT_BUMPER = "front_bumper",
REAR_BUMPER = "rear_bumper",
// Fenders
FRONT_RIGHT_FENDER = "front_right_fender",
FRONT_LEFT_FENDER = "front_left_fender",
REAR_RIGHT_FENDER = "rear_right_fender",
REAR_LEFT_FENDER = "rear_left_fender",
// Trunk & Roof
TRUNK = "trunk",
ROOF = "roof",
}
/**
* V2 DTO for selecting damaged outer car parts
* Much cleaner than the nested boolean structure
* V2 DTO for selecting damaged outer car parts.
* Submit `selectedPartIds` with IDs from the `GET outer-parts-catalog` response.
*/
export class SelectOuterPartsV2Dto {
@ApiProperty({
description: "Array of selected damaged outer car parts",
example: ["hood", "front_right_door", "rear_bumper", "roof"],
enum: OuterCarPart,
isArray: true,
minItems: 1,
maxItems: 13,
})
@IsOptional()
@IsArray({ message: "selectedParts must be an array" })
@ArrayMinSize(1, { message: "At least one damaged part must be selected" })
@ArrayUnique({ message: "Duplicate parts are not allowed" })
@IsEnum(OuterCarPart, {
each: true,
message: "Invalid part name. Must be one of the valid outer car parts",
})
selectedParts?: OuterCarPart[];
@ApiProperty({
description: "Selected outer part IDs from catalog",
example: [9, 10, 4],
@ApiPropertyOptional({
description:
"Selected outer part IDs from the Fanavaran car-components catalog. " +
"IDs must match values returned by GET outer-parts-catalog.",
example: [9, 10, 30],
type: [Number],
required: false,
})
@IsOptional()
@IsArray({ message: "selectedPartIds must be an array" })
@@ -79,12 +30,13 @@ export class SelectOuterPartsV2Dto {
@IsInt({ each: true, message: "Each selected part ID must be an integer" })
selectedPartIds?: number[];
@ApiProperty({
description: "Vehicle type for validating available outer parts",
@ApiPropertyOptional({
description:
"Vehicle type (sedan, suv, hatchback, pickup, van). Optional — stored for context only; " +
"all vehicle types share the same Fanavaran parts catalog.",
enum: ClaimVehicleTypeV2,
required: true,
})
@IsNotEmpty()
@IsOptional()
@IsEnum(ClaimVehicleTypeV2)
carType?: ClaimVehicleTypeV2;
}
@@ -156,53 +108,21 @@ export class SetClaimVehicleTypeV2Dto {
}
/**
* Shape returned by `GET .../outer-parts-catalog` (both user and expert
* controllers). It mirrors how items are persisted under
* `damage.selectedParts` (see `DamageSelectedPartV2BodyDto`) so the front-end
* can match catalog rows to stored selections without any field renaming:
* `name` is side-agnostic, `label_fa` is disambiguated with the side in
* parentheses, and the original full catalog key (`left_backfender`) is
* exposed as `catalogKey`.
* Shape returned by `GET .../outer-parts-catalog`.
* Sourced live from the Fanavaran `car/base-info/car-components` lookup so that
* the list always matches what Fanavaran accepts in `DmgSections[].DmgSectionId`.
*/
export class OuterPartCatalogItemDto {
@ApiProperty({
description: "Static catalog id (unique across all car types)",
example: 102,
description:
"Fanavaran DmgSectionId — use this value when submitting selectedPartIds",
example: 30,
})
id: number;
@ApiProperty({
description: "Side-agnostic part name (matches stored part `name`)",
example: "backWheel",
})
name: string;
@ApiProperty({
description: "Vehicle side / region",
enum: OuterPartSideV2,
example: "left",
})
side: string;
@ApiProperty({
description:
"Display label in Farsi, with side disambiguator in parentheses when needed",
example: "چرخ عقب (چپ)",
description: "Display label in Farsi (Caption from Fanavaran)",
example: "درب جلو سمت راننده",
})
label_fa: string;
@ApiProperty({
description: "Original full catalog key (matches stored `catalogKey`)",
example: "left_backWheel",
required: false,
})
catalogKey?: string;
@ApiProperty({
description: "Vehicle type this catalog row belongs to",
enum: ClaimVehicleTypeV2,
required: false,
example: "suv",
})
carType?: ClaimVehicleTypeV2;
}

View File

@@ -10,7 +10,6 @@ import {
Patch,
Post,
Put,
Query,
UploadedFile,
UseGuards,
UseInterceptors,
@@ -33,7 +32,6 @@ import { CurrentUser } from "src/decorators/user.decorator";
import { MediaPolicyService } from "src/media-policy/media-policy.service";
import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
import { ClaimRequestManagementService } from "./claim-request-management.service";
import {
OuterPartCatalogItemDto,
@@ -101,15 +99,15 @@ export class ExpertInitiatedClaimMirrorController {
@ApiOperation({
summary: "Get outer parts catalog (V2)",
description:
"Returns outer-damage parts with id/key/side. Optional `carType` filter returns only that type catalog.",
"Returns the Fanavaran car-components list. All vehicle types share the same catalog.",
})
@ApiResponse({
status: 200,
description: "Outer parts catalog",
type: [OuterPartCatalogItemDto],
})
async getOuterPartsCatalog(@Query("carType") carType?: ClaimVehicleTypeV2) {
return this.claimRequestManagementService.getOuterPartsCatalogV2(carType);
async getOuterPartsCatalog() {
return await this.claimRequestManagementService.getOuterPartsCatalogV2();
}
@Get("car-other-part")

View File

@@ -7,7 +7,6 @@ import {
Param,
Patch,
Post,
Query,
UploadedFile,
UseGuards,
UseInterceptors,
@@ -30,7 +29,6 @@ import { CurrentUser } from "src/decorators/user.decorator";
import { MediaPolicyService } from "src/media-policy/media-policy.service";
import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
import { ClaimRequestManagementService } from "./claim-request-management.service";
import {
OuterPartCatalogItemDto,
@@ -96,15 +94,15 @@ export class RegistrarClaimMirrorController {
@ApiOperation({
summary: "Get outer parts catalog (V2)",
description:
"Returns outer-damage parts with id/key/side. Optional `carType` filter returns only that type catalog.",
"Returns the Fanavaran car-components list. All vehicle types share the same catalog.",
})
@ApiResponse({
status: 200,
description: "Outer parts catalog",
type: [OuterPartCatalogItemDto],
})
async getOuterPartsCatalog(@Query("carType") carType?: ClaimVehicleTypeV2) {
return this.claimRequestManagementService.getOuterPartsCatalogV2(carType);
async getOuterPartsCatalog() {
return await this.claimRequestManagementService.getOuterPartsCatalogV2();
}
@Get("car-other-part")