YARA-1257, YARA-1272, YARA-985

This commit is contained in:
SepehrYahyaee
2026-09-12 11:19:57 +03:30
parent ebe45646c1
commit 15342e16d8
18 changed files with 279 additions and 207 deletions

View File

@@ -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.

View File

@@ -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<string> {
const ids = new Set<string>();
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: {},
},
},
});

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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,

View File

@@ -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<typeof createDependencies>) =>
new ExpertInitiatedClaimMirrorController(
deps.claimRequestManagementService as never,
deps.mediaPolicyService as never,
),
],
[
"V4 file-reviewer",
(deps: ReturnType<typeof createDependencies>) =>
new FileReviewerBlameV4Controller(
{} as never,
deps.claimRequestManagementService as never,
deps.mediaPolicyService as never,
),
],
[
"V5 file-reviewer",
(deps: ReturnType<typeof createDependencies>) =>
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<unknown>;
};
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);
},
);
});

View File

@@ -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(),
}),
}),
}),
);
});
});