This commit is contained in:
SepehrYahyaee
2026-05-24 13:34:43 +03:30
parent 866696094f
commit 2bdd0d507e
9 changed files with 566 additions and 234 deletions

View File

@@ -31,6 +31,10 @@ import { ClaimStepsEnum } from "src/Types&Enums/claim-request-management/steps.e
import { UserType } from "src/Types&Enums/userType.enum";
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
import { UserDbService } from "src/users/entities/db-service/user.db.service";
import {
ExpertFileAssignResultDto,
ExpertFileAssignStatus,
} from "src/common/dto/expert-file-assign-result.dto";
import { ClaimPerIdRs } from "./dto/claim-list-perId-rs.dto";
import { ClaimListDtoRs } from "./dto/claim-list-rs.dto";
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
@@ -2341,6 +2345,218 @@ export class ExpertClaimService {
throw new HttpException(error.response, error.claimStatus);
}
/**
* Assign the current damage expert to a claim for review (V2).
* Free cases are locked atomically; already-assigned-to-self is idempotent.
*/
async assignClaimForReviewV2(
claimRequestId: string,
actor: { sub: string; fullName?: string; clientKey?: string },
): Promise<ExpertFileAssignResultDto> {
requireActorClientKey(actor);
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException("Claim request not found");
}
assertClaimCaseForTenant(claim, actor);
const isFactorValidationLock = claimIsAwaitingExpertFactorValidationV2(claim);
const isDamageReviewLock =
claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT;
if (!isDamageReviewLock && !isFactorValidationLock) {
throw new BadRequestException({
success: false,
status: "unavailable" satisfies ExpertFileAssignStatus,
message: "Request is not available for expert review",
});
}
const lockedById = String(claim.workflow?.lockedBy?.actorId ?? "");
const lockEnforced =
claim.workflow?.locked &&
this.isClaimV2WorkflowLockCurrentlyEnforced(claim);
if (lockEnforced && lockedById && lockedById !== actor.sub) {
throw new ConflictException({
success: false,
status: "locked" satisfies ExpertFileAssignStatus,
message: "Request is already being processed by another expert",
lockedBy: {
actorId: lockedById,
actorName: claim.workflow?.lockedBy?.actorName,
lockedAt: claim.workflow?.lockedAt
? new Date(claim.workflow.lockedAt as Date).toISOString()
: undefined,
},
});
}
if (lockEnforced && lockedById === actor.sub) {
return {
success: true,
status: "already_assigned_to_you",
message: "You already started processing this request",
assignedAt: claim.workflow?.lockedAt
? new Date(claim.workflow.lockedAt as Date).toISOString()
: undefined,
};
}
const expertOid = new Types.ObjectId(actor.sub);
const now = new Date();
const lockSnapshot = await this.snapshotDamageExpert(actor.sub);
const expiredAt = new Date(now.getTime() + this.claimV2WorkflowLockTtlMs);
const baseLockUpdate: Record<string, unknown> = {
"workflow.locked": true,
"workflow.lockedAt": now,
"workflow.expiredAt": expiredAt,
"workflow.lockedBy": {
actorId: expertOid,
actorName: actor.fullName,
actorRole: "damage_expert",
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
},
"workflow.preLockQueueSnapshot": {
claimStatus: claim.claimStatus,
currentStep: this.normalizeClaimWorkflowStepForSnapshot(
claim.workflow?.currentStep,
ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
),
...(claim.workflow?.nextStep != null
? {
nextStep: this.normalizeClaimWorkflowStepForSnapshot(
claim.workflow.nextStep,
ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
),
}
: {}),
},
$push: {
history: {
type: "CLAIM_ASSIGNED",
actor: {
actorId: expertOid,
actorName: actor.fullName,
actorType: "damage_expert",
},
timestamp: now,
metadata: {
note: isFactorValidationLock
? "Expert assigned for factor validation review"
: "Expert assigned via review assign endpoint",
...(isFactorValidationLock ? { phase: "factorValidation" } : {}),
},
},
},
};
const lockUpdate: Record<string, unknown> = isFactorValidationLock
? baseLockUpdate
: {
...baseLockUpdate,
status: ClaimCaseStatus.EXPERT_REVIEWING,
claimStatus: ClaimStatus.UNDER_REVIEW,
"workflow.currentStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
};
const assignFilter: Record<string, unknown> = {
_id: new Types.ObjectId(claimRequestId),
$or: [
{ "workflow.locked": { $ne: true } },
{ "workflow.locked": false },
{ "workflow.expiredAt": { $lte: now } },
{ "workflow.lockedBy.actorId": expertOid },
],
};
if (isFactorValidationLock) {
assignFilter.claimStatus = ClaimStatus.UNDER_REVIEW;
assignFilter["workflow.currentStep"] =
ClaimWorkflowStep.EXPERT_COST_EVALUATION;
assignFilter.status = {
$in: [
ClaimCaseStatus.EXPERT_VALIDATING_REPAIR_FACTORS,
ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL,
],
};
} else {
assignFilter.status = ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT;
}
const updated = await this.claimCaseDbService.findOneAndUpdate(
assignFilter as any,
lockUpdate,
{ new: true },
);
if (!updated) {
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
const latest = await this.claimCaseDbService.findById(claimRequestId);
const otherId = String(latest?.workflow?.lockedBy?.actorId ?? "");
if (
latest?.workflow?.locked &&
this.isClaimV2WorkflowLockCurrentlyEnforced(latest) &&
otherId &&
otherId !== actor.sub
) {
throw new ConflictException({
success: false,
status: "locked" satisfies ExpertFileAssignStatus,
message: "Request is already being processed by another expert",
lockedBy: {
actorId: otherId,
actorName: latest.workflow?.lockedBy?.actorName,
lockedAt: latest.workflow?.lockedAt
? new Date(latest.workflow.lockedAt as Date).toISOString()
: undefined,
},
});
}
throw new BadRequestException({
success: false,
status: "unavailable" satisfies ExpertFileAssignStatus,
message: "Request could not be assigned",
});
}
await this.recordClaimExpertActivity({
expertId: String(actor.sub),
tenantId: this.claimActivityTenantId(updated, actor),
claimId: String(claimRequestId),
eventType: ExpertFileActivityType.CHECKED,
idempotencyKey: `claim:${claimRequestId}:checked:v2:${actor.sub}`,
});
const ownerPhone = await this.resolveClaimOwnerPhone(updated);
if (ownerPhone) {
const expertLastName =
actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice(
{
receptor: ownerPhone,
fileKind: "claim",
publicId: updated.publicId,
expertLastName,
},
);
}
this.logger.log(
`Claim ${claimRequestId} assigned to expert ${actor.sub} at ${now.toISOString()}`,
);
return {
success: true,
status: "assigned",
assignedAt: now.toISOString(),
};
}
/**
* V2: Lock a claim request for expert review.
*
@@ -2362,136 +2578,42 @@ export class ExpertClaimService {
* `workflow.currentStep` untouched so the claim stays in the factor-validation
* queue (so `expireClaimWorkflowLockV2IfStale` and the queue endpoint keep
* treating it correctly when the lock expires).
*
* Delegates to {@link assignClaimForReviewV2}; maps conflict to BadRequest for legacy clients.
*/
async lockClaimRequestV2(claimRequestId: string, actor: any) {
requireActorClientKey(actor);
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException('Claim request not found');
}
assertClaimCaseForTenant(claim, actor);
const isFactorValidationLock = claimIsAwaitingExpertFactorValidationV2(claim);
const isDamageReviewLock =
claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT;
if (!isDamageReviewLock && !isFactorValidationLock) {
throw new BadRequestException(
`Claim is not available for locking. Current status: ${claim.status}`,
try {
const result = await this.assignClaimForReviewV2(claimRequestId, actor);
const response: Record<string, unknown> = {
claimRequestId,
locked: true,
message: result.message,
};
if (result.status === "assigned") {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (claim?.status === ClaimCaseStatus.EXPERT_REVIEWING) {
response.status = ClaimCaseStatus.EXPERT_REVIEWING;
response.claimStatus = ClaimStatus.UNDER_REVIEW;
response.currentStep = ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT;
}
}
return response;
} catch (error) {
if (error instanceof ConflictException) {
const body = error.getResponse();
throw new BadRequestException(body);
}
if (error instanceof HttpException) throw error;
this.logger.error(
"lockClaimRequestV2 failed",
claimRequestId,
error instanceof Error ? error.stack : String(error),
);
throw new HttpException(
error instanceof Error ? error.message : "Failed to lock claim",
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
if (
claim.workflow?.locked &&
claim.workflow.lockedBy?.actorId?.toString() !== actor.sub
) {
throw new BadRequestException(
`Claim is already locked by another expert: ${claim.workflow.lockedBy?.actorName}`,
);
}
// Already locked by this same expert — idempotent
if (
claim.workflow?.locked &&
claim.workflow.lockedBy?.actorId?.toString() === actor.sub
) {
return { claimRequestId, locked: true, message: 'Already locked by you' };
}
const lockSnapshot = await this.snapshotDamageExpert(actor.sub);
const lockAt = new Date();
const expiredAt = new Date(lockAt.getTime() + this.claimV2WorkflowLockTtlMs);
// Common lock fields written in both branches.
const baseLockUpdate: Record<string, unknown> = {
'workflow.locked': true,
'workflow.lockedAt': lockAt,
'workflow.expiredAt': expiredAt,
'workflow.lockedBy': {
actorId: new Types.ObjectId(actor.sub),
actorName: actor.fullName,
actorRole: 'damage_expert',
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
},
'workflow.preLockQueueSnapshot': {
claimStatus: claim.claimStatus,
currentStep: this.normalizeClaimWorkflowStepForSnapshot(
claim.workflow?.currentStep,
ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
),
...(claim.workflow?.nextStep != null
? {
nextStep: this.normalizeClaimWorkflowStepForSnapshot(
claim.workflow.nextStep,
ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
),
}
: {}),
},
$push: {
history: {
type: "CLAIM_LOCKED",
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: actor.fullName,
actorType: "damage_expert",
},
timestamp: new Date(),
metadata: {
note: isFactorValidationLock
? `Claim locked for factor validation by damage expert ${actor.fullName}`
: `Claim locked by damage expert ${actor.fullName}`,
...(isFactorValidationLock ? { phase: "factorValidation" } : {}),
},
},
},
};
// Damage-review lock advances status/step; factor-validation lock keeps them
// intact so the claim remains in the factor-validation queue when the lock expires.
const update: Record<string, unknown> = isFactorValidationLock
? baseLockUpdate
: {
...baseLockUpdate,
status: ClaimCaseStatus.EXPERT_REVIEWING,
claimStatus: ClaimStatus.UNDER_REVIEW,
'workflow.currentStep': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
};
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, update);
await this.recordClaimExpertActivity({
expertId: String(actor.sub),
tenantId: this.claimActivityTenantId(claim, actor),
claimId: String(claimRequestId),
eventType: ExpertFileActivityType.CHECKED,
idempotencyKey: `claim:${claimRequestId}:checked:v2:${actor.sub}`,
});
const ownerPhone = await this.resolveClaimOwnerPhone(claim);
if (ownerPhone) {
const expertLastName =
actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice(
{
receptor: ownerPhone,
fileKind: "claim",
publicId: claim.publicId,
expertLastName,
},
);
}
return {
claimRequestId,
locked: true,
status: ClaimCaseStatus.EXPERT_REVIEWING,
claimStatus: ClaimStatus.UNDER_REVIEW,
currentStep: ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
};
}
async submitResendDocsV2(