forked from Yara724/api
Compare commits
2 Commits
07c4e5126a
...
fd4cd3128f
| Author | SHA1 | Date | |
|---|---|---|---|
| fd4cd3128f | |||
| 0d0cec4b20 |
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user