From 9463142ecf71a2b3e687105508eda477ebf5373f Mon Sep 17 00:00:00 2001 From: "s.hajizadeh" Date: Mon, 20 Apr 2026 11:19:49 +0330 Subject: [PATCH 1/3] blame status agreement fixed --- .../request-management.service.ts | 58 +++++++++++++++---- 1 file changed, 46 insertions(+), 12 deletions(-) diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 1f7500e..36bb5e5 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -1154,18 +1154,13 @@ export class RequestManagementService { (party.statement as any).lightCondition = body.lightCondition; } - // If second party finished description, we’re ready for expert flow. - if (stepKey === WorkflowStep.SECOND_DESCRIPTION) { - req.status = CaseStatus.WAITING_FOR_EXPERT; - } - - await this.advanceWorkflowToNext(req, stepKey); - - if ( + const isSecondThirdParty = stepKey === WorkflowStep.SECOND_DESCRIPTION && - req.type === BlameRequestType.THIRD_PARTY && - !(req as any).expert?.decision?.guiltyPartyId - ) { + req.type === BlameRequestType.THIRD_PARTY; + let closedByMutualAgreement = false; + + // THIRD_PARTY + AGREED: guilt/damage already implied by first-party confession — no field expert needed. + if (isSecondThirdParty && req.blameStatus === BlameStatus.AGREED) { const built = buildMutualAgreementExpertDecision( (req as any).toObject ? (req as any).toObject() @@ -1174,6 +1169,40 @@ export class RequestManagementService { if (built) { if (!(req as any).expert) (req as any).expert = {}; (req as any).expert.decision = built as any; + const completed = Array.isArray(req.workflow.completedSteps) + ? req.workflow.completedSteps + : []; + if (!completed.includes(stepKey)) completed.push(stepKey); + if (!completed.includes(WorkflowStep.COMPLETED as any)) { + completed.push(WorkflowStep.COMPLETED as any); + } + req.workflow.completedSteps = completed; + req.workflow.currentStep = WorkflowStep.COMPLETED; + req.workflow.nextStep = undefined; + req.status = CaseStatus.COMPLETED; + closedByMutualAgreement = true; + } + } + + if (!closedByMutualAgreement) { + if (stepKey === WorkflowStep.SECOND_DESCRIPTION) { + req.status = CaseStatus.WAITING_FOR_EXPERT; + } + await this.advanceWorkflowToNext(req, stepKey); + if ( + stepKey === WorkflowStep.SECOND_DESCRIPTION && + req.type === BlameRequestType.THIRD_PARTY && + !(req as any).expert?.decision?.guiltyPartyId + ) { + const built = buildMutualAgreementExpertDecision( + (req as any).toObject + ? (req as any).toObject() + : { ...req }, + ); + if (built) { + if (!(req as any).expert) (req as any).expert = {}; + (req as any).expert.decision = built as any; + } } } @@ -1187,7 +1216,12 @@ export class RequestManagementService { actorName: user?.fullName, actorType: "user", }, - metadata: { role }, + metadata: { + role, + ...(closedByMutualAgreement + ? { mutualAgreementCaseComplete: true, closedWithoutExpert: true } + : {}), + }, } as any); await (req as any).save(); From 41c44dcf23b02c78e22d1408b27fd6c0409ce427 Mon Sep 17 00:00:00 2001 From: "s.hajizadeh" Date: Mon, 20 Apr 2026 18:19:25 +0330 Subject: [PATCH 2/3] daghi + expert and damage expert name in decision + signature required added --- .../schema/claim-case.evaluation.schema.ts | 20 ++++ .../schema/claim-case.workflow.schema.ts | 8 ++ .../schema/claim-request-management.schema.ts | 20 ++++ src/expert-blame/expert-blame.service.ts | 37 ++++++ src/expert-claim/dto/expert-claim-v2.dto.ts | 34 +++++- src/expert-claim/expert-claim.service.ts | 109 +++++++++++++++++- .../expert-claim.v2.controller.ts | 2 +- src/helpers/expert-profile-snapshot.ts | 29 +++++ .../schema/expert-profile-snapshot.schema.ts | 26 +++++ .../entities/schema/expert-section.type.ts | 12 ++ .../schema/request-management.schema.ts | 16 +++ .../entities/schema/workflow.type.ts | 8 ++ .../request-management.service.ts | 15 ++- 13 files changed, 324 insertions(+), 12 deletions(-) create mode 100644 src/helpers/expert-profile-snapshot.ts create mode 100644 src/request-management/entities/schema/expert-profile-snapshot.schema.ts 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 75f36d7..9d4b28f 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 @@ -1,5 +1,9 @@ import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; import { Schema as MongooseSchema, Types } from "mongoose"; +import { + ExpertProfileSnapshot, + ExpertProfileSnapshotSchema, +} from "src/request-management/entities/schema/expert-profile-snapshot.schema"; import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum"; import { UserReplyEnum } from "src/Types&Enums/claim-request-management/userReply.enum"; @@ -83,6 +87,10 @@ export class ClaimExpertReply { @Prop({ type: ClaimUserCommentSchema }) userComment?: ClaimUserComment; + + /** Damage expert profile at reply time (from `damage-expert` collection). */ + @Prop({ type: ExpertProfileSnapshotSchema }) + expertProfileSnapshot?: ExpertProfileSnapshot; } export const ClaimExpertReplySchema = SchemaFactory.createForClass(ClaimExpertReply); @@ -160,6 +168,10 @@ export class ClaimResendRequest { /** Set when the owner has satisfied every requested document/part (or acknowledged description-only resend). */ @Prop({ type: Date }) fulfilledAt?: Date; + + /** Damage expert profile when resend was requested (`damage-expert` collection). */ + @Prop({ type: ExpertProfileSnapshotSchema }) + expertProfileSnapshot?: ExpertProfileSnapshot; } export const ClaimResendRequestSchema = SchemaFactory.createForClass(ClaimResendRequest); @@ -239,6 +251,14 @@ export class ClaimEvaluation { @Prop({ type: ClaimPriceDropSchema }) priceDrop?: ClaimPriceDrop; + + /** Damage expert profile at last factor validation (`damage-expert` collection). */ + @Prop({ type: ExpertProfileSnapshotSchema }) + factorValidationExpertProfileSnapshot?: ExpertProfileSnapshot; + + /** Damage expert profile when in-person visit was requested (`damage-expert` collection). */ + @Prop({ type: ExpertProfileSnapshotSchema }) + inPersonVisitExpertProfileSnapshot?: ExpertProfileSnapshot; } export const ClaimEvaluationSchema = SchemaFactory.createForClass(ClaimEvaluation); diff --git a/src/claim-request-management/entites/schema/claim-case.workflow.schema.ts b/src/claim-request-management/entites/schema/claim-case.workflow.schema.ts index ccdec8a..5fdca80 100644 --- a/src/claim-request-management/entites/schema/claim-case.workflow.schema.ts +++ b/src/claim-request-management/entites/schema/claim-case.workflow.schema.ts @@ -1,6 +1,10 @@ import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; import { Types } from "mongoose"; import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum"; +import { + ExpertProfileSnapshot, + ExpertProfileSnapshotSchema, +} from "src/request-management/entities/schema/expert-profile-snapshot.schema"; @Schema({ _id: false }) export class ClaimActorLock { @@ -12,6 +16,10 @@ export class ClaimActorLock { @Prop({ type: String, default: "damage_expert" }) actorRole: "damage_expert" | "expert" | "admin"; + + /** Damage expert profile when lock was taken (`damage-expert` collection). */ + @Prop({ type: ExpertProfileSnapshotSchema }) + expertProfileSnapshot?: ExpertProfileSnapshot; } export const ClaimActorLockSchema = SchemaFactory.createForClass(ClaimActorLock); diff --git a/src/claim-request-management/entites/schema/claim-request-management.schema.ts b/src/claim-request-management/entites/schema/claim-request-management.schema.ts index 8bb6624..db80819 100644 --- a/src/claim-request-management/entites/schema/claim-request-management.schema.ts +++ b/src/claim-request-management/entites/schema/claim-request-management.schema.ts @@ -122,6 +122,10 @@ export class SubmitReply { @Prop() userComment?: UserComment; + + /** Damage expert profile at reply time (`damage-expert` collection). */ + @Prop({ type: Object }) + expertProfileSnapshot?: Record; } export class ActorLockDetails { @@ -130,6 +134,10 @@ export class ActorLockDetails { @Prop({ type: Types.ObjectId }) actorId?: Types.ObjectId; + + /** Damage expert profile when lock was taken (`damage-expert` collection). */ + @Prop({ type: Object }) + expertProfileSnapshot?: Record; } export class ResendCarPartsDto { @@ -149,6 +157,10 @@ export class ClaimSubmitResend { @Prop({ required: true }) resendCarParts: ResendCarPartsDto[]; + + /** Damage expert profile when resend was requested (`damage-expert` collection). */ + @Prop({ type: Object }) + expertProfileSnapshot?: Record; } export class UserResendDocuments { @@ -360,6 +372,14 @@ export class ClaimRequestManagementModel { @Prop({ type: UserClaimRating, required: false }) userRating?: UserClaimRating; + /** Latest damage expert profile when validating repair factors (V1). */ + @Prop({ type: Object }) + factorValidationExpertProfileSnapshot?: Record; + + /** Damage expert profile when in-person visit was requested (V1). */ + @Prop({ type: Object }) + damageExpertInPersonVisitProfileSnapshot?: Record; + @Prop({ type: String, required: false }) visitLocation?: string; diff --git a/src/expert-blame/expert-blame.service.ts b/src/expert-blame/expert-blame.service.ts index be81346..027f015 100644 --- a/src/expert-blame/expert-blame.service.ts +++ b/src/expert-blame/expert-blame.service.ts @@ -39,6 +39,8 @@ import { ExpertDbService } from "src/users/entities/db-service/expert.db.service import { BlameDocumentDbService } from "src/request-management/entities/db-service/blame-document.db.service"; import { UserSignDbService } from "src/request-management/entities/db-service/sign.db.service"; import { RequestManagementService } from "src/request-management/request-management.service"; +import { snapshotFromFieldExpert } from "src/helpers/expert-profile-snapshot"; +import { ExpertModel } from "src/users/entities/schema/expert.schema"; interface CheckedRequestEntry { CheckedRequest?: { @@ -81,6 +83,14 @@ export class ExpertBlameService { private readonly requestManagementService: RequestManagementService, ) { } + /** Load immutable profile fields from `expert` for the acting field expert. */ + private async snapshotFieldExpert(actorId: string) { + const expertDoc = await this.expertDbService.findOne({ + _id: new Types.ObjectId(actorId), + }); + return snapshotFromFieldExpert(expertDoc as ExpertModel); + } + async findAll(actor: any): Promise { // 1. Fetch all potentially relevant requests from the database. // Exclude CAR_BODY type requests as they are automatically handled and don't need expert review @@ -767,6 +777,7 @@ export class ExpertBlameService { } const fifteenMinutes = new Date(Date.now() + 15 * 60 * 1000); + const lockSnapshot = await this.snapshotFieldExpert(actorDetail.sub); const updateResult = await this.requestManagementDbService.findOneAndUpdate( { @@ -782,6 +793,7 @@ export class ExpertBlameService { actorLocked: { fullName: actorDetail.fullName, actorId: new Types.ObjectId(actorDetail.sub), + ...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }), }, }, $push: { @@ -872,6 +884,8 @@ export class ExpertBlameService { } } + const lockSnapshot = await this.snapshotFieldExpert(actorDetail.sub); + // Lock the request (either unlocked or expired lock) const updateResult = await this.blameRequestDbService.findByIdAndUpdate( requestId, @@ -883,6 +897,7 @@ export class ExpertBlameService { actorId: new Types.ObjectId(actorDetail.sub), actorName: actorDetail.fullName || "Unknown Expert", actorRole: "expert", + ...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }), }, }, }, @@ -1004,6 +1019,7 @@ export class ExpertBlameService { } const now = new Date(); + const resendSnapshot = await this.snapshotFieldExpert(actorId); const partyResendRequests = resendDto.parties.map((party) => { const uid = String(party.partyId); @@ -1026,6 +1042,7 @@ export class ExpertBlameService { parties: partyResendRequests, requestedAt: now, requestedByExpertId: new Types.ObjectId(actorId), + ...(resendSnapshot && { expertProfileSnapshot: resendSnapshot }), }, status: CaseStatus.WAITING_FOR_DOCUMENT_RESEND, }, @@ -1151,12 +1168,15 @@ export class ExpertBlameService { const now = new Date(); + const expertProfileSnapshot = await this.snapshotFieldExpert(actorId); + // Build expert decision with fields const decisionPayload: any = { guiltyPartyId: new Types.ObjectId(String(reply.guiltyUserId)), description: reply.description, decidedAt: now, decidedByExpertId: new Types.ObjectId(actorId), + ...(expertProfileSnapshot && { expertProfileSnapshot }), fields: { accidentWay: { id: reply.fields.accidentWay.id, @@ -1279,6 +1299,7 @@ export class ExpertBlameService { } const now = new Date(); + const inPersonSnapshot = await this.snapshotFieldExpert(actorId); // Build expert decision with fields const decisionPayload: any = { @@ -1286,6 +1307,7 @@ export class ExpertBlameService { description: "حضوری مراجعه شود.", decidedAt: now, decidedByExpertId: new Types.ObjectId(actorId), + ...(inPersonSnapshot && { expertProfileSnapshot: inPersonSnapshot }), fields: { accidentWay: null, accidentReason: null, @@ -1445,6 +1467,8 @@ export class ExpertBlameService { ); } + const expertProfileSnapshot = await this.snapshotFieldExpert(userId); + const newReplyObject = { description: reply.description, submitTime: new Date(), @@ -1466,6 +1490,7 @@ export class ExpertBlameService { }, firstPartyComment: request.expertSubmitReply?.firstPartyComment || null, secondPartyComment: request.expertSubmitReply?.secondPartyComment || null, + ...(expertProfileSnapshot && { expertProfileSnapshot }), }; const updatePayload: any = { @@ -1522,6 +1547,7 @@ export class ExpertBlameService { } const partyType = req.route.path.split("/")[4]; + const resendSnapshot = await this.snapshotFieldExpert(userId); switch (partyType) { case "first": { @@ -1542,6 +1568,9 @@ export class ExpertBlameService { "expertResendReply.firstParty.firstPartyId": firstPartyId, "expertResendReply.firstParty.firstPartyDescription": firstPartyDescription, + ...(resendSnapshot && { + expertResendByExpertProfileSnapshot: resendSnapshot, + }), $push: { actorsChecker: { [ReqBlameStatus.UserPending]: request.actorLocked, @@ -1579,6 +1608,9 @@ export class ExpertBlameService { "expertResendReply.secondParty.secondPartyId": secondPartyId, "expertResendReply.secondParty.secondPartyDescription": secondPartyDescription, + ...(resendSnapshot && { + expertResendByExpertProfileSnapshot: resendSnapshot, + }), $push: { actorsChecker: { [ReqBlameStatus.UserPending]: request.actorLocked, @@ -1658,10 +1690,15 @@ export class ExpertBlameService { throw new NotFoundException("Blame not found"); } + const visitSnapshot = await this.snapshotFieldExpert(actorDetail.sub); + const updated = await this.requestManagementDbService.findAndUpdate( { _id: new Types.ObjectId(requestId) }, { blameStatus: ReqBlameStatus.InPersonVisit, + ...(visitSnapshot && { + fieldExpertInPersonVisitProfileSnapshot: visitSnapshot, + }), }, ); diff --git a/src/expert-claim/dto/expert-claim-v2.dto.ts b/src/expert-claim/dto/expert-claim-v2.dto.ts index 3420f8e..eb91c58 100644 --- a/src/expert-claim/dto/expert-claim-v2.dto.ts +++ b/src/expert-claim/dto/expert-claim-v2.dto.ts @@ -11,6 +11,7 @@ import { import { Type } from 'class-transformer'; import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum'; import { DamagedPartItem } from 'src/claim-request-management/dto/capture-requirements-v2.dto'; +import { DaghiOption } from 'src/Types&Enums/claim-request-management/daghi-option.enum'; export class CarPartDamageV2Dto { @ApiProperty({ example: 'left' }) @@ -22,6 +23,31 @@ export class CarPartDamageV2Dto { part: string; } +/** Same shape as V1 {@link PartsList} `daghi` (damage expert reply). */ +export class DaghiDetailsV2Dto { + @ApiProperty({ + enum: DaghiOption, + description: + 'Daghi option: ارزش لوازم بازیافتی, تحویل داغی, فاقد ارزش, با احتساب داغی', + }) + @IsEnum(DaghiOption) + option: DaghiOption; + + @ApiPropertyOptional({ + description: `Required when option is '${DaghiOption.RECYCLED_PARTS_VALUE}'`, + }) + @IsOptional() + @IsString() + price?: string; + + @ApiPropertyOptional({ + description: `Required when option is '${DaghiOption.DELIVER_DAMAGED_PART}' (Mongo ObjectId string)`, + }) + @IsOptional() + @IsString() + branchId?: string; +} + export class PartPricingV2Dto { @ApiProperty({ example: 'part-001' }) @IsString() @@ -49,10 +75,10 @@ export class PartPricingV2Dto { @IsString() totalPayment: string; - @ApiPropertyOptional({ example: '500000' }) - @IsOptional() - @IsString() - daghi?: string; + @ApiProperty({ type: DaghiDetailsV2Dto }) + @ValidateNested() + @Type(() => DaghiDetailsV2Dto) + daghi: DaghiDetailsV2Dto; @ApiProperty({ example: false }) @IsBoolean() diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index fced132..ec7af51 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -59,6 +59,8 @@ import { enrichBlamePartiesForAgreementView, } from "src/helpers/blame-party-agreement-decision"; import { resendRequestHasPayload } from "src/helpers/claim-expert-resend"; +import { snapshotFromDamageExpert } from "src/helpers/expert-profile-snapshot"; +import { DamageExpertModel } from "src/users/entities/schema/damage-expert.schema"; @Injectable() export class ExpertClaimService { @@ -183,6 +185,12 @@ export class ExpertClaimService { private readonly branchDbService: BranchDbService, ) { } + /** Load immutable profile fields from `damage-expert` for the acting expert. */ + private async snapshotDamageExpert(actorId: string) { + const doc = await this.damageExpertDbService.findById(actorId); + return snapshotFromDamageExpert(doc as DamageExpertModel); + } + /** Map blame party `Vehicle` (name/model/type) to expert panel shape. */ private expertVehicleFromPartyVehicle(v: any): { carName?: string; @@ -474,6 +482,7 @@ export class ExpertClaimService { } const fifteenMinutes = new Date(Date.now() + 15 * 60 * 1000); + const lockSnapshot = await this.snapshotDamageExpert(actorDetail.sub); await this.claimRequestManagementDbService.findOneAndUpdate( new Types.ObjectId(requestId), @@ -486,6 +495,7 @@ export class ExpertClaimService { actorLocked: { actorId: new Types.ObjectId(actorDetail.sub), fullName: actorDetail.fullName, + ...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }), }, }, $push: { @@ -535,6 +545,48 @@ export class ExpertClaimService { ); } + /** Same rules as V1 `submitReplyRequest`: daghi option + conditional price / branchId; `branchId` stored as ObjectId. */ + private validateAndNormalizeDaghiForExpertReplyV2( + parts: import("./dto/expert-claim-v2.dto").PartPricingV2Dto[], + ) { + for (const part of parts) { + if (!part.daghi || !part.daghi.option) { + throw new BadRequestException( + `Daghi option is required for part ${part.partId}`, + ); + } + if (part.daghi.option === DaghiOption.RECYCLED_PARTS_VALUE) { + if (!part.daghi.price) { + throw new BadRequestException( + `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}'`, + ); + } + if (!Types.ObjectId.isValid(part.daghi.branchId)) { + throw new BadRequestException( + `Invalid branch ID format for part ${part.partId}`, + ); + } + } + } + + return parts.map((part) => ({ + ...part, + daghi: { + option: part.daghi.option, + ...(part.daghi.price && { price: part.daghi.price }), + ...(part.daghi.branchId && { + branchId: new Types.ObjectId(part.daghi.branchId), + }), + }, + })); + } + private findClosestCar(input: string, cars: { carName: string }[]) { if (!input || !cars || cars.length === 0) { this.logger.debug( @@ -1298,6 +1350,8 @@ export class ExpertClaimService { }, })); + const expertProfileSnapshot = await this.snapshotDamageExpert(userId.sub); + const replyPayload = { description: reply.description, parts: processedParts, @@ -1306,6 +1360,7 @@ export class ExpertClaimService { actorName: userId.fullName, actorId: userId.sub, }, + ...(expertProfileSnapshot && { expertProfileSnapshot }), }; const updatePayload = { @@ -1378,6 +1433,8 @@ export class ExpertClaimService { if (request.damageExpertReplyFinal) throw new ForbiddenException("request already has final reply"); + const resendSnapshot = await this.snapshotDamageExpert(userId); + await this.claimRequestManagementDbService.findAndUpdate(requestId, { lockFile: false, claimStatus: ReqClaimStatus.WaitingForUserToResend, @@ -1385,6 +1442,7 @@ export class ExpertClaimService { resendDescription: body.resendDescription, resendDocuments: body.resendDocuments, resendCarParts: body.resendCarParts, + ...(resendSnapshot && { expertProfileSnapshot: resendSnapshot }), }, $push: { actorsChecker: { @@ -1668,6 +1726,16 @@ export class ExpertClaimService { ); } + const factorSnap = await this.snapshotDamageExpert(expertId); + if (factorSnap && decisions.length > 0) { + await this.claimRequestManagementDbService.findOneAndUpdate( + { _id: new Types.ObjectId(claimId) }, + { + $set: { factorValidationExpertProfileSnapshot: factorSnap }, + }, + ); + } + const updatedClaim = await this.claimRequestManagementDbService.findOne(claimId); const updatedReply = @@ -1748,6 +1816,8 @@ export class ExpertClaimService { assertClaimCaseForTenant(claim, actor); + const factorValidationSnapshot = await this.snapshotDamageExpert(actor.sub); + if ( claim.status !== ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL || claim.claimStatus !== ClaimStatus.UNDER_REVIEW || @@ -1828,6 +1898,11 @@ export class ExpertClaimService { throw new BadRequestException("No valid factor decisions to apply."); } + if (factorValidationSnapshot) { + $set["evaluation.factorValidationExpertProfileSnapshot"] = + factorValidationSnapshot; + } + await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { $set, }); @@ -1941,10 +2016,15 @@ export class ExpertClaimService { throw new NotFoundException("Blame not found"); } + const visitSnapshot = await this.snapshotDamageExpert(actorDetail.sub); + const updated = await this.claimRequestManagementDbService.findAndUpdate( requestId, { claimStatus: ReqClaimStatus.InPersonVisit, + ...(visitSnapshot && { + damageExpertInPersonVisitProfileSnapshot: visitSnapshot, + }), }, ); @@ -2013,6 +2093,8 @@ export class ExpertClaimService { return { claimRequestId, locked: true, message: 'Already locked by you' }; } + const lockSnapshot = await this.snapshotDamageExpert(actor.sub); + await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { status: ClaimCaseStatus.EXPERT_REVIEWING, claimStatus: ClaimStatus.UNDER_REVIEW, @@ -2022,6 +2104,7 @@ export class ExpertClaimService { actorId: new Types.ObjectId(actor.sub), actorName: actor.fullName, actorRole: 'damage_expert', + ...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }), }, 'workflow.currentStep': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, $push: { @@ -2095,6 +2178,8 @@ export class ExpertClaimService { ); } + const resendSnapshot = await this.snapshotDamageExpert(actor.sub); + await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { $set: { status: ClaimCaseStatus.WAITING_FOR_USER_RESEND, @@ -2106,6 +2191,7 @@ export class ExpertClaimService { resendDescription: desc || undefined, resendDocuments: docs, resendCarParts: parts, + ...(resendSnapshot && { expertProfileSnapshot: resendSnapshot }), }, }, $unset: { @@ -2148,6 +2234,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 30,000,000 + * - Each part must include `daghi` (option + conditional price/branchId) like V1 * * On success: * - Stores reply in evaluation.damageExpertReply @@ -2201,6 +2288,11 @@ export class ExpertClaimService { }); } + const processedParts = + reply.parts?.length > 0 + ? this.validateAndNormalizeDaghiForExpertReplyV2(reply.parts) + : []; + const needsFactorUpload = reply.parts?.some((p) => p.factorNeeded === true) ?? false; const objectionSubmitted = !!claim.evaluation?.objection?.submittedAt; @@ -2220,14 +2312,17 @@ export class ExpertClaimService { ? ClaimWorkflowStep.EXPERT_FINAL_REPLY : ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT; + const expertProfileSnapshot = await this.snapshotDamageExpert(actor.sub); + const replyPayload = { description: reply.description, - parts: reply.parts, + parts: processedParts, submittedAt: new Date(), actorDetail: { actorId: actor.sub, actorName: actor.fullName, }, + ...(expertProfileSnapshot && { expertProfileSnapshot }), }; const nextStep = needsFactorUpload @@ -2262,7 +2357,7 @@ export class ExpertClaimService { timestamp: new Date(), metadata: { factorNeeded: needsFactorUpload, - partsCount: reply.parts?.length ?? 0, + partsCount: processedParts.length, isFinalReplyAfterObjection, replyField, }, @@ -2325,10 +2420,15 @@ export class ExpertClaimService { throw new ForbiddenException('This claim is locked by another expert'); } + const visitSnapshot = await this.snapshotDamageExpert(actor.sub); + await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { claimStatus: ClaimStatus.NEEDS_REVISION, 'workflow.locked': false, ...(note ? { 'evaluation.visitLocation': note } : {}), + ...(visitSnapshot && { + 'evaluation.inPersonVisitExpertProfileSnapshot': visitSnapshot, + }), $push: { history: { event: 'IN_PERSON_VISIT_REQUESTED', @@ -2815,6 +2915,8 @@ export class ExpertClaimService { throw new ForbiddenException("This claim is locked by another expert"); } + const damagedPartsEditSnapshot = await this.snapshotDamageExpert(actor.sub); + const previous = Array.isArray((claim as any).damage?.selectedParts) ? [...(claim as any).damage.selectedParts] : []; @@ -2848,6 +2950,9 @@ export class ExpertClaimService { metadata: { previousSelectedParts: previous, selectedParts, + ...(damagedPartsEditSnapshot && { + expertProfileSnapshot: damagedPartsEditSnapshot, + }), }, }); diff --git a/src/expert-claim/expert-claim.v2.controller.ts b/src/expert-claim/expert-claim.v2.controller.ts index 72113a3..c688120 100644 --- a/src/expert-claim/expert-claim.v2.controller.ts +++ b/src/expert-claim/expert-claim.v2.controller.ts @@ -76,7 +76,7 @@ export class ExpertClaimV2Controller { @ApiOperation({ summary: "Submit expert damage assessment reply", description: - "Claim must be locked by this expert (status EXPERT_REVIEWING). Submitting unlocks the claim. If any part has factorNeeded=true the claim moves to EXPERT_COST_EVALUATION; otherwise moves to INSURER_REVIEW. Total payment cap is 30,000,000.", + "Claim must be locked by this expert (status EXPERT_REVIEWING). Submitting unlocks the claim. Each part requires `daghi` with the same rules as V1 (DaghiOption, price for recycled value, branchId for deliver damaged part). If any part has factorNeeded=true the claim moves to EXPERT_COST_EVALUATION; otherwise moves to INSURER_REVIEW. Total payment cap is 30,000,000.", }) @ApiParam({ name: "claimRequestId" }) @ApiBody({ type: SubmitExpertReplyV2Dto }) diff --git a/src/helpers/expert-profile-snapshot.ts b/src/helpers/expert-profile-snapshot.ts new file mode 100644 index 0000000..8d17562 --- /dev/null +++ b/src/helpers/expert-profile-snapshot.ts @@ -0,0 +1,29 @@ +import { ExpertModel } from "src/users/entities/schema/expert.schema"; +import { DamageExpertModel } from "src/users/entities/schema/damage-expert.schema"; +import { ExpertProfileSnapshot } from "src/request-management/entities/schema/expert-profile-snapshot.schema"; + +export function snapshotFromFieldExpert( + doc: ExpertModel | null | undefined, +): ExpertProfileSnapshot | undefined { + if (!doc) return undefined; + return { + firstName: doc.firstName ?? undefined, + lastName: doc.lastName ?? undefined, + email: doc.email ?? undefined, + insuActivityCo: doc.insuActivityCo ?? undefined, + mobile: (doc.mobile ?? doc.phone) ?? undefined, + }; +} + +export function snapshotFromDamageExpert( + doc: DamageExpertModel | null | undefined, +): ExpertProfileSnapshot | undefined { + if (!doc) return undefined; + return { + firstName: doc.firstName ?? undefined, + lastName: doc.lastName ?? undefined, + email: doc.email ?? undefined, + insuActivityCo: doc.insuActivityCo ?? undefined, + mobile: doc.mobile ?? undefined, + }; +} diff --git a/src/request-management/entities/schema/expert-profile-snapshot.schema.ts b/src/request-management/entities/schema/expert-profile-snapshot.schema.ts new file mode 100644 index 0000000..25e5742 --- /dev/null +++ b/src/request-management/entities/schema/expert-profile-snapshot.schema.ts @@ -0,0 +1,26 @@ +import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; + +/** + * Immutable snapshot of the expert at reply time (field expert or damage expert). + * Stored on blame `expert.decision` and claim `evaluation.damageExpertReply*`. + */ +@Schema({ _id: false }) +export class ExpertProfileSnapshot { + @Prop() + firstName?: string; + + @Prop() + lastName?: string; + + @Prop() + email?: string; + + @Prop() + insuActivityCo?: string; + + @Prop() + mobile?: string; +} + +export const ExpertProfileSnapshotSchema = + SchemaFactory.createForClass(ExpertProfileSnapshot); diff --git a/src/request-management/entities/schema/expert-section.type.ts b/src/request-management/entities/schema/expert-section.type.ts index 1b60704..d07f8be 100644 --- a/src/request-management/entities/schema/expert-section.type.ts +++ b/src/request-management/entities/schema/expert-section.type.ts @@ -1,6 +1,10 @@ //! NEW import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; import { Types } from "mongoose"; +import { + ExpertProfileSnapshot, + ExpertProfileSnapshotSchema, +} from "./expert-profile-snapshot.schema"; @Schema({ _id: false }) @@ -52,6 +56,10 @@ export class ExpertDecision { accidentReason: { id: string; label: string; fanavaran: number }; accidentType: { id: string; label: string }; }; + + /** Field expert profile at decision time (from `expert` collection). */ + @Prop({ type: ExpertProfileSnapshotSchema }) + expertProfileSnapshot?: ExpertProfileSnapshot; } export const ExpertDecisionSchema = SchemaFactory.createForClass(ExpertDecision); @@ -108,6 +116,10 @@ export class ExpertResend { @Prop({ type: Types.ObjectId }) requestedByExpertId?: Types.ObjectId; + /** Field expert profile when resend was requested (`expert` collection). */ + @Prop({ type: ExpertProfileSnapshotSchema }) + expertProfileSnapshot?: ExpertProfileSnapshot; + /** * @deprecated Unused for resend UI; clients use per-party requestedItems + inputKind. */ diff --git a/src/request-management/entities/schema/request-management.schema.ts b/src/request-management/entities/schema/request-management.schema.ts index 9f90deb..c1ad608 100644 --- a/src/request-management/entities/schema/request-management.schema.ts +++ b/src/request-management/entities/schema/request-management.schema.ts @@ -249,6 +249,10 @@ export class SubmitReply { @Prop() systemResponse: string; + + /** Field expert profile at reply time (`expert` collection). */ + @Prop({ type: Object }) + expertProfileSnapshot?: Record; } export class ExpertResendParties { @@ -271,6 +275,10 @@ export class ActorLockDetails { @Prop({ type: Types.ObjectId }) actorId?: Types.ObjectId; + + /** Field expert profile when lock was taken (`expert` collection). */ + @Prop({ type: Object }) + expertProfileSnapshot?: Record; } export enum CreationMethod { @@ -450,6 +458,14 @@ export class RequestManagementModel { @Prop({ type: Boolean, default: false }) isHandledStatsUpdated?: boolean; + /** Latest field expert profile when expert requested party resend (V1 `sendAgainRequest`). */ + @Prop({ type: Object }) + expertResendByExpertProfileSnapshot?: Record; + + /** Field expert profile when expert marked in-person visit (V1 `inPersonVisit`). */ + @Prop({ type: Object }) + fieldExpertInPersonVisitProfileSnapshot?: Record; + // Expert-initiated file tracking @Prop({ type: Boolean, default: false }) expertInitiated?: boolean; diff --git a/src/request-management/entities/schema/workflow.type.ts b/src/request-management/entities/schema/workflow.type.ts index 21be9e9..11333b9 100644 --- a/src/request-management/entities/schema/workflow.type.ts +++ b/src/request-management/entities/schema/workflow.type.ts @@ -2,6 +2,10 @@ import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; import { Types } from "mongoose"; import { WorkflowStep } from "src/Types&Enums/blame-request-management/blameWorkflow-steps.enum"; +import { + ExpertProfileSnapshot, + ExpertProfileSnapshotSchema, +} from "./expert-profile-snapshot.schema"; @Schema({ _id: false }) export class ActorLock { @@ -13,6 +17,10 @@ export class ActorLock { @Prop({ type: String, default: "expert" }) actorRole: "expert" | "admin"; + + /** Field expert profile when lock was taken (`expert` collection). */ + @Prop({ type: ExpertProfileSnapshotSchema }) + expertProfileSnapshot?: ExpertProfileSnapshot; } export const ActorLockSchema = SchemaFactory.createForClass(ActorLock); diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 36bb5e5..e8a30a6 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -1173,13 +1173,14 @@ export class RequestManagementService { ? req.workflow.completedSteps : []; if (!completed.includes(stepKey)) completed.push(stepKey); - if (!completed.includes(WorkflowStep.COMPLETED as any)) { - completed.push(WorkflowStep.COMPLETED as any); + // Same as post–field-expert reply: guilt phase is satisfied without an expert; parties must still sign. + if (!completed.includes(WorkflowStep.WAITING_FOR_GUILT_DECISION)) { + completed.push(WorkflowStep.WAITING_FOR_GUILT_DECISION); } req.workflow.completedSteps = completed; - req.workflow.currentStep = WorkflowStep.COMPLETED; + req.workflow.currentStep = WorkflowStep.WAITING_FOR_SIGNATURES; req.workflow.nextStep = undefined; - req.status = CaseStatus.COMPLETED; + req.status = CaseStatus.WAITING_FOR_SIGNATURES; closedByMutualAgreement = true; } } @@ -1219,7 +1220,11 @@ export class RequestManagementService { metadata: { role, ...(closedByMutualAgreement - ? { mutualAgreementCaseComplete: true, closedWithoutExpert: true } + ? { + mutualAgreementWithoutExpert: true, + closedWithoutExpert: true, + awaitingSignatures: true, + } : {}), }, } as any); From 75b7e5ecadc7a61b2fdc5407a95cee9703876f7b Mon Sep 17 00:00:00 2001 From: "s.hajizadeh" Date: Tue, 21 Apr 2026 16:43:15 +0330 Subject: [PATCH 3/3] daghi problem fixed , 2 apis for reporting counts added --- .../schema/claim-case.evaluation.schema.ts | 11 +++-- src/expert-blame/expert-blame.service.ts | 36 ++++++++++++++ .../expert-blame.v2.controller.ts | 19 ++++++- src/expert-claim/expert-claim.service.ts | 23 +++++++++ .../expert-claim.v2.controller.ts | 10 ++++ src/helpers/expert-panel-status-report.ts | 49 +++++++++++++++++++ 6 files changed, 144 insertions(+), 4 deletions(-) create mode 100644 src/helpers/expert-panel-status-report.ts 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 9d4b28f..da6440b 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 @@ -12,8 +12,12 @@ export class ClaimPartPricing { @Prop({ type: String }) partId: string; - @Prop({ type: String }) - carPartDamage?: string; + /** + * Legacy string or structured `{ part, side }` from expert reply DTOs (V1/V2). + * Must be Mixed — objects cannot cast to String. + */ + @Prop({ type: MongooseSchema.Types.Mixed }) + carPartDamage?: string | { part?: string; side?: string }; @Prop({ type: String }) typeOfDamage?: string; @@ -27,8 +31,9 @@ export class ClaimPartPricing { @Prop({ type: MongooseSchema.Types.Mixed }) totalPayment?: string | number; + /** V1/V2: string/number legacy, or `{ option, price?, branchId? }` after expert normalization. */ @Prop({ type: MongooseSchema.Types.Mixed }) - daghi?: string | number; + daghi?: string | number | Record; @Prop({ type: Boolean, default: false }) factorNeeded?: boolean; diff --git a/src/expert-blame/expert-blame.service.ts b/src/expert-blame/expert-blame.service.ts index 027f015..639aca2 100644 --- a/src/expert-blame/expert-blame.service.ts +++ b/src/expert-blame/expert-blame.service.ts @@ -12,6 +12,10 @@ import { blameCaseAccessibleToExpert, requireActorClientKey, } from "src/helpers/tenant-scope"; +import { + blameCaseStatusToReportBucket, + initialBlameExpertReportBuckets, +} from "src/helpers/expert-panel-status-report"; import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service"; import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service"; import { @@ -205,6 +209,38 @@ export class ExpertBlameService { * 2. Requests where current expert made the decision * Does NOT show requests decided by other experts. */ + /** + * V2: Count blame cases for this expert’s tenant, grouped for dashboard: + * `IN_PROGRESS` = OPEN + WAITING_FOR_SECOND_PARTY; other {@link CaseStatus} keys unchanged. + */ + async getStatusReportBucketsV2(actor: any): Promise> { + requireActorClientKey(actor); + const rows = await this.blameRequestDbService.find({}, { lean: true }); + const buckets = initialBlameExpertReportBuckets(); + for (const doc of rows as Record[]) { + if (!this.blameDocIncludedInExpertTenantReport(doc, actor)) continue; + const st = String(doc.status ?? ""); + const key = blameCaseStatusToReportBucket(st); + buckets.all++; + buckets[key] = (buckets[key] ?? 0) + 1; + } + return buckets; + } + + /** Tenant + expert-initiated visibility (same as list, without queue filters). */ + private blameDocIncludedInExpertTenantReport( + doc: Record, + actor: { sub: string; clientKey?: string }, + ): boolean { + if (!blameCaseAccessibleToExpert(doc, actor)) { + return false; + } + if (doc.expertInitiated && doc.initiatedByFieldExpertId) { + return String(doc.initiatedByFieldExpertId) === String(actor.sub); + } + return true; + } + async findAllV2(actor: any): Promise { try { requireActorClientKey(actor); diff --git a/src/expert-blame/expert-blame.v2.controller.ts b/src/expert-blame/expert-blame.v2.controller.ts index 708261a..2691b9f 100644 --- a/src/expert-blame/expert-blame.v2.controller.ts +++ b/src/expert-blame/expert-blame.v2.controller.ts @@ -8,7 +8,7 @@ import { Put, UseGuards, } from "@nestjs/common"; -import { ApiBearerAuth, ApiBody, ApiParam, ApiTags } from "@nestjs/swagger"; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from "@nestjs/swagger"; import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard"; import { RolesGuard } from "src/auth/guards/role.guard"; import { Roles } from "src/decorators/roles.decorator"; @@ -38,6 +38,23 @@ export class ExpertBlameV2Controller { } } + @Get("report/status-counts") + @ApiOperation({ + summary: "Count blame cases by grouped status bucket (tenant)", + description: + "IN_PROGRESS groups OPEN and WAITING_FOR_SECOND_PARTY. WAITING_FOR_EXPERT, WAITING_FOR_DOCUMENT_RESEND, WAITING_FOR_SIGNATURES, and terminal statuses are counted separately. Expert-initiated files count only for the initiating expert.", + }) + async getStatusReportBucketsV2(@CurrentUser() actor: any) { + try { + return await this.expertBlameService.getStatusReportBucketsV2(actor); + } catch (error) { + if (error instanceof HttpException) throw error; + throw new InternalServerErrorException( + error instanceof Error ? error.message : "Failed to load blame status report", + ); + } + } + @Get(":id") @ApiParam({ name: "id", description: "Blame case request id" }) async findOne(@Param("id") id: string, @CurrentUser() actor: any) { diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index ec7af51..8029fe4 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -39,6 +39,10 @@ import { claimCaseTouchesClient, requireActorClientKey, } from "src/helpers/tenant-scope"; +import { + claimCaseStatusToReportBucket, + initialClaimExpertReportBuckets, +} from "src/helpers/expert-panel-status-report"; import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum"; import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; import { PartyRole } from "src/request-management/entities/schema/partyRole.enum"; @@ -2448,6 +2452,25 @@ export class ExpertClaimService { }; } + /** + * V2: Count claim cases for this damage expert’s tenant, grouped for dashboard: + * `IN_PROGRESS` = user flow before submission complete (CREATED … CAPTURING_PART_DAMAGES); + * other {@link ClaimCaseStatus} keys unchanged. + */ + async getStatusReportBucketsV2(actor: any): Promise> { + const clientKey = requireActorClientKey(actor); + const rows = await this.claimCaseDbService.find({}, { lean: true }); + const buckets = initialClaimExpertReportBuckets(); + for (const doc of rows as Record[]) { + if (!claimCaseTouchesClient(doc, clientKey)) continue; + const st = String(doc.status ?? ""); + const key = claimCaseStatusToReportBucket(st); + buckets.all++; + buckets[key] = (buckets[key] ?? 0) + 1; + } + return buckets; + } + /** * V2: Get claim list for damage expert * diff --git a/src/expert-claim/expert-claim.v2.controller.ts b/src/expert-claim/expert-claim.v2.controller.ts index c688120..e555de3 100644 --- a/src/expert-claim/expert-claim.v2.controller.ts +++ b/src/expert-claim/expert-claim.v2.controller.ts @@ -34,6 +34,16 @@ class InPersonVisitV2Dto { export class ExpertClaimV2Controller { constructor(private readonly expertClaimService: ExpertClaimService) { } + @Get("report/status-counts") + @ApiOperation({ + summary: "Count claim cases by grouped status bucket (tenant)", + description: + "IN_PROGRESS groups user-phase statuses before submission is complete (CREATED through CAPTURING_PART_DAMAGES). Other keys match ClaimCaseStatus. Scoped to the insurer in the JWT.", + }) + async getStatusReportBucketsV2(@CurrentUser() actor: any) { + return await this.expertClaimService.getStatusReportBucketsV2(actor); + } + @Get("requests") @ApiOperation({ summary: "List available claim requests for damage expert", diff --git a/src/helpers/expert-panel-status-report.ts b/src/helpers/expert-panel-status-report.ts new file mode 100644 index 0000000..aa13797 --- /dev/null +++ b/src/helpers/expert-panel-status-report.ts @@ -0,0 +1,49 @@ +import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum"; +import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum"; + +/** Blame: early workflow before expert queue / resend / signatures / terminals. */ +const BLAME_IN_PROGRESS_STATUSES = new Set([ + CaseStatus.OPEN, + CaseStatus.WAITING_FOR_SECOND_PARTY, +]); + +/** + * Claim: user flow before submission is complete (analog to `ClaimWorkflowStep.USER_SUBMISSION_COMPLETE`). + */ +const CLAIM_IN_PROGRESS_STATUSES = new Set([ + ClaimCaseStatus.CREATED, + ClaimCaseStatus.SELECTING_OUTER_PARTS, + ClaimCaseStatus.SELECTING_OTHER_PARTS, + ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS, + ClaimCaseStatus.CAPTURING_PART_DAMAGES, +]); + +export function blameCaseStatusToReportBucket(status: string): string { + if (BLAME_IN_PROGRESS_STATUSES.has(status)) return "IN_PROGRESS"; + return status; +} + +export function claimCaseStatusToReportBucket(status: string): string { + if (CLAIM_IN_PROGRESS_STATUSES.has(status)) return "IN_PROGRESS"; + return status; +} + +/** Keys returned for blame V2 expert report (IN_PROGRESS + each native post-early status). */ +export function initialBlameExpertReportBuckets(): Record { + const out: Record = { all: 0, IN_PROGRESS: 0 }; + for (const s of Object.values(CaseStatus)) { + if (BLAME_IN_PROGRESS_STATUSES.has(s)) continue; + out[s] = 0; + } + return out; +} + +/** Keys returned for claim V2 expert report. */ +export function initialClaimExpertReportBuckets(): Record { + const out: Record = { all: 0, IN_PROGRESS: 0 }; + for (const s of Object.values(ClaimCaseStatus)) { + if (CLAIM_IN_PROGRESS_STATUSES.has(s)) continue; + out[s] = 0; + } + return out; +}