This commit is contained in:
SepehrYahyaee
2026-05-18 10:06:14 +03:30
parent f0ba8949cb
commit 7ff3e9fd10
6 changed files with 113 additions and 58 deletions

View File

@@ -93,6 +93,11 @@ export class ClaimDetailV2ResponseDto {
type: 'object', type: 'object',
properties: { properties: {
index: { type: 'number' }, index: { type: 'number' },
partId: {
type: 'string',
description:
'Send this in PUT reply/submit parts[] — stable id (`id:{catalogId}` or `{side}|{name}`)',
},
id: { type: 'number', nullable: true }, id: { type: 'number', nullable: true },
name: { type: 'string' }, name: { type: 'string' },
side: { type: 'string' }, side: { type: 'string' },
@@ -104,6 +109,7 @@ export class ClaimDetailV2ResponseDto {
}) })
damagedParts?: Array<{ damagedParts?: Array<{
index: number; index: number;
partId: string;
id?: number | null; id?: number | null;
name: string; name: string;
side: string; side: string;

View File

@@ -7,59 +7,12 @@ import {
IsOptional, IsOptional,
ValidateNested, ValidateNested,
IsEnum, IsEnum,
IsInt,
} from 'class-validator'; } from 'class-validator';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum'; import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum';
import { DamagedPartItem } from 'src/claim-request-management/dto/capture-requirements-v2.dto'; import { DamagedPartItem } from 'src/claim-request-management/dto/capture-requirements-v2.dto';
import { DaghiOption } from 'src/Types&Enums/claim-request-management/daghi-option.enum'; import { DaghiOption } from 'src/Types&Enums/claim-request-management/daghi-option.enum';
/**
* Damage line part reference — same unified shape as `damage.selectedParts` / catalog rows.
* Send either `{ name, side, label_fa?, id?, catalogKey? }` or legacy `{ part, side }`
* (e.g. part `backFender`, side `left`); the API persists canonical `{ id, name, side, label_fa, catalogKey? }`.
*/
export class ExpertReplyCarPartDamageV2Dto {
@ApiPropertyOptional({ description: 'Catalog id when known' })
@IsOptional()
@IsInt()
id?: number;
@ApiPropertyOptional({
description: 'Side-agnostic catalog segment, e.g. backfender',
example: 'backfender',
})
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional({ example: 'left' })
@IsOptional()
@IsString()
side?: string;
@ApiPropertyOptional({
description:
'Farsi label; optional when server can resolve from catalog + vehicle.carType',
})
@IsOptional()
@IsString()
label_fa?: string;
@ApiPropertyOptional({ example: 'left_backfender' })
@IsOptional()
@IsString()
catalogKey?: string;
@ApiPropertyOptional({
description: 'Legacy expert UI: camelCase segment with `side`, e.g. backFender',
example: 'backFender',
})
@IsOptional()
@IsString()
part?: string;
}
/** Same shape as V1 {@link PartsList} `daghi` (damage expert reply). */ /** Same shape as V1 {@link PartsList} `daghi` (damage expert reply). */
export class DaghiDetailsV2Dto { export class DaghiDetailsV2Dto {
@ApiProperty({ @ApiProperty({
@@ -86,16 +39,15 @@ export class DaghiDetailsV2Dto {
} }
export class PartPricingV2Dto { export class PartPricingV2Dto {
@ApiProperty({ example: 'part-001' }) @ApiProperty({
example: '12',
description:
'Stable part id from GET claim detail `damagedParts[].partId` or `selectedParts` identity (`id:{catalogId}` or `{side}|{name}`). Required.',
})
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
partId: string; partId: string;
@ApiProperty({ type: ExpertReplyCarPartDamageV2Dto })
@ValidateNested()
@Type(() => ExpertReplyCarPartDamageV2Dto)
carPartDamage: ExpertReplyCarPartDamageV2Dto;
@ApiProperty({ example: 'Minor' }) @ApiProperty({ example: 'Minor' })
@IsString() @IsString()
typeOfDamage: string; typeOfDamage: string;
@@ -135,7 +87,7 @@ export class SubmitExpertReplyV2Dto {
@ApiProperty({ @ApiProperty({
type: [PartPricingV2Dto], type: [PartPricingV2Dto],
description: description:
"Each part: manual pricing and `daghi`; set `factorNeeded` only where a repair factor upload is required from the owner.", "One line per damaged part (`partId` from claim detail `damagedParts[]`), plus pricing, `daghi`, and `factorNeeded`.",
}) })
@IsArray() @IsArray()
@ValidateNested({ each: true }) @ValidateNested({ each: true })

View File

@@ -84,6 +84,7 @@ import {
normalizeDamageSelectedParts, normalizeDamageSelectedParts,
partIdentityKey, partIdentityKey,
partIdentityKeyFromCarPartDamage, partIdentityKeyFromCarPartDamage,
resolveSelectedPartByPartId,
} from "src/helpers/outer-damage-parts"; } from "src/helpers/outer-damage-parts";
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog"; import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
import { snapshotFromDamageExpert } from "src/helpers/expert-profile-snapshot"; import { snapshotFromDamageExpert } from "src/helpers/expert-profile-snapshot";
@@ -2698,13 +2699,29 @@ export class ExpertClaimService {
const processedParts = daghiNormalized.map((p) => { const processedParts = daghiNormalized.map((p) => {
let carPartDamage: Record<string, unknown>; let carPartDamage: Record<string, unknown>;
try { try {
const selected = resolveSelectedPartByPartId(
p.partId,
ownerSelectedParts,
);
if (!selected) {
throw new BadRequestException(
`Unknown partId "${p.partId}". Use partId from claim detail damagedParts (after editing parts on the claim).`,
);
}
carPartDamage = normalizeCarPartDamageForExpertReply( carPartDamage = normalizeCarPartDamageForExpertReply(
p.carPartDamage as unknown, {
id: selected.id,
name: selected.name,
side: selected.side,
label_fa: selected.label_fa,
...(selected.catalogKey ? { catalogKey: selected.catalogKey } : {}),
},
carTypeSubmit, carTypeSubmit,
); );
} catch (err) { } catch (err) {
if (err instanceof BadRequestException) throw err;
throw new BadRequestException( throw new BadRequestException(
`Invalid carPartDamage for part ${p.partId}: ${ `Invalid part ${p.partId}: ${
err instanceof Error ? err.message : String(err) err instanceof Error ? err.message : String(err)
}`, }`,
); );
@@ -2713,7 +2730,7 @@ export class ExpertClaimService {
const identityKey = partIdentityKeyFromCarPartDamage(carPartDamage); const identityKey = partIdentityKeyFromCarPartDamage(carPartDamage);
if (!identityKey) { if (!identityKey) {
throw new BadRequestException( throw new BadRequestException(
`Invalid carPartDamage for part ${p.partId}: cannot derive a stable identity (need a catalog id or both name and side).`, `Invalid part ${p.partId}: cannot derive a stable identity (need a catalog id or both name and side).`,
); );
} }
@@ -3537,6 +3554,7 @@ export class ExpertClaimService {
) as { url?: string; path?: string } | undefined; ) as { url?: string; path?: string } | undefined;
return { return {
index, index,
partId: partIdentityKey(sp),
id: sp.id, id: sp.id,
name: sp.name, name: sp.name,
side: sp.side, side: sp.side,

View File

@@ -132,7 +132,7 @@ export class ExpertClaimV2Controller {
@ApiOperation({ @ApiOperation({
summary: "Submit expert damage assessment reply", summary: "Submit expert damage assessment reply",
description: description:
"**Preconditions:** claim locked by this expert (`EXPERT_REVIEWING`). **Unlocks** the claim. Each part needs `daghi` (V1 rules) and may set `factorNeeded` (repair factor file required from owner) or priced lines only. **Cap:** sum of line `totalPayment` values ≤ 53,000,000 (same limit as factor-validation totals across priced + factor lines). Clears any prior `evaluation.ownerInsurerApproval` / `ownerPricedPartsApproval`.\n\n" + "**Preconditions:** claim locked by this expert (`EXPERT_REVIEWING`). **Unlocks** the claim. Each `parts[]` line needs `partId` (from GET claim detail `damagedParts[].partId`), plus pricing, `daghi`, and optional `factorNeeded`. **Cap:** sum of line `totalPayment` values ≤ 53,000,000 (same limit as factor-validation totals across priced + factor lines). Clears any prior `evaluation.ownerInsurerApproval` / `ownerPricedPartsApproval`.\n\n" +
"**Frontend routing by `ClaimCaseStatus` (`status`):**\n" + "**Frontend routing by `ClaimCaseStatus` (`status`):**\n" +
"- **All parts `factorNeeded`:** `OWNER_REPAIR_FACTOR_UPLOAD_PENDING`, `claimStatus=NEEDS_REVISION`, `workflow.currentStep=OWNER_UPLOAD_FACTOR_DOCUMENTS`, `workflow.nextStep=EXPERT_COST_EVALUATION` → owner uploads all factors; then `status` becomes **`EXPERT_VALIDATING_REPAIR_FACTORS`**, `claimStatus=UNDER_REVIEW`, `currentStep=EXPERT_COST_EVALUATION` for expert **validate-factors**.\n" + "- **All parts `factorNeeded`:** `OWNER_REPAIR_FACTOR_UPLOAD_PENDING`, `claimStatus=NEEDS_REVISION`, `workflow.currentStep=OWNER_UPLOAD_FACTOR_DOCUMENTS`, `workflow.nextStep=EXPERT_COST_EVALUATION` → owner uploads all factors; then `status` becomes **`EXPERT_VALIDATING_REPAIR_FACTORS`**, `claimStatus=UNDER_REVIEW`, `currentStep=EXPERT_COST_EVALUATION` for expert **validate-factors**.\n" +
"- **Mixed (some priced, some factorNeeded):** `INSURER_REVIEW_MIXED_FACTORS_PENDING`, `claimStatus=NEEDS_REVISION`, `currentStep=INSURER_REVIEW`, `nextStep=OWNER_UPLOAD_FACTOR_DOCUMENTS` → owner must call **owner-insurer-approval/sign** first (priced-line acceptance); `currentStep` then moves to `OWNER_UPLOAD_FACTOR_DOCUMENTS` (same case `status` until factors are done).\n" + "- **Mixed (some priced, some factorNeeded):** `INSURER_REVIEW_MIXED_FACTORS_PENDING`, `claimStatus=NEEDS_REVISION`, `currentStep=INSURER_REVIEW`, `nextStep=OWNER_UPLOAD_FACTOR_DOCUMENTS` → owner must call **owner-insurer-approval/sign** first (priced-line acceptance); `currentStep` then moves to `OWNER_UPLOAD_FACTOR_DOCUMENTS` (same case `status` until factors are done).\n" +

View File

@@ -0,0 +1,37 @@
import {
partIdentityKey,
resolveSelectedPartByPartId,
type DamageSelectedPartV2,
} from "./outer-damage-parts";
describe("resolveSelectedPartByPartId", () => {
const selected: DamageSelectedPartV2[] = [
{
id: 12,
name: "backfender",
side: "left",
label_fa: "گلگیر",
catalogKey: "left_backfender",
},
{
id: null,
name: "hood",
side: "front",
label_fa: "کاپوت",
},
];
it("resolves by partIdentityKey", () => {
expect(
resolveSelectedPartByPartId(partIdentityKey(selected[0]), selected),
).toBe(selected[0]);
});
it("resolves by id:N catalog prefix", () => {
expect(resolveSelectedPartByPartId("id:12", selected)).toBe(selected[0]);
});
it("resolves by index:N", () => {
expect(resolveSelectedPartByPartId("index:1", selected)).toBe(selected[1]);
});
});

View File

@@ -298,6 +298,48 @@ export function partIdentityKey(p: DamageSelectedPartV2): string {
return `${p.side}|${p.name}`; return `${p.side}|${p.name}`;
} }
/**
* Resolve a client `partId` from claim detail / expert reply to a selected part row.
* Accepts `partIdentityKey` values (`id:12`, `left|backfender`), `id:12`, catalog id
* when unique, or `index:N` for the Nth entry in `selectedParts`.
*/
export function resolveSelectedPartByPartId(
partId: string,
selected: DamageSelectedPartV2[],
): DamageSelectedPartV2 | undefined {
const raw = String(partId ?? "").trim();
if (!raw || !selected.length) return undefined;
for (const p of selected) {
if (partIdentityKey(p) === raw) return p;
}
const idColon = raw.match(/^id:(\d+)$/i);
if (idColon) {
const id = Number(idColon[1]);
return selected.find((p) => p.id === id);
}
const indexColon = raw.match(/^index:(\d+)$/i);
if (indexColon) {
const i = Number(indexColon[1]);
if (Number.isInteger(i) && i >= 0 && i < selected.length) {
return selected[i];
}
}
if (/^\d+$/.test(raw)) {
const n = Number(raw);
const byCatalogId = selected.filter((p) => p.id === n);
if (byCatalogId.length === 1) return byCatalogId[0];
if (Number.isInteger(n) && n >= 0 && n < selected.length) {
return selected[n];
}
}
return undefined;
}
/** /**
* Same canonical identity as `partIdentityKey`, but derived directly from a * Same canonical identity as `partIdentityKey`, but derived directly from a
* unified `carPartDamage` snapshot (the shape we persist on * unified `carPartDamage` snapshot (the shape we persist on