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({
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()
@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;

View File

@@ -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,

View File

@@ -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 })