merge upstream

This commit is contained in:
2026-07-29 17:33:58 +03:30
5 changed files with 195 additions and 53 deletions

View File

@@ -77,7 +77,7 @@ export class UserAuthService {
role: "user", role: "user",
}; };
const accToken = this.jwtService.sign(payload, { const accToken = this.jwtService.sign(payload, {
secret: `${process.env.JWT_SECRET}`, secret: `${process.env.JWT_SECRET}`, expiresIn: '1h'
}); });
await this.userDbService.findOneAndUpdate( await this.userDbService.findOneAndUpdate(
{ username: user.username }, { username: user.username },

View File

@@ -138,6 +138,25 @@ export class ClaimDetailV2ResponseDto {
}) })
requiresFileMakerApproval?: boolean; requiresFileMakerApproval?: boolean;
@ApiPropertyOptional({
description:
"V5 only. Number of times the FileMaker has rejected this claim (02). When 2, the next action must be approval.",
example: 1,
})
fileMakerRejectionCount?: number;
@ApiPropertyOptional({
description: "V5 only. Reason text from the most recent FileMaker rejection.",
example: "قیمت نامعقول",
})
fileMakerRejectionReason?: string;
@ApiPropertyOptional({
description: "V5 only. ObjectId of the FileMaker who must approve (or has approved) this claim.",
example: "6a43a2689faee3d20bf24030",
})
fileMakerApprovalActorId?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: description:
"Slice of `claim.evaluation` exposed to the damage expert. " + "Slice of `claim.evaluation` exposed to the damage expert. " +

View File

@@ -971,7 +971,7 @@ export class ExpertClaimService {
daghi: { daghi: {
option: part.daghi.option, option: part.daghi.option,
...(part.daghi.price && { price: part.daghi.price }), ...(part.daghi.price && { price: part.daghi.price }),
...(part.daghi.branchId && { ...(part.daghi.branchId && Types.ObjectId.isValid(part.daghi.branchId) && {
branchId: new Types.ObjectId(part.daghi.branchId), branchId: new Types.ObjectId(part.daghi.branchId),
}), }),
}, },
@@ -2671,16 +2671,23 @@ export class ExpertClaimService {
if (!reviewerBlame) { if (!reviewerBlame) {
throw new NotFoundException("Linked blame file not found."); throw new NotFoundException("Linked blame file not found.");
} }
const blameStatus = (reviewerBlame as any).status as string;
if (blameStatus === "WAITING_FOR_FILE_REVIEWER") {
// Phase 1: blame-assign path
return this.assignFileReviewerToV4Blame(claimRequestId, claim, actor);
}
// Phase 2: blame is past the initial assignment step.
// Only the reviewer who was assigned during Phase 1 may proceed.
const assignedReviewerId = (reviewerBlame as any).assignedFileReviewerId const assignedReviewerId = (reviewerBlame as any).assignedFileReviewerId
? String((reviewerBlame as any).assignedFileReviewerId) ? String((reviewerBlame as any).assignedFileReviewerId)
: null; : null;
const blameStatus = (reviewerBlame as any).status as string;
if (blameStatus === "WAITING_FOR_FILE_REVIEWER") {
if (assignedReviewerId && assignedReviewerId === actor.sub) {
// Reviewer already assigned (e.g. after a FileMaker rejection that reset
// blame back to WAITING_FOR_FILE_REVIEWER) — skip Phase 1 and fall
// through to the damage-expert workflow lock below.
} else {
// Phase 1: first-time blame assignment
return this.assignFileReviewerToV4Blame(claimRequestId, claim, actor);
}
} else {
// Phase 2: blame is past the initial assignment step.
// Only the reviewer who was assigned during Phase 1 may proceed.
if (!assignedReviewerId) { if (!assignedReviewerId) {
throw new BadRequestException({ throw new BadRequestException({
success: false, success: false,
@@ -2695,8 +2702,8 @@ export class ExpertClaimService {
message: "This file is assigned to another reviewer.", message: "This file is assigned to another reviewer.",
}); });
} }
// Assigned reviewer — fall through to the damage-expert workflow lock below. }
// (actor.role stays FILE_REVIEWER; assertExpertActorOnClaim checks clientKey scope) // Fall through to the damage-expert workflow lock below.
} }
await this.assertExpertActorOnClaim(claim, actor); await this.assertExpertActorOnClaim(claim, actor);
@@ -2777,9 +2784,15 @@ export class ExpertClaimService {
const now = new Date(); const now = new Date();
const lockSnapshot = await this.snapshotDamageExpert(actor.sub); const lockSnapshot = await this.snapshotDamageExpert(actor.sub);
const expiredAt = new Date(now.getTime() + EXPERT_WORKFLOW_LOCK_TTL_MS); const expiredAt = new Date(now.getTime() + EXPERT_WORKFLOW_LOCK_TTL_MS);
const actorType = isFieldExpertOwner ? "field_expert" : "damage_expert"; const actorType = isFieldExpertOwner
? "field_expert"
: (actor as any).role === RoleEnum.FILE_REVIEWER
? "file_reviewer"
: "damage_expert";
const actorRoleLabel = isFieldExpertOwner const actorRoleLabel = isFieldExpertOwner
? ("field_expert" as const) ? ("field_expert" as const)
: (actor as any).role === RoleEnum.FILE_REVIEWER
? ("file_reviewer" as const)
: ("damage_expert" as const); : ("damage_expert" as const);
const lockedByPayload = { const lockedByPayload = {
@@ -2834,7 +2847,13 @@ export class ExpertClaimService {
}, },
}; };
if (!claim.workflow?.assignedForReviewBy) { // Update assignedForReviewBy whenever a FILE_REVIEWER re-locks after a
// FileMaker rejection — the stale payload (from a prior damage-expert cycle)
// must be replaced with the actual reviewer so submit checks pass.
if (
!claim.workflow?.assignedForReviewBy ||
(actor as any).role === RoleEnum.FILE_REVIEWER
) {
baseLockUpdate["workflow.assignedForReviewBy"] = lockedByPayload; baseLockUpdate["workflow.assignedForReviewBy"] = lockedByPayload;
} }
@@ -3363,6 +3382,12 @@ export class ExpertClaimService {
const nextCaseStatus = claimCaseStatusAfterExpertReplyV2(processedParts); const nextCaseStatus = claimCaseStatusAfterExpertReplyV2(processedParts);
let nextClaimStatus = ClaimStatus.APPROVED; let nextClaimStatus = ClaimStatus.APPROVED;
// V5 flow: FileMaker must approve before the owner is asked to sign.
// When no factor upload is needed, skip the owner-sign step entirely here
// and park the claim at WAITING_FOR_FILE_MAKER_APPROVAL. The owner SMS
// and INSURER_REVIEW status are emitted by fileMakerApproveV5 instead.
const isV5Claim = !!(claim as any).requiresFileMakerApproval;
if (needsFactorUpload) { if (needsFactorUpload) {
nextClaimStatus = ClaimStatus.NEEDS_REVISION; nextClaimStatus = ClaimStatus.NEEDS_REVISION;
if (mixedFactorAndPrice) { if (mixedFactorAndPrice) {
@@ -3372,6 +3397,10 @@ export class ExpertClaimService {
currentStep = ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS; currentStep = ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS;
nextWorkflowStep = ClaimWorkflowStep.EXPERT_COST_EVALUATION; nextWorkflowStep = ClaimWorkflowStep.EXPERT_COST_EVALUATION;
} }
} else if (isV5Claim) {
// V5, no factors: hold at WAITING_FOR_FILE_MAKER_APPROVAL
currentStep = ClaimWorkflowStep.INSURER_REVIEW;
nextWorkflowStep = ClaimWorkflowStep.CLAIM_COMPLETED;
} }
const mergedSelectedParts = [...ownerSelectedParts]; const mergedSelectedParts = [...ownerSelectedParts];
@@ -3389,7 +3418,9 @@ export class ExpertClaimService {
} }
const updatePayload: Record<string, unknown> = { const updatePayload: Record<string, unknown> = {
status: nextCaseStatus, status: isV5Claim && !needsFactorUpload
? ClaimCaseStatus.WAITING_FOR_FILE_MAKER_APPROVAL
: nextCaseStatus,
claimStatus: nextClaimStatus, claimStatus: nextClaimStatus,
...(expertAddedParts.length > 0 ...(expertAddedParts.length > 0
? { "damage.selectedParts": mergedSelectedParts } ? { "damage.selectedParts": mergedSelectedParts }
@@ -3445,8 +3476,9 @@ export class ExpertClaimService {
idempotencyKey: `claim:${claimRequestId}:handled:${actor.sub}`, idempotencyKey: `claim:${claimRequestId}:handled:${actor.sub}`,
}); });
// V5: owner SMS is sent by fileMakerApproveV5 after FileMaker approval.
const ownerPhoneNotify = await this.resolveClaimOwnerPhone(claim); const ownerPhoneNotify = await this.resolveClaimOwnerPhone(claim);
if (ownerPhoneNotify && !needsFactorUpload) { if (ownerPhoneNotify && !needsFactorUpload && !isV5Claim) {
const expertLastName = const expertLastName =
actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس"; actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
await this.smsOrchestrationService.sendSignatureReviewNotice({ await this.smsOrchestrationService.sendSignatureReviewNotice({
@@ -3454,7 +3486,7 @@ export class ExpertClaimService {
fileKind: "claim", fileKind: "claim",
publicId: claim.publicId, publicId: claim.publicId,
expertLastName, expertLastName,
link: this.smsOrchestrationService.buildClaimLink(String(claim._id), "v1"), link: this.smsOrchestrationService.buildClaimLink(String(claim._id), "v2"),
}); });
} }
@@ -5073,6 +5105,11 @@ export class ExpertClaimService {
damagedParts, damagedParts,
awaitingFactorValidation: isFactorValidationPending, awaitingFactorValidation: isFactorValidationPending,
requiresFileMakerApproval: !!(claim as any).requiresFileMakerApproval, requiresFileMakerApproval: !!(claim as any).requiresFileMakerApproval,
fileMakerRejectionCount: (claim as any).fileMakerRejectionCount ?? 0,
fileMakerRejectionReason: (claim as any).fileMakerRejectionReason ?? undefined,
fileMakerApprovalActorId: (claim as any).fileMakerApprovalActorId
? String((claim as any).fileMakerApprovalActorId)
: undefined,
evaluation: evaluation:
(evaluationForApi as (evaluationForApi as
| ClaimDetailV2ResponseDto["evaluation"] | ClaimDetailV2ResponseDto["evaluation"]

View File

@@ -64,26 +64,19 @@ export class FileMakerClaimApprovalV5Controller {
@ApiOperation({ @ApiOperation({
summary: "Approve the completed claim (FileMaker V5)", summary: "Approve the completed claim (FileMaker V5)",
description: description:
"Approves a claim that is in WAITING_FOR_FILE_MAKER_APPROVAL status. " + "Approves a claim that is in `WAITING_FOR_FILE_MAKER_APPROVAL` status. " +
"Marks the claim COMPLETED and triggers fanavaran submission.", "Moves the claim to `INSURER_REVIEW_AWAITING_OWNER_SIGN` and sends the owner an SMS " +
"with a signature link. Fanavaran submission is triggered automatically after the " +
"owner signs.",
}) })
async approve( async approve(
@Param("claimRequestId") claimRequestId: string, @Param("claimRequestId") claimRequestId: string,
@CurrentUser() fileMaker: any, @CurrentUser() fileMaker: any,
) { ) {
const result = await this.requestManagementService.fileMakerApproveV5( return this.requestManagementService.fileMakerApproveV5(
fileMaker, fileMaker,
claimRequestId, claimRequestId,
); );
// Trigger fanavaran auto-submit now that the claim is COMPLETED and the gate
// (requiresFileMakerApproval) has been cleared.
const fanavaran =
await this.claimRequestManagementService.autoSubmitToFanavaranV2OnClaimCompleted(
claimRequestId,
);
return { ...result, fanavaran };
} }
@Post("reject/:claimRequestId") @Post("reject/:claimRequestId")

View File

@@ -10389,28 +10389,53 @@ export class RequestManagementService {
metadata: {}, metadata: {},
}; };
// Move claim to COMPLETED and clear the approval gate // V5 approval: move claim to INSURER_REVIEW_AWAITING_OWNER_SIGN so the
// owner can now sign. Clear requiresFileMakerApproval so that when the
// owner later signs and autoSubmitToFanavaranV2OnClaimCompleted runs, it
// does not re-intercept the claim as a pending V5 gate.
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: { $set: {
status: ClaimCaseStatus.COMPLETED, status: ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN,
claimStatus: ClaimStatus.APPROVED, claimStatus: ClaimStatus.APPROVED,
requiresFileMakerApproval: false, requiresFileMakerApproval: false,
"workflow.currentStep": ClaimWorkflowStep.CLAIM_COMPLETED, "workflow.currentStep": ClaimWorkflowStep.INSURER_REVIEW,
"workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED, "workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED,
}, },
$push: { $push: {
"workflow.completedSteps": ClaimWorkflowStep.INSURER_REVIEW,
history: historyEntry, history: historyEntry,
}, },
}); });
// Notify the owner that the claim is ready for their signature.
const notifyUserId = (claim as any).damagedPartyUserId ?? (claim as any).owner?.userId;
if (notifyUserId && claim.blameRequestId) {
const blame = await this.blameRequestDbService.findById(
String(claim.blameRequestId),
);
const ownerParty = (blame?.parties || []).find(
(p: any) =>
p?.person?.userId && String(p.person.userId) === String(notifyUserId),
);
const ownerPhone = ownerParty?.person?.phoneNumber
?? (await this.userDbService.findOne({ _id: new Types.ObjectId(String(notifyUserId)) }))?.mobile;
if (ownerPhone && typeof ownerPhone === "string") {
await this.smsOrchestrationService.sendSignatureReviewNotice({
receptor: ownerPhone,
fileKind: "claim",
publicId: claim.publicId,
expertLastName: actorName.split(/\s+/).pop() || "کارشناس",
link: this.smsOrchestrationService.buildClaimLink(claimRequestId, "v2"),
});
}
}
return { return {
claimRequestId, claimRequestId,
publicId: claim.publicId, publicId: claim.publicId,
status: ClaimCaseStatus.COMPLETED, status: ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN,
message: message:
"Claim approved by FileMaker. Claim is now marked COMPLETED. " + "Claim approved by FileMaker. Owner has been notified to sign. " +
"Proceed with fanavaran submission via the claim service.", "Once the owner signs, the claim will be completed and submitted to Fanavaran.",
}; };
} }
@@ -10467,6 +10492,25 @@ export class RequestManagementService {
"workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, "workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
"workflow.locked": false, "workflow.locked": false,
}, },
// Clear all stale lock fields and prior-cycle evaluation data so the
// FileReviewer starts a completely clean lock cycle:
// - workflow lock fields left over from the completed review cycle
// - damageExpertReply / damageExpertResend / ownerInsurerApproval /
// ownerPricedPartsApproval — all belong to the rejected cycle and
// must not bleed into the new one (resend fulfilledAt would block
// submitResendDocsV2; stale reply would mislead the frontend)
// - assignedForReviewBy is intentionally kept so the same reviewer
// is still scoped to this file on re-lock
$unset: {
"workflow.lockedAt": "",
"workflow.expiredAt": "",
"workflow.lockedBy": "",
"workflow.preLockQueueSnapshot": "",
"evaluation.damageExpertReply": "",
"evaluation.damageExpertResend": "",
"evaluation.ownerInsurerApproval": "",
"evaluation.ownerPricedPartsApproval": "",
},
$push: { $push: {
history: { history: {
type: "V5_FILE_MAKER_REJECTED", type: "V5_FILE_MAKER_REJECTED",
@@ -10623,7 +10667,7 @@ export class RequestManagementService {
agent: any, agent: any,
requestId: string, requestId: string,
dto: Omit<RunInquiriesV3Dto, "sheba">, dto: Omit<RunInquiriesV3Dto, "sheba">,
): Promise<{ blameRequestId: string; message: string }> { ): Promise<Record<string, unknown>> {
if (agent?.role !== RoleEnum.CALL_CENTER) { if (agent?.role !== RoleEnum.CALL_CENTER) {
throw new ForbiddenException("Only call-center agents can use this endpoint."); throw new ForbiddenException("Only call-center agents can use this endpoint.");
} }
@@ -10651,9 +10695,41 @@ export class RequestManagementService {
}); });
await (req as any).save(); await (req as any).save();
// Return the inquiry-populated data so the agent can review before sending the link.
const firstPartyAfter = req.parties[firstIdx];
return { return {
blameRequestId: requestId, blameRequestId: requestId,
publicId: (req as any).publicId,
type: (req as any).type,
status: (req as any).status,
message: "Guilty-party inquiry complete. You can now send the blame link to the user.", message: "Guilty-party inquiry complete. You can now send the blame link to the user.",
guiltyParty: {
vehicle: firstPartyAfter?.vehicle
? {
plateId: firstPartyAfter.vehicle.plateId,
name: firstPartyAfter.vehicle.name,
type: firstPartyAfter.vehicle.type,
}
: undefined,
insurance: firstPartyAfter?.insurance
? {
company: firstPartyAfter.insurance.company,
policyNumber: firstPartyAfter.insurance.policyNumber,
startDate: firstPartyAfter.insurance.startDate,
endDate: firstPartyAfter.insurance.endDate,
financialCeiling: (firstPartyAfter.insurance as any).financialCeiling,
}
: undefined,
person: firstPartyAfter?.person
? {
nationalCodeOfInsurer: firstPartyAfter.person.nationalCodeOfInsurer,
nationalCodeOfDriver: firstPartyAfter.person.nationalCodeOfDriver,
driverIsInsurer: firstPartyAfter.person.driverIsInsurer,
insurerBirthday: firstPartyAfter.person.insurerBirthday,
driverBirthday: firstPartyAfter.person.driverBirthday,
}
: undefined,
},
}; };
} }
@@ -10734,14 +10810,24 @@ export class RequestManagementService {
type: (req as any).type, type: (req as any).type,
status: (req as any).status, status: (req as any).status,
blameStatus: (req as any).blameStatus, blameStatus: (req as any).blameStatus,
workflow: (req as any).workflow, workflow: {
currentStep: (req as any).workflow?.currentStep,
nextStep: (req as any).workflow?.nextStep,
completedSteps: (req as any).workflow?.completedSteps,
},
skipInitialFormStep: (req as any).skipInitialFormStep, skipInitialFormStep: (req as any).skipInitialFormStep,
parties: ((req as any).parties ?? []).map((p: any) => ({ parties: ((req as any).parties ?? []).map((p: any) => ({
role: p.role, role: p.role,
person: { person: {
phoneNumber: p.person?.phoneNumber, phoneNumber: p.person?.phoneNumber,
nationalCodeOfInsurer: p.person?.nationalCodeOfInsurer, nationalCodeOfInsurer: p.person?.nationalCodeOfInsurer,
clientId: p.person?.clientId, nationalCodeOfDriver: p.person?.nationalCodeOfDriver,
driverIsInsurer: p.person?.driverIsInsurer,
insurerBirthday: p.person?.insurerBirthday,
driverBirthday: p.person?.driverBirthday,
clientId: p.person?.clientId
? String(p.person.clientId)
: undefined,
}, },
insurance: p.insurance insurance: p.insurance
? { ? {
@@ -10749,13 +10835,20 @@ export class RequestManagementService {
policyNumber: p.insurance.policyNumber, policyNumber: p.insurance.policyNumber,
startDate: p.insurance.startDate, startDate: p.insurance.startDate,
endDate: p.insurance.endDate, endDate: p.insurance.endDate,
financialCeiling: p.insurance.financialCeiling,
} }
: undefined, : undefined,
vehicle: p.vehicle vehicle: p.vehicle
? { plateId: p.vehicle.plateId, name: p.vehicle.name } ? {
plateId: p.vehicle.plateId,
name: p.vehicle.name,
type: p.vehicle.type,
}
: undefined, : undefined,
inquiryComplete: !!(p.person?.inquiriesCompleted),
})), })),
createdAt: (req as any).createdAt, createdAt: (req as any).createdAt,
updatedAt: (req as any).updatedAt,
}; };
} }