forked from Yara724/api
Compare commits
18 Commits
5e4897f609
...
70160543a2
| Author | SHA1 | Date | |
|---|---|---|---|
| 70160543a2 | |||
|
|
8460c86820 | ||
| 61f4181065 | |||
|
|
4058cb4a61 | ||
| b2ad43d050 | |||
|
|
9fc4ad9931 | ||
| 213cdd765b | |||
|
|
06d69aa4d0 | ||
| 318a5c74dd | |||
|
|
7241ae3270 | ||
| 1f4a520145 | |||
|
|
59a1b9064e | ||
| 0420eda35f | |||
|
|
482d2b01f1 | ||
|
|
04d7966776 | ||
| b129c1ef9b | |||
|
|
a1fca82cb2 | ||
|
|
5b114c2069 |
@@ -2543,10 +2543,28 @@ export class ClaimRequestManagementService {
|
||||
return mapped;
|
||||
};
|
||||
|
||||
// Resolve objection invoice paths to downloadable URLs (user-side view).
|
||||
const objectionRaw = evaluation.objection;
|
||||
let objection: any = objectionRaw
|
||||
? typeof objectionRaw?.toObject === "function"
|
||||
? objectionRaw.toObject()
|
||||
: { ...objectionRaw }
|
||||
: undefined;
|
||||
if (objection && Array.isArray(objection.invoices) && objection.invoices.length > 0) {
|
||||
objection = {
|
||||
...objection,
|
||||
invoices: objection.invoices.map((inv: any) => ({
|
||||
...inv,
|
||||
url: inv.path ? buildFileLink(inv.path) : undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...evaluation,
|
||||
damageExpertReply: await mapReply(evaluation.damageExpertReply),
|
||||
damageExpertReplyFinal: await mapReply(evaluation.damageExpertReplyFinal),
|
||||
...(objection !== undefined ? { objection } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6587,11 +6605,11 @@ export class ClaimRequestManagementService {
|
||||
}
|
||||
|
||||
// 2. Validate blame status is COMPLETED
|
||||
if (blameRequest.status !== BlameCaseStatus.COMPLETED) {
|
||||
throw new BadRequestException(
|
||||
`Blame request must be COMPLETED. Current status: ${blameRequest.status}`,
|
||||
);
|
||||
}
|
||||
// if (blameRequest.status !== BlameCaseStatus.COMPLETED) {
|
||||
// throw new BadRequestException(
|
||||
// `Blame request must be COMPLETED. Current status: ${blameRequest.status}`,
|
||||
// );
|
||||
// }
|
||||
|
||||
const parties = blameRequest.parties || [];
|
||||
const isCarBody = blameRequest.type === BlameRequestType.CAR_BODY;
|
||||
@@ -6792,11 +6810,11 @@ export class ClaimRequestManagementService {
|
||||
if (!blameRequest) {
|
||||
throw new NotFoundException("Blame request not found");
|
||||
}
|
||||
if (blameRequest.status !== BlameCaseStatus.COMPLETED) {
|
||||
throw new BadRequestException(
|
||||
`Blame request must be COMPLETED. Current status: ${blameRequest.status}`,
|
||||
);
|
||||
}
|
||||
// if (blameRequest.status !== BlameCaseStatus.COMPLETED) {
|
||||
// throw new BadRequestException(
|
||||
// `Blame request must be COMPLETED. Current status: ${blameRequest.status}`,
|
||||
// );
|
||||
// }
|
||||
if (
|
||||
!blameRequest.expertInitiated ||
|
||||
blameRequest.creationMethod !== "IN_PERSON" ||
|
||||
@@ -9012,6 +9030,7 @@ export class ClaimRequestManagementService {
|
||||
body: UserObjectionV2Dto,
|
||||
currentUserId: string,
|
||||
actor?: { sub: string; role?: string },
|
||||
invoiceFiles?: Express.Multer.File[],
|
||||
) {
|
||||
try {
|
||||
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
||||
@@ -9176,10 +9195,18 @@ export class ClaimRequestManagementService {
|
||||
}
|
||||
}
|
||||
|
||||
const invoices = (invoiceFiles ?? []).map((f) => ({
|
||||
fileId: new Types.ObjectId(),
|
||||
path: f.path,
|
||||
fileName: f.filename,
|
||||
uploadedAt: new Date(),
|
||||
}));
|
||||
|
||||
const objectionPayload = {
|
||||
objectionParts,
|
||||
newParts,
|
||||
submittedAt: new Date(),
|
||||
...(invoices.length > 0 && { invoices }),
|
||||
};
|
||||
|
||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||
@@ -9211,6 +9238,7 @@ export class ClaimRequestManagementService {
|
||||
metadata: {
|
||||
objectionPartCount: objectionParts.length,
|
||||
newPartCount: newParts.length,
|
||||
invoiceCount: invoices.length,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -10598,42 +10626,46 @@ export class ClaimRequestManagementService {
|
||||
actor,
|
||||
);
|
||||
|
||||
// V4/V5 (in-person FileReviewer flow): car-capture is the last step.
|
||||
// Both variants land at WAITING_FOR_DAMAGE_EXPERT here.
|
||||
// For V5 (requiresFileMakerApproval=true), autoSubmitToFanavaranV2OnClaimCompleted
|
||||
// intercepts COMPLETED → WAITING_FOR_FILE_MAKER_APPROVAL after expert review.
|
||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||
$set: {
|
||||
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
|
||||
claimStatus: ClaimStatus.PENDING,
|
||||
"workflow.currentStep": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
|
||||
"workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
|
||||
},
|
||||
$push: {
|
||||
"workflow.completedSteps": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
|
||||
history: {
|
||||
type: "STEP_COMPLETED",
|
||||
actor: {
|
||||
actorId: new Types.ObjectId(currentUserId),
|
||||
actorName: claimCase.owner?.fullName || "User",
|
||||
actorType: actor?.role === RoleEnum.FILE_REVIEWER ? "file_reviewer" : "user",
|
||||
},
|
||||
timestamp: new Date(),
|
||||
metadata: {
|
||||
stepKey: ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
|
||||
description: "V4/V5 walk-around video uploaded. Claim ready for damage expert review.",
|
||||
v3InPersonFlow: true,
|
||||
// V4/V5 only (isMadeByFileMaker): car-capture is the FINAL FileReviewer step.
|
||||
// For the v3 mirror (FIELD_EXPERT IN_PERSON), car-capture is penultimate —
|
||||
// expertUploadBlameVideoV3 finalises the blame with the accident video next.
|
||||
if ((blame as any).isMadeByFileMaker) {
|
||||
// Both V4 and V5 land at WAITING_FOR_DAMAGE_EXPERT here.
|
||||
// For V5 (requiresFileMakerApproval=true), autoSubmitToFanavaranV2OnClaimCompleted
|
||||
// intercepts COMPLETED → WAITING_FOR_FILE_MAKER_APPROVAL after expert review.
|
||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||
$set: {
|
||||
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
|
||||
claimStatus: ClaimStatus.PENDING,
|
||||
"workflow.currentStep": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
|
||||
"workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
|
||||
},
|
||||
$push: {
|
||||
"workflow.completedSteps": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
|
||||
history: {
|
||||
type: "STEP_COMPLETED",
|
||||
actor: {
|
||||
actorId: new Types.ObjectId(currentUserId),
|
||||
actorName: claimCase.owner?.fullName || "User",
|
||||
actorType: actor?.role === RoleEnum.FILE_REVIEWER ? "file_reviewer" : "user",
|
||||
},
|
||||
timestamp: new Date(),
|
||||
metadata: {
|
||||
stepKey: ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
|
||||
description: "V4/V5 walk-around video uploaded. Claim ready for damage expert review.",
|
||||
v3InPersonFlow: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Mark the linked blame file as COMPLETED.
|
||||
if (claimCase.blameRequestId) {
|
||||
await this.blameRequestDbService.findByIdAndUpdate(
|
||||
String(claimCase.blameRequestId),
|
||||
{ $set: { status: BlameCaseStatus.COMPLETED } },
|
||||
);
|
||||
// Mark the linked blame file as COMPLETED.
|
||||
if (claimCase.blameRequestId) {
|
||||
await this.blameRequestDbService.findByIdAndUpdate(
|
||||
String(claimCase.blameRequestId),
|
||||
{ $set: { status: BlameCaseStatus.COMPLETED } },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Get,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
UploadedFiles,
|
||||
} from "@nestjs/common";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import {
|
||||
@@ -24,7 +25,7 @@ import {
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
} from "@nestjs/swagger";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { FileInterceptor, FilesInterceptor } from "@nestjs/platform-express";
|
||||
import { diskStorage } from "multer";
|
||||
import { extname } from "node:path";
|
||||
import { Types } from "mongoose";
|
||||
@@ -188,8 +189,10 @@ export class ClaimRequestManagementV2Controller {
|
||||
|
||||
/**
|
||||
* V2: User objection after expert resend (same intent as v1 PUT …/request/resend/:id/objection).
|
||||
* Accepts multipart/form-data so optional supporting invoices can be attached in the same request.
|
||||
*/
|
||||
@Put("request/:claimRequestId/objection")
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary: "Submit user objection (V2)",
|
||||
description:
|
||||
@@ -197,14 +200,38 @@ export class ClaimRequestManagementV2Controller {
|
||||
"(2) **Legacy resend:** active expert resend (`WAITING_FOR_USER_RESEND` @ `USER_EXPERT_RESEND`).\n\n" +
|
||||
"`objectionParts` may only reference **priced** repair lines (`factorNeeded=false`). Factor-only lines cannot be disputed until they have expert pricing.\n\n" +
|
||||
"After **`damageExpertReplyFinal`** exists (final reply following a prior objection), **no second objection** — owner uses **owner-insurer-approval/sign** to accept/reject and close the case.\n\n" +
|
||||
"Stores `evaluation.objection`, clears partial/final owner approval fields, merges `newParts` into `damage.selectedParts`, returns case to `WAITING_FOR_DAMAGE_EXPERT`.",
|
||||
"Stores `evaluation.objection`, clears partial/final owner approval fields, merges `newParts` into `damage.selectedParts`, returns case to `WAITING_FOR_DAMAGE_EXPERT`.\n\n" +
|
||||
"**Invoices:** optionally attach up to 5 supporting documents (images/PDFs) as `invoices` file fields. Stored in `evaluation.objection.invoices[]` and visible to the reviewing expert.",
|
||||
})
|
||||
@ApiParam({
|
||||
name: "claimRequestId",
|
||||
description: "The claim case ID (MongoDB ObjectId)",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
})
|
||||
@ApiBody({ type: UserObjectionV2Dto })
|
||||
@ApiBody({
|
||||
description:
|
||||
"Objection payload as multipart form fields. `objectionParts` and `newParts` are JSON-encoded strings.",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
objectionParts: {
|
||||
type: "string",
|
||||
description:
|
||||
'JSON-encoded array of disputed priced parts. Example: `[{"partId":201,"reason":"Price too high"}]`',
|
||||
},
|
||||
newParts: {
|
||||
type: "string",
|
||||
description:
|
||||
'JSON-encoded array of new parts to add. Example: `[{"partName":"سپر جلو","side":"front"}]`',
|
||||
},
|
||||
invoices: {
|
||||
type: "array",
|
||||
items: { type: "string", format: "binary" },
|
||||
description: "Up to 5 supporting invoice or document files (image or PDF).",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 200, description: "Objection stored" })
|
||||
@ApiResponse({
|
||||
status: 400,
|
||||
@@ -213,17 +240,35 @@ export class ClaimRequestManagementV2Controller {
|
||||
@ApiResponse({ status: 403, description: "Not the claim owner" })
|
||||
@ApiResponse({ status: 404, description: "Claim not found" })
|
||||
@ApiResponse({ status: 409, description: "Objection already submitted" })
|
||||
@UseInterceptors(
|
||||
FilesInterceptor("invoices", 5, {
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
storage: diskStorage({
|
||||
destination: "./files/claim-objection-invoices",
|
||||
filename: (req, file, callback) => {
|
||||
const unique = Date.now() + "-" + Math.round(Math.random() * 1e6);
|
||||
const ex = extname(file.originalname);
|
||||
callback(null, `objection-invoice-${unique}${ex}`);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
async submitUserObjectionV2(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: UserObjectionV2Dto,
|
||||
@CurrentUser() user: any,
|
||||
@UploadedFiles() invoices?: Express.Multer.File[],
|
||||
) {
|
||||
for (const file of invoices ?? []) {
|
||||
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||
}
|
||||
try {
|
||||
return await this.claimRequestManagementService.handleUserObjectionV2(
|
||||
claimRequestId,
|
||||
body,
|
||||
user.sub,
|
||||
user,
|
||||
invoices,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { Type, Transform } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
IsOptional,
|
||||
@@ -10,23 +10,52 @@ import { HasObjectionEntriesConstraint } from "src/common/validators/has-objecti
|
||||
import { NewPartDto, UserObjectionPartDto } from "./user-objection.dto";
|
||||
|
||||
/**
|
||||
* V2 user objection body — same shape as v1 {@link UserObjectionDto}
|
||||
* with nested validation enabled for the v2 controller pipeline.
|
||||
* V2 user objection body — submitted as multipart/form-data.
|
||||
* `objectionParts` and `newParts` are JSON-encoded strings in the form fields.
|
||||
* Optional `invoices` files are uploaded as a `invoices` file array.
|
||||
*/
|
||||
export class UserObjectionV2Dto {
|
||||
@ApiPropertyOptional({ type: [UserObjectionPartDto] })
|
||||
@ApiPropertyOptional({
|
||||
type: [UserObjectionPartDto],
|
||||
description:
|
||||
"JSON-encoded array of disputed priced parts. Pass as a JSON string in the multipart field.",
|
||||
})
|
||||
@Validate(HasObjectionEntriesConstraint)
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => UserObjectionPartDto)
|
||||
@Transform(({ value }) => {
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
})
|
||||
objectionParts?: UserObjectionPartDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: [NewPartDto] })
|
||||
@ApiPropertyOptional({
|
||||
type: [NewPartDto],
|
||||
description:
|
||||
"JSON-encoded array of new parts to add. Pass as a JSON string in the multipart field.",
|
||||
})
|
||||
@Validate(HasObjectionEntriesConstraint)
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => NewPartDto)
|
||||
@Transform(({ value }) => {
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
})
|
||||
newParts?: NewPartDto[];
|
||||
}
|
||||
|
||||
@@ -180,6 +180,27 @@ export class ClaimUserObjectionNewPart {
|
||||
export const ClaimUserObjectionNewPartSchema =
|
||||
SchemaFactory.createForClass(ClaimUserObjectionNewPart);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Objection invoice (supporting document uploaded alongside the objection)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class ClaimObjectionInvoice {
|
||||
@Prop({ type: Types.ObjectId, default: () => new Types.ObjectId() })
|
||||
fileId: Types.ObjectId;
|
||||
|
||||
@Prop({ type: String, required: true })
|
||||
path: string;
|
||||
|
||||
@Prop({ type: String, required: true })
|
||||
fileName: string;
|
||||
|
||||
@Prop({ type: Date, default: () => new Date() })
|
||||
uploadedAt: Date;
|
||||
}
|
||||
export const ClaimObjectionInvoiceSchema =
|
||||
SchemaFactory.createForClass(ClaimObjectionInvoice);
|
||||
|
||||
/**
|
||||
* Full user objection payload (matches v1 DTO: objectionParts + newParts).
|
||||
* Stored on {@link ClaimEvaluation.objection}.
|
||||
@@ -194,6 +215,10 @@ export class ClaimUserObjectionPayload {
|
||||
|
||||
@Prop({ type: Date, default: () => new Date() })
|
||||
submittedAt?: Date;
|
||||
|
||||
/** Optional supporting invoices uploaded at objection time. */
|
||||
@Prop({ type: [ClaimObjectionInvoiceSchema], default: [] })
|
||||
invoices?: ClaimObjectionInvoice[];
|
||||
}
|
||||
export const ClaimUserObjectionPayloadSchema =
|
||||
SchemaFactory.createForClass(ClaimUserObjectionPayload);
|
||||
|
||||
@@ -372,6 +372,21 @@ export class ExpertClaimService {
|
||||
await enrichApproval("ownerInsurerApproval");
|
||||
await enrichApproval("ownerPricedPartsApproval");
|
||||
|
||||
// Resolve objection invoice paths to downloadable URLs.
|
||||
const objection = ev.objection as Record<string, unknown> | undefined;
|
||||
if (objection) {
|
||||
const invoices = objection.invoices as Array<Record<string, unknown>> | undefined;
|
||||
if (Array.isArray(invoices) && invoices.length > 0) {
|
||||
ev.objection = {
|
||||
...objection,
|
||||
invoices: invoices.map((inv) => ({
|
||||
...inv,
|
||||
url: inv.path ? buildFileLink(inv.path as string) : undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return ev;
|
||||
}
|
||||
|
||||
@@ -4998,6 +5013,10 @@ export class ExpertClaimService {
|
||||
if (enrichedEvaluation.priceDrop !== undefined) {
|
||||
evaluationForApi.priceDrop = enrichedEvaluation.priceDrop;
|
||||
}
|
||||
// objection: include when present so experts can see the disputed parts and any attached invoices.
|
||||
if (enrichedEvaluation.objection !== undefined) {
|
||||
evaluationForApi.objection = enrichedEvaluation.objection;
|
||||
}
|
||||
if (Object.keys(evaluationForApi).length === 0) {
|
||||
evaluationForApi = undefined;
|
||||
}
|
||||
|
||||
@@ -240,6 +240,23 @@ export class ExpertInsurerController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get("files/:publicId/timeline")
|
||||
@ApiParam({ name: "publicId" })
|
||||
@ApiOperation({
|
||||
summary: "Activity timeline for a case",
|
||||
description:
|
||||
"Returns a chronological list of all history events for the blame and/or claim associated with the given publicId. Each event has: source, type, timestamp, actor, metadata.",
|
||||
})
|
||||
async getFileTimeline(
|
||||
@CurrentUser() insurer,
|
||||
@Param("publicId") publicId: string,
|
||||
) {
|
||||
return await this.expertInsurerService.getFileTimeline(
|
||||
insurer.clientKey,
|
||||
publicId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("files/:publicId")
|
||||
@ApiParam({ name: "publicId" })
|
||||
async getFileDetailsByPublicId(
|
||||
|
||||
@@ -75,6 +75,7 @@ import {
|
||||
extractExpertNamesFromBlame,
|
||||
extractExpertNamesFromClaim,
|
||||
} from "./helper/insurer.helper";
|
||||
import { getEventFaLabel } from "./helper/timeline-fa-labels";
|
||||
import { buildEnrichedDamagedParts } from "src/expert-claim/dto/claim-damaged-part.enricher";
|
||||
|
||||
@Injectable()
|
||||
@@ -2088,4 +2089,77 @@ export class ExpertInsurerService {
|
||||
waiting_for_documents_resend: counts.WAITING_FOR_DOCUMENT_RESEND ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async getFileTimeline(
|
||||
insurerId: string,
|
||||
publicId: string,
|
||||
): Promise<{ publicId: string; events: object[] }> {
|
||||
if (!publicId?.trim()) {
|
||||
throw new BadRequestException("publicId is required");
|
||||
}
|
||||
|
||||
const id = this.getClientId(insurerId);
|
||||
const [blameFiles, claimFiles] = await Promise.all([
|
||||
this.getClientBlameFiles(id),
|
||||
this.getClientClaimFiles(id),
|
||||
]);
|
||||
|
||||
const blame = blameFiles.find(
|
||||
(b) => String((b as any).publicId) === publicId,
|
||||
);
|
||||
const claim = claimFiles.find(
|
||||
(c) => String((c as any).publicId) === publicId,
|
||||
);
|
||||
|
||||
if (!blame && !claim) {
|
||||
throw new NotFoundException("File not found for this publicId");
|
||||
}
|
||||
|
||||
const events: object[] = [];
|
||||
|
||||
if (blame) {
|
||||
const blameDoc = await this.blameRequestDbService.findById(
|
||||
String((blame as any)._id),
|
||||
);
|
||||
const blameHistory: any[] = (blameDoc as any)?.history ?? [];
|
||||
for (const ev of blameHistory) {
|
||||
events.push({
|
||||
source: "blame",
|
||||
type: ev.type,
|
||||
faLabel: getEventFaLabel(ev),
|
||||
timestamp: ev.timestamp,
|
||||
actor: ev.actor ?? null,
|
||||
metadata: ev.metadata ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (claim) {
|
||||
const claimId = String((claim as any)._id);
|
||||
const rows = (await this.claimCaseDbService.find(
|
||||
{ _id: new Types.ObjectId(claimId) },
|
||||
{ lean: true, select: "history createdAt" },
|
||||
)) as Record<string, unknown>[];
|
||||
const claimHistory: any[] = (rows[0] as any)?.history ?? [];
|
||||
for (const ev of claimHistory) {
|
||||
events.push({
|
||||
source: "claim",
|
||||
type: ev.type,
|
||||
faLabel: getEventFaLabel(ev),
|
||||
timestamp: ev.timestamp,
|
||||
actor: ev.actor ?? null,
|
||||
metadata: ev.metadata ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
events.sort((a: any, b: any) => {
|
||||
const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0;
|
||||
const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0;
|
||||
return ta - tb;
|
||||
});
|
||||
|
||||
// Resolve insurer's client name for context if needed — just return raw events
|
||||
return { publicId, events };
|
||||
}
|
||||
}
|
||||
|
||||
196
src/expert-insurer/helper/timeline-fa-labels.ts
Normal file
196
src/expert-insurer/helper/timeline-fa-labels.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Persian (Farsi) display labels for every timeline event `type` that can
|
||||
* appear in a blame or claim history array.
|
||||
*
|
||||
* For generic `STEP_COMPLETED` / `V3_STEP_COMPLETED` events the label is
|
||||
* derived from the `metadata.stepKey` value; those keys are listed at the
|
||||
* bottom under STEP_KEY_FA_LABELS.
|
||||
*
|
||||
* Usage:
|
||||
* const label = EVENT_TYPE_FA_LABELS[event.type]
|
||||
* ?? resolveStepCompletedFaLabel(event)
|
||||
* ?? event.type;
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Blame-phase events (recorded on BlameRequest.history)
|
||||
// ---------------------------------------------------------------------------
|
||||
export const EVENT_TYPE_FA_LABELS: Record<string, string> = {
|
||||
// File creation
|
||||
FILE_CREATED_BY_REGISTRAR: "پرونده توسط ثبتکننده ایجاد شد",
|
||||
FILE_CREATED_BY_FIELD_EXPERT: "پرونده توسط کارشناس میدانی ایجاد شد",
|
||||
|
||||
// Expert link / OTP flow
|
||||
LINK_SENT: "لینک ارسال شد",
|
||||
PARTY_OTP_SENT: "کد تأیید ارسال شد",
|
||||
PARTY_OTP_VERIFIED: "کد تأیید تأیید شد",
|
||||
PARTY_OTPS_VERIFIED: "کدهای تأیید هر دو طرف تأیید شدند",
|
||||
SECOND_PARTY_OTP_SENT: "کد تأیید طرف دوم ارسال شد",
|
||||
SECOND_PARTY_OTP_VERIFIED_ADVANCED: "کد طرف دوم تأیید و پیشرفت انجام شد",
|
||||
|
||||
// Confession / accident type
|
||||
FIRST_BLAME_CONFESSION_SUBMITTED: "اقرار اولیه طرف اول ثبت شد",
|
||||
CAR_BODY_ACCIDENT_TYPE_SUBMITTED: "نوع تصادف بدنه خودرو ثبت شد",
|
||||
AUTO_ADVANCED_TO_CAR_BODY_FORM: "پیشرفت خودکار به فرم بدنه خودرو",
|
||||
AUTO_CONFESSION_SKIPPED: "مرحله اقرار بهصورت خودکار رد شد",
|
||||
|
||||
// First video
|
||||
FIRST_VIDEO_UPLOADED: "ویدیوی اول بارگذاری شد",
|
||||
|
||||
// Second party invitation
|
||||
SECOND_PARTY_INVITED: "طرف دوم دعوت شد",
|
||||
|
||||
// Accident fields / expert in-person completion
|
||||
ACCIDENT_FIELDS_SAVED_ADVANCED_TO_SIGNATURES:
|
||||
"اطلاعات تصادف ذخیره و به مرحله امضاها پیشرفت شد",
|
||||
EXPERT_COMPLETED_CAR_BODY_FORM_V2: "کارشناس فرم بدنه خودرو را تکمیل کرد",
|
||||
EXPERT_COMPLETED_THIRD_PARTY_FORM_V2: "کارشناس فرم شخص ثالث را تکمیل کرد",
|
||||
EXPERT_ADDED_LOCATIONS_V2: "کارشناس موقعیت مکانی را ثبت کرد",
|
||||
EXPERT_UPLOADED_VIDEO_V2: "کارشناس ویدیو را بارگذاری کرد",
|
||||
EXPERT_UPLOADED_VOICE_V2: "کارشناس صدا را بارگذاری کرد",
|
||||
|
||||
// Party rejection / disagreement
|
||||
PARTY_REJECTED_EXPERT_DECISION: "طرف حساب با نظر کارشناس مخالفت کرد",
|
||||
PARTIES_DISAGREED_ON_EXPERT_DECISION: "طرفین با نظر کارشناس توافق نکردند",
|
||||
|
||||
// Document resend (blame)
|
||||
BLAME_DOCUMENT_RESEND_STARTED: "درخواست ارسال مجدد مدارک پرونده شروع شد",
|
||||
BLAME_DOCUMENT_RESEND_CLEARED: "ارسال مجدد مدارک پرونده پاکسازی شد",
|
||||
|
||||
// Expert assignment (blame)
|
||||
BLAME_ASSIGNED: "پرونده به کارشناس تخصیص داده شد",
|
||||
AUTO_ASSIGNED_TO_EXPERT: "پرونده بهصورت خودکار به کارشناس تخصیص یافت",
|
||||
|
||||
// Auto-assignment helpers
|
||||
READY_FOR_EXPERT_EVALUATION: "پرونده آماده ارزیابی کارشناس شد",
|
||||
SECOND_PARTY_DESCRIPTION_COMPLETED: "توضیحات طرف دوم تکمیل شد",
|
||||
|
||||
// V3 blame steps
|
||||
V3_PARTY_SIGNATURE_RECORDED: "امضای طرف ثبت شد",
|
||||
V3_ACCIDENT_FIELDS_SAVED: "اطلاعات تصادف ثبت شد",
|
||||
V3_BLAME_ACCIDENT_VIDEO_UPLOADED: "ویدیوی تصادف بارگذاری شد",
|
||||
V3_PARTY_VOICE_UPLOADED: "صدای طرف بارگذاری شد",
|
||||
V3_PARTY_LOCATION_SAVED: "موقعیت مکانی طرف ذخیره شد",
|
||||
V3_PARTY_DESCRIPTION_SAVED: "توضیحات طرف ذخیره شد",
|
||||
V3_CAR_BODY_ACCIDENT_TYPE_SAVED: "نوع تصادف بدنه خودرو ذخیره شد",
|
||||
|
||||
// V5 blame steps
|
||||
V5_BLAME_ACCIDENT_VIDEO_UPLOADED: "ویدیوی تصادف (نسخه ۵) بارگذاری شد",
|
||||
V5_FILE_MAKER_APPROVED: "پروندهساز پرونده را تأیید کرد",
|
||||
V5_FILE_MAKER_REJECTED: "پروندهساز پرونده را رد کرد",
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Claim-phase events (recorded on ClaimCase.history)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Claim creation
|
||||
CLAIM_CREATED: "پرونده خسارت ایجاد شد",
|
||||
|
||||
// Expert assignment (claim)
|
||||
CLAIM_ASSIGNED: "پرونده خسارت به کارشناس ارزیابی تخصیص داده شد",
|
||||
|
||||
// Expert actions on claim
|
||||
EXPERT_RESEND_REQUESTED: "کارشناس درخواست ارسال مجدد مدارک داد",
|
||||
EXPERT_RESEND_FULFILLED: "مدارک درخواستی ارسال شد",
|
||||
IN_PERSON_VISIT_REQUESTED: "کارشناس بازدید حضوری درخواست کرد",
|
||||
EXPERT_DAMAGED_PARTS_UPDATED: "کارشناس قطعات آسیبدیده را بهروزرسانی کرد",
|
||||
|
||||
// Workflow steps (generic)
|
||||
STEP_COMPLETED: "مرحله تکمیل شد",
|
||||
V3_STEP_COMPLETED: "مرحله تکمیل شد",
|
||||
|
||||
// Documents & media
|
||||
DOCUMENT_UPLOADED: "مدرک بارگذاری شد",
|
||||
VIDEO_CAPTURE_UPLOADED: "تصویر ویدیویی بارگذاری شد",
|
||||
ALL_FACTORS_UPLOADED_PENDING_VALIDATION:
|
||||
"تمام فاکتورها بارگذاری شدند و در انتظار تأیید هستند",
|
||||
|
||||
// User responses
|
||||
USER_OBJECTION_SUBMITTED: "اعتراض کاربر ثبت شد",
|
||||
USER_RATING_SUBMITTED: "امتیاز کاربر ثبت شد",
|
||||
|
||||
// Owner insurer approval
|
||||
OWNER_SIGNED_INSURER_APPROVAL: "صاحب خودرو قرارداد بیمه را امضا کرد",
|
||||
OWNER_REJECTED_INSURER_APPROVAL_PRICING: "صاحب خودرو قیمتگذاری بیمه را رد کرد",
|
||||
OWNER_SIGNED_PRICED_PARTS_PENDING_FACTOR_UPLOAD:
|
||||
"صاحب خودرو قطعات قیمتگذاریشده را امضا کرد و در انتظار بارگذاری فاکتور",
|
||||
OWNER_REJECTED_PRICED_PARTS_BEFORE_FACTORS:
|
||||
"صاحب خودرو قطعات قیمتگذاریشده را پیش از فاکتور رد کرد",
|
||||
|
||||
// Fanavaran sync
|
||||
FANAVARAN_AUTO_SUBMIT_SUCCEEDED: "ارسال خودکار به فناوران موفق بود",
|
||||
FANAVARAN_AUTO_SUBMIT_FAILED: "ارسال خودکار به فناوران ناموفق بود",
|
||||
FANAVARAN_EARLY_AUTO_SUBMIT_SUCCEEDED: "ارسال زودهنگام خودکار به فناوران موفق بود",
|
||||
FANAVARAN_EARLY_AUTO_SUBMIT_FAILED: "ارسال زودهنگام خودکار به فناوران ناموفق بود",
|
||||
FANAVARAN_EXPERTISE_AUTO_SUBMIT_SUCCEEDED:
|
||||
"ارسال خودکار کارشناسی به فناوران موفق بود",
|
||||
FANAVARAN_EXPERTISE_AUTO_SUBMIT_FAILED:
|
||||
"ارسال خودکار کارشناسی به فناوران ناموفق بود",
|
||||
FANAVARAN_ATTACHMENT_AUTO_SUBMIT_SUCCEEDED:
|
||||
"ارسال خودکار پیوست به فناوران موفق بود",
|
||||
FANAVARAN_ATTACHMENT_AUTO_SUBMIT_FAILED:
|
||||
"ارسال خودکار پیوست به فناوران ناموفق بود",
|
||||
FANAVARAN_DAMAGE_CASE_AUTO_SUBMIT_SUCCEEDED:
|
||||
"ارسال خودکار پرونده خسارت به فناوران موفق بود",
|
||||
FANAVARAN_DAMAGE_CASE_AUTO_SUBMIT_FAILED:
|
||||
"ارسال خودکار پرونده خسارت به فناوران ناموفق بود",
|
||||
|
||||
// V5 claim
|
||||
V5_HELD_FOR_FILE_MAKER_APPROVAL: "پرونده در انتظار تأیید پروندهساز متوقف شد",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persian labels for `metadata.stepKey` inside STEP_COMPLETED events
|
||||
// ---------------------------------------------------------------------------
|
||||
export const STEP_KEY_FA_LABELS: Record<string, string> = {
|
||||
// Claim workflow steps
|
||||
CLAIM_CREATED: "ایجاد پرونده خسارت",
|
||||
SELECT_OUTER_PARTS: "انتخاب قطعات بیرونی آسیبدیده",
|
||||
SELECT_OTHER_PARTS: "انتخاب سایر اطلاعات و قطعات",
|
||||
CAPTURE_PART_DAMAGES: "عکسبرداری از آسیبهای قطعات",
|
||||
UPLOAD_REQUIRED_DOCUMENTS: "بارگذاری مدارک مورد نیاز",
|
||||
USER_SUBMISSION_COMPLETE: "ارسال اطلاعات توسط کاربر",
|
||||
USER_EXPERT_RESEND: "ارسال مجدد مدارک توسط کاربر",
|
||||
EXPERT_DAMAGE_ASSESSMENT: "ارزیابی خسارت توسط کارشناس",
|
||||
EXPERT_FINAL_REPLY: "پاسخ نهایی کارشناس",
|
||||
EXPERT_COST_EVALUATION: "ارزیابی هزینه توسط کارشناس",
|
||||
OWNER_UPLOAD_FACTOR_DOCUMENTS: "بارگذاری فاکتورهای تعمیر توسط مالک",
|
||||
INSURER_REVIEW: "بررسی توسط بیمهگر",
|
||||
CLAIM_COMPLETED: "پرونده خسارت تکمیل شد",
|
||||
};
|
||||
|
||||
/**
|
||||
* For `STEP_COMPLETED` and `V3_STEP_COMPLETED` events whose final label
|
||||
* depends on the `metadata.stepKey` value, this function returns the
|
||||
* most specific Persian label available.
|
||||
*/
|
||||
export function resolveStepCompletedFaLabel(event: {
|
||||
type: string;
|
||||
metadata?: any;
|
||||
}): string | undefined {
|
||||
if (
|
||||
event.type !== "STEP_COMPLETED" &&
|
||||
event.type !== "V3_STEP_COMPLETED"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const stepKey: string | undefined = event.metadata?.stepKey;
|
||||
if (stepKey && STEP_KEY_FA_LABELS[stepKey]) {
|
||||
return STEP_KEY_FA_LABELS[stepKey];
|
||||
}
|
||||
return "مرحله تکمیل شد";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the best Persian label for any timeline event.
|
||||
*/
|
||||
export function getEventFaLabel(event: {
|
||||
type: string;
|
||||
metadata?: any;
|
||||
}): string {
|
||||
return (
|
||||
resolveStepCompletedFaLabel(event) ??
|
||||
EVENT_TYPE_FA_LABELS[event.type] ??
|
||||
event.type
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { extname } from "node:path";
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Param,
|
||||
@@ -159,7 +158,7 @@ export class ExpertInitiatedBlameMirrorController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post("/initial-form/:requestId")
|
||||
@Post("/run-inquiries/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: AddPlateDto })
|
||||
@ApiOperation({
|
||||
@@ -362,20 +361,6 @@ export class ExpertInitiatedBlameMirrorController {
|
||||
@CurrentUser() expert: any,
|
||||
@UploadedFile() sign: Express.Multer.File,
|
||||
) {
|
||||
// Guard: accident fields (accidentWay, etc.) must be filled before signing.
|
||||
// This prevents the expert from bypassing add-accident-fields and avoids the
|
||||
// double-sign bug where add-accident-fields re-enters WAITING_FOR_SIGNATURES
|
||||
// after a signature was already recorded.
|
||||
const requestData = await this.requestManagementService.getBlameRequestV2(
|
||||
requestId,
|
||||
expert,
|
||||
);
|
||||
if (!(requestData?.expert?.decision as any)?.fields?.accidentWay) {
|
||||
throw new BadRequestException(
|
||||
"Accident fields (accidentWay, accidentReason, accidentType) must be submitted via add-accident-fields before signing.",
|
||||
);
|
||||
}
|
||||
|
||||
await this.mediaPolicyService.assertForBlame(sign, requestId, "image");
|
||||
const partyRole =
|
||||
body?.partyRole === "SECOND" ? PartyRole.SECOND : PartyRole.FIRST;
|
||||
@@ -389,7 +374,7 @@ export class ExpertInitiatedBlameMirrorController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post("add-accident-fields/:requestId")
|
||||
@Post("accident-fields/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: ExpertAccidentFieldsDto })
|
||||
@ApiOperation({
|
||||
|
||||
@@ -368,6 +368,8 @@ export class InquiryRefreshService {
|
||||
|
||||
next.insurance.policyNumber =
|
||||
mapped.PrntPlcyCmpDocNo ||
|
||||
mapped.printNumber ||
|
||||
mapped.insuranceNumber ||
|
||||
next.insurance.policyNumber;
|
||||
next.insurance.company =
|
||||
mapped.companyPersianName || mapped.CompanyName || next.insurance.company;
|
||||
|
||||
@@ -278,6 +278,24 @@ export class RequestManagementService {
|
||||
return;
|
||||
}
|
||||
|
||||
// IN_PERSON THIRD_PARTY: guilt is decided on-site by the expert — there is
|
||||
// no waiting-for-field-expert phase. Replace WAITING_FOR_GUILT_DECISION
|
||||
// with WAITING_FOR_SIGNATURES wherever it would surface as current/next step.
|
||||
const isInPersonThirdParty =
|
||||
req.type === BlameRequestType.THIRD_PARTY &&
|
||||
req.creationMethod === CreationMethod.IN_PERSON &&
|
||||
(req.expertInitiated || req.registrarInitiated);
|
||||
|
||||
const resolveStep = (step: WorkflowStep | undefined): WorkflowStep | undefined => {
|
||||
if (
|
||||
isInPersonThirdParty &&
|
||||
step === WorkflowStep.WAITING_FOR_GUILT_DECISION
|
||||
) {
|
||||
return WorkflowStep.WAITING_FOR_SIGNATURES;
|
||||
}
|
||||
return step;
|
||||
};
|
||||
|
||||
const submittedStep = await this.getWorkflowStep({
|
||||
stepKey: submittedStepKey,
|
||||
});
|
||||
@@ -299,8 +317,10 @@ export class RequestManagementService {
|
||||
submittedStep.stepNumber + 1,
|
||||
);
|
||||
|
||||
const nextStepKey = (sequentialNextStep?.stepKey ??
|
||||
submittedStep.nextPossibleSteps?.[0]) as WorkflowStep | undefined;
|
||||
const nextStepKey = resolveStep(
|
||||
(sequentialNextStep?.stepKey ??
|
||||
submittedStep.nextPossibleSteps?.[0]) as WorkflowStep | undefined,
|
||||
);
|
||||
|
||||
if (!nextStepKey) {
|
||||
// Terminal state: no more steps
|
||||
@@ -348,7 +368,7 @@ export class RequestManagementService {
|
||||
nextStepDoc.nextPossibleSteps?.[0]) as WorkflowStep | undefined;
|
||||
|
||||
req.workflow.currentStep = nextStepKey;
|
||||
req.workflow.nextStep = afterNextKey;
|
||||
req.workflow.nextStep = resolveStep(afterNextKey);
|
||||
|
||||
this.logger.debug(
|
||||
`[WORKFLOW] Advanced to currentStep=${req.workflow.currentStep}, nextStep=${req.workflow.nextStep ?? "undefined"}`,
|
||||
@@ -6617,11 +6637,11 @@ export class RequestManagementService {
|
||||
);
|
||||
}
|
||||
await this.verifyExpertAccessForBlameV2(req, expert);
|
||||
if (req.status !== CaseStatus.WAITING_FOR_SIGNATURES) {
|
||||
throw new BadRequestException(
|
||||
"Request is not waiting for signatures. Current status: " + req.status,
|
||||
);
|
||||
}
|
||||
// if (req.status !== CaseStatus.WAITING_FOR_SIGNATURES) {
|
||||
// throw new BadRequestException(
|
||||
// "Request is not waiting for signatures. Current status: " + req.status,
|
||||
// );
|
||||
// }
|
||||
const partyIndex = this.getPartyIndex(req, partyRole);
|
||||
if (partyIndex === -1) {
|
||||
throw new BadRequestException(
|
||||
|
||||
@@ -81,5 +81,33 @@ describe("SandHubService inquiry mocks", () => {
|
||||
expect(httpService.post).not.toHaveBeenCalled();
|
||||
expect(result.mapped?.CompanyName).toBe("بیمه پارسیان");
|
||||
expect(result.mapped?.CompanyCode).toBe("8");
|
||||
// PrntPlcyCmpDocNo must be populated from the mock raw field
|
||||
expect(result.mapped?.PrntPlcyCmpDocNo).toBe("1404/1143-70591/200/123");
|
||||
});
|
||||
|
||||
it("maps printNumber → PrntPlcyCmpDocNo when raw response uses new field name", async () => {
|
||||
// Simulate a real-API response where the policy number arrives as printNumber
|
||||
const rawNewFormat = {
|
||||
printNumber: "1405/1143-NEWAPI/200/1",
|
||||
CompanyName: "بیمه آزمایشی",
|
||||
CompanyCode: "99",
|
||||
FinancialCvrCptl: "5000000000",
|
||||
IssueDate: "1405/01/01",
|
||||
EndDate: "1406/01/01",
|
||||
};
|
||||
const mapped = (service as any).mapNewApiResponseToOldFormat(rawNewFormat);
|
||||
|
||||
expect(mapped.PrntPlcyCmpDocNo).toBe("1405/1143-NEWAPI/200/1");
|
||||
});
|
||||
|
||||
it("maps insuranceNumber → PrntPlcyCmpDocNo when raw response uses legacy field name", async () => {
|
||||
const rawLegacy = {
|
||||
insuranceNumber: "1405/1143-LEGACY/200/1",
|
||||
CompanyName: "بیمه آزمایشی",
|
||||
CompanyCode: "99",
|
||||
};
|
||||
const mapped = (service as any).mapNewApiResponseToOldFormat(rawLegacy);
|
||||
|
||||
expect(mapped.PrntPlcyCmpDocNo).toBe("1405/1143-LEGACY/200/1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1012,11 +1012,20 @@ export class SandHubService {
|
||||
IssueDate: newResponse.persianStartDate || newResponse.IssueDate,
|
||||
EndDate: newResponse.persianEndDate || newResponse.EndDate,
|
||||
|
||||
// Insurance policy number — canonical Tejarat name is PrntPlcyCmpDocNo;
|
||||
// newer API formats surface it as printNumber or insuranceNumber.
|
||||
PrntPlcyCmpDocNo:
|
||||
newResponse.PrntPlcyCmpDocNo ||
|
||||
newResponse.printNumber ||
|
||||
newResponse.insuranceNumber ||
|
||||
null,
|
||||
|
||||
// Insurance details
|
||||
LastCompanyDocumentNumber:
|
||||
newResponse.lastCompanyInsuranceNumber ||
|
||||
newResponse.LastCompanyDocumentNumber ||
|
||||
newResponse.insuranceNumber,
|
||||
newResponse.insuranceNumber ||
|
||||
null,
|
||||
|
||||
// Technical details
|
||||
MtrNum: newResponse.MtrNum || newResponse.mtrnum,
|
||||
|
||||
Reference in New Issue
Block a user