From 0d0cec4b201e298ff7066de7efdc696133a66609 Mon Sep 17 00:00:00 2001 From: "s.hajizadeh" Date: Tue, 12 May 2026 13:32:13 +0330 Subject: [PATCH] - 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 --- src/expert-claim/dto/factor-validation.dto.ts | 12 +- src/expert-claim/expert-claim.service.ts | 216 ++++++++++-------- .../expert-claim.v2.controller.ts | 12 +- 3 files changed, 141 insertions(+), 99 deletions(-) diff --git a/src/expert-claim/dto/factor-validation.dto.ts b/src/expert-claim/dto/factor-validation.dto.ts index 36336cf..4650fb7 100644 --- a/src/expert-claim/dto/factor-validation.dto.ts +++ b/src/expert-claim/dto/factor-validation.dto.ts @@ -49,18 +49,24 @@ class FactorDecisionV2Dto { @ApiPropertyOptional({ description: - "Expert-adjusted values (Persian/ASCII digits ok). For REJECTED factors, provide at least totalPayment (or price and salary).", + "Part price (Persian/ASCII digits). For each factor line, send totalPayment **or** both price and salary. Required for **APPROVED** (accepted amount read from factor / expert) and **REJECTED** (expert’s replacement pricing).", }) @IsOptional() @IsString() price?: string; - @ApiPropertyOptional() + @ApiPropertyOptional({ + description: + "Salary portion when not using a single totalPayment (must be sent together with price for APPROVED/REJECTED factor lines).", + }) @IsOptional() @IsString() salary?: string; - @ApiPropertyOptional() + @ApiPropertyOptional({ + description: + "Line total payment; preferred when the expert enters one number. Required together with APPROVED/REJECTED unless both price and salary are sent.", + }) @IsOptional() @IsString() totalPayment?: string; diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 1a6ef96..9ead291 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -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, + ): Promise { + 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, diff --git a/src/expert-claim/expert-claim.v2.controller.ts b/src/expert-claim/expert-claim.v2.controller.ts index 04c46a0..d6bce6d 100644 --- a/src/expert-claim/expert-claim.v2.controller.ts +++ b/src/expert-claim/expert-claim.v2.controller.ts @@ -116,7 +116,7 @@ export class ExpertClaimV2Controller { @ApiOperation({ summary: "Submit expert damage assessment reply", description: - "**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" + + "**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 line `totalPayment` values ≤ 53,000,000 (same limit as factor-validation totals across priced + factor lines). Clears any prior `evaluation.ownerInsurerApproval` / `ownerPricedPartsApproval`.\n\n" + "**Frontend routing by `ClaimCaseStatus` (`status`):**\n" + "- **All parts `factorNeeded`:** `OWNER_REPAIR_FACTOR_UPLOAD_PENDING`, `claimStatus=NEEDS_REVISION`, `workflow.currentStep=OWNER_UPLOAD_FACTOR_DOCUMENTS`, `workflow.nextStep=EXPERT_COST_EVALUATION` → owner uploads all factors; then `status` becomes **`EXPERT_VALIDATING_REPAIR_FACTORS`**, `claimStatus=UNDER_REVIEW`, `currentStep=EXPERT_COST_EVALUATION` for expert **validate-factors**.\n" + "- **Mixed (some priced, some factorNeeded):** `INSURER_REVIEW_MIXED_FACTORS_PENDING`, `claimStatus=NEEDS_REVISION`, `currentStep=INSURER_REVIEW`, `nextStep=OWNER_UPLOAD_FACTOR_DOCUMENTS` → owner must call **owner-insurer-approval/sign** first (priced-line acceptance); `currentStep` then moves to `OWNER_UPLOAD_FACTOR_DOCUMENTS` (same case `status` until factors are done).\n" + @@ -176,12 +176,14 @@ export class ExpertClaimV2Controller { @ApiOperation({ summary: "Validate uploaded repair factors (V2 ClaimCase)", description: + "**Response:** `claimStatus` = `ClaimStatus` (e.g. APPROVED). `caseStatus` = `ClaimCaseStatus` (e.g. COMPLETED vs insurer-review) — they are not interchangeable.\n\n" + "**Preconditions:** `status=EXPERT_VALIDATING_REPAIR_FACTORS` (or legacy `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" + + "**Decisions:** each factor line gets `APPROVED` or `REJECTED`. **Every** decided line must include expert-entered `totalPayment` **or** both `price` and `salary` (factor photos are not read for amounts).\n\n" + + "**Cap (when every factor line is decided):** sum of **all** reply lines (priced parts + factor lines) must be ≤ **53,000,000**; otherwise `PRICE_CAP_ERROR` with message that the maximum acceptable total was exceeded.\n\n" + "**Outcomes:**\n" + - "- **All approved:** `status=INSURER_REVIEW_AWAITING_OWNER_SIGN`, `claimStatus=APPROVED`, `currentStep=INSURER_REVIEW` → owner must **sign or reject** via `owner-insurer-approval/sign`; agree completes the case.\n" + - "- **Any rejected (repriced):** same as above (`INSURER_REVIEW_AWAITING_OWNER_SIGN` + `INSURER_REVIEW`) → owner must **accept or reject** the repriced totals.\n" + - "- **Partial batch:** service may return pending until every factor line has a non-pending decision.", + "- **All approved:** `caseStatus=COMPLETED`, `claimStatus=APPROVED`, workflow `CLAIM_COMPLETED` — no owner signature.\n" + + "- **Any rejected (repriced):** same auto-complete for now (owner acceptance may be added later).\n" + + "- **Partial batch:** returns pending until every factor line has a non-pending decision (no cap error until the batch is complete).", }) @ApiParam({ name: "claimRequestId" }) @ApiBody({ type: FactorValidationV2Dto })