1
0
forked from Yara724/api

Compare commits

..

7 Commits

15 changed files with 509 additions and 23 deletions

View File

@@ -1,5 +1,9 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { Schema as MongooseSchema, Types } from "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 { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum";
import { UserReplyEnum } from "src/Types&Enums/claim-request-management/userReply.enum"; import { UserReplyEnum } from "src/Types&Enums/claim-request-management/userReply.enum";
@@ -8,8 +12,12 @@ export class ClaimPartPricing {
@Prop({ type: String }) @Prop({ type: String })
partId: 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 }) @Prop({ type: String })
typeOfDamage?: string; typeOfDamage?: string;
@@ -23,8 +31,9 @@ export class ClaimPartPricing {
@Prop({ type: MongooseSchema.Types.Mixed }) @Prop({ type: MongooseSchema.Types.Mixed })
totalPayment?: string | number; totalPayment?: string | number;
/** V1/V2: string/number legacy, or `{ option, price?, branchId? }` after expert normalization. */
@Prop({ type: MongooseSchema.Types.Mixed }) @Prop({ type: MongooseSchema.Types.Mixed })
daghi?: string | number; daghi?: string | number | Record<string, unknown>;
@Prop({ type: Boolean, default: false }) @Prop({ type: Boolean, default: false })
factorNeeded?: boolean; factorNeeded?: boolean;
@@ -83,6 +92,10 @@ export class ClaimExpertReply {
@Prop({ type: ClaimUserCommentSchema }) @Prop({ type: ClaimUserCommentSchema })
userComment?: ClaimUserComment; userComment?: ClaimUserComment;
/** Damage expert profile at reply time (from `damage-expert` collection). */
@Prop({ type: ExpertProfileSnapshotSchema })
expertProfileSnapshot?: ExpertProfileSnapshot;
} }
export const ClaimExpertReplySchema = export const ClaimExpertReplySchema =
SchemaFactory.createForClass(ClaimExpertReply); SchemaFactory.createForClass(ClaimExpertReply);
@@ -160,6 +173,10 @@ export class ClaimResendRequest {
/** Set when the owner has satisfied every requested document/part (or acknowledged description-only resend). */ /** Set when the owner has satisfied every requested document/part (or acknowledged description-only resend). */
@Prop({ type: Date }) @Prop({ type: Date })
fulfilledAt?: Date; fulfilledAt?: Date;
/** Damage expert profile when resend was requested (`damage-expert` collection). */
@Prop({ type: ExpertProfileSnapshotSchema })
expertProfileSnapshot?: ExpertProfileSnapshot;
} }
export const ClaimResendRequestSchema = export const ClaimResendRequestSchema =
SchemaFactory.createForClass(ClaimResendRequest); SchemaFactory.createForClass(ClaimResendRequest);
@@ -239,6 +256,14 @@ export class ClaimEvaluation {
@Prop({ type: ClaimPriceDropSchema }) @Prop({ type: ClaimPriceDropSchema })
priceDrop?: ClaimPriceDrop; 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 = export const ClaimEvaluationSchema =
SchemaFactory.createForClass(ClaimEvaluation); SchemaFactory.createForClass(ClaimEvaluation);

View File

@@ -1,6 +1,10 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { Types } from "mongoose"; import { Types } from "mongoose";
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum"; 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 }) @Schema({ _id: false })
export class ClaimActorLock { export class ClaimActorLock {
@@ -12,6 +16,10 @@ export class ClaimActorLock {
@Prop({ type: String, default: "damage_expert" }) @Prop({ type: String, default: "damage_expert" })
actorRole: "damage_expert" | "expert" | "admin"; 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); export const ClaimActorLockSchema = SchemaFactory.createForClass(ClaimActorLock);

View File

@@ -122,6 +122,10 @@ export class SubmitReply {
@Prop() @Prop()
userComment?: UserComment; userComment?: UserComment;
/** Damage expert profile at reply time (`damage-expert` collection). */
@Prop({ type: Object })
expertProfileSnapshot?: Record<string, string | undefined>;
} }
export class ActorLockDetails { export class ActorLockDetails {
@@ -130,6 +134,10 @@ export class ActorLockDetails {
@Prop({ type: Types.ObjectId }) @Prop({ type: Types.ObjectId })
actorId?: Types.ObjectId; actorId?: Types.ObjectId;
/** Damage expert profile when lock was taken (`damage-expert` collection). */
@Prop({ type: Object })
expertProfileSnapshot?: Record<string, string | undefined>;
} }
export class ResendCarPartsDto { export class ResendCarPartsDto {
@@ -149,6 +157,10 @@ export class ClaimSubmitResend {
@Prop({ required: true }) @Prop({ required: true })
resendCarParts: ResendCarPartsDto[]; resendCarParts: ResendCarPartsDto[];
/** Damage expert profile when resend was requested (`damage-expert` collection). */
@Prop({ type: Object })
expertProfileSnapshot?: Record<string, string | undefined>;
} }
export class UserResendDocuments { export class UserResendDocuments {
@@ -360,6 +372,14 @@ export class ClaimRequestManagementModel {
@Prop({ type: UserClaimRating, required: false }) @Prop({ type: UserClaimRating, required: false })
userRating?: UserClaimRating; userRating?: UserClaimRating;
/** Latest damage expert profile when validating repair factors (V1). */
@Prop({ type: Object })
factorValidationExpertProfileSnapshot?: Record<string, string | undefined>;
/** Damage expert profile when in-person visit was requested (V1). */
@Prop({ type: Object })
damageExpertInPersonVisitProfileSnapshot?: Record<string, string | undefined>;
@Prop({ type: String, required: false }) @Prop({ type: String, required: false })
visitLocation?: string; visitLocation?: string;

View File

@@ -12,6 +12,10 @@ import {
blameCaseAccessibleToExpert, blameCaseAccessibleToExpert,
requireActorClientKey, requireActorClientKey,
} from "src/helpers/tenant-scope"; } 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 { 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 { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
import { import {
@@ -39,6 +43,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 { 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 { UserSignDbService } from "src/request-management/entities/db-service/sign.db.service";
import { RequestManagementService } from "src/request-management/request-management.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 { interface CheckedRequestEntry {
CheckedRequest?: { CheckedRequest?: {
@@ -81,6 +87,14 @@ export class ExpertBlameService {
private readonly requestManagementService: RequestManagementService, 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<AllRequestDtoRs> { async findAll(actor: any): Promise<AllRequestDtoRs> {
// 1. Fetch all potentially relevant requests from the database. // 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 // Exclude CAR_BODY type requests as they are automatically handled and don't need expert review
@@ -195,6 +209,38 @@ export class ExpertBlameService {
* 2. Requests where current expert made the decision * 2. Requests where current expert made the decision
* Does NOT show requests decided by other experts. * Does NOT show requests decided by other experts.
*/ */
/**
* V2: Count blame cases for this experts tenant, grouped for dashboard:
* `IN_PROGRESS` = OPEN + WAITING_FOR_SECOND_PARTY; other {@link CaseStatus} keys unchanged.
*/
async getStatusReportBucketsV2(actor: any): Promise<Record<string, number>> {
requireActorClientKey(actor);
const rows = await this.blameRequestDbService.find({}, { lean: true });
const buckets = initialBlameExpertReportBuckets();
for (const doc of rows as Record<string, unknown>[]) {
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<string, unknown>,
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<AllRequestDtoRsV2> { async findAllV2(actor: any): Promise<AllRequestDtoRsV2> {
try { try {
requireActorClientKey(actor); requireActorClientKey(actor);
@@ -767,6 +813,7 @@ export class ExpertBlameService {
} }
const fifteenMinutes = new Date(Date.now() + 15 * 60 * 1000); const fifteenMinutes = new Date(Date.now() + 15 * 60 * 1000);
const lockSnapshot = await this.snapshotFieldExpert(actorDetail.sub);
const updateResult = await this.requestManagementDbService.findOneAndUpdate( const updateResult = await this.requestManagementDbService.findOneAndUpdate(
{ {
@@ -782,6 +829,7 @@ export class ExpertBlameService {
actorLocked: { actorLocked: {
fullName: actorDetail.fullName, fullName: actorDetail.fullName,
actorId: new Types.ObjectId(actorDetail.sub), actorId: new Types.ObjectId(actorDetail.sub),
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
}, },
}, },
$push: { $push: {
@@ -872,6 +920,8 @@ export class ExpertBlameService {
} }
} }
const lockSnapshot = await this.snapshotFieldExpert(actorDetail.sub);
// Lock the request (either unlocked or expired lock) // Lock the request (either unlocked or expired lock)
const updateResult = await this.blameRequestDbService.findByIdAndUpdate( const updateResult = await this.blameRequestDbService.findByIdAndUpdate(
requestId, requestId,
@@ -883,6 +933,7 @@ export class ExpertBlameService {
actorId: new Types.ObjectId(actorDetail.sub), actorId: new Types.ObjectId(actorDetail.sub),
actorName: actorDetail.fullName || "Unknown Expert", actorName: actorDetail.fullName || "Unknown Expert",
actorRole: "expert", actorRole: "expert",
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
}, },
}, },
}, },
@@ -1004,6 +1055,7 @@ export class ExpertBlameService {
} }
const now = new Date(); const now = new Date();
const resendSnapshot = await this.snapshotFieldExpert(actorId);
const partyResendRequests = resendDto.parties.map((party) => { const partyResendRequests = resendDto.parties.map((party) => {
const uid = String(party.partyId); const uid = String(party.partyId);
@@ -1026,6 +1078,7 @@ export class ExpertBlameService {
parties: partyResendRequests, parties: partyResendRequests,
requestedAt: now, requestedAt: now,
requestedByExpertId: new Types.ObjectId(actorId), requestedByExpertId: new Types.ObjectId(actorId),
...(resendSnapshot && { expertProfileSnapshot: resendSnapshot }),
}, },
status: CaseStatus.WAITING_FOR_DOCUMENT_RESEND, status: CaseStatus.WAITING_FOR_DOCUMENT_RESEND,
}, },
@@ -1151,12 +1204,15 @@ export class ExpertBlameService {
const now = new Date(); const now = new Date();
const expertProfileSnapshot = await this.snapshotFieldExpert(actorId);
// Build expert decision with fields // Build expert decision with fields
const decisionPayload: any = { const decisionPayload: any = {
guiltyPartyId: new Types.ObjectId(String(reply.guiltyUserId)), guiltyPartyId: new Types.ObjectId(String(reply.guiltyUserId)),
description: reply.description, description: reply.description,
decidedAt: now, decidedAt: now,
decidedByExpertId: new Types.ObjectId(actorId), decidedByExpertId: new Types.ObjectId(actorId),
...(expertProfileSnapshot && { expertProfileSnapshot }),
fields: { fields: {
accidentWay: { accidentWay: {
id: reply.fields.accidentWay.id, id: reply.fields.accidentWay.id,
@@ -1279,6 +1335,7 @@ export class ExpertBlameService {
} }
const now = new Date(); const now = new Date();
const inPersonSnapshot = await this.snapshotFieldExpert(actorId);
// Build expert decision with fields // Build expert decision with fields
const decisionPayload: any = { const decisionPayload: any = {
@@ -1286,6 +1343,7 @@ export class ExpertBlameService {
description: "حضوری مراجعه شود.", description: "حضوری مراجعه شود.",
decidedAt: now, decidedAt: now,
decidedByExpertId: new Types.ObjectId(actorId), decidedByExpertId: new Types.ObjectId(actorId),
...(inPersonSnapshot && { expertProfileSnapshot: inPersonSnapshot }),
fields: { fields: {
accidentWay: null, accidentWay: null,
accidentReason: null, accidentReason: null,
@@ -1445,6 +1503,8 @@ export class ExpertBlameService {
); );
} }
const expertProfileSnapshot = await this.snapshotFieldExpert(userId);
const newReplyObject = { const newReplyObject = {
description: reply.description, description: reply.description,
submitTime: new Date(), submitTime: new Date(),
@@ -1466,6 +1526,7 @@ export class ExpertBlameService {
}, },
firstPartyComment: request.expertSubmitReply?.firstPartyComment || null, firstPartyComment: request.expertSubmitReply?.firstPartyComment || null,
secondPartyComment: request.expertSubmitReply?.secondPartyComment || null, secondPartyComment: request.expertSubmitReply?.secondPartyComment || null,
...(expertProfileSnapshot && { expertProfileSnapshot }),
}; };
const updatePayload: any = { const updatePayload: any = {
@@ -1522,6 +1583,7 @@ export class ExpertBlameService {
} }
const partyType = req.route.path.split("/")[4]; const partyType = req.route.path.split("/")[4];
const resendSnapshot = await this.snapshotFieldExpert(userId);
switch (partyType) { switch (partyType) {
case "first": { case "first": {
@@ -1542,6 +1604,9 @@ export class ExpertBlameService {
"expertResendReply.firstParty.firstPartyId": firstPartyId, "expertResendReply.firstParty.firstPartyId": firstPartyId,
"expertResendReply.firstParty.firstPartyDescription": "expertResendReply.firstParty.firstPartyDescription":
firstPartyDescription, firstPartyDescription,
...(resendSnapshot && {
expertResendByExpertProfileSnapshot: resendSnapshot,
}),
$push: { $push: {
actorsChecker: { actorsChecker: {
[ReqBlameStatus.UserPending]: request.actorLocked, [ReqBlameStatus.UserPending]: request.actorLocked,
@@ -1579,6 +1644,9 @@ export class ExpertBlameService {
"expertResendReply.secondParty.secondPartyId": secondPartyId, "expertResendReply.secondParty.secondPartyId": secondPartyId,
"expertResendReply.secondParty.secondPartyDescription": "expertResendReply.secondParty.secondPartyDescription":
secondPartyDescription, secondPartyDescription,
...(resendSnapshot && {
expertResendByExpertProfileSnapshot: resendSnapshot,
}),
$push: { $push: {
actorsChecker: { actorsChecker: {
[ReqBlameStatus.UserPending]: request.actorLocked, [ReqBlameStatus.UserPending]: request.actorLocked,
@@ -1658,10 +1726,15 @@ export class ExpertBlameService {
throw new NotFoundException("Blame not found"); throw new NotFoundException("Blame not found");
} }
const visitSnapshot = await this.snapshotFieldExpert(actorDetail.sub);
const updated = await this.requestManagementDbService.findAndUpdate( const updated = await this.requestManagementDbService.findAndUpdate(
{ _id: new Types.ObjectId(requestId) }, { _id: new Types.ObjectId(requestId) },
{ {
blameStatus: ReqBlameStatus.InPersonVisit, blameStatus: ReqBlameStatus.InPersonVisit,
...(visitSnapshot && {
fieldExpertInPersonVisitProfileSnapshot: visitSnapshot,
}),
}, },
); );

View File

@@ -8,7 +8,7 @@ import {
Put, Put,
UseGuards, UseGuards,
} from "@nestjs/common"; } 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 { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
import { RolesGuard } from "src/auth/guards/role.guard"; import { RolesGuard } from "src/auth/guards/role.guard";
import { Roles } from "src/decorators/roles.decorator"; 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") @Get(":id")
@ApiParam({ name: "id", description: "Blame case request id" }) @ApiParam({ name: "id", description: "Blame case request id" })
async findOne(@Param("id") id: string, @CurrentUser() actor: any) { async findOne(@Param("id") id: string, @CurrentUser() actor: any) {

View File

@@ -11,6 +11,7 @@ import {
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum'; 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 { 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 { export class CarPartDamageV2Dto {
@ApiProperty({ example: 'left' }) @ApiProperty({ example: 'left' })
@@ -22,6 +23,31 @@ export class CarPartDamageV2Dto {
part: string; 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 { export class PartPricingV2Dto {
@ApiProperty({ example: 'part-001' }) @ApiProperty({ example: 'part-001' })
@IsString() @IsString()
@@ -49,10 +75,10 @@ export class PartPricingV2Dto {
@IsString() @IsString()
totalPayment: string; totalPayment: string;
@ApiPropertyOptional({ example: '500000' }) @ApiProperty({ type: DaghiDetailsV2Dto })
@IsOptional() @ValidateNested()
@IsString() @Type(() => DaghiDetailsV2Dto)
daghi?: string; daghi: DaghiDetailsV2Dto;
@ApiProperty({ example: false }) @ApiProperty({ example: false })
@IsBoolean() @IsBoolean()

View File

@@ -39,6 +39,10 @@ import {
claimCaseTouchesClient, claimCaseTouchesClient,
requireActorClientKey, requireActorClientKey,
} from "src/helpers/tenant-scope"; } 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 { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum"; import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
@@ -59,6 +63,8 @@ import {
enrichBlamePartiesForAgreementView, enrichBlamePartiesForAgreementView,
} from "src/helpers/blame-party-agreement-decision"; } from "src/helpers/blame-party-agreement-decision";
import { resendRequestHasPayload } from "src/helpers/claim-expert-resend"; 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() @Injectable()
export class ExpertClaimService { export class ExpertClaimService {
@@ -183,6 +189,12 @@ export class ExpertClaimService {
private readonly branchDbService: BranchDbService, 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. */ /** Map blame party `Vehicle` (name/model/type) to expert panel shape. */
private expertVehicleFromPartyVehicle(v: any): { private expertVehicleFromPartyVehicle(v: any): {
carName?: string; carName?: string;
@@ -474,6 +486,7 @@ export class ExpertClaimService {
} }
const fifteenMinutes = new Date(Date.now() + 15 * 60 * 1000); const fifteenMinutes = new Date(Date.now() + 15 * 60 * 1000);
const lockSnapshot = await this.snapshotDamageExpert(actorDetail.sub);
await this.claimRequestManagementDbService.findOneAndUpdate( await this.claimRequestManagementDbService.findOneAndUpdate(
new Types.ObjectId(requestId), new Types.ObjectId(requestId),
@@ -486,6 +499,7 @@ export class ExpertClaimService {
actorLocked: { actorLocked: {
actorId: new Types.ObjectId(actorDetail.sub), actorId: new Types.ObjectId(actorDetail.sub),
fullName: actorDetail.fullName, fullName: actorDetail.fullName,
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
}, },
}, },
$push: { $push: {
@@ -535,6 +549,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 }[]) { private findClosestCar(input: string, cars: { carName: string }[]) {
if (!input || !cars || cars.length === 0) { if (!input || !cars || cars.length === 0) {
this.logger.debug( this.logger.debug(
@@ -1298,6 +1354,8 @@ export class ExpertClaimService {
}, },
})); }));
const expertProfileSnapshot = await this.snapshotDamageExpert(userId.sub);
const replyPayload = { const replyPayload = {
description: reply.description, description: reply.description,
parts: processedParts, parts: processedParts,
@@ -1306,6 +1364,7 @@ export class ExpertClaimService {
actorName: userId.fullName, actorName: userId.fullName,
actorId: userId.sub, actorId: userId.sub,
}, },
...(expertProfileSnapshot && { expertProfileSnapshot }),
}; };
const updatePayload = { const updatePayload = {
@@ -1378,6 +1437,8 @@ export class ExpertClaimService {
if (request.damageExpertReplyFinal) if (request.damageExpertReplyFinal)
throw new ForbiddenException("request already has final reply"); throw new ForbiddenException("request already has final reply");
const resendSnapshot = await this.snapshotDamageExpert(userId);
await this.claimRequestManagementDbService.findAndUpdate(requestId, { await this.claimRequestManagementDbService.findAndUpdate(requestId, {
lockFile: false, lockFile: false,
claimStatus: ReqClaimStatus.WaitingForUserToResend, claimStatus: ReqClaimStatus.WaitingForUserToResend,
@@ -1385,6 +1446,7 @@ export class ExpertClaimService {
resendDescription: body.resendDescription, resendDescription: body.resendDescription,
resendDocuments: body.resendDocuments, resendDocuments: body.resendDocuments,
resendCarParts: body.resendCarParts, resendCarParts: body.resendCarParts,
...(resendSnapshot && { expertProfileSnapshot: resendSnapshot }),
}, },
$push: { $push: {
actorsChecker: { actorsChecker: {
@@ -1668,6 +1730,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 = const updatedClaim =
await this.claimRequestManagementDbService.findOne(claimId); await this.claimRequestManagementDbService.findOne(claimId);
const updatedReply = const updatedReply =
@@ -1748,6 +1820,8 @@ export class ExpertClaimService {
assertClaimCaseForTenant(claim, actor); assertClaimCaseForTenant(claim, actor);
const factorValidationSnapshot = await this.snapshotDamageExpert(actor.sub);
if ( if (
claim.status !== ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL || claim.status !== ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL ||
claim.claimStatus !== ClaimStatus.UNDER_REVIEW || claim.claimStatus !== ClaimStatus.UNDER_REVIEW ||
@@ -1828,6 +1902,11 @@ export class ExpertClaimService {
throw new BadRequestException("No valid factor decisions to apply."); throw new BadRequestException("No valid factor decisions to apply.");
} }
if (factorValidationSnapshot) {
$set["evaluation.factorValidationExpertProfileSnapshot"] =
factorValidationSnapshot;
}
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set, $set,
}); });
@@ -1941,10 +2020,15 @@ export class ExpertClaimService {
throw new NotFoundException("Blame not found"); throw new NotFoundException("Blame not found");
} }
const visitSnapshot = await this.snapshotDamageExpert(actorDetail.sub);
const updated = await this.claimRequestManagementDbService.findAndUpdate( const updated = await this.claimRequestManagementDbService.findAndUpdate(
requestId, requestId,
{ {
claimStatus: ReqClaimStatus.InPersonVisit, claimStatus: ReqClaimStatus.InPersonVisit,
...(visitSnapshot && {
damageExpertInPersonVisitProfileSnapshot: visitSnapshot,
}),
}, },
); );
@@ -2013,6 +2097,8 @@ export class ExpertClaimService {
return { claimRequestId, locked: true, message: 'Already locked by you' }; return { claimRequestId, locked: true, message: 'Already locked by you' };
} }
const lockSnapshot = await this.snapshotDamageExpert(actor.sub);
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
status: ClaimCaseStatus.EXPERT_REVIEWING, status: ClaimCaseStatus.EXPERT_REVIEWING,
claimStatus: ClaimStatus.UNDER_REVIEW, claimStatus: ClaimStatus.UNDER_REVIEW,
@@ -2022,6 +2108,7 @@ export class ExpertClaimService {
actorId: new Types.ObjectId(actor.sub), actorId: new Types.ObjectId(actor.sub),
actorName: actor.fullName, actorName: actor.fullName,
actorRole: 'damage_expert', actorRole: 'damage_expert',
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
}, },
'workflow.currentStep': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, 'workflow.currentStep': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
$push: { $push: {
@@ -2095,6 +2182,8 @@ export class ExpertClaimService {
); );
} }
const resendSnapshot = await this.snapshotDamageExpert(actor.sub);
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: { $set: {
status: ClaimCaseStatus.WAITING_FOR_USER_RESEND, status: ClaimCaseStatus.WAITING_FOR_USER_RESEND,
@@ -2106,6 +2195,7 @@ export class ExpertClaimService {
resendDescription: desc || undefined, resendDescription: desc || undefined,
resendDocuments: docs, resendDocuments: docs,
resendCarParts: parts, resendCarParts: parts,
...(resendSnapshot && { expertProfileSnapshot: resendSnapshot }),
}, },
}, },
$unset: { $unset: {
@@ -2148,6 +2238,7 @@ export class ExpertClaimService {
* - Must be locked by this expert (workflow.lockedBy.actorId === actor.sub) * - Must be locked by this expert (workflow.lockedBy.actorId === actor.sub)
* - Must be in EXPERT_REVIEWING status * - Must be in EXPERT_REVIEWING status
* - Total payment across all parts must not exceed 30,000,000 * - Total payment across all parts must not exceed 30,000,000
* - Each part must include `daghi` (option + conditional price/branchId) like V1
* *
* On success: * On success:
* - Stores reply in evaluation.damageExpertReply * - Stores reply in evaluation.damageExpertReply
@@ -2201,6 +2292,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 needsFactorUpload = reply.parts?.some((p) => p.factorNeeded === true) ?? false;
const objectionSubmitted = !!claim.evaluation?.objection?.submittedAt; const objectionSubmitted = !!claim.evaluation?.objection?.submittedAt;
@@ -2220,14 +2316,17 @@ export class ExpertClaimService {
? ClaimWorkflowStep.EXPERT_FINAL_REPLY ? ClaimWorkflowStep.EXPERT_FINAL_REPLY
: ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT; : ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT;
const expertProfileSnapshot = await this.snapshotDamageExpert(actor.sub);
const replyPayload = { const replyPayload = {
description: reply.description, description: reply.description,
parts: reply.parts, parts: processedParts,
submittedAt: new Date(), submittedAt: new Date(),
actorDetail: { actorDetail: {
actorId: actor.sub, actorId: actor.sub,
actorName: actor.fullName, actorName: actor.fullName,
}, },
...(expertProfileSnapshot && { expertProfileSnapshot }),
}; };
const nextStep = needsFactorUpload const nextStep = needsFactorUpload
@@ -2262,7 +2361,7 @@ export class ExpertClaimService {
timestamp: new Date(), timestamp: new Date(),
metadata: { metadata: {
factorNeeded: needsFactorUpload, factorNeeded: needsFactorUpload,
partsCount: reply.parts?.length ?? 0, partsCount: processedParts.length,
isFinalReplyAfterObjection, isFinalReplyAfterObjection,
replyField, replyField,
}, },
@@ -2325,10 +2424,15 @@ export class ExpertClaimService {
throw new ForbiddenException('This claim is locked by another expert'); throw new ForbiddenException('This claim is locked by another expert');
} }
const visitSnapshot = await this.snapshotDamageExpert(actor.sub);
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
claimStatus: ClaimStatus.NEEDS_REVISION, claimStatus: ClaimStatus.NEEDS_REVISION,
'workflow.locked': false, 'workflow.locked': false,
...(note ? { 'evaluation.visitLocation': note } : {}), ...(note ? { 'evaluation.visitLocation': note } : {}),
...(visitSnapshot && {
'evaluation.inPersonVisitExpertProfileSnapshot': visitSnapshot,
}),
$push: { $push: {
history: { history: {
event: 'IN_PERSON_VISIT_REQUESTED', event: 'IN_PERSON_VISIT_REQUESTED',
@@ -2348,6 +2452,25 @@ export class ExpertClaimService {
}; };
} }
/**
* V2: Count claim cases for this damage experts 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<Record<string, number>> {
const clientKey = requireActorClientKey(actor);
const rows = await this.claimCaseDbService.find({}, { lean: true });
const buckets = initialClaimExpertReportBuckets();
for (const doc of rows as Record<string, unknown>[]) {
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 * V2: Get claim list for damage expert
* *
@@ -2815,6 +2938,8 @@ export class ExpertClaimService {
throw new ForbiddenException("This claim is locked by another expert"); 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) const previous = Array.isArray((claim as any).damage?.selectedParts)
? [...(claim as any).damage.selectedParts] ? [...(claim as any).damage.selectedParts]
: []; : [];
@@ -2848,6 +2973,9 @@ export class ExpertClaimService {
metadata: { metadata: {
previousSelectedParts: previous, previousSelectedParts: previous,
selectedParts, selectedParts,
...(damagedPartsEditSnapshot && {
expertProfileSnapshot: damagedPartsEditSnapshot,
}),
}, },
}); });

View File

@@ -34,6 +34,16 @@ class InPersonVisitV2Dto {
export class ExpertClaimV2Controller { export class ExpertClaimV2Controller {
constructor(private readonly expertClaimService: ExpertClaimService) { } 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") @Get("requests")
@ApiOperation({ @ApiOperation({
summary: "List available claim requests for damage expert", summary: "List available claim requests for damage expert",
@@ -76,7 +86,7 @@ export class ExpertClaimV2Controller {
@ApiOperation({ @ApiOperation({
summary: "Submit expert damage assessment reply", summary: "Submit expert damage assessment reply",
description: 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" }) @ApiParam({ name: "claimRequestId" })
@ApiBody({ type: SubmitExpertReplyV2Dto }) @ApiBody({ type: SubmitExpertReplyV2Dto })

View File

@@ -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<string>([
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<string>([
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<string, number> {
const out: Record<string, number> = { 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<string, number> {
const out: Record<string, number> = { 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;
}

View File

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

View File

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

View File

@@ -1,6 +1,10 @@
//! NEW //! NEW
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { Types } from "mongoose"; import { Types } from "mongoose";
import {
ExpertProfileSnapshot,
ExpertProfileSnapshotSchema,
} from "./expert-profile-snapshot.schema";
@Schema({ _id: false }) @Schema({ _id: false })
@@ -52,6 +56,10 @@ export class ExpertDecision {
accidentReason: { id: string; label: string; fanavaran: number }; accidentReason: { id: string; label: string; fanavaran: number };
accidentType: { id: string; label: string }; 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); export const ExpertDecisionSchema = SchemaFactory.createForClass(ExpertDecision);
@@ -108,6 +116,10 @@ export class ExpertResend {
@Prop({ type: Types.ObjectId }) @Prop({ type: Types.ObjectId })
requestedByExpertId?: 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. * @deprecated Unused for resend UI; clients use per-party requestedItems + inputKind.
*/ */

View File

@@ -249,6 +249,10 @@ export class SubmitReply {
@Prop() @Prop()
systemResponse: string; systemResponse: string;
/** Field expert profile at reply time (`expert` collection). */
@Prop({ type: Object })
expertProfileSnapshot?: Record<string, string | undefined>;
} }
export class ExpertResendParties { export class ExpertResendParties {
@@ -271,6 +275,10 @@ export class ActorLockDetails {
@Prop({ type: Types.ObjectId }) @Prop({ type: Types.ObjectId })
actorId?: Types.ObjectId; actorId?: Types.ObjectId;
/** Field expert profile when lock was taken (`expert` collection). */
@Prop({ type: Object })
expertProfileSnapshot?: Record<string, string | undefined>;
} }
export enum CreationMethod { export enum CreationMethod {
@@ -450,6 +458,14 @@ export class RequestManagementModel {
@Prop({ type: Boolean, default: false }) @Prop({ type: Boolean, default: false })
isHandledStatsUpdated?: boolean; isHandledStatsUpdated?: boolean;
/** Latest field expert profile when expert requested party resend (V1 `sendAgainRequest`). */
@Prop({ type: Object })
expertResendByExpertProfileSnapshot?: Record<string, string | undefined>;
/** Field expert profile when expert marked in-person visit (V1 `inPersonVisit`). */
@Prop({ type: Object })
fieldExpertInPersonVisitProfileSnapshot?: Record<string, string | undefined>;
// Expert-initiated file tracking // Expert-initiated file tracking
@Prop({ type: Boolean, default: false }) @Prop({ type: Boolean, default: false })
expertInitiated?: boolean; expertInitiated?: boolean;

View File

@@ -2,6 +2,10 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { Types } from "mongoose"; import { Types } from "mongoose";
import { WorkflowStep } from "src/Types&Enums/blame-request-management/blameWorkflow-steps.enum"; import { WorkflowStep } from "src/Types&Enums/blame-request-management/blameWorkflow-steps.enum";
import {
ExpertProfileSnapshot,
ExpertProfileSnapshotSchema,
} from "./expert-profile-snapshot.schema";
@Schema({ _id: false }) @Schema({ _id: false })
export class ActorLock { export class ActorLock {
@@ -13,6 +17,10 @@ export class ActorLock {
@Prop({ type: String, default: "expert" }) @Prop({ type: String, default: "expert" })
actorRole: "expert" | "admin"; actorRole: "expert" | "admin";
/** Field expert profile when lock was taken (`expert` collection). */
@Prop({ type: ExpertProfileSnapshotSchema })
expertProfileSnapshot?: ExpertProfileSnapshot;
} }
export const ActorLockSchema = SchemaFactory.createForClass(ActorLock); export const ActorLockSchema = SchemaFactory.createForClass(ActorLock);

View File

@@ -1154,13 +1154,42 @@ export class RequestManagementService {
(party.statement as any).lightCondition = body.lightCondition; (party.statement as any).lightCondition = body.lightCondition;
} }
// If second party finished description, were ready for expert flow. const isSecondThirdParty =
stepKey === WorkflowStep.SECOND_DESCRIPTION &&
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()
: { ...req },
);
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);
// Same as postfield-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.WAITING_FOR_SIGNATURES;
req.workflow.nextStep = undefined;
req.status = CaseStatus.WAITING_FOR_SIGNATURES;
closedByMutualAgreement = true;
}
}
if (!closedByMutualAgreement) {
if (stepKey === WorkflowStep.SECOND_DESCRIPTION) { if (stepKey === WorkflowStep.SECOND_DESCRIPTION) {
req.status = CaseStatus.WAITING_FOR_EXPERT; req.status = CaseStatus.WAITING_FOR_EXPERT;
} }
await this.advanceWorkflowToNext(req, stepKey); await this.advanceWorkflowToNext(req, stepKey);
if ( if (
stepKey === WorkflowStep.SECOND_DESCRIPTION && stepKey === WorkflowStep.SECOND_DESCRIPTION &&
req.type === BlameRequestType.THIRD_PARTY && req.type === BlameRequestType.THIRD_PARTY &&
@@ -1176,6 +1205,7 @@ export class RequestManagementService {
(req as any).expert.decision = built as any; (req as any).expert.decision = built as any;
} }
} }
}
if (!Array.isArray(req.history)) req.history = []; if (!Array.isArray(req.history)) req.history = [];
req.history.push({ req.history.push({
@@ -1187,7 +1217,16 @@ export class RequestManagementService {
actorName: user?.fullName, actorName: user?.fullName,
actorType: "user", actorType: "user",
}, },
metadata: { role }, metadata: {
role,
...(closedByMutualAgreement
? {
mutualAgreementWithoutExpert: true,
closedWithoutExpert: true,
awaitingSignatures: true,
}
: {}),
},
} as any); } as any);
await (req as any).save(); await (req as any).save();