1
0
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:
2026-05-12 13:32:13 +03:30
parent 07c4e5126a
commit 0d0cec4b20
3 changed files with 141 additions and 99 deletions

View File

@@ -49,18 +49,24 @@ class FactorDecisionV2Dto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 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** (experts replacement pricing).",
}) })
@IsOptional() @IsOptional()
@IsString() @IsString()
price?: string; 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() @IsOptional()
@IsString() @IsString()
salary?: string; 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() @IsOptional()
@IsString() @IsString()
totalPayment?: string; totalPayment?: string;

View File

@@ -93,6 +93,9 @@ import {
ExpertFileKind, ExpertFileKind,
} from "src/users/entities/schema/expert-file-activity.schema"; } 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() @Injectable()
export class ExpertClaimService { export class ExpertClaimService {
private readonly logger = new Logger(ExpertClaimService.name); 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. */ /** Same rules as V1 `submitReplyRequest`: daghi option + conditional price / branchId; `branchId` stored as ObjectId. */
private validateAndNormalizeDaghiForExpertReplyV2( private validateAndNormalizeDaghiForExpertReplyV2(
parts: import("./dto/expert-claim-v2.dto").PartPricingV2Dto[], 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) { if (reply.parts && reply.parts.length > 0) {
const PRICE_CAP = 30000000; const PRICE_CAP = CLAIM_V2_TOTAL_PAYMENT_CAP;
let totalPrice = 0; let totalPrice = 0;
for (const part of reply.parts) { for (const part of reply.parts) {
@@ -1466,7 +1502,7 @@ export class ExpertClaimService {
if (totalPrice > PRICE_CAP) { if (totalPrice > PRICE_CAP) {
throw new BadRequestException({ 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", error: "PRICE_CAP_ERROR",
code: "PRICE_CAP_ERROR", code: "PRICE_CAP_ERROR",
totalPrice: totalPrice, 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. * 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 → claimStatus APPROVED, INSURER_REVIEW: owner accepts/rejects to close. * — All approved → COMPLETED + APPROVED (expert-entered line totals; no extra owner signature).
* — Any rejected (expert repriced) → INSURER_REVIEW_AWAITING_OWNER_SIGN, APPROVED, INSURER_REVIEW until owner accepts/rejects. * — 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( async validateClaimFactorsV2(
claimRequestId: string, claimRequestId: string,
@@ -2065,13 +2145,13 @@ export class ExpertClaimService {
$set[`${base}.totalPayment`] = decision.totalPayment; $set[`${base}.totalPayment`] = decision.totalPayment;
} }
if (decision.status === FactorStatus.REJECTED) { if (
const hasOverride = decision.status === FactorStatus.APPROVED ||
decision.totalPayment != null || decision.status === FactorStatus.REJECTED
(decision.price != null && decision.salary != null); ) {
if (!hasOverride) { if (!this.expertFactorValidationDecisionHasLinePricing(decision)) {
throw new BadRequestException( 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; let totalPrice = 0;
for (const part of updatedReply.parts || []) { for (const part of updatedReply.parts || []) {
const parsed = this.parsePersianNumber(String(part.totalPayment ?? "0")); const line = this.claimReplyPartLineTotalForCap(part);
if (!isNaN(parsed)) { if (isNaN(line)) {
totalPrice += parsed; 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) { if (totalPrice > PRICE_CAP) {
throw new BadRequestException({ 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", error: "PRICE_CAP_ERROR",
totalPrice, totalPrice,
priceCap: PRICE_CAP, priceCap: PRICE_CAP,
@@ -2147,90 +2231,40 @@ export class ExpertClaimService {
}; };
if (anyRejected) { if (anyRejected) {
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { await this.completeClaimCaseAfterFactorValidationV2(
$set: { claimRequestId,
status: ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN, claim,
claimStatus: ClaimStatus.APPROVED, actor,
"workflow.currentStep": ClaimWorkflowStep.INSURER_REVIEW, "FACTORS_REJECTED_REPRICED_AUTO_COMPLETED",
"workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED, historyActor,
}, { replyField },
$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)),
});
}
return { return {
message: 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, claimRequestId,
claimStatus: ClaimStatus.APPROVED, claimStatus: ClaimStatus.APPROVED,
caseStatus: ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN, caseStatus: ClaimCaseStatus.COMPLETED,
outcome: "REJECTED_REPRICED_OWNER_SIGN", outcome: "REJECTED_REPRICED_AUTO_COMPLETED",
}; };
} }
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { await this.completeClaimCaseAfterFactorValidationV2(
$set: { claimRequestId,
claimStatus: ClaimStatus.APPROVED, claim,
"workflow.currentStep": ClaimWorkflowStep.INSURER_REVIEW, actor,
"workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED, "FACTORS_VALIDATED_ALL_APPROVED_AUTO_COMPLETED",
}, historyActor,
$push: { { replyField },
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)),
});
}
return { return {
message: 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, claimRequestId,
claimStatus: ClaimStatus.APPROVED, claimStatus: ClaimStatus.APPROVED,
caseStatus: ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN, caseStatus: ClaimCaseStatus.COMPLETED,
outcome: "ALL_APPROVED_INSURER_REVIEW", outcome: "ALL_APPROVED_AUTO_COMPLETED",
}; };
} }
@@ -2578,7 +2612,7 @@ export class ExpertClaimService {
* - Claim must exist * - Claim must exist
* - Must be locked by this expert (workflow.lockedBy.actorId === actor.sub) * - Must be locked by this expert (workflow.lockedBy.actorId === actor.sub)
* - Must be in EXPERT_REVIEWING status * - 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 * - Each part must include `daghi` (option + conditional price/branchId) like V1
* *
* On success: * On success:
@@ -2619,7 +2653,7 @@ export class ExpertClaimService {
} }
// Price cap validation // Price cap validation
const PRICE_CAP = 53_000_000; const PRICE_CAP = CLAIM_V2_TOTAL_PAYMENT_CAP;
let totalPrice = 0; let totalPrice = 0;
for (const part of reply.parts || []) { for (const part of reply.parts || []) {
const parsed = this.parsePersianNumber(String(part.totalPayment ?? '0')); const parsed = this.parsePersianNumber(String(part.totalPayment ?? '0'));
@@ -2627,7 +2661,7 @@ export class ExpertClaimService {
} }
if (totalPrice > PRICE_CAP) { if (totalPrice > PRICE_CAP) {
throw new BadRequestException({ 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', error: 'PRICE_CAP_ERROR',
totalPrice, totalPrice,
priceCap: PRICE_CAP, priceCap: PRICE_CAP,

View File

@@ -116,7 +116,7 @@ export class ExpertClaimV2Controller {
@ApiOperation({ @ApiOperation({
summary: "Submit expert damage assessment reply", summary: "Submit expert damage assessment reply",
description: 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" + "**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" + "- **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" + "- **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({ @ApiOperation({
summary: "Validate uploaded repair factors (V2 ClaimCase)", summary: "Validate uploaded repair factors (V2 ClaimCase)",
description: 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" + "**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" + "**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" + "- **All approved:** `caseStatus=COMPLETED`, `claimStatus=APPROVED`, workflow `CLAIM_COMPLETED` — no owner signature.\n" +
"- **Any rejected (repriced):** same as above (`INSURER_REVIEW_AWAITING_OWNER_SIGN` + `INSURER_REVIEW`) → owner must **accept or reject** the repriced totals.\n" + "- **Any rejected (repriced):** same auto-complete for now (owner acceptance may be added later).\n" +
"- **Partial batch:** service may return pending until every factor line has a non-pending decision.", "- **Partial batch:** returns pending until every factor line has a non-pending decision (no cap error until the batch is complete).",
}) })
@ApiParam({ name: "claimRequestId" }) @ApiParam({ name: "claimRequestId" })
@ApiBody({ type: FactorValidationV2Dto }) @ApiBody({ type: FactorValidationV2Dto })