forked from Yara724/api
- Expert-entered amounts on factor validation
- Cap raised to 53,000,000 - Cap error copy - Repriced all-factor (and any repriced completion) - Docs added to expert-claim.v2.controller and PATCH 2/expert-claim-validate-factors/:id
This commit is contained in:
@@ -93,6 +93,9 @@ import {
|
||||
ExpertFileKind,
|
||||
} from "src/users/entities/schema/expert-file-activity.schema";
|
||||
|
||||
/** Maximum sum of line `totalPayment` across the claim (priced parts + factor lines after validation). */
|
||||
const CLAIM_V2_TOTAL_PAYMENT_CAP = 53_000_000;
|
||||
|
||||
@Injectable()
|
||||
export class ExpertClaimService {
|
||||
private readonly logger = new Logger(ExpertClaimService.name);
|
||||
@@ -731,6 +734,39 @@ export class ExpertClaimService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Factor validation: expert must send a line amount (OCR is not used for factor photos). */
|
||||
private expertFactorValidationDecisionHasLinePricing(decision: {
|
||||
totalPayment?: string;
|
||||
price?: string;
|
||||
salary?: string;
|
||||
}): boolean {
|
||||
const tp =
|
||||
decision.totalPayment != null &&
|
||||
String(decision.totalPayment).trim() !== "";
|
||||
const pr =
|
||||
decision.price != null && String(decision.price).trim() !== "";
|
||||
const sa =
|
||||
decision.salary != null && String(decision.salary).trim() !== "";
|
||||
return tp || (pr && sa);
|
||||
}
|
||||
|
||||
/** Price-cap total for one reply line: `totalPayment` if set, otherwise `price` + `salary`. */
|
||||
private claimReplyPartLineTotalForCap(part: {
|
||||
partId?: string;
|
||||
totalPayment?: unknown;
|
||||
price?: unknown;
|
||||
salary?: unknown;
|
||||
}): number {
|
||||
const tpRaw =
|
||||
part.totalPayment != null ? String(part.totalPayment).trim() : "";
|
||||
if (tpRaw !== "") {
|
||||
return this.parsePersianNumber(tpRaw);
|
||||
}
|
||||
const pr = this.parsePersianNumber(String(part.price ?? "0"));
|
||||
const sa = this.parsePersianNumber(String(part.salary ?? "0"));
|
||||
return pr + sa;
|
||||
}
|
||||
|
||||
/** Same rules as V1 `submitReplyRequest`: daghi option + conditional price / branchId; `branchId` stored as ObjectId. */
|
||||
private validateAndNormalizeDaghiForExpertReplyV2(
|
||||
parts: import("./dto/expert-claim-v2.dto").PartPricingV2Dto[],
|
||||
@@ -1433,9 +1469,9 @@ export class ExpertClaimService {
|
||||
);
|
||||
}
|
||||
|
||||
// Validate total price cap (30,000,000)
|
||||
// Validate total price cap (priced lines sum)
|
||||
if (reply.parts && reply.parts.length > 0) {
|
||||
const PRICE_CAP = 30000000;
|
||||
const PRICE_CAP = CLAIM_V2_TOTAL_PAYMENT_CAP;
|
||||
let totalPrice = 0;
|
||||
|
||||
for (const part of reply.parts) {
|
||||
@@ -1466,7 +1502,7 @@ export class ExpertClaimService {
|
||||
|
||||
if (totalPrice > PRICE_CAP) {
|
||||
throw new BadRequestException({
|
||||
message: `[PRICE_CAP_ERROR] Total price of damaged parts (${totalPrice.toLocaleString()}) exceeds the maximum allowed amount (${PRICE_CAP.toLocaleString()}). Please adjust the prices.`,
|
||||
message: `You have reached the maximum acceptable total price. The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${PRICE_CAP.toLocaleString()}).`,
|
||||
error: "PRICE_CAP_ERROR",
|
||||
code: "PRICE_CAP_ERROR",
|
||||
totalPrice: totalPrice,
|
||||
@@ -1985,11 +2021,55 @@ export class ExpertClaimService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the claim after factor validation without a second insurer-review signature.
|
||||
* Owner already uploaded factor files; mixed replies already have priced-line acceptance on file.
|
||||
*/
|
||||
private async completeClaimCaseAfterFactorValidationV2(
|
||||
claimRequestId: string,
|
||||
claimForTenant: any,
|
||||
actor: { sub: string; fullName?: string; clientKey?: string },
|
||||
historyType: string,
|
||||
historyActor: {
|
||||
actorId: Types.ObjectId;
|
||||
actorName?: string;
|
||||
actorType: string;
|
||||
},
|
||||
metadata: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||
$set: {
|
||||
status: ClaimCaseStatus.COMPLETED,
|
||||
claimStatus: ClaimStatus.APPROVED,
|
||||
"workflow.currentStep": ClaimWorkflowStep.CLAIM_COMPLETED,
|
||||
"workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED,
|
||||
},
|
||||
$push: {
|
||||
"workflow.completedSteps": ClaimWorkflowStep.INSURER_REVIEW,
|
||||
history: {
|
||||
type: historyType,
|
||||
actor: historyActor,
|
||||
timestamp: new Date(),
|
||||
metadata,
|
||||
},
|
||||
},
|
||||
});
|
||||
await this.recordClaimExpertActivity({
|
||||
expertId: String(actor.sub),
|
||||
tenantId: this.claimActivityTenantId(claimForTenant, actor),
|
||||
claimId: String(claimRequestId),
|
||||
eventType: ExpertFileActivityType.HANDLED,
|
||||
idempotencyKey: `claim:${claimRequestId}:factor_validation:${historyType}:${actor.sub}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* — All approved → claimStatus APPROVED, INSURER_REVIEW: owner accepts/rejects to close.
|
||||
* — Any rejected (expert repriced) → INSURER_REVIEW_AWAITING_OWNER_SIGN, APPROVED, INSURER_REVIEW until owner accepts/rejects.
|
||||
* — All approved → COMPLETED + APPROVED (expert-entered line totals; no extra owner signature).
|
||||
* — Any rejected (repriced) → COMPLETED + APPROVED (auto-close for now; owner sign may be added later).
|
||||
* Total of all repair lines must be ≤ `CLAIM_V2_TOTAL_PAYMENT_CAP` (53_000_000; same as initial expert reply).
|
||||
* Response: `claimStatus` = `ClaimStatus`; `caseStatus` = `ClaimCaseStatus`.
|
||||
*/
|
||||
async validateClaimFactorsV2(
|
||||
claimRequestId: string,
|
||||
@@ -2065,13 +2145,13 @@ export class ExpertClaimService {
|
||||
$set[`${base}.totalPayment`] = decision.totalPayment;
|
||||
}
|
||||
|
||||
if (decision.status === FactorStatus.REJECTED) {
|
||||
const hasOverride =
|
||||
decision.totalPayment != null ||
|
||||
(decision.price != null && decision.salary != null);
|
||||
if (!hasOverride) {
|
||||
if (
|
||||
decision.status === FactorStatus.APPROVED ||
|
||||
decision.status === FactorStatus.REJECTED
|
||||
) {
|
||||
if (!this.expertFactorValidationDecisionHasLinePricing(decision)) {
|
||||
throw new BadRequestException(
|
||||
`Part ${decision.partId}: rejected factors require expert totalPayment (or both price and salary).`,
|
||||
`Part ${decision.partId}: ${decision.status === FactorStatus.APPROVED ? "approved" : "rejected"} factor lines require expert totalPayment (or both price and salary).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2123,17 +2203,21 @@ export class ExpertClaimService {
|
||||
};
|
||||
}
|
||||
|
||||
const PRICE_CAP = 30_000_000;
|
||||
const PRICE_CAP = CLAIM_V2_TOTAL_PAYMENT_CAP;
|
||||
let totalPrice = 0;
|
||||
for (const part of updatedReply.parts || []) {
|
||||
const parsed = this.parsePersianNumber(String(part.totalPayment ?? "0"));
|
||||
if (!isNaN(parsed)) {
|
||||
totalPrice += parsed;
|
||||
const line = this.claimReplyPartLineTotalForCap(part);
|
||||
if (isNaN(line)) {
|
||||
throw new BadRequestException({
|
||||
message: `Part ${String((part as { partId?: string }).partId ?? "")}: invalid amount for price total — use totalPayment or price and salary.`,
|
||||
error: "PRICE_CAP_ERROR",
|
||||
});
|
||||
}
|
||||
totalPrice += line;
|
||||
}
|
||||
if (totalPrice > PRICE_CAP) {
|
||||
throw new BadRequestException({
|
||||
message: `Total price (${totalPrice.toLocaleString()}) exceeds the cap of ${PRICE_CAP.toLocaleString()}`,
|
||||
message: `You have reached the maximum acceptable total price. The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${PRICE_CAP.toLocaleString()}).`,
|
||||
error: "PRICE_CAP_ERROR",
|
||||
totalPrice,
|
||||
priceCap: PRICE_CAP,
|
||||
@@ -2147,90 +2231,40 @@ export class ExpertClaimService {
|
||||
};
|
||||
|
||||
if (anyRejected) {
|
||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||
$set: {
|
||||
status: ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN,
|
||||
claimStatus: ClaimStatus.APPROVED,
|
||||
"workflow.currentStep": ClaimWorkflowStep.INSURER_REVIEW,
|
||||
"workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED,
|
||||
},
|
||||
$push: {
|
||||
history: {
|
||||
type: "FACTORS_VALIDATED_REJECTED_EXPERT_REPRICED",
|
||||
actor: historyActor,
|
||||
timestamp: new Date(),
|
||||
metadata: { replyField },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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)),
|
||||
});
|
||||
}
|
||||
|
||||
await this.completeClaimCaseAfterFactorValidationV2(
|
||||
claimRequestId,
|
||||
claim,
|
||||
actor,
|
||||
"FACTORS_REJECTED_REPRICED_AUTO_COMPLETED",
|
||||
historyActor,
|
||||
{ replyField },
|
||||
);
|
||||
return {
|
||||
message:
|
||||
"Factors reviewed with repricing. Owner must accept or reject via owner-insurer-approval/sign.",
|
||||
"Factors were reviewed with expert repricing on rejected lines. The claim is completed without an owner signature (temporary policy; may require owner acceptance later).",
|
||||
claimRequestId,
|
||||
claimStatus: ClaimStatus.APPROVED,
|
||||
caseStatus: ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN,
|
||||
outcome: "REJECTED_REPRICED_OWNER_SIGN",
|
||||
caseStatus: ClaimCaseStatus.COMPLETED,
|
||||
outcome: "REJECTED_REPRICED_AUTO_COMPLETED",
|
||||
};
|
||||
}
|
||||
|
||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||
$set: {
|
||||
claimStatus: ClaimStatus.APPROVED,
|
||||
"workflow.currentStep": ClaimWorkflowStep.INSURER_REVIEW,
|
||||
"workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED,
|
||||
},
|
||||
$push: {
|
||||
history: {
|
||||
type: "FACTORS_VALIDATED_ALL_APPROVED",
|
||||
actor: historyActor,
|
||||
timestamp: new Date(),
|
||||
metadata: { replyField },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await this.recordClaimExpertActivity({
|
||||
expertId: String(actor.sub),
|
||||
tenantId: this.claimActivityTenantId(claim, actor),
|
||||
claimId: String(claimRequestId),
|
||||
eventType: ExpertFileActivityType.UNCHECKED,
|
||||
idempotencyKey: `claim:${claimRequestId}:unchecked:resend:${actor.sub}`,
|
||||
});
|
||||
|
||||
const ownerPhoneFactors = await this.resolveClaimOwnerPhone(claim);
|
||||
if (ownerPhoneFactors) {
|
||||
const expertLastName =
|
||||
actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
|
||||
await this.smsOrchestrationService.sendSignatureReviewNotice({
|
||||
receptor: ownerPhoneFactors,
|
||||
fileKind: "claim",
|
||||
publicId: claim.publicId,
|
||||
expertLastName,
|
||||
link: this.smsOrchestrationService.buildClaimLink(String(claim._id)),
|
||||
});
|
||||
}
|
||||
await this.completeClaimCaseAfterFactorValidationV2(
|
||||
claimRequestId,
|
||||
claim,
|
||||
actor,
|
||||
"FACTORS_VALIDATED_ALL_APPROVED_AUTO_COMPLETED",
|
||||
historyActor,
|
||||
{ replyField },
|
||||
);
|
||||
|
||||
return {
|
||||
message:
|
||||
"All factors approved. The claim proceeds to insurer review (same as non-factor expert reply).",
|
||||
"All factors were approved by the expert. The claim is completed without an additional owner signature.",
|
||||
claimRequestId,
|
||||
claimStatus: ClaimStatus.APPROVED,
|
||||
caseStatus: ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN,
|
||||
outcome: "ALL_APPROVED_INSURER_REVIEW",
|
||||
caseStatus: ClaimCaseStatus.COMPLETED,
|
||||
outcome: "ALL_APPROVED_AUTO_COMPLETED",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2578,7 +2612,7 @@ export class ExpertClaimService {
|
||||
* - Claim must exist
|
||||
* - Must be locked by this expert (workflow.lockedBy.actorId === actor.sub)
|
||||
* - Must be in EXPERT_REVIEWING status
|
||||
* - Total payment across all parts must not exceed 30,000,000
|
||||
* - Total payment across all parts must not exceed 53,000,000 (same cap as factor validation totals)
|
||||
* - Each part must include `daghi` (option + conditional price/branchId) like V1
|
||||
*
|
||||
* On success:
|
||||
@@ -2619,7 +2653,7 @@ export class ExpertClaimService {
|
||||
}
|
||||
|
||||
// Price cap validation
|
||||
const PRICE_CAP = 53_000_000;
|
||||
const PRICE_CAP = CLAIM_V2_TOTAL_PAYMENT_CAP;
|
||||
let totalPrice = 0;
|
||||
for (const part of reply.parts || []) {
|
||||
const parsed = this.parsePersianNumber(String(part.totalPayment ?? '0'));
|
||||
@@ -2627,7 +2661,7 @@ export class ExpertClaimService {
|
||||
}
|
||||
if (totalPrice > PRICE_CAP) {
|
||||
throw new BadRequestException({
|
||||
message: `Total price (${totalPrice.toLocaleString()}) exceeds the cap of ${PRICE_CAP.toLocaleString()}`,
|
||||
message: `You have reached the maximum acceptable total price. The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${PRICE_CAP.toLocaleString()}).`,
|
||||
error: 'PRICE_CAP_ERROR',
|
||||
totalPrice,
|
||||
priceCap: PRICE_CAP,
|
||||
|
||||
Reference in New Issue
Block a user