Added expert field mirror flow

This commit is contained in:
SepehrYahyaee
2026-06-15 11:24:41 +03:30
committed by Sepehr Yahyaee
parent 79905345e5
commit 19dc2a76f2
39 changed files with 2310 additions and 182 deletions

View File

@@ -46,8 +46,11 @@ import { ClaimListDtoRs } from "./dto/claim-list-rs.dto";
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
import { RoleEnum } from "src/Types&Enums/role.enum";
import {
assertClaimCaseForTenant,
assertClaimCaseForExpertActor,
claimCaseInitiatedByFieldExpert,
claimCaseTouchesClient,
requireActorClientKey,
} from "src/helpers/tenant-scope";
@@ -2148,7 +2151,7 @@ export class ExpertClaimService {
throw new NotFoundException("Claim request not found");
}
assertClaimCaseForTenant(claim, actor);
assertClaimCaseForExpertActor(claim, actor);
const factorValidationSnapshot = await this.snapshotDamageExpert(actor.sub);
@@ -2389,9 +2392,9 @@ export class ExpertClaimService {
*/
async assignClaimForReviewV2(
claimRequestId: string,
actor: { sub: string; fullName?: string; clientKey?: string },
actor: { sub: string; fullName?: string; clientKey?: string; role?: string },
): Promise<ExpertFileAssignResultDto> {
requireActorClientKey(actor);
if ((actor as any).role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor);
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
const claim = await this.claimCaseDbService.findById(claimRequestId);
@@ -2399,7 +2402,7 @@ export class ExpertClaimService {
throw new NotFoundException("Claim request not found");
}
assertClaimCaseForTenant(claim, actor);
assertClaimCaseForExpertActor(claim, actor);
const isFactorValidationLock =
claimIsAwaitingExpertFactorValidationV2(claim);
@@ -2692,7 +2695,7 @@ export class ExpertClaimService {
throw new NotFoundException("Claim request not found");
}
assertClaimCaseForTenant(claim, actor);
assertClaimCaseForExpertActor(claim, actor);
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
throw new BadRequestException(
@@ -2850,14 +2853,14 @@ export class ExpertClaimService {
reply: import("./dto/expert-claim-v2.dto").SubmitExpertReplyV2Dto,
actor: any,
) {
requireActorClientKey(actor);
if (actor.role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor);
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException("Claim request not found");
}
assertClaimCaseForTenant(claim, actor);
assertClaimCaseForExpertActor(claim, actor);
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
throw new BadRequestException(
@@ -3218,6 +3221,28 @@ export class ExpertClaimService {
* Does not include the open tenant queue (`WAITING_FOR_DAMAGE_EXPERT`) unless this expert touched the file.
*/
async getStatusReportBucketsV2(actor: any): Promise<Record<string, number>> {
if (actor.role === RoleEnum.FIELD_EXPERT) {
const rows = (await this.claimCaseDbService.find(
{ initiatedByFieldExpertId: new Types.ObjectId(actor.sub) },
{ lean: true },
)) as any[];
const buckets = initialClaimExpertReportBuckets();
for (const doc of rows) {
const key = claimCaseStatusToReportBucket(String(doc.status ?? ""));
buckets.all++;
buckets[key] = (buckets[key] ?? 0) + 1;
}
buckets["PENDING_ON_USER"] =
(buckets[ClaimCaseStatus.WAITING_FOR_USER_RESEND] ?? 0) +
(buckets[ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL] ?? 0) +
(buckets[ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN] ?? 0) +
(buckets[ClaimCaseStatus.INSURER_REVIEW_MIXED_FACTORS_PENDING] ?? 0) +
(buckets[ClaimCaseStatus.OWNER_REPAIR_FACTOR_UPLOAD_PENDING] ?? 0);
buckets["CHECK_NEEDED"] =
buckets["PENDING_ON_USER"] +
(buckets[ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT] ?? 0);
return buckets;
}
const clientKey = requireActorClientKey(actor);
const expertId = String(actor.sub);
const expertOid = new Types.ObjectId(expertId);
@@ -3290,6 +3315,9 @@ export class ExpertClaimService {
actor: any,
query: ListQueryV2Dto = {},
): Promise<GetClaimListV2ResponseDto> {
if (actor.role === RoleEnum.FIELD_EXPERT) {
return this.getFieldExpertClaimListV2(actor, query);
}
requireActorClientKey(actor);
const actorId = actor.sub;
const clientKey = actor.clientKey as string;
@@ -3493,6 +3521,96 @@ export class ExpertClaimService {
};
}
/**
* Claim list for a FIELD_EXPERT — returns only the claims they personally
* initiated (expert-initiated IN_PERSON blame → createClaimFromBlameForExpertV2).
*/
private async getFieldExpertClaimListV2(
actor: any,
query: ListQueryV2Dto = {},
): Promise<GetClaimListV2ResponseDto> {
const claims = (await this.claimCaseDbService.find({
initiatedByFieldExpertId: new Types.ObjectId(actor.sub),
})) as any[];
const blameIds = [
...new Set(
claims
.map((c) => c.blameRequestId?.toString())
.filter((id): id is string => !!id),
),
];
const blames =
blameIds.length > 0
? ((await this.blameRequestDbService.find(
{ _id: { $in: blameIds.map((id) => new Types.ObjectId(id)) } },
{ lean: true, select: "type parties expert.decision blameStatus" },
)) as any[])
: [];
const blameById = new Map<string, any>(
blames.map((b) => [String(b._id), b]),
);
const list = claims.map((c) => {
const v = this.vehicleForExpertFromClaimAndBlameMap(c, blameById);
const blame = c.blameRequestId
? blameById.get(c.blameRequestId.toString())
: undefined;
const fileCtx = blame ? this.blameFileContextForExpert(blame) : {};
const lockActive = !!(
c.workflow?.locked && this.isClaimV2WorkflowLockCurrentlyEnforced(c)
);
return {
claimRequestId: c._id.toString(),
publicId: c.publicId,
status: c.status,
currentStep: c.workflow?.currentStep || "",
locked: lockActive,
lockedBy:
lockActive && c.workflow?.lockedBy
? {
actorId: c.workflow.lockedBy.actorId?.toString(),
actorName: c.workflow.lockedBy.actorName,
lockedAt: (c.workflow as any).lockedAt?.toISOString?.(),
expiredAt: (c.workflow as any).expiredAt?.toISOString?.(),
}
: undefined,
vehicle: v
? { carName: v.carName, carModel: v.carModel, carType: v.carType }
: undefined,
...fileCtx,
createdAt: c.createdAt,
awaitingFactorValidation: claimIsAwaitingExpertFactorValidationV2(c),
};
}) as ClaimListItemV2Dto[];
const paged = applyListQueryV2(
list,
{
publicId: (r) => r.publicId,
createdAt: (r) => r.createdAt,
requestNo: (r) => r.publicId,
status: (r) => r.status,
searchExtras: (r) =>
[
r.claimRequestId,
r.currentStep,
r.vehicle?.carName,
r.vehicle?.carModel,
].filter(Boolean) as string[],
},
query,
);
return {
list: paged.list,
total: paged.total,
page: paged.page,
limit: paged.limit,
totalPages: paged.totalPages,
};
}
/**
* Load linked blame case for damage-expert claim detail (same enrichment as expert-blame `findOneV2`:
* party evidence `videoUrl` / `voiceUrls`, Jalali date strings).
@@ -3668,7 +3786,9 @@ export class ExpertClaimService {
private async reconcileStaleExpertReviewingClaimLocksForTenant(actor: {
sub: string;
clientKey?: string;
role?: string;
}): Promise<void> {
if ((actor as any).role === RoleEnum.FIELD_EXPERT) return;
const clientKey = requireActorClientKey(actor);
const lockTtlMs = this.claimV2WorkflowLockTtlMs;
const now = new Date();
@@ -3828,7 +3948,9 @@ export class ExpertClaimService {
actor: any,
): Promise<ClaimDetailV2ResponseDto> {
const actorId = actor.sub;
await this.reconcileStaleExpertReviewingClaimLocksForTenant(actor);
if (actor.role !== RoleEnum.FIELD_EXPERT) {
await this.reconcileStaleExpertReviewingClaimLocksForTenant(actor);
}
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
const claim = await this.claimCaseDbService.findById(claimRequestId);
@@ -3836,13 +3958,22 @@ export class ExpertClaimService {
throw new NotFoundException("Claim request not found");
}
assertClaimCaseForTenant(claim, actor);
assertClaimCaseForExpertActor(claim, actor);
// Variables used both in the gate block and in the detail-build section below
const isDamageExpertPhase =
claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT ||
claim.status === ClaimCaseStatus.EXPERT_REVIEWING;
const isFactorValidationPending =
claimIsAwaitingExpertFactorValidationV2(claim);
// Field experts can always view their own initiated claims regardless of status
if (actor.role === RoleEnum.FIELD_EXPERT) {
if (!claimCaseInitiatedByFieldExpert(claim, actor)) {
throw new ForbiddenException("This claim was not initiated by you.");
}
// Fall through to the detail build below (skip the damage-expert gate)
} else {
const isResendPending =
claim.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND;
@@ -3891,6 +4022,7 @@ export class ExpertClaimService {
"You do not have permission to view this claim.",
);
}
} // end damage-expert gate (else block)
// Build requiredDocuments map
const requiredDocs = claim.requiredDocuments as any;
@@ -4134,8 +4266,8 @@ export class ExpertClaimService {
/** V2 price-drop: claim locked by this expert during damage assessment. */
private assertExpertCanEditClaimDuringReviewV2(claim: any, actor: any): void {
requireActorClientKey(actor);
assertClaimCaseForTenant(claim, actor);
if (actor.role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor);
assertClaimCaseForExpertActor(claim, actor);
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
throw new BadRequestException(
`Claim must be EXPERT_REVIEWING to edit price drop. Current status: ${claim.status}`,
@@ -4364,12 +4496,12 @@ export class ExpertClaimService {
body: UpdateClaimDamagedPartsV2Dto,
actor: any,
) {
requireActorClientKey(actor);
if (actor.role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor);
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException("Claim request not found");
}
assertClaimCaseForTenant(claim, actor);
assertClaimCaseForExpertActor(claim, actor);
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
throw new BadRequestException(
`Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`,