forked from Yara724/api
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:
72
src/helpers/claim-damaged-part-audit.spec.ts
Normal file
72
src/helpers/claim-damaged-part-audit.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
170
src/helpers/claim-damaged-part-audit.ts
Normal file
170
src/helpers/claim-damaged-part-audit.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user