forked from Yara724/api
Compare commits
6 Commits
55ec6f1fd1
...
c2d59112cf
| Author | SHA1 | Date | |
|---|---|---|---|
| c2d59112cf | |||
|
|
e1115b0632 | ||
| 908292b0c3 | |||
| d33cff8438 | |||
| 70306d42e0 | |||
| f66fa5d7b4 |
@@ -25,6 +25,9 @@ export enum ClaimWorkflowStep {
|
|||||||
EXPERT_FINAL_REPLY = "EXPERT_FINAL_REPLY",
|
EXPERT_FINAL_REPLY = "EXPERT_FINAL_REPLY",
|
||||||
EXPERT_COST_EVALUATION = "EXPERT_COST_EVALUATION",
|
EXPERT_COST_EVALUATION = "EXPERT_COST_EVALUATION",
|
||||||
|
|
||||||
|
/** Owner must upload repair factor files for factorNeeded lines (before expert COST_EVALUATION). */
|
||||||
|
OWNER_UPLOAD_FACTOR_DOCUMENTS = "OWNER_UPLOAD_FACTOR_DOCUMENTS",
|
||||||
|
|
||||||
// Insurer approval
|
// Insurer approval
|
||||||
INSURER_REVIEW = "INSURER_REVIEW",
|
INSURER_REVIEW = "INSURER_REVIEW",
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,12 @@ import {
|
|||||||
normalizeResendPartKeys,
|
normalizeResendPartKeys,
|
||||||
partKeyAllowedForExpertResend,
|
partKeyAllowedForExpertResend,
|
||||||
} from "src/helpers/claim-expert-resend";
|
} from "src/helpers/claim-expert-resend";
|
||||||
|
import {
|
||||||
|
classifyV2ExpertPricingParts,
|
||||||
|
getActiveV2ExpertReply,
|
||||||
|
objectionDisallowedDueToOutstandingFactorWorkflow,
|
||||||
|
} from "src/helpers/claim-v2-expert-reply-workflow";
|
||||||
|
import { buildClaimDetailsV2OwnerGuidance } from "src/helpers/claim-details-v2-owner-guidance";
|
||||||
import {
|
import {
|
||||||
canonicalizeClaimCarAngleKey,
|
canonicalizeClaimCarAngleKey,
|
||||||
getClaimCarAngleCaptureBlob,
|
getClaimCarAngleCaptureBlob,
|
||||||
@@ -2363,9 +2369,30 @@ export class ClaimRequestManagementService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (claim.status !== ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Factors can only be uploaded while the insurer-approval phase is pending.",
|
||||||
|
);
|
||||||
|
}
|
||||||
if (claim.claimStatus !== ClaimStatus.NEEDS_REVISION) {
|
if (claim.claimStatus !== ClaimStatus.NEEDS_REVISION) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Factors can only be uploaded while the claim is in NEEDS_REVISION (expert requested factor documents). After all factors are uploaded the claim moves to UNDER_REVIEW for expert validation only.",
|
"Factors can only be uploaded while the claim needs owner action (claimStatus NEEDS_REVISION).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (claim.workflow?.currentStep !== ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Repair factors are uploaded at workflow step ${ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS} (after accepting priced lines when applicable). Current: ${String(claim.workflow?.currentStep ?? "")}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const flowParts = finalReply.parts ?? [];
|
||||||
|
const pricingShape = classifyV2ExpertPricingParts(flowParts);
|
||||||
|
if (
|
||||||
|
pricingShape.mixedFactorAndPrice &&
|
||||||
|
!claim.evaluation?.ownerPricedPartsApproval?.signedAt
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Sign acceptance of the priced repair lines first (`PUT .../owner-insurer-approval/sign` while claimStatus is NEEDS_REVISION at INSURER_REVIEW), then upload factors for factor-needed parts.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2459,7 +2486,11 @@ export class ClaimRequestManagementService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||||
$set: { claimStatus: ClaimStatus.UNDER_REVIEW },
|
$set: {
|
||||||
|
claimStatus: ClaimStatus.UNDER_REVIEW,
|
||||||
|
"workflow.currentStep": ClaimWorkflowStep.EXPERT_COST_EVALUATION,
|
||||||
|
"workflow.nextStep": ClaimWorkflowStep.INSURER_REVIEW,
|
||||||
|
},
|
||||||
$push: {
|
$push: {
|
||||||
history: {
|
history: {
|
||||||
type: "ALL_FACTORS_UPLOADED_PENDING_VALIDATION",
|
type: "ALL_FACTORS_UPLOADED_PENDING_VALIDATION",
|
||||||
@@ -5033,8 +5064,10 @@ export class ClaimRequestManagementService {
|
|||||||
/**
|
/**
|
||||||
* V2 API: User objection (ClaimCase + evaluation.objection payload).
|
* V2 API: User objection (ClaimCase + evaluation.objection payload).
|
||||||
*
|
*
|
||||||
* Preconditions: damage expert resend and/or first expert reply exists; no prior objection; no final reply yet.
|
* Priced-flow: WAITING_FOR_INSURER_APPROVAL @ INSURER_REVIEW — either NEEDS_REVISION (mixed-priced gate before factor upload)
|
||||||
* Post: claim returns to expert queue (WAITING_FOR_DAMAGE_EXPERT), objection stored, optional new parts merged into damage.selectedParts.
|
* or APPROVED (final totals after pricing / factor validation); never while uploading factors or while expert validates.
|
||||||
|
* Legacy: objection during unpaid expert resend (`WAITING_FOR_USER_RESEND` @ `USER_EXPERT_RESEND`).
|
||||||
|
* Post: resets owner signatures when applicable and returns claim to WAITING_FOR_DAMAGE_EXPERT.
|
||||||
*/
|
*/
|
||||||
async handleUserObjectionV2(
|
async handleUserObjectionV2(
|
||||||
claimRequestId: string,
|
claimRequestId: string,
|
||||||
@@ -5058,26 +5091,26 @@ export class ClaimRequestManagementService {
|
|||||||
throw new ForbiddenException('Only the claim owner can submit an objection');
|
throw new ForbiddenException('Only the claim owner can submit an objection');
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasExpertResend = this.claimCaseHasExpertResend(claimCase);
|
|
||||||
const hasFirstExpertReply = !!claimCase.evaluation?.damageExpertReply;
|
|
||||||
const hasFinalExpertReply = !!claimCase.evaluation?.damageExpertReplyFinal;
|
|
||||||
|
|
||||||
if (!hasExpertResend && !hasFirstExpertReply) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'You can only object after the damage expert has submitted an assessment or requested a resend.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasFinalExpertReply) {
|
|
||||||
throw new ConflictException(
|
|
||||||
'The expert has already submitted the final reply after objection; you cannot submit another objection through this endpoint.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (claimCase.evaluation?.objection?.submittedAt) {
|
if (claimCase.evaluation?.objection?.submittedAt) {
|
||||||
throw new ConflictException('An objection has already been submitted for this claim.');
|
throw new ConflictException('An objection has already been submitted for this claim.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasExpertResend = this.claimCaseHasExpertResend(claimCase);
|
||||||
|
const pendingResendGate =
|
||||||
|
hasExpertResend &&
|
||||||
|
claimCase.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND &&
|
||||||
|
claimCase.workflow?.currentStep === ClaimWorkflowStep.USER_EXPERT_RESEND;
|
||||||
|
|
||||||
|
if (claimCase.evaluation?.damageExpertReplyFinal) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'The expert already submitted the final reply after objection; submit accept/reject via the owner signer.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeExpert = getActiveV2ExpertReply(
|
||||||
|
claimCase as unknown as { evaluation?: Record<string, unknown> },
|
||||||
|
);
|
||||||
|
|
||||||
const partsIn = body.objectionParts ?? [];
|
const partsIn = body.objectionParts ?? [];
|
||||||
const newPartsIn = body.newParts ?? [];
|
const newPartsIn = body.newParts ?? [];
|
||||||
if (partsIn.length === 0 && newPartsIn.length === 0) {
|
if (partsIn.length === 0 && newPartsIn.length === 0) {
|
||||||
@@ -5086,6 +5119,61 @@ export class ClaimRequestManagementService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let pricingObjectionEligible = false;
|
||||||
|
if (
|
||||||
|
claimCase.status === ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL &&
|
||||||
|
claimCase.workflow?.currentStep === ClaimWorkflowStep.INSURER_REVIEW &&
|
||||||
|
!claimCase.evaluation?.ownerInsurerApproval?.signedAt
|
||||||
|
) {
|
||||||
|
if (objectionDisallowedDueToOutstandingFactorWorkflow(claimCase)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Objections are paused while factor files are uploading or the expert is validating repair factors.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shape = classifyV2ExpertPricingParts(activeExpert?.parts ?? []);
|
||||||
|
const atMixedPartialGate =
|
||||||
|
claimCase.claimStatus === ClaimStatus.NEEDS_REVISION &&
|
||||||
|
shape.mixedFactorAndPrice &&
|
||||||
|
!claimCase.evaluation?.ownerPricedPartsApproval?.signedAt;
|
||||||
|
const atFinalPricingGate =
|
||||||
|
claimCase.claimStatus === ClaimStatus.APPROVED;
|
||||||
|
pricingObjectionEligible = atMixedPartialGate || atFinalPricingGate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(pendingResendGate || pricingObjectionEligible)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'No open objection window. Use this after a priced insurer-review step without a recorded final approval, ' +
|
||||||
|
'or while completing an unpaid expert resend.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pendingResendGate && pricingObjectionEligible && !activeExpert?.parts?.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A priced assessment is required before you can object.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pricedPartIdSet =
|
||||||
|
activeExpert?.parts?.length ?
|
||||||
|
new Set(
|
||||||
|
activeExpert.parts
|
||||||
|
.filter((p) => p.factorNeeded !== true)
|
||||||
|
.map((p) => String((p as { partId?: string }).partId ?? ""))
|
||||||
|
.filter(Boolean),
|
||||||
|
)
|
||||||
|
: new Set<string>();
|
||||||
|
|
||||||
|
if (partsIn.length && pricingObjectionEligible) {
|
||||||
|
for (const op of partsIn) {
|
||||||
|
if (!pricedPartIdSet.has(String(op.partId))) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Only priced repair lines can be disputed (${String(op.partId)} is factor-only or unknown).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const objectionParts = partsIn.map((p) => ({
|
const objectionParts = partsIn.map((p) => ({
|
||||||
partId: p.partId,
|
partId: p.partId,
|
||||||
reason: p.reason,
|
reason: p.reason,
|
||||||
@@ -5126,6 +5214,10 @@ export class ClaimRequestManagementService {
|
|||||||
'evaluation.objection': objectionPayload,
|
'evaluation.objection': objectionPayload,
|
||||||
'damage.selectedParts': mergedSelected,
|
'damage.selectedParts': mergedSelected,
|
||||||
},
|
},
|
||||||
|
$unset: {
|
||||||
|
'evaluation.ownerPricedPartsApproval': "",
|
||||||
|
'evaluation.ownerInsurerApproval': "",
|
||||||
|
},
|
||||||
$push: {
|
$push: {
|
||||||
history: {
|
history: {
|
||||||
type: 'USER_OBJECTION_SUBMITTED',
|
type: 'USER_OBJECTION_SUBMITTED',
|
||||||
@@ -5227,9 +5319,13 @@ export class ClaimRequestManagementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V2: Claim owner signs final priced expert reply (single-party), same intent as blame
|
* V2: Claim owner signature on expert pricing.
|
||||||
* `PUT …/sign/:requestId` — only when case status is WAITING_FOR_INSURER_APPROVAL and
|
*
|
||||||
* workflow is at INSURER_REVIEW (not factor-validation queue at EXPERT_COST_EVALUATION).
|
* Phase 1 (mixed priced + factor-needed lines): WAITING_FOR_INSURER_APPROVAL, NEEDS_REVISION, INSURER_REVIEW,
|
||||||
|
* owner has not yet accepted priced lines — agreeing records `evaluation.ownerPricedPartsApproval` and moves the
|
||||||
|
* workflow to OWNER_UPLOAD_FACTOR_DOCUMENTS. Rejecting rejects the whole case.
|
||||||
|
*
|
||||||
|
* Phase 2 (final): WAITING_FOR_INSURER_APPROVAL, APPROVED, INSURER_REVIEW — full accept/reject completes the case.
|
||||||
*/
|
*/
|
||||||
async submitOwnerInsurerApprovalSignV2(
|
async submitOwnerInsurerApprovalSignV2(
|
||||||
claimRequestId: string,
|
claimRequestId: string,
|
||||||
@@ -5245,6 +5341,7 @@ export class ClaimRequestManagementService {
|
|||||||
claimStatus: ClaimStatus;
|
claimStatus: ClaimStatus;
|
||||||
currentStep?: ClaimWorkflowStep;
|
currentStep?: ClaimWorkflowStep;
|
||||||
accepted: boolean;
|
accepted: boolean;
|
||||||
|
phase?: "PRICED_PARTS_FOR_FACTORS" | "FINAL_APPROVAL";
|
||||||
}> {
|
}> {
|
||||||
if (!Types.ObjectId.isValid(claimRequestId)) {
|
if (!Types.ObjectId.isValid(claimRequestId)) {
|
||||||
throw new BadRequestException("Invalid claim request id");
|
throw new BadRequestException("Invalid claim request id");
|
||||||
@@ -5271,7 +5368,7 @@ export class ClaimRequestManagementService {
|
|||||||
|
|
||||||
if (claimCase.status !== ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL) {
|
if (claimCase.status !== ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Claim is not waiting for your approval signature. Current status: ${String(claimCase.status)}`,
|
`Claim is not waiting for your insurer-approval signature. Current status: ${String(claimCase.status)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (claimCase.workflow?.currentStep !== ClaimWorkflowStep.INSURER_REVIEW) {
|
if (claimCase.workflow?.currentStep !== ClaimWorkflowStep.INSURER_REVIEW) {
|
||||||
@@ -5279,21 +5376,41 @@ export class ClaimRequestManagementService {
|
|||||||
`Signing is only allowed at workflow step ${ClaimWorkflowStep.INSURER_REVIEW}. Current: ${String(claimCase.workflow?.currentStep ?? "")}`,
|
`Signing is only allowed at workflow step ${ClaimWorkflowStep.INSURER_REVIEW}. Current: ${String(claimCase.workflow?.currentStep ?? "")}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (claimCase.claimStatus !== ClaimStatus.APPROVED) {
|
|
||||||
|
const active =
|
||||||
|
claimCase.evaluation?.damageExpertReplyFinal ||
|
||||||
|
claimCase.evaluation?.damageExpertReply;
|
||||||
|
if (!active?.submittedAt) {
|
||||||
|
throw new BadRequestException("No expert reply is available to sign off on yet.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const shape = classifyV2ExpertPricingParts(active.parts ?? []);
|
||||||
|
const pricedBranchReply = {
|
||||||
|
parts: (active.parts ?? []).filter((p: { factorNeeded?: boolean }) => !p.factorNeeded),
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMixedPartialGate =
|
||||||
|
shape.mixedFactorAndPrice &&
|
||||||
|
claimCase.claimStatus === ClaimStatus.NEEDS_REVISION &&
|
||||||
|
!claimCase.evaluation?.ownerPricedPartsApproval?.signedAt;
|
||||||
|
|
||||||
|
const isFinalApprovalGate =
|
||||||
|
claimCase.claimStatus === ClaimStatus.APPROVED &&
|
||||||
|
!claimCase.evaluation?.ownerInsurerApproval?.signedAt;
|
||||||
|
|
||||||
|
if (!isMixedPartialGate && !isFinalApprovalGate) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Claim must be insurer-approved before owner signature. Current claimStatus: ${String(claimCase.claimStatus)}`,
|
"No owner signature action is available at this state. " +
|
||||||
|
"If repair factors are pending upload, complete the priced-line signature first (when claimStatus is NEEDS_REVISION). " +
|
||||||
|
"If you already completed the final approval signature, refresh the claim.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (claimCase.evaluation?.ownerInsurerApproval?.signedAt) {
|
if (isMixedPartialGate && claimCase.evaluation?.ownerInsurerApproval?.signedAt) {
|
||||||
throw new ConflictException("You have already submitted a signature for this claim.");
|
throw new ConflictException("Claim already has a final approval record; refresh the claim state.");
|
||||||
}
|
}
|
||||||
|
if (isFinalApprovalGate && claimCase.evaluation?.ownerInsurerApproval?.signedAt) {
|
||||||
const activeReply =
|
throw new ConflictException("You have already submitted a signature for this claim.");
|
||||||
claimCase.evaluation?.damageExpertReplyFinal ||
|
|
||||||
claimCase.evaluation?.damageExpertReply;
|
|
||||||
if (!activeReply?.submittedAt) {
|
|
||||||
throw new BadRequestException("No expert reply is available to sign off on yet.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawBranchId = (branchId ?? "").trim();
|
const rawBranchId = (branchId ?? "").trim();
|
||||||
@@ -5312,9 +5429,10 @@ export class ClaimRequestManagementService {
|
|||||||
"This branch does not belong to the insurer for this claim.",
|
"This branch does not belong to the insurer for this claim.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const branchIdsOnPricing = this.collectBranchIdsFromClaimExpertReply(
|
const branchIdsOnPricing =
|
||||||
activeReply as { parts?: unknown[] },
|
isMixedPartialGate
|
||||||
);
|
? this.collectBranchIdsFromClaimExpertReply(pricedBranchReply)
|
||||||
|
: this.collectBranchIdsFromClaimExpertReply(active as { parts?: unknown[] });
|
||||||
if (
|
if (
|
||||||
branchIdsOnPricing.size > 0 &&
|
branchIdsOnPricing.size > 0 &&
|
||||||
!branchIdsOnPricing.has(String(rawBranchId))
|
!branchIdsOnPricing.has(String(rawBranchId))
|
||||||
@@ -5332,7 +5450,7 @@ export class ClaimRequestManagementService {
|
|||||||
} as any);
|
} as any);
|
||||||
const signId = new Types.ObjectId(String((signDoc as any)._id));
|
const signId = new Types.ObjectId(String((signDoc as any)._id));
|
||||||
const signedAt = new Date();
|
const signedAt = new Date();
|
||||||
const approvalPayload = {
|
const scopedApprovalPayload = {
|
||||||
agree,
|
agree,
|
||||||
branchId: new Types.ObjectId(rawBranchId),
|
branchId: new Types.ObjectId(rawBranchId),
|
||||||
signDetailId: signId,
|
signDetailId: signId,
|
||||||
@@ -5345,12 +5463,73 @@ export class ClaimRequestManagementService {
|
|||||||
actorType: "user",
|
actorType: "user",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Mixed reply: priced lines acceptance before factor uploads */
|
||||||
|
if (isMixedPartialGate) {
|
||||||
if (!agree) {
|
if (!agree) {
|
||||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||||
$set: {
|
$set: {
|
||||||
status: ClaimCaseStatus.REJECTED,
|
status: ClaimCaseStatus.REJECTED,
|
||||||
claimStatus: ClaimStatus.REJECTED,
|
claimStatus: ClaimStatus.REJECTED,
|
||||||
"evaluation.ownerInsurerApproval": approvalPayload,
|
"evaluation.ownerPricedPartsApproval": scopedApprovalPayload,
|
||||||
|
"workflow.nextStep": null,
|
||||||
|
},
|
||||||
|
$push: {
|
||||||
|
history: {
|
||||||
|
type: "OWNER_REJECTED_PRICED_PARTS_BEFORE_FACTORS",
|
||||||
|
actor: historyActor,
|
||||||
|
timestamp: signedAt,
|
||||||
|
metadata: { branchId: rawBranchId },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
message:
|
||||||
|
"Your rejection has been recorded. This claim is marked rejected and will not proceed.",
|
||||||
|
claimRequestId,
|
||||||
|
status: ClaimCaseStatus.REJECTED,
|
||||||
|
claimStatus: ClaimStatus.REJECTED,
|
||||||
|
currentStep: claimCase.workflow?.currentStep,
|
||||||
|
accepted: false,
|
||||||
|
phase: "PRICED_PARTS_FOR_FACTORS",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||||
|
$set: {
|
||||||
|
claimStatus: ClaimStatus.NEEDS_REVISION,
|
||||||
|
"evaluation.ownerPricedPartsApproval": scopedApprovalPayload,
|
||||||
|
"workflow.currentStep": ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS,
|
||||||
|
"workflow.nextStep": ClaimWorkflowStep.EXPERT_COST_EVALUATION,
|
||||||
|
},
|
||||||
|
$push: {
|
||||||
|
history: {
|
||||||
|
type: "OWNER_SIGNED_PRICED_PARTS_PENDING_FACTOR_UPLOAD",
|
||||||
|
actor: historyActor,
|
||||||
|
timestamp: signedAt,
|
||||||
|
metadata: { branchId: rawBranchId },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message:
|
||||||
|
"Priced repair lines accepted. Upload repair factor files for each factor-needed part; the claim will return to the damage expert for cost validation.",
|
||||||
|
claimRequestId,
|
||||||
|
status: ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL,
|
||||||
|
claimStatus: ClaimStatus.NEEDS_REVISION,
|
||||||
|
currentStep: ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS,
|
||||||
|
accepted: true,
|
||||||
|
phase: "PRICED_PARTS_FOR_FACTORS",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Final accept / reject (no outstanding factor gate at this step) */
|
||||||
|
if (!agree) {
|
||||||
|
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||||
|
$set: {
|
||||||
|
status: ClaimCaseStatus.REJECTED,
|
||||||
|
claimStatus: ClaimStatus.REJECTED,
|
||||||
|
"evaluation.ownerInsurerApproval": scopedApprovalPayload,
|
||||||
"workflow.nextStep": null,
|
"workflow.nextStep": null,
|
||||||
},
|
},
|
||||||
$push: {
|
$push: {
|
||||||
@@ -5370,6 +5549,7 @@ export class ClaimRequestManagementService {
|
|||||||
claimStatus: ClaimStatus.REJECTED,
|
claimStatus: ClaimStatus.REJECTED,
|
||||||
currentStep: claimCase.workflow?.currentStep,
|
currentStep: claimCase.workflow?.currentStep,
|
||||||
accepted: false,
|
accepted: false,
|
||||||
|
phase: "FINAL_APPROVAL",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5377,7 +5557,7 @@ export class ClaimRequestManagementService {
|
|||||||
$set: {
|
$set: {
|
||||||
status: ClaimCaseStatus.COMPLETED,
|
status: ClaimCaseStatus.COMPLETED,
|
||||||
claimStatus: ClaimStatus.APPROVED,
|
claimStatus: ClaimStatus.APPROVED,
|
||||||
"evaluation.ownerInsurerApproval": approvalPayload,
|
"evaluation.ownerInsurerApproval": scopedApprovalPayload,
|
||||||
"workflow.currentStep": ClaimWorkflowStep.CLAIM_COMPLETED,
|
"workflow.currentStep": ClaimWorkflowStep.CLAIM_COMPLETED,
|
||||||
"workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED,
|
"workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED,
|
||||||
},
|
},
|
||||||
@@ -5399,6 +5579,7 @@ export class ClaimRequestManagementService {
|
|||||||
claimStatus: ClaimStatus.APPROVED,
|
claimStatus: ClaimStatus.APPROVED,
|
||||||
currentStep: ClaimWorkflowStep.CLAIM_COMPLETED,
|
currentStep: ClaimWorkflowStep.CLAIM_COMPLETED,
|
||||||
accepted: true,
|
accepted: true,
|
||||||
|
phase: "FINAL_APPROVAL",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5458,7 +5639,8 @@ export class ClaimRequestManagementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V2 API: Get claim details by ID (owner only)
|
* V2 API: Get claim details by ID (owner — or FIELD_EXPERT with enriched money/rating fields).
|
||||||
|
* Owners receive `ownerGuidance` computed from workflow state.
|
||||||
*/
|
*/
|
||||||
async getClaimDetailsV2(
|
async getClaimDetailsV2(
|
||||||
claimRequestId: string,
|
claimRequestId: string,
|
||||||
@@ -5618,6 +5800,14 @@ export class ClaimRequestManagementService {
|
|||||||
damageExpertReplyFinal: mappedEvaluation.damageExpertReplyFinal,
|
damageExpertReplyFinal: mappedEvaluation.damageExpertReplyFinal,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
...(!isExpertViewer
|
||||||
|
? {
|
||||||
|
ownerGuidance: buildClaimDetailsV2OwnerGuidance(
|
||||||
|
claim,
|
||||||
|
claim._id.toString(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(isExpertViewer
|
...(isExpertViewer
|
||||||
? {
|
? {
|
||||||
userRating: claim.userRating
|
userRating: claim.userRating
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Get Claim Details (V2)",
|
summary: "Get Claim Details (V2)",
|
||||||
description:
|
description:
|
||||||
"Get claim details for current actor. USER (claim owner) receives the same core claim payload including `owner` (userId, clientId, optional userClientKey). FIELD_EXPERT additionally receives unmasked money and userRating when present.",
|
"Returns the claim snapshot for **USER** (owner) or **FIELD_EXPERT** when permitted. Owners get `ownerGuidance`: `{ phaseKey, headline, nextActions[] (method + pathTemplate), objectionAllowed }` mapped from `status` / `claimStatus` / workflow so the client can show the correct screen without duplicating orchestration logic. FIELD_EXPERT does not receive `ownerGuidance`. Core payload includes documents, captures, evaluation replies, optional expert resend, and masked bank info.",
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -156,9 +156,11 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Submit user objection (V2)",
|
summary: "Submit user objection (V2)",
|
||||||
description:
|
description:
|
||||||
"After the damage expert submits a resend request (`damageExpertResend`), the owner may dispute priced parts " +
|
"**Windows:** (1) **Insurer-review:** `WAITING_FOR_INSURER_APPROVAL` + `workflow.currentStep=INSURER_REVIEW` without a recorded **final** `evaluation.ownerInsurerApproval` signature — including **mixed** priced+factor gate (`NEEDS_REVISION` before factor upload signature) or **final** totals (`claimStatus=APPROVED`). Not allowed while `OWNER_UPLOAD_FACTOR_DOCUMENTS` or `EXPERT_COST_EVALUATION` (finish factor uploads / validation first).\n" +
|
||||||
"and/or propose additional damaged parts. Stores a structured payload on `evaluation.objection`, merges " +
|
"(2) **Legacy resend:** active expert resend (`WAITING_FOR_USER_RESEND` @ `USER_EXPERT_RESEND`).\n\n" +
|
||||||
"`newParts` into `damage.selectedParts`, and moves the case back to `WAITING_FOR_DAMAGE_EXPERT` for re-review.",
|
"`objectionParts` may only reference **priced** repair lines (`factorNeeded=false`). Factor-only lines cannot be disputed until they have expert pricing.\n\n" +
|
||||||
|
"After **`damageExpertReplyFinal`** exists (final reply following a prior objection), **no second objection** — owner uses **owner-insurer-approval/sign** to accept/reject and close the case.\n\n" +
|
||||||
|
"Stores `evaluation.objection`, clears partial/final owner approval fields, merges `newParts` into `damage.selectedParts`, returns case to `WAITING_FOR_DAMAGE_EXPERT`.",
|
||||||
})
|
})
|
||||||
@ApiParam({
|
@ApiParam({
|
||||||
name: "claimRequestId",
|
name: "claimRequestId",
|
||||||
@@ -233,7 +235,7 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V2: Owner signs acceptance of final expert pricing (WAITING_FOR_INSURER_APPROVAL @ INSURER_REVIEW).
|
* V2: Owner signature — priced-line gate (mixed factors) or final accept/reject.
|
||||||
*/
|
*/
|
||||||
@Put("request/:claimRequestId/owner-insurer-approval/sign")
|
@Put("request/:claimRequestId/owner-insurer-approval/sign")
|
||||||
@ApiParam({
|
@ApiParam({
|
||||||
@@ -243,12 +245,12 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
})
|
})
|
||||||
@ApiConsumes("multipart/form-data")
|
@ApiConsumes("multipart/form-data")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Sign final claim pricing (owner)",
|
summary: "Sign priced lines or final claim pricing (owner)",
|
||||||
description:
|
description:
|
||||||
"Multipart: `sign` (signature image), `agree` (boolean), and `branchId` (Mongo ObjectId of the insurer branch the file is reviewed under). " +
|
"Multipart: `sign`, `agree`, `branchId`. Always requires `status=WAITING_FOR_INSURER_APPROVAL` and `workflow.currentStep=INSURER_REVIEW` (never during `EXPERT_COST_EVALUATION`).\n\n" +
|
||||||
"Allowed only when `status` is WAITING_FOR_INSURER_APPROVAL, " +
|
"**Phase A — Mixed reply, priced lines only:** `claimStatus=NEEDS_REVISION`, no `evaluation.ownerPricedPartsApproval` yet. `agree=true` records that signature and moves to `OWNER_UPLOAD_FACTOR_DOCUMENTS` for factor uploads; `agree=false` rejects the whole case (`REJECTED`).\n\n" +
|
||||||
"`workflow.currentStep` is INSURER_REVIEW (not EXPERT_COST_EVALUATION), and `claimStatus` is APPROVED. " +
|
"**Phase B — Final:** `claimStatus=APPROVED`, no `evaluation.ownerInsurerApproval` yet. `agree=true` → `COMPLETED`; `agree=false` → `REJECTED`.\n\n" +
|
||||||
"If `agree` is true, the case moves to COMPLETED; if false, to REJECTED.",
|
"Response may include `phase`: `PRICED_PARTS_FOR_FACTORS` or `FINAL_APPROVAL` for UI state.",
|
||||||
})
|
})
|
||||||
@ApiBody({
|
@ApiBody({
|
||||||
description: "Signature file, agreement, and branch",
|
description: "Signature file, agreement, and branch",
|
||||||
@@ -890,12 +892,12 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
@Patch("request/reply/:claimRequestId/:partId/upload-factor")
|
@Patch("request/reply/:claimRequestId/:partId/upload-factor")
|
||||||
@ApiConsumes("multipart/form-data")
|
@ApiConsumes("multipart/form-data")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Upload factor file for a priced part (V2)",
|
summary: "Upload repair factor file for a factor-needed part (V2)",
|
||||||
description:
|
description:
|
||||||
"Use when the damage expert reply marks `factorNeeded: true` for this `partId`. " +
|
"Part must have `factorNeeded: true` on the active reply (`evaluation.damageExpertReply` or `evaluation.damageExpertReplyFinal`). One file per part.\n\n" +
|
||||||
"Stores file in `claim-factors-image`, sets `factorLink` / `factorStatus` on the matching part in " +
|
"**Requires:** `status=WAITING_FOR_INSURER_APPROVAL`, `claimStatus=NEEDS_REVISION`, `workflow.currentStep=OWNER_UPLOAD_FACTOR_DOCUMENTS`.\n\n" +
|
||||||
"`evaluation.damageExpertReply` or `evaluation.damageExpertReplyFinal`. " +
|
"**Mixed priced+factor replies:** owner must complete **owner-insurer-approval/sign** (priced-line phase) first so `evaluation.ownerPricedPartsApproval` exists.\n\n" +
|
||||||
"Requires claim `claimStatus` NEEDS_REVISION or UNDER_REVIEW.",
|
"When every `factorNeeded` line has `factorLink`, the case moves to `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_COST_EVALUATION` for damage expert validation.",
|
||||||
})
|
})
|
||||||
@ApiParam({ name: "claimRequestId", description: "Claim case ID" })
|
@ApiParam({ name: "claimRequestId", description: "Claim case ID" })
|
||||||
@ApiParam({ name: "partId", description: "Part id from expert reply" })
|
@ApiParam({ name: "partId", description: "Part id from expert reply" })
|
||||||
|
|||||||
@@ -1,5 +1,63 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
/** Suggested HTTP call for the owner UI (`pathTemplate`: replace placeholders). */
|
||||||
|
export class ClaimDetailsOwnerNextActionV2Dto {
|
||||||
|
@ApiProperty({ description: 'Stable UI key', example: 'FINAL_SIGN' })
|
||||||
|
key: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'HTTP verb', example: 'PUT' })
|
||||||
|
method?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Path under API root',
|
||||||
|
example: 'v2/claim-request-management/request/{claimRequestId}/owner-insurer-approval/sign',
|
||||||
|
})
|
||||||
|
pathTemplate?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'What this endpoint does for the user' })
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server-derived hints: which phase the claim is in and what to offer next (owners only). */
|
||||||
|
export class ClaimDetailsOwnerGuidanceV2Dto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Machine-readable phase',
|
||||||
|
enum: [
|
||||||
|
'COMPLETED',
|
||||||
|
'REJECTED',
|
||||||
|
'CANCELLED',
|
||||||
|
'EXPERT_RESEND',
|
||||||
|
'WAITING_DAMAGE_EXPERT',
|
||||||
|
'EXPERT_REVIEWING',
|
||||||
|
'USER_FLOW',
|
||||||
|
'UPLOAD_FACTORS',
|
||||||
|
'EXPERT_VALIDATING_FACTORS',
|
||||||
|
'SIGN_PRICED_LINES',
|
||||||
|
'INSURER_REVIEW_NEEDS_REVISION',
|
||||||
|
'FINAL_SIGN_OR_REJECT',
|
||||||
|
'INSURER_APPROVAL_FALLBACK',
|
||||||
|
],
|
||||||
|
})
|
||||||
|
phaseKey: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Short headline for the current phase' })
|
||||||
|
headline: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Longer UX copy' })
|
||||||
|
detail?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [ClaimDetailsOwnerNextActionV2Dto] })
|
||||||
|
nextActions: ClaimDetailsOwnerNextActionV2Dto[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Whether PUT objection is permitted (see server validation for exact rules)',
|
||||||
|
})
|
||||||
|
objectionAllowed?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'How objection relates to priced vs factor-only lines' })
|
||||||
|
objectionHint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Active damage-expert resend request (owner must upload or acknowledge). */
|
/** Active damage-expert resend request (owner must upload or acknowledge). */
|
||||||
export class ExpertResendDetailsV2Dto {
|
export class ExpertResendDetailsV2Dto {
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@@ -97,6 +155,13 @@ export class ClaimDetailsV2ResponseDto {
|
|||||||
damageExpertReplyFinal?: unknown;
|
damageExpertReplyFinal?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: ClaimDetailsOwnerGuidanceV2Dto,
|
||||||
|
description:
|
||||||
|
'Owner-only: derived headline, suggested API routes, and whether objection is plausible. Omitted when the actor is FIELD_EXPERT.',
|
||||||
|
})
|
||||||
|
ownerGuidance?: ClaimDetailsOwnerGuidanceV2Dto;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'User satisfaction rating (if submitted)' })
|
@ApiPropertyOptional({ description: 'User satisfaction rating (if submitted)' })
|
||||||
userRating?: {
|
userRating?: {
|
||||||
progressSpeed: number;
|
progressSpeed: number;
|
||||||
|
|||||||
@@ -95,6 +95,24 @@ export class ClaimOwnerInsurerApproval {
|
|||||||
export const ClaimOwnerInsurerApprovalSchema =
|
export const ClaimOwnerInsurerApprovalSchema =
|
||||||
SchemaFactory.createForClass(ClaimOwnerInsurerApproval);
|
SchemaFactory.createForClass(ClaimOwnerInsurerApproval);
|
||||||
|
|
||||||
|
/** Owner signed acceptance of priced lines only — required before uploading factors when reply is mixed priced + factorNeeded. */
|
||||||
|
@Schema({ _id: false })
|
||||||
|
export class ClaimOwnerPricedPartsApproval {
|
||||||
|
@Prop({ type: Boolean, required: true })
|
||||||
|
agree: boolean;
|
||||||
|
|
||||||
|
@Prop({ type: Types.ObjectId })
|
||||||
|
branchId?: Types.ObjectId;
|
||||||
|
|
||||||
|
@Prop({ type: Types.ObjectId })
|
||||||
|
signDetailId?: Types.ObjectId;
|
||||||
|
|
||||||
|
@Prop({ type: Date, default: () => new Date() })
|
||||||
|
signedAt?: Date;
|
||||||
|
}
|
||||||
|
export const ClaimOwnerPricedPartsApprovalSchema =
|
||||||
|
SchemaFactory.createForClass(ClaimOwnerPricedPartsApproval);
|
||||||
|
|
||||||
@Schema({ _id: false })
|
@Schema({ _id: false })
|
||||||
export class ClaimExpertReply {
|
export class ClaimExpertReply {
|
||||||
@Prop({ type: String })
|
@Prop({ type: String })
|
||||||
@@ -276,6 +294,9 @@ export class ClaimEvaluation {
|
|||||||
@Prop({ type: ClaimOwnerInsurerApprovalSchema })
|
@Prop({ type: ClaimOwnerInsurerApprovalSchema })
|
||||||
ownerInsurerApproval?: ClaimOwnerInsurerApproval;
|
ownerInsurerApproval?: ClaimOwnerInsurerApproval;
|
||||||
|
|
||||||
|
@Prop({ type: ClaimOwnerPricedPartsApprovalSchema })
|
||||||
|
ownerPricedPartsApproval?: ClaimOwnerPricedPartsApproval;
|
||||||
|
|
||||||
@Prop({ type: String })
|
@Prop({ type: String })
|
||||||
visitLocation?: string;
|
visitLocation?: string;
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,11 @@ export class PartPricingV2Dto {
|
|||||||
@Type(() => DaghiDetailsV2Dto)
|
@Type(() => DaghiDetailsV2Dto)
|
||||||
daghi: DaghiDetailsV2Dto;
|
daghi: DaghiDetailsV2Dto;
|
||||||
|
|
||||||
@ApiProperty({ example: false })
|
@ApiProperty({
|
||||||
|
example: false,
|
||||||
|
description:
|
||||||
|
"If true, the owner must upload a repair factor image for this line; expert confirms cost later (validate-factors). Priced lines omit this or set false.",
|
||||||
|
})
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
factorNeeded: boolean;
|
factorNeeded: boolean;
|
||||||
}
|
}
|
||||||
@@ -91,7 +95,11 @@ export class SubmitExpertReplyV2Dto {
|
|||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
description: string;
|
description: string;
|
||||||
|
|
||||||
@ApiProperty({ type: [PartPricingV2Dto] })
|
@ApiProperty({
|
||||||
|
type: [PartPricingV2Dto],
|
||||||
|
description:
|
||||||
|
"Each part: manual pricing and `daghi`; set `factorNeeded` only where a repair factor upload is required from the owner.",
|
||||||
|
})
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@Type(() => PartPricingV2Dto)
|
@Type(() => PartPricingV2Dto)
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ 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 {
|
||||||
|
classifyV2ExpertPricingParts,
|
||||||
|
} from "src/helpers/claim-v2-expert-reply-workflow";
|
||||||
import {
|
import {
|
||||||
getClaimCarAngleCaptureBlob,
|
getClaimCarAngleCaptureBlob,
|
||||||
getDamagedPartCaptureBlob,
|
getDamagedPartCaptureBlob,
|
||||||
@@ -1907,8 +1910,8 @@ export class ExpertClaimService {
|
|||||||
/**
|
/**
|
||||||
* V2 (ClaimCase): validate user-uploaded repair factors on the active expert reply.
|
* V2 (ClaimCase): validate user-uploaded repair factors on the active expert reply.
|
||||||
* Preconditions: all `factorNeeded` parts have `factorLink`; case is UNDER_REVIEW at EXPERT_COST_EVALUATION.
|
* Preconditions: all `factorNeeded` parts have `factorLink`; case is UNDER_REVIEW at EXPERT_COST_EVALUATION.
|
||||||
* — All approved → same as non-factor path: claimStatus APPROVED, step INSURER_REVIEW.
|
* — All approved → claimStatus APPROVED, INSURER_REVIEW: owner accepts/rejects to close.
|
||||||
* — Any rejected (expert repriced) → case COMPLETED with expert amounts (damage-expert finalization).
|
* — Any rejected (expert repriced) → WAITING_FOR_INSURER_APPROVAL, APPROVED, INSURER_REVIEW until owner accepts/rejects.
|
||||||
*/
|
*/
|
||||||
async validateClaimFactorsV2(
|
async validateClaimFactorsV2(
|
||||||
claimRequestId: string,
|
claimRequestId: string,
|
||||||
@@ -2072,14 +2075,14 @@ export class ExpertClaimService {
|
|||||||
if (anyRejected) {
|
if (anyRejected) {
|
||||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||||
$set: {
|
$set: {
|
||||||
status: ClaimCaseStatus.COMPLETED,
|
status: ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL,
|
||||||
claimStatus: ClaimStatus.APPROVED,
|
claimStatus: ClaimStatus.APPROVED,
|
||||||
"workflow.currentStep": ClaimWorkflowStep.CLAIM_COMPLETED,
|
"workflow.currentStep": ClaimWorkflowStep.INSURER_REVIEW,
|
||||||
"workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED,
|
"workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED,
|
||||||
},
|
},
|
||||||
$push: {
|
$push: {
|
||||||
history: {
|
history: {
|
||||||
type: "FACTORS_VALIDATED_REJECTED_EXPERT_FINALIZED",
|
type: "FACTORS_VALIDATED_REJECTED_EXPERT_REPRICED",
|
||||||
actor: historyActor,
|
actor: historyActor,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
metadata: { replyField },
|
metadata: { replyField },
|
||||||
@@ -2087,13 +2090,26 @@ export class ExpertClaimService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ownerPhoneRejected = await this.resolveClaimOwnerPhone(claim);
|
||||||
|
if (ownerPhoneRejected) {
|
||||||
|
const expertLastName =
|
||||||
|
actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
|
||||||
|
await this.smsOrchestrationService.sendSignatureReviewNotice({
|
||||||
|
receptor: ownerPhoneRejected,
|
||||||
|
fileKind: "claim",
|
||||||
|
publicId: claim.publicId,
|
||||||
|
expertLastName,
|
||||||
|
link: this.smsOrchestrationService.buildClaimLink(String(claim._id)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message:
|
message:
|
||||||
"Factors reviewed. Rejected parts were repriced by the expert; the claim is completed.",
|
"Factors reviewed with repricing. Owner must accept or reject via owner-insurer-approval/sign.",
|
||||||
claimRequestId,
|
claimRequestId,
|
||||||
claimStatus: ClaimStatus.APPROVED,
|
claimStatus: ClaimStatus.APPROVED,
|
||||||
caseStatus: ClaimCaseStatus.COMPLETED,
|
caseStatus: ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL,
|
||||||
outcome: "REJECTED_REPRICED_COMPLETED",
|
outcome: "REJECTED_REPRICED_OWNER_SIGN",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2350,6 +2366,12 @@ export class ExpertClaimService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (existingResend?.fulfilledAt) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"The owner has already fulfilled a damage-expert resend for this claim. You cannot request another resend.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const allowedDocTypes = new Set<string>(
|
const allowedDocTypes = new Set<string>(
|
||||||
Object.values(ClaimRequiredDocumentType),
|
Object.values(ClaimRequiredDocumentType),
|
||||||
);
|
);
|
||||||
@@ -2458,10 +2480,11 @@ export class ExpertClaimService {
|
|||||||
* - Each part must include `daghi` (option + conditional price/branchId) like V1
|
* - Each part must include `daghi` (option + conditional price/branchId) like V1
|
||||||
*
|
*
|
||||||
* On success:
|
* On success:
|
||||||
* - Stores reply in evaluation.damageExpertReply
|
* - Stores reply; clears owner signature fields (`ownerInsurerApproval`, `ownerPricedPartsApproval`)
|
||||||
* - Unlocks the workflow
|
* - Unlocks the workflow
|
||||||
* - If any part has factorNeeded=true → status = WAITING_FOR_INSURER_APPROVAL, step = EXPERT_COST_EVALUATION
|
* - Pricing-only (`factorNeeded=false` everywhere): WAITING_FOR_INSURER_APPROVAL, APPROVED, INSURER_REVIEW → owner final sign/reject.
|
||||||
* - Otherwise → status = WAITING_FOR_INSURER_APPROVAL, step = INSURER_REVIEW, claimStatus = APPROVED
|
* - All lines `factorNeeded`: NEEDS_REVISION, OWNER_UPLOAD_FACTOR_DOCUMENTS → uploads → UNDER_REVIEW, EXPERT_COST_EVALUATION (validate factors).
|
||||||
|
* - Mixed priced + factor: NEEDS_REVISION, INSURER_REVIEW with next OWNER_UPLOAD_FACTOR_DOCUMENTS → owner signs priced lines first → upload factors → expert validates → final sign.
|
||||||
*/
|
*/
|
||||||
async submitExpertReplyV2(
|
async submitExpertReplyV2(
|
||||||
claimRequestId: string,
|
claimRequestId: string,
|
||||||
@@ -2514,7 +2537,11 @@ export class ExpertClaimService {
|
|||||||
? this.validateAndNormalizeDaghiForExpertReplyV2(reply.parts)
|
? this.validateAndNormalizeDaghiForExpertReplyV2(reply.parts)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const needsFactorUpload = reply.parts?.some((p) => p.factorNeeded === true) ?? false;
|
const { mixedFactorAndPrice, allFactorLines } = classifyV2ExpertPricingParts(
|
||||||
|
processedParts,
|
||||||
|
);
|
||||||
|
const needsFactorUpload =
|
||||||
|
reply.parts?.some((p) => p.factorNeeded === true) ?? false;
|
||||||
|
|
||||||
const objectionSubmitted = !!claim.evaluation?.objection?.submittedAt;
|
const objectionSubmitted = !!claim.evaluation?.objection?.submittedAt;
|
||||||
const hasFinalReply = !!claim.evaluation?.damageExpertReplyFinal;
|
const hasFinalReply = !!claim.evaluation?.damageExpertReplyFinal;
|
||||||
@@ -2546,14 +2573,22 @@ export class ExpertClaimService {
|
|||||||
...(expertProfileSnapshot && { expertProfileSnapshot }),
|
...(expertProfileSnapshot && { expertProfileSnapshot }),
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextStep = needsFactorUpload
|
let currentStep = ClaimWorkflowStep.INSURER_REVIEW;
|
||||||
? ClaimWorkflowStep.INSURER_REVIEW
|
let nextWorkflowStep = ClaimWorkflowStep.CLAIM_COMPLETED;
|
||||||
: ClaimWorkflowStep.CLAIM_COMPLETED;
|
|
||||||
|
|
||||||
const nextCaseStatus = ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL;
|
const nextCaseStatus = ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL;
|
||||||
const nextClaimStatus = needsFactorUpload
|
let nextClaimStatus = ClaimStatus.APPROVED;
|
||||||
? ClaimStatus.NEEDS_REVISION
|
|
||||||
: ClaimStatus.APPROVED;
|
if (needsFactorUpload) {
|
||||||
|
nextClaimStatus = ClaimStatus.NEEDS_REVISION;
|
||||||
|
if (mixedFactorAndPrice) {
|
||||||
|
currentStep = ClaimWorkflowStep.INSURER_REVIEW;
|
||||||
|
nextWorkflowStep = ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS;
|
||||||
|
} else {
|
||||||
|
currentStep = ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS;
|
||||||
|
nextWorkflowStep = ClaimWorkflowStep.EXPERT_COST_EVALUATION;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updatePayload: Record<string, unknown> = {
|
const updatePayload: Record<string, unknown> = {
|
||||||
status: nextCaseStatus,
|
status: nextCaseStatus,
|
||||||
@@ -2564,11 +2599,11 @@ export class ExpertClaimService {
|
|||||||
'workflow.expiredAt': '',
|
'workflow.expiredAt': '',
|
||||||
'workflow.lockedBy': '',
|
'workflow.lockedBy': '',
|
||||||
'workflow.preLockQueueSnapshot': '',
|
'workflow.preLockQueueSnapshot': '',
|
||||||
|
'evaluation.ownerInsurerApproval': "",
|
||||||
|
'evaluation.ownerPricedPartsApproval': "",
|
||||||
},
|
},
|
||||||
'workflow.currentStep': nextStep,
|
'workflow.currentStep': currentStep,
|
||||||
'workflow.nextStep': needsFactorUpload
|
'workflow.nextStep': needsFactorUpload ? nextWorkflowStep : ClaimWorkflowStep.CLAIM_COMPLETED,
|
||||||
? ClaimWorkflowStep.EXPERT_COST_EVALUATION
|
|
||||||
: ClaimWorkflowStep.CLAIM_COMPLETED,
|
|
||||||
[`evaluation.${replyField}`]: replyPayload,
|
[`evaluation.${replyField}`]: replyPayload,
|
||||||
$push: {
|
$push: {
|
||||||
'workflow.completedSteps': completedStep,
|
'workflow.completedSteps': completedStep,
|
||||||
@@ -2584,6 +2619,8 @@ export class ExpertClaimService {
|
|||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
metadata: {
|
metadata: {
|
||||||
factorNeeded: needsFactorUpload,
|
factorNeeded: needsFactorUpload,
|
||||||
|
mixedFactorAndPrice,
|
||||||
|
allFactorLines,
|
||||||
partsCount: processedParts.length,
|
partsCount: processedParts.length,
|
||||||
isFinalReplyAfterObjection,
|
isFinalReplyAfterObjection,
|
||||||
replyField,
|
replyField,
|
||||||
@@ -2602,30 +2639,28 @@ export class ExpertClaimService {
|
|||||||
idempotencyKey: `claim:${claimRequestId}:handled:${actor.sub}`,
|
idempotencyKey: `claim:${claimRequestId}:handled:${actor.sub}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Same effective step as WAITING_FOR_INSURER_APPROVAL @ INSURER_REVIEW: owner reviews/signs (yara-signature).
|
const ownerPhoneNotify = await this.resolveClaimOwnerPhone(claim);
|
||||||
if (!needsFactorUpload) {
|
if (ownerPhoneNotify && !needsFactorUpload) {
|
||||||
const ownerPhone = await this.resolveClaimOwnerPhone(claim);
|
|
||||||
if (ownerPhone) {
|
|
||||||
const expertLastName =
|
const expertLastName =
|
||||||
actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
|
actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
|
||||||
await this.smsOrchestrationService.sendSignatureReviewNotice({
|
await this.smsOrchestrationService.sendSignatureReviewNotice({
|
||||||
receptor: ownerPhone,
|
receptor: ownerPhoneNotify,
|
||||||
fileKind: "claim",
|
fileKind: "claim",
|
||||||
publicId: claim.publicId,
|
publicId: claim.publicId,
|
||||||
expertLastName,
|
expertLastName,
|
||||||
link: this.smsOrchestrationService.buildClaimLink(
|
link: this.smsOrchestrationService.buildClaimLink(String(claim._id)),
|
||||||
String(claim._id),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
claimRequestId,
|
claimRequestId,
|
||||||
status: nextCaseStatus,
|
status: nextCaseStatus,
|
||||||
claimStatus: nextClaimStatus,
|
claimStatus: nextClaimStatus,
|
||||||
currentStep: nextStep,
|
currentStep,
|
||||||
|
workflowNextStep: needsFactorUpload ? nextWorkflowStep : ClaimWorkflowStep.CLAIM_COMPLETED,
|
||||||
factorNeeded: needsFactorUpload,
|
factorNeeded: needsFactorUpload,
|
||||||
|
mixedPricingAndFactors: mixedFactorAndPrice,
|
||||||
|
allPartsFactorNeeded: !!needsFactorUpload && !!allFactorLines,
|
||||||
isFinalReplyAfterObjection,
|
isFinalReplyAfterObjection,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,12 @@ 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. 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.",
|
"**Preconditions:** claim locked by this expert (`EXPERT_REVIEWING`). **Unlocks** the claim. Each part needs `daghi` (V1 rules) and may set `factorNeeded` (repair factor file required from owner) or priced lines only. **Cap:** sum of `totalPayment` ≤ 30,000,000. Clears any prior `evaluation.ownerInsurerApproval` / `ownerPricedPartsApproval`.\n\n" +
|
||||||
|
"**Frontend routing by outcome:**\n" +
|
||||||
|
"- **All parts `factorNeeded`:** `status=WAITING_FOR_INSURER_APPROVAL`, `claimStatus=NEEDS_REVISION`, `workflow.currentStep=OWNER_UPLOAD_FACTOR_DOCUMENTS`, `workflow.nextStep=EXPERT_COST_EVALUATION` → owner uploads all factors, then `claimStatus` becomes `UNDER_REVIEW` and `currentStep=EXPERT_COST_EVALUATION` for expert validate-factors.\n" +
|
||||||
|
"- **Mixed (some priced, some factorNeeded):** same `status`, `claimStatus=NEEDS_REVISION`, `currentStep=INSURER_REVIEW`, `nextStep=OWNER_UPLOAD_FACTOR_DOCUMENTS` → owner must call **owner-insurer-approval/sign** first (priced-line acceptance), then `currentStep` moves to `OWNER_UPLOAD_FACTOR_DOCUMENTS` for factor uploads, then expert validation.\n" +
|
||||||
|
"- **No factors:** `claimStatus=APPROVED`, `currentStep=INSURER_REVIEW`, `nextStep=CLAIM_COMPLETED` → owner final sign/reject only.\n\n" +
|
||||||
|
"**After the owner fulfilled an expert resend** (`damageExpertResend.fulfilledAt`), this expert **cannot** initiate **another resend**—use **reply/submit**, in-person visit, or **validate-factors** as appropriate.",
|
||||||
})
|
})
|
||||||
@ApiParam({ name: "claimRequestId" })
|
@ApiParam({ name: "claimRequestId" })
|
||||||
@ApiBody({ type: SubmitExpertReplyV2Dto })
|
@ApiBody({ type: SubmitExpertReplyV2Dto })
|
||||||
@@ -103,7 +108,8 @@ export class ExpertClaimV2Controller {
|
|||||||
summary: "Submit expert damage resend request",
|
summary: "Submit expert damage resend request",
|
||||||
description:
|
description:
|
||||||
"Claim must be locked by this expert (`EXPERT_REVIEWING`). Sets `WAITING_FOR_USER_RESEND`, `USER_EXPERT_RESEND`, `NEEDS_REVISION`, clears the lock, and stores `evaluation.damageExpertResend`. " +
|
"Claim must be locked by this expert (`EXPERT_REVIEWING`). Sets `WAITING_FOR_USER_RESEND`, `USER_EXPERT_RESEND`, `NEEDS_REVISION`, clears the lock, and stores `evaluation.damageExpertResend`. " +
|
||||||
"The owner uploads via the same document/capture endpoints or `POST .../expert-resend/acknowledge` when only instructions were given.",
|
"Owner completes via document/capture endpoints or `POST .../expert-resend/acknowledge` when only instructions were given.\n\n" +
|
||||||
|
"**One resend per claim lifecycle:** if the owner has already fulfilled a resend (`damageExpertResend.fulfilledAt`), this endpoint returns **400**—the expert may only submit a priced reply or request in-person visit afterward.",
|
||||||
})
|
})
|
||||||
@ApiParam({ name: "claimRequestId" })
|
@ApiParam({ name: "claimRequestId" })
|
||||||
@ApiBody({ type: ClaimSubmitResendV2Dto })
|
@ApiBody({ type: ClaimSubmitResendV2Dto })
|
||||||
@@ -139,7 +145,12 @@ export class ExpertClaimV2Controller {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Validate uploaded repair factors (V2 ClaimCase)",
|
summary: "Validate uploaded repair factors (V2 ClaimCase)",
|
||||||
description:
|
description:
|
||||||
"After all required factors are uploaded, expert approves or rejects each part. Rejected parts must include expert totalPayment (or price and salary). All approved → insurer review. Any rejected → expert repricing and case COMPLETED.",
|
"**Preconditions:** `status=WAITING_FOR_INSURER_APPROVAL`, `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_COST_EVALUATION`, every `factorNeeded` line has `factorLink`.\n\n" +
|
||||||
|
"**Decisions:** each factor line gets `APPROVED` or `REJECTED`; rejected lines require expert `totalPayment` (or both `price` and `salary`).\n\n" +
|
||||||
|
"**Outcomes:**\n" +
|
||||||
|
"- **All approved:** `claimStatus=APPROVED`, `currentStep=INSURER_REVIEW` → owner must **sign or reject** via `owner-insurer-approval/sign`; agree completes the case.\n" +
|
||||||
|
"- **Any rejected (repriced):** same waiter + `INSURER_REVIEW` → owner still must **accept or reject** the repriced totals (case is not completed until they sign).\n" +
|
||||||
|
"- **Partial batch:** service may return pending until every factor line has a non-pending decision.",
|
||||||
})
|
})
|
||||||
@ApiParam({ name: "claimRequestId" })
|
@ApiParam({ name: "claimRequestId" })
|
||||||
@ApiBody({ type: FactorValidationV2Dto })
|
@ApiBody({ type: FactorValidationV2Dto })
|
||||||
|
|||||||
339
src/helpers/claim-details-v2-owner-guidance.ts
Normal file
339
src/helpers/claim-details-v2-owner-guidance.ts
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
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 { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
|
||||||
|
import {
|
||||||
|
classifyV2ExpertPricingParts,
|
||||||
|
getActiveV2ExpertReply,
|
||||||
|
objectionDisallowedDueToOutstandingFactorWorkflow,
|
||||||
|
} from "src/helpers/claim-v2-expert-reply-workflow";
|
||||||
|
|
||||||
|
const BASE = "v2/claim-request-management";
|
||||||
|
|
||||||
|
/** One actionable next step for owner UI (`pathTemplate` embeds claim id; replace `{partId}` per line item). */
|
||||||
|
export interface ClaimDetailsOwnerNextActionV2 {
|
||||||
|
key: string;
|
||||||
|
method?: string;
|
||||||
|
pathTemplate?: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derived hints so the client can route without re-implementing server rules (returned only for claim owners, not FIELD_EXPERT). */
|
||||||
|
export interface ClaimDetailsOwnerGuidanceV2 {
|
||||||
|
phaseKey: string;
|
||||||
|
headline: string;
|
||||||
|
detail?: string;
|
||||||
|
nextActions: ClaimDetailsOwnerNextActionV2[];
|
||||||
|
objectionAllowed?: boolean;
|
||||||
|
objectionHint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectionWindowForOwner(claim: any): {
|
||||||
|
pricingEligible: boolean;
|
||||||
|
resendEligible: boolean;
|
||||||
|
} {
|
||||||
|
const r = claim.evaluation?.damageExpertResend;
|
||||||
|
const hasExpertResend = !!(
|
||||||
|
r &&
|
||||||
|
(String(r.resendDescription || "").trim() !== "" ||
|
||||||
|
(Array.isArray(r.resendDocuments) && r.resendDocuments.length > 0) ||
|
||||||
|
(Array.isArray(r.resendCarParts) && r.resendCarParts.length > 0))
|
||||||
|
);
|
||||||
|
|
||||||
|
const pendingResend =
|
||||||
|
hasExpertResend &&
|
||||||
|
claim.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND &&
|
||||||
|
claim.workflow?.currentStep === ClaimWorkflowStep.USER_EXPERT_RESEND;
|
||||||
|
|
||||||
|
let pricingEligible = false;
|
||||||
|
if (
|
||||||
|
claim.status === ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL &&
|
||||||
|
claim.workflow?.currentStep === ClaimWorkflowStep.INSURER_REVIEW &&
|
||||||
|
!claim.evaluation?.ownerInsurerApproval?.signedAt
|
||||||
|
) {
|
||||||
|
if (!objectionDisallowedDueToOutstandingFactorWorkflow(claim)) {
|
||||||
|
const active = getActiveV2ExpertReply(
|
||||||
|
claim as { evaluation?: Record<string, unknown> },
|
||||||
|
);
|
||||||
|
const shape = classifyV2ExpertPricingParts(active?.parts ?? []);
|
||||||
|
const atMixed =
|
||||||
|
claim.claimStatus === ClaimStatus.NEEDS_REVISION &&
|
||||||
|
shape.mixedFactorAndPrice &&
|
||||||
|
!claim.evaluation?.ownerPricedPartsApproval?.signedAt;
|
||||||
|
const atFinal = claim.claimStatus === ClaimStatus.APPROVED;
|
||||||
|
pricingEligible = atMixed || atFinal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pricingEligible, resendEligible: !!pendingResend };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors server rules for PUT .../objection (read-only hint for UI). */
|
||||||
|
export function ownerMaySubmitObjectionForClaimDetails(claim: any): boolean {
|
||||||
|
if (claim.evaluation?.objection?.submittedAt) return false;
|
||||||
|
if (claim.evaluation?.damageExpertReplyFinal) return false;
|
||||||
|
const { pricingEligible, resendEligible } = objectionWindowForOwner(claim);
|
||||||
|
if (resendEligible) return true;
|
||||||
|
return (
|
||||||
|
pricingEligible &&
|
||||||
|
!claim.evaluation?.ownerInsurerApproval?.signedAt &&
|
||||||
|
!objectionDisallowedDueToOutstandingFactorWorkflow(claim)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectionHintWhen(allowed: boolean): string | undefined {
|
||||||
|
return allowed
|
||||||
|
? "Optional: PUT v2/claim-request-management/request/{claimRequestId}/objection — only priced repair lines (`factorNeeded=false` in the expert reply)."
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build owner-facing guidance for GET claim details V2.
|
||||||
|
* Caller should omit this field for FIELD_EXPERT viewers.
|
||||||
|
*/
|
||||||
|
export function buildClaimDetailsV2OwnerGuidance(
|
||||||
|
claim: any,
|
||||||
|
claimRequestId: string,
|
||||||
|
): ClaimDetailsOwnerGuidanceV2 {
|
||||||
|
const id = claimRequestId;
|
||||||
|
const act = (key: string, method: string, path: string, description: string) =>
|
||||||
|
({ key, method, pathTemplate: path, description });
|
||||||
|
|
||||||
|
const objectionAllowed = ownerMaySubmitObjectionForClaimDetails(claim);
|
||||||
|
const objectionHint = objectionHintWhen(objectionAllowed);
|
||||||
|
|
||||||
|
if (claim.status === ClaimCaseStatus.COMPLETED) {
|
||||||
|
return {
|
||||||
|
phaseKey: "COMPLETED",
|
||||||
|
headline: "Claim completed",
|
||||||
|
detail:
|
||||||
|
claim.userRating?.createdAt
|
||||||
|
? "Rating already submitted."
|
||||||
|
: "You may submit a satisfaction rating once.",
|
||||||
|
nextActions: [
|
||||||
|
act(
|
||||||
|
"USER_RATING",
|
||||||
|
"PUT",
|
||||||
|
`${BASE}/request/${id}/user-rating`,
|
||||||
|
"Optional satisfaction rating",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
objectionAllowed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (claim.status === ClaimCaseStatus.REJECTED || claim.claimStatus === ClaimStatus.REJECTED) {
|
||||||
|
return {
|
||||||
|
phaseKey: "REJECTED",
|
||||||
|
headline: "Claim rejected",
|
||||||
|
detail: "This case is closed as rejected.",
|
||||||
|
nextActions: [],
|
||||||
|
objectionAllowed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (claim.status === ClaimCaseStatus.CANCELLED) {
|
||||||
|
return {
|
||||||
|
phaseKey: "CANCELLED",
|
||||||
|
headline: "Claim cancelled",
|
||||||
|
nextActions: [],
|
||||||
|
objectionAllowed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (claim.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND) {
|
||||||
|
return {
|
||||||
|
phaseKey: "EXPERT_RESEND",
|
||||||
|
headline: "Expert requested more information",
|
||||||
|
detail:
|
||||||
|
"Upload requested documents or damaged-part photos, or acknowledge if the expert gave instructions only.",
|
||||||
|
nextActions: [
|
||||||
|
act(
|
||||||
|
"UPLOAD_DOC",
|
||||||
|
"POST",
|
||||||
|
`${BASE}/upload-document/${id}`,
|
||||||
|
"Multipart: required document upload",
|
||||||
|
),
|
||||||
|
act(
|
||||||
|
"CAPTURE_PART",
|
||||||
|
"POST",
|
||||||
|
`${BASE}/capture-part/${id}`,
|
||||||
|
"Multipart: damaged-part or angle capture",
|
||||||
|
),
|
||||||
|
act(
|
||||||
|
"RESEND_ACK",
|
||||||
|
"POST",
|
||||||
|
`${BASE}/request/${id}/expert-resend/acknowledge`,
|
||||||
|
"If no document/part list — instructions only",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
objectionAllowed: ownerMaySubmitObjectionForClaimDetails(claim),
|
||||||
|
objectionHint: objectionHintWhen(ownerMaySubmitObjectionForClaimDetails(claim)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT) {
|
||||||
|
return {
|
||||||
|
phaseKey: "WAITING_DAMAGE_EXPERT",
|
||||||
|
headline: "Waiting for damage expert",
|
||||||
|
detail: "Your file is in the expert queue.",
|
||||||
|
nextActions: [],
|
||||||
|
objectionAllowed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (claim.status === ClaimCaseStatus.EXPERT_REVIEWING) {
|
||||||
|
return {
|
||||||
|
phaseKey: "EXPERT_REVIEWING",
|
||||||
|
headline: "Expert is reviewing",
|
||||||
|
detail: "A damage expert is assessing your claim.",
|
||||||
|
nextActions: [],
|
||||||
|
objectionAllowed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (claim.status !== ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL) {
|
||||||
|
return {
|
||||||
|
phaseKey: "USER_FLOW",
|
||||||
|
headline: "Continue your claim registration",
|
||||||
|
detail: "Complete remaining selection, captures, documents, or bank info.",
|
||||||
|
nextActions: [
|
||||||
|
act(
|
||||||
|
"CAPTURE_REQUIREMENTS",
|
||||||
|
"GET",
|
||||||
|
`${BASE}/capture-requirements/${id}`,
|
||||||
|
"Shows remaining angles, parts, documents",
|
||||||
|
),
|
||||||
|
act(
|
||||||
|
"UPLOAD_DOC",
|
||||||
|
"POST",
|
||||||
|
`${BASE}/upload-document/${id}`,
|
||||||
|
"Upload required document when workflow allows",
|
||||||
|
),
|
||||||
|
act(
|
||||||
|
"CAPTURE_PART",
|
||||||
|
"POST",
|
||||||
|
`${BASE}/capture-part/${id}`,
|
||||||
|
"Upload angle or damaged-part photo when workflow allows",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
objectionAllowed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const step = claim.workflow?.currentStep;
|
||||||
|
const cs = claim.claimStatus;
|
||||||
|
|
||||||
|
if (step === ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS && cs === ClaimStatus.NEEDS_REVISION) {
|
||||||
|
return {
|
||||||
|
phaseKey: "UPLOAD_FACTORS",
|
||||||
|
headline: "Upload repair factor files",
|
||||||
|
detail:
|
||||||
|
"One upload per factor-needed part. When all factors are uploaded, the expert validates costs.",
|
||||||
|
nextActions: [
|
||||||
|
act(
|
||||||
|
"UPLOAD_FACTOR",
|
||||||
|
"PATCH",
|
||||||
|
`${BASE}/request/reply/${id}/{partId}/upload-factor`,
|
||||||
|
"Multipart `file` — use each expert reply `partId` with factorNeeded=true",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
objectionAllowed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === ClaimWorkflowStep.EXPERT_COST_EVALUATION && cs === ClaimStatus.UNDER_REVIEW) {
|
||||||
|
return {
|
||||||
|
phaseKey: "EXPERT_VALIDATING_FACTORS",
|
||||||
|
headline: "Expert is validating factors",
|
||||||
|
detail: "No owner action until the expert finishes validating repair-factor pricing.",
|
||||||
|
nextActions: [],
|
||||||
|
objectionAllowed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === ClaimWorkflowStep.INSURER_REVIEW) {
|
||||||
|
if (cs === ClaimStatus.NEEDS_REVISION) {
|
||||||
|
const active = getActiveV2ExpertReply(
|
||||||
|
claim as { evaluation?: Record<string, unknown> },
|
||||||
|
);
|
||||||
|
const shape = classifyV2ExpertPricingParts(active?.parts ?? []);
|
||||||
|
if (
|
||||||
|
shape.mixedFactorAndPrice &&
|
||||||
|
!claim.evaluation?.ownerPricedPartsApproval?.signedAt
|
||||||
|
) {
|
||||||
|
const ow = objectionWindowForOwner(claim);
|
||||||
|
const allowObj = ow.pricingEligible;
|
||||||
|
return {
|
||||||
|
phaseKey: "SIGN_PRICED_LINES",
|
||||||
|
headline: "Sign acceptance of priced lines",
|
||||||
|
detail:
|
||||||
|
"Required before uploading repair-factor files for mixed priced + factor-needed replies.",
|
||||||
|
nextActions: [
|
||||||
|
act(
|
||||||
|
"SIGN_PRICED_PARTS",
|
||||||
|
"PUT",
|
||||||
|
`${BASE}/request/${id}/owner-insurer-approval/sign`,
|
||||||
|
"Multipart sign + agree + branchId",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
objectionAllowed: allowObj,
|
||||||
|
objectionHint: objectionHintWhen(allowObj),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** NEEDS_REVISION at INSURER_REVIEW without mixed gate — all-factor initial sign not used; fallback */
|
||||||
|
const allowObj = objectionWindowForOwner(claim).pricingEligible;
|
||||||
|
return {
|
||||||
|
phaseKey: "INSURER_REVIEW_NEEDS_REVISION",
|
||||||
|
headline: "Insurer approval — action pending",
|
||||||
|
detail:
|
||||||
|
"If you see priced lines without factors, use sign or objection; otherwise verify workflow data.",
|
||||||
|
nextActions: [
|
||||||
|
act(
|
||||||
|
"SIGN_OR_CHECK",
|
||||||
|
"PUT",
|
||||||
|
`${BASE}/request/${id}/owner-insurer-approval/sign`,
|
||||||
|
"Sign if prompted by app state",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
objectionAllowed: allowObj,
|
||||||
|
objectionHint: objectionHintWhen(allowObj),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cs === ClaimStatus.APPROVED && !claim.evaluation?.ownerInsurerApproval?.signedAt) {
|
||||||
|
const allowObj = objectionWindowForOwner(claim).pricingEligible;
|
||||||
|
return {
|
||||||
|
phaseKey: "FINAL_SIGN_OR_REJECT",
|
||||||
|
headline: "Accept or reject final pricing",
|
||||||
|
detail: "Sign to complete the claim, or reject. You may object to priced lines instead of signing.",
|
||||||
|
nextActions: [
|
||||||
|
act(
|
||||||
|
"FINAL_SIGN",
|
||||||
|
"PUT",
|
||||||
|
`${BASE}/request/${id}/owner-insurer-approval/sign`,
|
||||||
|
"Multipart sign + agree + branchId — completes or rejects claim",
|
||||||
|
),
|
||||||
|
act(
|
||||||
|
"OBJECTION",
|
||||||
|
"PUT",
|
||||||
|
`${BASE}/request/${id}/objection`,
|
||||||
|
"Dispute priced lines before signing",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
objectionAllowed: allowObj,
|
||||||
|
objectionHint: objectionHintWhen(allowObj),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
phaseKey: "INSURER_APPROVAL_FALLBACK",
|
||||||
|
headline: "Awaiting next step",
|
||||||
|
detail:
|
||||||
|
"Inspect `status`, `claimStatus`, `workflow.currentStep`/`nextStep` or contact support.",
|
||||||
|
nextActions: [],
|
||||||
|
objectionAllowed,
|
||||||
|
objectionHint,
|
||||||
|
};
|
||||||
|
}
|
||||||
67
src/helpers/claim-v2-expert-reply-workflow.ts
Normal file
67
src/helpers/claim-v2-expert-reply-workflow.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
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 { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
|
||||||
|
|
||||||
|
export interface ClaimPricingPartLite {
|
||||||
|
factorNeeded?: boolean;
|
||||||
|
partId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveV2ExpertReply(claim: { evaluation?: Record<string, unknown> }): {
|
||||||
|
replyKey: "damageExpertReplyFinal" | "damageExpertReply";
|
||||||
|
reply: Record<string, unknown>;
|
||||||
|
parts: ClaimPricingPartLite[];
|
||||||
|
} | null {
|
||||||
|
const fin = claim.evaluation?.damageExpertReplyFinal as
|
||||||
|
| { parts?: ClaimPricingPartLite[]; submittedAt?: Date }
|
||||||
|
| undefined;
|
||||||
|
const ini = claim.evaluation?.damageExpertReply as
|
||||||
|
| { parts?: ClaimPricingPartLite[]; submittedAt?: Date }
|
||||||
|
| undefined;
|
||||||
|
if (fin?.submittedAt && fin?.parts?.length) {
|
||||||
|
return { replyKey: "damageExpertReplyFinal", reply: fin as Record<string, unknown>, parts: fin.parts };
|
||||||
|
}
|
||||||
|
if (ini?.submittedAt && ini?.parts?.length) {
|
||||||
|
return { replyKey: "damageExpertReply", reply: ini as Record<string, unknown>, parts: ini.parts };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyV2ExpertPricingParts(parts: ClaimPricingPartLite[]) {
|
||||||
|
const factorNeeded = parts.filter((p) => p.factorNeeded === true);
|
||||||
|
const priced = parts.filter((p) => p.factorNeeded !== true);
|
||||||
|
return {
|
||||||
|
pricedPartsCount: priced.length,
|
||||||
|
factorNeededPartsCount: factorNeeded.length,
|
||||||
|
mixedFactorAndPrice:
|
||||||
|
factorNeeded.length > 0 && priced.length > 0,
|
||||||
|
allFactorLines: factorNeeded.length > 0 && priced.length === 0,
|
||||||
|
noFactors: factorNeeded.length === 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function claimIsV2ExpertFactorUploadStep(claim: { workflow?: { currentStep?: string } }) {
|
||||||
|
return claim.workflow?.currentStep === ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function claimIsV2PendingFactorExpertValidation(claim: {
|
||||||
|
status?: ClaimCaseStatus;
|
||||||
|
claimStatus?: ClaimStatus;
|
||||||
|
workflow?: { currentStep?: string };
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
claim.status === ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL &&
|
||||||
|
claim.claimStatus === ClaimStatus.UNDER_REVIEW &&
|
||||||
|
claim.workflow?.currentStep === ClaimWorkflowStep.EXPERT_COST_EVALUATION
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function objectionDisallowedDueToOutstandingFactorWorkflow(claim: {
|
||||||
|
workflow?: { currentStep?: string };
|
||||||
|
}) {
|
||||||
|
const step = claim.workflow?.currentStep;
|
||||||
|
return (
|
||||||
|
step === ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS ||
|
||||||
|
step === ClaimWorkflowStep.EXPERT_COST_EVALUATION
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user