forked from Yara724/api
Merge pull request 'YARA-1257, YARA-1272, YARA-985' (#304) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#304
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}): Record<string, unknown> | null {
|
||||
if (!event.metadata) return null;
|
||||
|
||||
return {
|
||||
...event.metadata,
|
||||
...(Object.prototype.hasOwnProperty.call(event.metadata, "description")
|
||||
? { description: getEventFaDescription(event) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
25
src/expert-insurer/helper/timeline-fa-labels.spec.ts
Normal file
25
src/expert-insurer/helper/timeline-fa-labels.spec.ts
Normal file
@@ -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(
|
||||
"رویدادی در روند پرونده ثبت شد.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -120,7 +120,8 @@ export const EVENT_TYPE_FA_LABELS: Record<string, string> = {
|
||||
|
||||
// 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<string, string> = {
|
||||
// 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<string, string> = {
|
||||
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<string, string> = {
|
||||
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}.`;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user