fix: harden claim review and inquiry workflows

Preserve damage history and current vehicle price, restore depreciation mapping, normalize inquiry/report output, and support resumable expert review with paginated case retrieval.
This commit is contained in:
2026-09-18 16:04:33 +03:30
parent a84d83a135
commit 7d1a50db7b
38 changed files with 2345 additions and 370 deletions

View File

@@ -0,0 +1,72 @@
import {
buildDamagedPartSelectionRevision,
serializeDamagedPartSelectionHistory,
} from "./claim-damaged-part-audit";
describe("damaged-part selection audit", () => {
const hood = {
id: 101,
name: "hood",
side: "front",
label_fa: "کاپوت",
catalogKey: "front_hood",
};
const door = {
id: 202,
name: "door",
side: "left",
label_fa: "درب چپ",
catalogKey: "left_door",
};
it("keeps the removed part capture and serializes it for panel display", () => {
const revision = buildDamagedPartSelectionRevision({
revisionId: "revision-1",
changedAt: new Date("2026-09-18T10:00:00.000Z"),
changedBy: {
actorId: "expert-1",
actorName: "Expert One",
actorType: "damage_expert",
},
previousParts: [hood, door],
selectedParts: [door],
previousMedia: [
{ path: "claims/hood.jpg", fileName: "hood.jpg" },
{ path: "claims/door.jpg", fileName: "door.jpg" },
],
});
expect(revision?.removedParts).toEqual([
expect.objectContaining({
id: 101,
capture: expect.objectContaining({ path: "claims/hood.jpg" }),
}),
]);
const serialized = serializeDamagedPartSelectionHistory({
history: [revision],
currentSelectedParts: [door],
});
expect(serialized[0].removedParts[0]).toEqual(
expect.objectContaining({
id: 101,
captured: true,
currentlySelected: false,
url: expect.stringContaining("claims/hood.jpg"),
}),
);
});
it("does not create a revision for an unchanged or order-only submission", () => {
expect(
buildDamagedPartSelectionRevision({
revisionId: "revision-2",
changedAt: new Date(),
changedBy: { actorId: "expert-1", actorType: "damage_expert" },
previousParts: [hood, door],
selectedParts: [door, hood],
previousMedia: [],
}),
).toBeNull();
});
});

View File

@@ -0,0 +1,170 @@
import { resolveStoredFileUrl } from "src/helpers/urlCreator";
import {
coerceDamagedPartsMediaToArray,
partLookupKey,
type DamageSelectedPartV2,
} from "src/helpers/outer-damage-parts";
export interface DamagedPartAuditActor {
actorId: string;
actorName?: string;
actorType: string;
}
export interface StoredDamagedPartAuditRow extends DamageSelectedPartV2 {
capture?: {
path?: string;
fileName?: string;
url?: string;
capturedAt?: Date | string;
};
}
export interface StoredDamagedPartSelectionRevision {
revisionId: string;
changedAt: Date;
changedBy: DamagedPartAuditActor;
expertProfileSnapshot?: unknown;
previousSelectedParts: DamageSelectedPartV2[];
selectedParts: DamageSelectedPartV2[];
removedParts: StoredDamagedPartAuditRow[];
addedParts: StoredDamagedPartAuditRow[];
}
export interface DamagedPartSelectionHistoryRow {
revisionId: string;
changedAt: Date | string;
changedBy: DamagedPartAuditActor;
expertProfileSnapshot?: unknown;
removedParts: Array<
DamageSelectedPartV2 & {
partId: number | null;
captured: boolean;
url?: string;
fileName?: string;
capturedAt?: Date | string;
currentlySelected: boolean;
}
>;
addedParts: Array<
DamageSelectedPartV2 & {
partId: number | null;
currentlySelected: boolean;
}
>;
}
function plainCapture(row: unknown): StoredDamagedPartAuditRow["capture"] {
if (!row || typeof row !== "object") return undefined;
const source = row as Record<string, unknown>;
const capture = {
...(source.path ? { path: String(source.path) } : {}),
...(source.fileName ? { fileName: String(source.fileName) } : {}),
...(source.url ? { url: String(source.url) } : {}),
...(source.capturedAt
? { capturedAt: source.capturedAt as Date | string }
: {}),
};
return Object.keys(capture).length > 0 ? capture : undefined;
}
/**
* Builds one immutable audit revision before the live selected-parts/media arrays
* are replaced. Returns null for a semantic no-op (including order-only changes).
*/
export function buildDamagedPartSelectionRevision(params: {
revisionId: string;
changedAt: Date;
changedBy: DamagedPartAuditActor;
expertProfileSnapshot?: unknown;
previousParts: DamageSelectedPartV2[];
selectedParts: DamageSelectedPartV2[];
previousMedia: unknown;
}): StoredDamagedPartSelectionRevision | null {
const previousKeys = new Set(params.previousParts.map(partLookupKey));
const selectedKeys = new Set(params.selectedParts.map(partLookupKey));
const mediaRows = coerceDamagedPartsMediaToArray(
params.previousMedia,
params.previousParts,
);
const removedParts = params.previousParts.flatMap((part, index) => {
if (selectedKeys.has(partLookupKey(part))) return [];
const capture = plainCapture(mediaRows[index]);
return [{ ...part, ...(capture ? { capture } : {}) }];
});
const addedParts = params.selectedParts
.filter((part) => !previousKeys.has(partLookupKey(part)))
.map((part) => ({ ...part }));
if (removedParts.length === 0 && addedParts.length === 0) return null;
return {
revisionId: params.revisionId,
changedAt: params.changedAt,
changedBy: params.changedBy,
...(params.expertProfileSnapshot
? { expertProfileSnapshot: params.expertProfileSnapshot }
: {}),
previousSelectedParts: params.previousParts.map((part) => ({ ...part })),
selectedParts: params.selectedParts.map((part) => ({ ...part })),
removedParts,
addedParts,
};
}
/** Converts stored revisions into a stable, URL-enriched panel contract. */
export function serializeDamagedPartSelectionHistory(params: {
history: unknown;
currentSelectedParts: DamageSelectedPartV2[];
}): DamagedPartSelectionHistoryRow[] {
if (!Array.isArray(params.history)) return [];
const currentKeys = new Set(params.currentSelectedParts.map(partLookupKey));
return params.history
.filter((revision) => revision && typeof revision === "object")
.map((revision: any) => ({
revisionId: String(revision.revisionId ?? ""),
changedAt: revision.changedAt,
changedBy: {
actorId: String(revision.changedBy?.actorId ?? ""),
actorName: revision.changedBy?.actorName,
actorType: String(revision.changedBy?.actorType ?? "damage_expert"),
},
...(revision.expertProfileSnapshot
? { expertProfileSnapshot: revision.expertProfileSnapshot }
: {}),
removedParts: (Array.isArray(revision.removedParts)
? revision.removedParts
: []
).map((part: StoredDamagedPartAuditRow) => ({
id: part.id ?? null,
partId: part.id ?? null,
name: part.name,
side: part.side,
label_fa: part.label_fa,
...(part.catalogKey ? { catalogKey: part.catalogKey } : {}),
captured: !!part.capture,
...(part.capture?.fileName ? { fileName: part.capture.fileName } : {}),
...(part.capture?.capturedAt
? { capturedAt: part.capture.capturedAt }
: {}),
...(resolveStoredFileUrl(part.capture)
? { url: resolveStoredFileUrl(part.capture) }
: {}),
currentlySelected: currentKeys.has(partLookupKey(part)),
})),
addedParts: (Array.isArray(revision.addedParts)
? revision.addedParts
: []
).map((part: DamageSelectedPartV2) => ({
...part,
partId: part.id ?? null,
currentlySelected: currentKeys.has(partLookupKey(part)),
})),
}))
.filter(
(revision) =>
revision.removedParts.length > 0 || revision.addedParts.length > 0,
);
}

View File

@@ -110,12 +110,42 @@ for (const [seg, key] of Object.entries(SEGMENT_ALIASES)) {
NORM_TO_PART_KEY.set(seg, key);
}
const PERSIAN_LABEL_ALIASES: Array<[string, string]> = [
["گلگیر عقب", "backFender"],
["درب عقب", "backDoor"],
["درب جلو", "frontDoor"],
["گلگیر جلو", "frontFender"],
["سپر جلو", "frontBumper"],
["سپر عقب", "frontBumper"],
["درب موتور", "Hood"],
["کاپوت", "Hood"],
["درب صندوق", "Trunk"],
["صندوق عقب", "Trunk"],
["سقف", "Roof"],
["کلاف", "coil"],
["ستون", "column"],
["سینی جلو", "frontTray"],
["سینی عقب", "backTray"],
["شاسی جلو", "frontChassis"],
["شاسی عقب", "backChassis"],
["رکاب", "carFootrest"],
["کف اتاق", "carFloor"],
];
export function normalizePriceDropKey(str: string): string {
return String(str ?? "")
.toLowerCase()
.replace(/[^a-z0-9]/g, "");
}
function normalizePersianLabel(str: string): string {
return String(str ?? "")
.replace(/[يى]/g, "ی")
.replace(/ك/g, "ک")
.replace(/[\u200c\s()\-_]/g, "")
.trim();
}
export function parsePriceDropNumber(input: number | string): number {
if (typeof input === "number" && Number.isFinite(input)) return input;
return Number(
@@ -191,6 +221,7 @@ export function buildPriceDropCatalogForApi(): Array<{
export function resolvePriceDropPartKeyFromDamagePart(part: {
name?: string;
catalogKey?: string;
label_fa?: string;
}): string | null {
const candidates: string[] = [];
if (part.catalogKey) {
@@ -205,6 +236,11 @@ export function resolvePriceDropPartKeyFromDamagePart(part: {
const hit = NORM_TO_PART_KEY.get(norm);
if (hit && PRICE_DROP_PART_TABLE[hit]) return hit;
}
const normalizedLabel = normalizePersianLabel(part.label_fa ?? part.name ?? "");
for (const [label, key] of PERSIAN_LABEL_ALIASES) {
if (normalizedLabel.includes(normalizePersianLabel(label))) return key;
}
return null;
}

View File

@@ -128,21 +128,21 @@ describe("getExpertReplyPricingValidationError", () => {
it.each([
["price", "99,999", "parts[0].price"],
["salary", "10,000,000,001", "parts[0].salary"],
["salary", "100,000,000,001", "parts[0].salary"],
["totalPayment", "99,999", "parts[0].totalPayment"],
["daghi.price", "10,000,000,001", "parts[0].daghi.price"],
["daghi.price", "100,000,000,001", "parts[0].daghi.price"],
])(
"enforces the amount range for %s",
(field, invalidValue, expectedField) => {
const part = {
partId: 201,
typeOfDamage: TypeOfDamage.Change,
price: "100000",
salary: "100000",
totalPayment: "100000",
price: "1000000",
salary: "1000000",
totalPayment: "1000000",
daghi: {
option: DaghiOption.RECYCLED_PARTS_VALUE,
price: "100000",
price: "1000000",
},
};
if (field === "daghi.price") part.daghi.price = invalidValue;

View File

@@ -43,36 +43,36 @@ describe("resolveSelectedPartByPartId", () => {
});
it("resolves catalog id from outer catalog when not on claim", () => {
const hit = resolveCatalogPartByPartId(201, ClaimVehicleTypeV2.HATCHBACK);
expect(hit?.id).toBe(201);
expect(hit?.side).toBe("left");
expect(hit?.catalogKey).toBe("left_backfender");
const hit = resolveCatalogPartByPartId(36, ClaimVehicleTypeV2.HATCHBACK);
expect(hit?.id).toBe(36);
expect(hit?.side).toBe("");
expect(hit?.catalogKey).toBe("36");
});
it("resolvePartForExpertReply uses catalog for new expert line", () => {
const hit = resolvePartForExpertReply(
201,
36,
[],
ClaimVehicleTypeV2.HATCHBACK,
);
expect(hit?.id).toBe(201);
expect(hit?.id).toBe(36);
});
it("sanitize fixes Persian side and re-hydrates from catalog id", () => {
const fixed = sanitizeDamageSelectedPartV2(
{
id: 201,
name: "گلگیر عقب (چپ)",
id: 36,
name: "گلگير عقب سمت راننده",
side: "چپ",
label_fa: "",
catalogKey: "چپ",
},
ClaimVehicleTypeV2.HATCHBACK,
);
expect(fixed.side).toBe("left");
expect(fixed.name).toBe("backfender");
expect(fixed.catalogKey).toBe("left_backfender");
expect(catalogPartIdFromSelectedPart(fixed)).toBe(201);
expect(fixed.side).toBe("");
expect(fixed.name).toBe("36");
expect(fixed.catalogKey).toBe("36");
expect(catalogPartIdFromSelectedPart(fixed)).toBe(36);
});
it("internal parts have null catalog partId", () => {
@@ -90,30 +90,30 @@ describe("resolveSelectedPartByPartId", () => {
const fixed = sanitizeDamageSelectedPartV2(
{
id: null,
name: "گلگیر عقب (چپ)",
name: "گلگير عقب سمت راننده",
side: "internal",
label_fa: "گلگیر عقب (چپ)",
label_fa: "گلگير عقب سمت راننده",
},
ClaimVehicleTypeV2.HATCHBACK,
);
expect(fixed.id).toBe(201);
expect(fixed.side).toBe("left");
expect(fixed.name).toBe("backfender");
expect(fixed.catalogKey).toBe("left_backfender");
expect(fixed.id).toBe(36);
expect(fixed.side).toBe("");
expect(fixed.name).toBe("36");
expect(fixed.catalogKey).toBe("36");
});
it("price drop resolves catalog id when claim row is corrupt", () => {
const corrupt: DamageSelectedPartV2[] = [
{
id: null,
name: "گلگیر عقب (چپ)",
name: "گلگير عقب سمت راننده",
side: "internal",
label_fa: "گلگیر عقب (چپ)",
label_fa: "گلگير عقب سمت راننده",
},
];
const { coefficients, errors } = buildCoefficientsFromPartSeverities(
corrupt,
[{ partId: 201, severity: "Minor" }],
[{ partId: 36, severity: "Minor" }],
ClaimVehicleTypeV2.HATCHBACK,
);
expect(errors).toHaveLength(0);