From 15342e16d8d3f83b7297184336ee98fce2e72faf Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Sat, 12 Sep 2026 11:19:57 +0330 Subject: [PATCH] YARA-1257, YARA-1272, YARA-985 --- .../claim-request-management.controller.ts | 10 +-- .../claim-request-management.service.ts | 86 ++----------------- .../claim-request-management.v2.controller.ts | 16 +--- .../dto/in-person-visit.dto.ts | 12 --- .../schema/claim-case.evaluation.schema.ts | 8 -- ...xpert-initiated-claim.mirror.controller.ts | 7 +- ...-insurer-approval-mirror-no-branch.spec.ts | 71 +++++++++++++++ .../owner-insurer-approval-no-branch.spec.ts | 62 +++++++++++++ src/expert-claim/dto/claim-detail-v2.dto.ts | 2 - src/expert-claim/dto/expert-claim-v2.dto.ts | 6 -- src/expert-claim/dto/reply.dto.ts | 6 -- src/expert-claim/expert-claim.service.spec.ts | 26 ++++++ src/expert-claim/expert-claim.service.ts | 50 +---------- src/expert-insurer/expert-insurer.service.ts | 29 ++++++- .../helper/timeline-fa-labels.spec.ts | 25 ++++++ .../helper/timeline-fa-labels.ts | 56 ++++++++++-- .../file-reviewer-blame-v4.controller.ts | 7 +- .../file-reviewer-blame-v5.controller.ts | 7 +- 18 files changed, 279 insertions(+), 207 deletions(-) delete mode 100644 src/claim-request-management/dto/in-person-visit.dto.ts create mode 100644 src/claim-request-management/owner-insurer-approval-mirror-no-branch.spec.ts create mode 100644 src/claim-request-management/owner-insurer-approval-no-branch.spec.ts create mode 100644 src/expert-insurer/helper/timeline-fa-labels.spec.ts diff --git a/src/claim-request-management/claim-request-management.controller.ts b/src/claim-request-management/claim-request-management.controller.ts index a00e6c7..300dc3a 100644 --- a/src/claim-request-management/claim-request-management.controller.ts +++ b/src/claim-request-management/claim-request-management.controller.ts @@ -37,7 +37,6 @@ import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-managem import { CarDamagePartDto, OtherCarDamagePartDto } from "./dto/car-part.dto"; import { UserCommentDto } from "./dto/user-comment.dto"; import { UserObjectionDto } from "./dto/user-objection.dto"; -import { InPersonVisitDto } from "./dto/in-person-visit.dto"; import { UserRatingDto } from "./dto/user-rating.dto"; @ApiExcludeController() @@ -464,21 +463,14 @@ export class ClaimRequestManagementController { ); } - // @ApiBody({ type: InPersonVisitDto }) // @ApiParam({ name: "id" }) // @ApiOperation({ deprecated: true }) @Patch(":id/visit") async inPersonVisit( @Param("id") requestId: string, - @Body() body: InPersonVisitDto, @CurrentUser() actor, ) { - // Pass the branchId from the body to the service - return await this.claimRequestManagementService.inPersonVisit( - requestId, - body.branchId, - actor, - ); + return await this.claimRequestManagementService.inPersonVisit(requestId, actor); } // Legacy V1 owner branch picker — intentionally disabled. diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 5ad5fba..27427ef 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -2526,7 +2526,7 @@ export class ClaimRequestManagementService { /** * V2 response mapper: * - convert factorLink ObjectId -> public URL - * - enrich daghi.branchId with branchName when available + * - enrich legacy daghi.branchId with branchName when available */ private async mapEvaluationForClient( evaluation: any, @@ -3381,29 +3381,17 @@ export class ClaimRequestManagementService { return updated.objection; } - async inPersonVisit(requestId: string, branchId: string, actorDetail: any) { + async inPersonVisit(requestId: string, actorDetail: any) { const request = await this.claimRequestManagementDbService.findOne(requestId); if (!request) { throw new NotFoundException("Claim not found"); } - const branch = await this.branchDbService.findById(branchId); - if (!branch) { - throw new NotFoundException(`Branch with ID ${branchId} not found.`); - } - - if (String(request.userClientKey) !== String(branch.clientKey)) { - throw new ForbiddenException( - "This branch does not belong to the insurer of this claim.", - ); - } - const updated = await this.claimRequestManagementDbService.findAndUpdate( requestId, { claimStatus: ReqClaimStatus.InPersonVisit, - visitLocation: `Branch ${branch.name} at ${branch.address}`, }, ); @@ -3523,27 +3511,6 @@ export class ClaimRequestManagementService { return amount > 0 ? amount : FANAVARAN_PROVISIONAL_ESTIMATE_AMOUNT; } - /** Branch ids referenced on expert-priced parts (`daghi` object with `branchId`). */ - private collectBranchIdsFromClaimExpertReply(reply: { - parts?: unknown[]; - }): Set { - const ids = new Set(); - const parts = reply?.parts; - if (!Array.isArray(parts)) { - return ids; - } - for (const p of parts) { - const d = (p as { daghi?: unknown })?.daghi; - if (d && typeof d === "object" && d !== null && "branchId" in d) { - const bid = (d as { branchId?: unknown }).branchId; - if (bid != null && Types.ObjectId.isValid(String(bid))) { - ids.add(String(bid)); - } - } - } - return ids; - } - private getTejaratnoFanavaranConfig() { return { appName: this.APP_NAME, @@ -11012,7 +10979,6 @@ export class ClaimRequestManagementService { async submitOwnerInsurerApprovalSignV2( claimRequestId: string, agree: boolean, - branchId: string, signFile: Express.Multer.File, currentUserId: string, actor?: { sub: string; role?: string; fullName?: string }, @@ -11072,12 +11038,6 @@ export class ClaimRequestManagementService { } const shape = classifyV2ExpertPricingParts(active.parts ?? []); - const pricedBranchReply = { - parts: (active.parts ?? []).filter( - (p: { factorNeeded?: boolean }) => !p.factorNeeded, - ), - }; - const isMixedPartialGate = shape.mixedFactorAndPrice && claimCase.claimStatus === ClaimStatus.NEEDS_REVISION && @@ -11112,39 +11072,6 @@ export class ClaimRequestManagementService { ); } - const rawBranchId = (branchId ?? "").trim(); - if (!rawBranchId || !Types.ObjectId.isValid(rawBranchId)) { - throw new BadRequestException( - "branchId is required and must be a valid MongoDB ObjectId.", - ); - } - const branchDoc = await this.branchDbService.findById(rawBranchId); - if (!branchDoc) { - throw new NotFoundException(`Branch with ID ${rawBranchId} not found.`); - } - const ownerClientId = claimCase.owner?.clientId; - if ( - !ownerClientId || - String(branchDoc.clientKey) !== String(ownerClientId) - ) { - throw new ForbiddenException( - "This branch does not belong to the insurer for this claim.", - ); - } - const branchIdsOnPricing = isMixedPartialGate - ? this.collectBranchIdsFromClaimExpertReply(pricedBranchReply) - : this.collectBranchIdsFromClaimExpertReply( - active as { parts?: unknown[] }, - ); - if ( - branchIdsOnPricing.size > 0 && - !branchIdsOnPricing.has(String(rawBranchId)) - ) { - throw new BadRequestException( - "branchId must match a branch used in the expert pricing for this claim.", - ); - } - const signDoc = await this.claimSignDbService.create({ fileName: signFile.filename, userId: effectiveUserId, @@ -11155,7 +11082,6 @@ export class ClaimRequestManagementService { const signedAt = new Date(); const scopedApprovalPayload = { agree, - branchId: new Types.ObjectId(rawBranchId), signDetailId: signId, signedAt, }; @@ -11181,7 +11107,7 @@ export class ClaimRequestManagementService { type: "OWNER_REJECTED_PRICED_PARTS_BEFORE_FACTORS", actor: historyActor, timestamp: signedAt, - metadata: { branchId: rawBranchId }, + metadata: {}, }, }, }); @@ -11210,7 +11136,7 @@ export class ClaimRequestManagementService { type: "OWNER_SIGNED_PRICED_PARTS_PENDING_FACTOR_UPLOAD", actor: historyActor, timestamp: signedAt, - metadata: { branchId: rawBranchId }, + metadata: {}, }, }, }); @@ -11241,7 +11167,7 @@ export class ClaimRequestManagementService { type: "OWNER_REJECTED_INSURER_APPROVAL_PRICING", actor: historyActor, timestamp: signedAt, - metadata: { branchId: rawBranchId }, + metadata: {}, }, }, }); @@ -11275,7 +11201,7 @@ export class ClaimRequestManagementService { type: "OWNER_SIGNED_INSURER_APPROVAL", actor: historyActor, timestamp: signedAt, - metadata: { branchId: rawBranchId }, + metadata: {}, }, }, }); diff --git a/src/claim-request-management/claim-request-management.v2.controller.ts b/src/claim-request-management/claim-request-management.v2.controller.ts index bd6c620..1fae1b9 100644 --- a/src/claim-request-management/claim-request-management.v2.controller.ts +++ b/src/claim-request-management/claim-request-management.v2.controller.ts @@ -335,16 +335,16 @@ export class ClaimRequestManagementV2Controller { @ApiOperation({ summary: "Sign priced lines before factor uploads (owner; final phase is legacy only)", description: - "Multipart: `sign`, `agree`, `branchId`. Requires `ClaimCaseStatus` **`INSURER_REVIEW_AWAITING_OWNER_SIGN`**, **`INSURER_REVIEW_MIXED_FACTORS_PENDING`**, or legacy **`WAITING_FOR_INSURER_APPROVAL`**, and `workflow.currentStep=INSURER_REVIEW` (not during owner factor upload or `EXPERT_COST_EVALUATION`).\n\n" + + "Multipart: `sign`, `agree`. Requires `ClaimCaseStatus` **`INSURER_REVIEW_AWAITING_OWNER_SIGN`**, **`INSURER_REVIEW_MIXED_FACTORS_PENDING`**, or legacy **`WAITING_FOR_INSURER_APPROVAL`**, and `workflow.currentStep=INSURER_REVIEW` (not during owner factor upload or `EXPERT_COST_EVALUATION`).\n\n" + "**Phase A — Mixed reply, priced lines only:** `claimStatus=NEEDS_REVISION`, no `evaluation.ownerPricedPartsApproval` yet. `agree=true` records that signature and moves to `OWNER_UPLOAD_FACTOR_DOCUMENTS` for factor uploads; `agree=false` rejects the whole case (`REJECTED`).\n\n" + "**Phase B — Legacy final phase only:** pre-existing rows with `claimStatus=APPROVED` may still be accepted or rejected through this endpoint. New V2–V5 claims complete after expert work (and V5 FileMaker approval) without a final owner signature.\n\n" + "Response may include `phase`: `PRICED_PARTS_FOR_FACTORS` or legacy `FINAL_APPROVAL` for UI state.", }) @ApiBody({ - description: "Signature file, agreement, and branch", + description: "Signature file and agreement", schema: { type: "object", - required: ["sign", "agree", "branchId"], + required: ["sign", "agree"], properties: { sign: { type: "string", @@ -355,12 +355,6 @@ export class ClaimRequestManagementV2Controller { type: "boolean", description: "true to accept expert pricing and complete the claim", }, - branchId: { - type: "string", - description: - "Insurer branch id (must belong to the claim owner's insurer; if pricing lists branch options, must match one of them)", - example: "507f1f77bcf86cd799439011", - }, }, }, }) @@ -392,7 +386,6 @@ export class ClaimRequestManagementV2Controller { async submitOwnerInsurerApprovalSignV2( @Param("claimRequestId") claimRequestId: string, @Body("agree") agree: string | boolean, - @Body("branchId") branchId: string, @CurrentUser() user: any, @UploadedFile() sign: Express.Multer.File, ) { @@ -408,7 +401,6 @@ export class ClaimRequestManagementV2Controller { return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2( claimRequestId, agreed, - typeof branchId === "string" ? branchId : "", sign, user.sub, user, @@ -467,7 +459,7 @@ export class ClaimRequestManagementV2Controller { @ApiOperation({ summary: "Get insurer branches (V2)", description: - "Returns branch list for a given insurer/client id so frontend can render branch options (name/code/address/city/state) and submit selected branchId in daghi part options.", + "Returns branch list for a given insurer/client id. Claim pricing and owner approval no longer require branch selection.", }) @ApiParam({ name: "insuranceId", diff --git a/src/claim-request-management/dto/in-person-visit.dto.ts b/src/claim-request-management/dto/in-person-visit.dto.ts deleted file mode 100644 index 210bf9f..0000000 --- a/src/claim-request-management/dto/in-person-visit.dto.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { IsMongoId, IsNotEmpty } from "class-validator"; - -export class InPersonVisitDto { - @ApiProperty({ - example: "60d5ec49e7b2f8001c8e4d2a", - description: "The unique ID of the branch the user is being sent to.", - }) - @IsNotEmpty() - @IsMongoId() - branchId: string; -} diff --git a/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts b/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts index 729a992..f9faac5 100644 --- a/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts +++ b/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts @@ -83,10 +83,6 @@ export class ClaimOwnerInsurerApproval { @Prop({ type: Boolean, required: true }) agree: boolean; - /** Branch the owner is signing for (must belong to their insurer; aligned with expert daghi options when present). */ - @Prop({ type: Types.ObjectId }) - branchId?: Types.ObjectId; - @Prop({ type: Types.ObjectId }) signDetailId?: Types.ObjectId; @@ -102,9 +98,6 @@ export class ClaimOwnerPricedPartsApproval { @Prop({ type: Boolean, required: true }) agree: boolean; - @Prop({ type: Types.ObjectId }) - branchId?: Types.ObjectId; - @Prop({ type: Types.ObjectId }) signDetailId?: Types.ObjectId; @@ -349,4 +342,3 @@ export class ClaimEvaluation { } export const ClaimEvaluationSchema = SchemaFactory.createForClass(ClaimEvaluation); - diff --git a/src/claim-request-management/expert-initiated-claim.mirror.controller.ts b/src/claim-request-management/expert-initiated-claim.mirror.controller.ts index a3474f8..e09660a 100644 --- a/src/claim-request-management/expert-initiated-claim.mirror.controller.ts +++ b/src/claim-request-management/expert-initiated-claim.mirror.controller.ts @@ -533,10 +533,10 @@ Returns status of each item (uploaded/captured or not). @ApiParam({ name: "claimRequestId" }) @ApiConsumes("multipart/form-data") @ApiBody({ - description: "Signature file, agreement, and branch", + description: "Signature file and agreement", schema: { type: "object", - required: ["sign", "agree", "branchId"], + required: ["sign", "agree"], properties: { sign: { type: "string", @@ -547,7 +547,6 @@ Returns status of each item (uploaded/captured or not). type: "boolean", description: "true to accept, false to reject", }, - branchId: { type: "string", description: "Insurer branch ID" }, }, }, }) @@ -577,7 +576,6 @@ Returns status of each item (uploaded/captured or not). async submitOwnerSign( @Param("claimRequestId") claimRequestId: string, @Body("agree") agree: string | boolean, - @Body("branchId") branchId: string, @CurrentUser() expert: any, @UploadedFile() sign: Express.Multer.File, ) { @@ -590,7 +588,6 @@ Returns status of each item (uploaded/captured or not). return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2( claimRequestId, agreed, - typeof branchId === "string" ? branchId : "", sign, expert.sub, expert, diff --git a/src/claim-request-management/owner-insurer-approval-mirror-no-branch.spec.ts b/src/claim-request-management/owner-insurer-approval-mirror-no-branch.spec.ts new file mode 100644 index 0000000..cb8e736 --- /dev/null +++ b/src/claim-request-management/owner-insurer-approval-mirror-no-branch.spec.ts @@ -0,0 +1,71 @@ +import { ExpertInitiatedClaimMirrorController } from "./expert-initiated-claim.mirror.controller"; +import { FileReviewerBlameV4Controller } from "../request-management/file-reviewer-blame-v4.controller"; +import { FileReviewerBlameV5Controller } from "../request-management/file-reviewer-blame-v5.controller"; + +describe("owner insurer approval mirror endpoints", () => { + const claimRequestId = "507f1f77bcf86cd799439011"; + const actor = { sub: "507f1f77bcf86cd799439012" }; + const sign = { + filename: "sign.png", + path: "/tmp/sign.png", + } as Express.Multer.File; + + const createDependencies = () => { + const claimRequestManagementService = { + submitOwnerInsurerApprovalSignV2: jest + .fn() + .mockResolvedValue({ accepted: true }), + }; + const mediaPolicyService = { + assertForClaim: jest.fn().mockResolvedValue(undefined), + }; + return { claimRequestManagementService, mediaPolicyService }; + }; + + it.each([ + [ + "expert-initiated", + (deps: ReturnType) => + new ExpertInitiatedClaimMirrorController( + deps.claimRequestManagementService as never, + deps.mediaPolicyService as never, + ), + ], + [ + "V4 file-reviewer", + (deps: ReturnType) => + new FileReviewerBlameV4Controller( + {} as never, + deps.claimRequestManagementService as never, + deps.mediaPolicyService as never, + ), + ], + [ + "V5 file-reviewer", + (deps: ReturnType) => + new FileReviewerBlameV5Controller( + {} as never, + deps.claimRequestManagementService as never, + deps.mediaPolicyService as never, + ), + ], + ])( + "forwards a signature without branchId through the %s endpoint", + async (_name, createController) => { + const dependencies = createDependencies(); + const controller = createController(dependencies) as { + submitOwnerSign: (...args: any[]) => Promise; + }; + + await controller.submitOwnerSign(claimRequestId, "true", actor, sign); + + expect( + dependencies.mediaPolicyService.assertForClaim, + ).toHaveBeenCalledWith(sign, claimRequestId, "image"); + expect( + dependencies.claimRequestManagementService + .submitOwnerInsurerApprovalSignV2, + ).toHaveBeenCalledWith(claimRequestId, true, sign, actor.sub, actor); + }, + ); +}); diff --git a/src/claim-request-management/owner-insurer-approval-no-branch.spec.ts b/src/claim-request-management/owner-insurer-approval-no-branch.spec.ts new file mode 100644 index 0000000..686f641 --- /dev/null +++ b/src/claim-request-management/owner-insurer-approval-no-branch.spec.ts @@ -0,0 +1,62 @@ +import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum"; +import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum"; +import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum"; +import { ClaimRequestManagementService } from "./claim-request-management.service"; + +describe("owner insurer approval without branch selection", () => { + it("records a final rejection when no branchId is supplied", async () => { + const service = Object.create( + ClaimRequestManagementService.prototype, + ) as ClaimRequestManagementService; + const findByIdAndUpdate = jest.fn().mockResolvedValue(undefined); + + (service as any).claimCaseDbService = { + findById: jest.fn().mockResolvedValue({ + _id: "507f1f77bcf86cd799439011", + status: ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN, + claimStatus: ClaimStatus.APPROVED, + workflow: { currentStep: ClaimWorkflowStep.INSURER_REVIEW }, + owner: { fullName: "مالک تست", clientId: "client-id" }, + evaluation: { + damageExpertReply: { submittedAt: new Date(), parts: [] }, + }, + }), + findByIdAndUpdate, + }; + (service as any).resolveClaimEffectiveUserId = jest + .fn() + .mockResolvedValue("507f1f77bcf86cd799439012"); + (service as any).assertEffectiveUserIsDamagedPartyOnClaim = jest + .fn() + .mockResolvedValue(undefined); + (service as any).claimSignDbService = { + create: jest + .fn() + .mockResolvedValue({ _id: "507f1f77bcf86cd799439013" }), + }; + + await expect( + service.submitOwnerInsurerApprovalSignV2( + "507f1f77bcf86cd799439011", + false, + { filename: "sign.png", path: "/tmp/sign.png" } as Express.Multer.File, + "507f1f77bcf86cd799439012", + ), + ).resolves.toMatchObject({ + accepted: false, + phase: "FINAL_APPROVAL", + status: ClaimCaseStatus.REJECTED, + }); + + expect(findByIdAndUpdate).toHaveBeenCalledWith( + "507f1f77bcf86cd799439011", + expect.objectContaining({ + $set: expect.objectContaining({ + "evaluation.ownerInsurerApproval": expect.not.objectContaining({ + branchId: expect.anything(), + }), + }), + }), + ); + }); +}); diff --git a/src/expert-claim/dto/claim-detail-v2.dto.ts b/src/expert-claim/dto/claim-detail-v2.dto.ts index 2a2cc2f..fc51e08 100644 --- a/src/expert-claim/dto/claim-detail-v2.dto.ts +++ b/src/expert-claim/dto/claim-detail-v2.dto.ts @@ -207,14 +207,12 @@ export class ClaimDetailV2ResponseDto { damageExpertResend: unknown; ownerInsurerApproval?: { agree: boolean; - branchId?: string; signDetailId?: string; signLink?: string; signedAt?: Date | string; }; ownerPricedPartsApproval?: { agree: boolean; - branchId?: string; signDetailId?: string; signLink?: string; signedAt?: Date | string; diff --git a/src/expert-claim/dto/expert-claim-v2.dto.ts b/src/expert-claim/dto/expert-claim-v2.dto.ts index 144add0..bdc58eb 100644 --- a/src/expert-claim/dto/expert-claim-v2.dto.ts +++ b/src/expert-claim/dto/expert-claim-v2.dto.ts @@ -38,12 +38,6 @@ export class DaghiDetailsV2Dto { @IsMoneyAmountString() price?: string; - @ApiPropertyOptional({ - description: `Required when option is '${DaghiOption.DELIVER_DAMAGED_PART}' (Mongo ObjectId string)`, - }) - @IsOptional() - @IsString() - branchId?: string; } export class PartPricingV2Dto { diff --git a/src/expert-claim/dto/reply.dto.ts b/src/expert-claim/dto/reply.dto.ts index 79c0b73..ee0a9a1 100644 --- a/src/expert-claim/dto/reply.dto.ts +++ b/src/expert-claim/dto/reply.dto.ts @@ -25,12 +25,6 @@ export class DaghiDetailsDto { }) price?: string; - @ApiProperty({ - required: false, - type: String, - description: "Branch ID required when option is 'تحویل داغی'", - }) - branchId?: string; } export class PartsList { diff --git a/src/expert-claim/expert-claim.service.spec.ts b/src/expert-claim/expert-claim.service.spec.ts index 4884fdc..582e1d6 100644 --- a/src/expert-claim/expert-claim.service.spec.ts +++ b/src/expert-claim/expert-claim.service.spec.ts @@ -52,6 +52,32 @@ describe("ExpertClaimService expert-reply pricing", () => { ]); }); + it("allows a deliver-damaged-part line without a branch", () => { + const service = createService() as any; + + expect( + service.validateAndNormalizeDaghiForExpertReplyV2([ + { + partId: 201, + typeOfDamage: TypeOfDamage.Change, + price: "100000", + salary: "100000", + totalPayment: "200000", + daghi: { option: DaghiOption.DELIVER_DAMAGED_PART }, + }, + ]), + ).toEqual([ + { + partId: 201, + typeOfDamage: TypeOfDamage.Change, + price: "100000", + salary: "100000", + totalPayment: "200000", + daghi: { option: DaghiOption.DELIVER_DAMAGED_PART }, + }, + ]); + }); + it("rejects a blank pricing line on the legacy submit endpoint before changing status", async () => { const service = createService() as any; const findAndUpdate = jest.fn(); diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 125a182..69abe5f 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -957,7 +957,7 @@ export class ExpertClaimService { return pr + sa; } - /** Same rules as V1 `submitReplyRequest`: daghi option + conditional price / branchId; `branchId` stored as ObjectId. */ + /** Same rules as V1 `submitReplyRequest`: daghi option plus conditional price. */ private validateAndNormalizeDaghiForExpertReplyV2( parts: import("./dto/expert-claim-v2.dto").PartPricingV2Dto[], ) { @@ -985,31 +985,6 @@ export class ExpertClaimService { ), ); } - } else if (part.daghi.option === DaghiOption.DELIVER_DAMAGED_PART) { - if (!part.daghi.branchId) { - throw new BadRequestException( - this.expertReplySubmissionError( - `شعبه تحویل داغی برای قطعه ${part.partId} الزامی است.`, - "DAGHI_BRANCH_REQUIRED", - { - field: `parts[${partIndex}].daghi.branchId`, - partId: part.partId, - }, - ), - ); - } - if (!Types.ObjectId.isValid(part.daghi.branchId)) { - throw new BadRequestException( - this.expertReplySubmissionError( - `شناسه شعبه برای قطعه ${part.partId} معتبر نیست.`, - "DAGHI_BRANCH_INVALID", - { - field: `parts[${partIndex}].daghi.branchId`, - partId: part.partId, - }, - ), - ); - } } } @@ -1023,10 +998,6 @@ export class ExpertClaimService { daghi: { option: part.daghi!.option, ...(part.daghi!.price && { price: part.daghi!.price }), - ...(part.daghi!.branchId && - Types.ObjectId.isValid(part.daghi!.branchId) && { - branchId: new Types.ObjectId(part.daghi!.branchId), - }), }, }; }); @@ -1773,18 +1744,6 @@ export class ExpertClaimService { `Price is required for part ${part.partId} when option is '${DaghiOption.RECYCLED_PARTS_VALUE}'`, ); } - } else if (part.daghi.option === DaghiOption.DELIVER_DAMAGED_PART) { - if (!part.daghi.branchId) { - throw new BadRequestException( - `Branch ID is required for part ${part.partId} when option is '${DaghiOption.DELIVER_DAMAGED_PART}'`, - ); - } - // Validate branchId is a valid ObjectId - if (!Types.ObjectId.isValid(part.daghi.branchId)) { - throw new BadRequestException( - `Invalid branch ID format for part ${part.partId}`, - ); - } } // For NO_VALUE and WITH_DAMAGED_PART_CALCULATION, no additional fields needed } @@ -1806,7 +1765,7 @@ export class ExpertClaimService { ? ClaimStepsEnum.WaitingForFactorUpload : ClaimStepsEnum.WaitingForUserToReact; - // Process parts: convert branchId string to ObjectId if present + // Branch selection is no longer part of claim pricing. const processedParts = reply.parts.map((part) => { if (part.typeOfDamage === TypeOfDamage.Repair) { const { daghi: _daghi, ...repairPart } = part; @@ -1817,9 +1776,6 @@ export class ExpertClaimService { daghi: { option: part.daghi!.option, ...(part.daghi!.price && { price: part.daghi!.price }), - ...(part.daghi!.branchId && { - branchId: new Types.ObjectId(part.daghi!.branchId), - }), }, }; }); @@ -3290,7 +3246,7 @@ export class ExpertClaimService { * - Must be locked by this expert (workflow.lockedBy.actorId === actor.sub) * - Must be in EXPERT_REVIEWING status * - Total payment across all parts must not exceed 53,000,000 (same cap as factor validation totals) - * - Each part must include `daghi` (option + conditional price/branchId) like V1 + * - Each part must include `daghi` (option + conditional price) like V1 * * On success: * - Stores reply; clears owner signature fields (`ownerInsurerApproval`, `ownerPricedPartsApproval`) diff --git a/src/expert-insurer/expert-insurer.service.ts b/src/expert-insurer/expert-insurer.service.ts index c5183da..b586240 100644 --- a/src/expert-insurer/expert-insurer.service.ts +++ b/src/expert-insurer/expert-insurer.service.ts @@ -78,7 +78,10 @@ import { extractExpertNamesFromBlame, extractExpertNamesFromClaim, } from "./helper/insurer.helper"; -import { getEventFaLabel } from "./helper/timeline-fa-labels"; +import { + getEventFaDescription, + getEventFaLabel, +} from "./helper/timeline-fa-labels"; import { buildEnrichedDamagedParts } from "src/expert-claim/dto/claim-damaged-part.enricher"; @Injectable() @@ -2819,6 +2822,7 @@ export class ExpertInsurerService { source: "blame", type: ev.type, faLabel: getEventFaLabel(ev), + description: getEventFaDescription(ev), timestamp: ev.timestamp, actor: this.buildTimelineActor(ev.actor, actorName, performedBy), actorName, @@ -2827,7 +2831,7 @@ export class ExpertInsurerService { ExpertInsurerService.resolveTimelinePerformedByCategory(performedBy), performedByFaLabel: ExpertInsurerService.resolveTimelinePerformedByFaLabel(performedBy), - metadata: ev.metadata ?? null, + metadata: this.localizeTimelineMetadata(ev), }); } } @@ -2856,6 +2860,7 @@ export class ExpertInsurerService { source: "claim", type: ev.type, faLabel: getEventFaLabel(ev), + description: getEventFaDescription(ev), timestamp: ev.timestamp, actor: this.buildTimelineActor(ev.actor, actorName, performedBy), actorName, @@ -2864,7 +2869,7 @@ export class ExpertInsurerService { ExpertInsurerService.resolveTimelinePerformedByCategory(performedBy), performedByFaLabel: ExpertInsurerService.resolveTimelinePerformedByFaLabel(performedBy), - metadata: ev.metadata ?? null, + metadata: this.localizeTimelineMetadata(ev), }); } } @@ -2878,4 +2883,22 @@ export class ExpertInsurerService { // Resolve insurer's client name for context if needed — just return raw events return { publicId, events }; } + + /** + * Keep the timeline metadata intact, but replace persisted system-generated + * descriptions with their Persian presentation value. + */ + private localizeTimelineMetadata(event: { + type: string; + metadata?: Record; + }): Record | null { + if (!event.metadata) return null; + + return { + ...event.metadata, + ...(Object.prototype.hasOwnProperty.call(event.metadata, "description") + ? { description: getEventFaDescription(event) } + : {}), + }; + } } diff --git a/src/expert-insurer/helper/timeline-fa-labels.spec.ts b/src/expert-insurer/helper/timeline-fa-labels.spec.ts new file mode 100644 index 0000000..8e04ef4 --- /dev/null +++ b/src/expert-insurer/helper/timeline-fa-labels.spec.ts @@ -0,0 +1,25 @@ +import { getEventFaDescription, getEventFaLabel } from "./timeline-fa-labels"; + +describe("insurer timeline Persian labels", () => { + it("localizes the user-submission detail shown to insurers", () => { + const event = { + type: "STEP_COMPLETED", + metadata: { + stepKey: "USER_SUBMISSION_COMPLETE", + description: + "User submission complete. Claim ready for damage expert review.", + }, + }; + + expect(getEventFaLabel(event)).toBe("ارسال اطلاعات توسط کاربر"); + expect(getEventFaDescription(event)).toBe( + "ثبت اطلاعات کاربر تکمیل شد و پرونده آماده بررسی کارشناس خسارت است.", + ); + }); + + it("never returns an English event type as the description fallback", () => { + expect(getEventFaDescription({ type: "UNRECOGNIZED_EVENT" })).toBe( + "رویدادی در روند پرونده ثبت شد.", + ); + }); +}); diff --git a/src/expert-insurer/helper/timeline-fa-labels.ts b/src/expert-insurer/helper/timeline-fa-labels.ts index d6767fa..c41e110 100644 --- a/src/expert-insurer/helper/timeline-fa-labels.ts +++ b/src/expert-insurer/helper/timeline-fa-labels.ts @@ -120,7 +120,8 @@ export const EVENT_TYPE_FA_LABELS: Record = { // Owner insurer approval OWNER_SIGNED_INSURER_APPROVAL: "صاحب خودرو قرارداد بیمه را امضا کرد", - OWNER_REJECTED_INSURER_APPROVAL_PRICING: "صاحب خودرو قیمت‌گذاری بیمه را رد کرد", + OWNER_REJECTED_INSURER_APPROVAL_PRICING: + "صاحب خودرو قیمت‌گذاری بیمه را رد کرد", OWNER_SIGNED_PRICED_PARTS_PENDING_FACTOR_UPLOAD: "صاحب خودرو قطعات قیمت‌گذاری‌شده را امضا کرد و در انتظار بارگذاری فاکتور", OWNER_REJECTED_PRICED_PARTS_BEFORE_FACTORS: @@ -129,8 +130,10 @@ export const EVENT_TYPE_FA_LABELS: Record = { // Fanavaran sync FANAVARAN_AUTO_SUBMIT_SUCCEEDED: "ارسال خودکار به فناوران موفق بود", FANAVARAN_AUTO_SUBMIT_FAILED: "ارسال خودکار به فناوران ناموفق بود", - FANAVARAN_EARLY_AUTO_SUBMIT_SUCCEEDED: "ارسال زودهنگام خودکار به فناوران موفق بود", - FANAVARAN_EARLY_AUTO_SUBMIT_FAILED: "ارسال زودهنگام خودکار به فناوران ناموفق بود", + FANAVARAN_EARLY_AUTO_SUBMIT_SUCCEEDED: + "ارسال زودهنگام خودکار به فناوران موفق بود", + FANAVARAN_EARLY_AUTO_SUBMIT_FAILED: + "ارسال زودهنگام خودکار به فناوران ناموفق بود", FANAVARAN_EXPERTISE_AUTO_SUBMIT_SUCCEEDED: "ارسال خودکار کارشناسی به فناوران موفق بود", FANAVARAN_EXPERTISE_AUTO_SUBMIT_FAILED: @@ -168,6 +171,28 @@ export const STEP_KEY_FA_LABELS: Record = { CLAIM_COMPLETED: "پرونده خسارت تکمیل شد", }; +/** + * Persian descriptions for workflow-step events. These are deliberately + * complete sentences because the insurer timeline exposes them as the event + * detail, while `STEP_KEY_FA_LABELS` is intended for short UI labels. + */ +const STEP_KEY_FA_DESCRIPTIONS: Record = { + 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 @@ -177,10 +202,7 @@ export function resolveStepCompletedFaLabel(event: { type: string; metadata?: any; }): string | undefined { - if ( - event.type !== "STEP_COMPLETED" && - event.type !== "V3_STEP_COMPLETED" - ) { + if (event.type !== "STEP_COMPLETED" && event.type !== "V3_STEP_COMPLETED") { return undefined; } const stepKey: string | undefined = event.metadata?.stepKey; @@ -203,3 +225,23 @@ export function getEventFaLabel(event: { event.type ); } + +/** + * Returns a Persian sentence suitable for the timeline event `description`. + * + * History is persisted over time, so its older metadata can contain English + * system messages. The insurer endpoint uses this derived value instead of + * exposing persisted text, which localizes both old and new timeline records. + */ +export function getEventFaDescription(event: { + type: string; + metadata?: any; +}): string { + const stepKey: string | undefined = event.metadata?.stepKey; + if (stepKey && STEP_KEY_FA_DESCRIPTIONS[stepKey]) { + return STEP_KEY_FA_DESCRIPTIONS[stepKey]; + } + + const label = getEventFaLabel(event); + return label === event.type ? "رویدادی در روند پرونده ثبت شد." : `${label}.`; +} diff --git a/src/request-management/file-reviewer-blame-v4.controller.ts b/src/request-management/file-reviewer-blame-v4.controller.ts index 6710f09..65dec6d 100644 --- a/src/request-management/file-reviewer-blame-v4.controller.ts +++ b/src/request-management/file-reviewer-blame-v4.controller.ts @@ -350,14 +350,13 @@ export class FileReviewerBlameV4Controller { @ApiParam({ name: "claimRequestId" }) @ApiConsumes("multipart/form-data") @ApiBody({ - description: "Signature file, agreement, and branch", + description: "Signature file and agreement", schema: { type: "object", - required: ["sign", "agree", "branchId"], + required: ["sign", "agree"], properties: { sign: { type: "string", format: "binary", description: "Signature image" }, agree: { type: "boolean", description: "true to accept, false to reject" }, - branchId: { type: "string", description: "Insurer branch ID" }, }, }, }) @@ -386,7 +385,6 @@ export class FileReviewerBlameV4Controller { async submitOwnerSign( @Param("claimRequestId") claimRequestId: string, @Body("agree") agree: string | boolean, - @Body("branchId") branchId: string, @CurrentUser() fileReviewer: any, @UploadedFile() sign: Express.Multer.File, ) { @@ -399,7 +397,6 @@ export class FileReviewerBlameV4Controller { return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2( claimRequestId, agreed, - typeof branchId === "string" ? branchId : "", sign, fileReviewer.sub, fileReviewer, diff --git a/src/request-management/file-reviewer-blame-v5.controller.ts b/src/request-management/file-reviewer-blame-v5.controller.ts index f67f35e..5c76cf0 100644 --- a/src/request-management/file-reviewer-blame-v5.controller.ts +++ b/src/request-management/file-reviewer-blame-v5.controller.ts @@ -348,14 +348,13 @@ export class FileReviewerBlameV5Controller { @ApiParam({ name: "claimRequestId" }) @ApiConsumes("multipart/form-data") @ApiBody({ - description: "Signature file, agreement, and branch", + description: "Signature file and agreement", schema: { type: "object", - required: ["sign", "agree", "branchId"], + required: ["sign", "agree"], properties: { sign: { type: "string", format: "binary", description: "Signature image" }, agree: { type: "boolean", description: "true to accept, false to reject" }, - branchId: { type: "string", description: "Insurer branch ID" }, }, }, }) @@ -384,7 +383,6 @@ export class FileReviewerBlameV5Controller { async submitOwnerSign( @Param("claimRequestId") claimRequestId: string, @Body("agree") agree: string | boolean, - @Body("branchId") branchId: string, @CurrentUser() fileReviewer: any, @UploadedFile() sign: Express.Multer.File, ) { @@ -397,7 +395,6 @@ export class FileReviewerBlameV5Controller { return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2( claimRequestId, agreed, - typeof branchId === "string" ? branchId : "", sign, fileReviewer.sub, fileReviewer,