fix: align inquiry errors and expert review rules

This commit is contained in:
SepehrYahyaee
2026-09-19 12:11:43 +03:30
parent 45e0ad883a
commit 406b139b3d
21 changed files with 457 additions and 55 deletions

View File

@@ -53,11 +53,19 @@ export class ClaimDetailV2ResponseDto {
blameRequestType?: BlameRequestType;
@ApiPropertyOptional({
description: "How the blame file was initiated: IN_PERSON or LINK",
description: "How the blame file was initiated: NORMAL, IN_PERSON, or LINK",
example: "IN_PERSON",
})
creationMethod?: string;
@ApiProperty({
nullable: true,
description:
"Maximum expert-reply total for V1 user-created files. Null for V2-V6 flows or when the cap is disabled.",
example: 530000000,
})
priceCap: number | null;
@ApiPropertyOptional({
description:
"CAR_BODY only: first-step flags — another car (`car`) and/or object (`object`)",
@@ -278,7 +286,7 @@ export class ClaimDetailV2ResponseDto {
@ApiPropertyOptional({
description:
"Linked blame case (`blameCases`), same shape as expert-blame detail: parties with video/voice URLs, workflow, expert, formatted dates.",
"Linked blame case (`blameCases`), same shape as expert-blame detail. Each party exposes its authoritative normalized `participants` and `participantRoles`, plus video/voice URLs, workflow, expert, and formatted dates.",
})
blameCase?: Record<string, unknown>;

View File

@@ -168,6 +168,88 @@ describe("ExpertClaimService expert-reply pricing", () => {
);
});
it("enforces the configured total-payment cap for a V1 user-created file", async () => {
const previousEnabled = process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED;
process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED = "true";
try {
const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
BlameRequestType.THIRD_PARTY,
);
(service as any).blameRequestDbService.findById.mockResolvedValue({
type: BlameRequestType.THIRD_PARTY,
creationMethod: "NORMAL",
});
await expect(
service.submitExpertReplyV2(
"v1-claim",
{
...validV2Reply,
parts: [
{
...validV2Reply.parts[0],
salary: "1000000",
totalPayment: "600000000",
},
],
},
{
sub: V2_EXPERT_ID,
fullName: "Expert One",
role: RoleEnum.FIELD_EXPERT,
},
),
).rejects.toMatchObject({ response: { code: "PRICE_CAP_ERROR" } });
expect(findByIdAndUpdate).not.toHaveBeenCalled();
} finally {
if (previousEnabled === undefined) {
delete process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED;
} else {
process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED = previousEnabled;
}
}
});
it("does not enforce the V1 cap for an expert-initiated flow", async () => {
const previousEnabled = process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED;
process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED = "true";
try {
const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
BlameRequestType.THIRD_PARTY,
);
await service.submitExpertReplyV2(
"v3-claim",
{
...validV2Reply,
parts: [
{
...validV2Reply.parts[0],
salary: "1000000",
totalPayment: "600000000",
},
],
},
{
sub: V2_EXPERT_ID,
fullName: "Expert One",
role: RoleEnum.FIELD_EXPERT,
},
);
expect(findByIdAndUpdate).toHaveBeenCalledTimes(1);
} finally {
if (previousEnabled === undefined) {
delete process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED;
} else {
process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED = previousEnabled;
}
}
});
it("allows a repair line without daghi and removes a stray daghi payload", () => {
const service = createService() as any;

View File

@@ -156,7 +156,7 @@ import {
ExpertFileKind,
} from "src/users/entities/schema/expert-file-activity.schema";
/** Maximum sum of line `totalPayment` across the claim (Toman; priced parts + factor lines after validation). */
/** Configured V1 maximum sum of line `totalPayment` across the claim (Rial). */
import { getClaimV2TotalPaymentCapToman } from "src/constants/repair-amount-limits";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
import {
@@ -180,6 +180,7 @@ import {
normalizeMoneyAmountString,
parseMoneyAmountToman,
} from "src/utils/unicode-digits";
import { claimPriceCapAppliesToBlame } from "src/helpers/claim-price-cap";
@Injectable()
export class ExpertClaimService {
@@ -1694,8 +1695,13 @@ export class ExpertClaimService {
throw new BadRequestException(pricingValidationError);
}
// Validate total price cap (priced lines sum), when enabled.
const priceCap = getClaimV2TotalPaymentCapToman();
// The total cap is a V1-only rule. Legacy claims embed their blame file.
const configuredPriceCap = getClaimV2TotalPaymentCapToman();
const priceCap =
configuredPriceCap !== null &&
claimPriceCapAppliesToBlame(request.blameFile)
? configuredPriceCap
: null;
if (priceCap !== null && reply.parts && reply.parts.length > 0) {
let totalPrice = 0;
@@ -1727,7 +1733,7 @@ export class ExpertClaimService {
if (totalPrice > priceCap) {
throw new BadRequestException({
message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`,
message: `You have reached the maximum acceptable total price (Rial). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`,
error: "PRICE_CAP_ERROR",
code: "PRICE_CAP_ERROR",
totalPrice: totalPrice,
@@ -2293,7 +2299,8 @@ export class ExpertClaimService {
* Preconditions: all `factorNeeded` parts have `factorLink`; case is UNDER_REVIEW at EXPERT_COST_EVALUATION.
* — 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).
* When enabled by env, total of all repair lines must be ≤ the claim v2 total payment cap.
* For V1 user-created files, when enabled by env, the total of all repair
* lines must be no greater than the configured total-payment cap.
* Response: `claimStatus` = `ClaimStatus`; `caseStatus` = `ClaimCaseStatus`.
*/
async validateClaimFactorsV2(
@@ -2433,7 +2440,12 @@ export class ExpertClaimService {
};
}
const priceCap = getClaimV2TotalPaymentCapToman();
const configuredPriceCap = getClaimV2TotalPaymentCapToman();
const priceCap =
configuredPriceCap !== null &&
claimPriceCapAppliesToBlame(await this.loadBlameForClaim(claim))
? configuredPriceCap
: null;
if (priceCap !== null) {
let totalPrice = 0;
for (const part of updatedReply.parts || []) {
@@ -2448,7 +2460,7 @@ export class ExpertClaimService {
}
if (totalPrice > priceCap) {
throw new BadRequestException({
message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`,
message: `You have reached the maximum acceptable total price (Rial). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`,
error: "PRICE_CAP_ERROR",
totalPrice,
priceCap,
@@ -3259,7 +3271,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 53,000,000 (same cap as factor validation totals)
* - V1 only: total payment across all parts must not exceed the configured cap
* - Each part must include `daghi` (option + conditional price) like V1
*
* On success:
@@ -3364,8 +3376,12 @@ export class ExpertClaimService {
throw new BadRequestException(pricingValidationError);
}
// Price cap validation, when enabled.
const priceCap = getClaimV2TotalPaymentCapToman();
// The configured total cap is enforced only for V1 user-created files.
const configuredPriceCap = getClaimV2TotalPaymentCapToman();
const priceCap =
configuredPriceCap !== null && claimPriceCapAppliesToBlame(blame)
? configuredPriceCap
: null;
if (priceCap !== null) {
let totalPrice = 0;
for (const part of reply.parts || []) {
@@ -3376,7 +3392,7 @@ export class ExpertClaimService {
}
if (totalPrice > priceCap) {
throw new BadRequestException({
message: `مجموع مبلغ قطعات (${totalPrice.toLocaleString("fa-IR")}) از سقف مجاز (${priceCap.toLocaleString("fa-IR")}) تومان بیشتر است.`,
message: `مجموع مبلغ قطعات (${totalPrice.toLocaleString("fa-IR")}) از سقف مجاز (${priceCap.toLocaleString("fa-IR")}) ریال بیشتر است.`,
error: "PRICE_CAP_ERROR",
code: "PRICE_CAP_ERROR",
totalPrice,
@@ -5125,7 +5141,11 @@ export class ExpertClaimService {
claim.blameRequestId
? this.blameRequestDbService.find(
{ _id: new Types.ObjectId(claim.blameRequestId.toString()) },
{ lean: true, select: "type parties expert.decision" },
{
lean: true,
select:
"type parties expert.decision creationMethod expertInitiated registrarInitiated callCenterInitiated initiatedByFieldExpertId initiatedByRegistrarId initiatedByCallCenterId",
},
)
: Promise.resolve([]),
]);
@@ -5160,6 +5180,11 @@ export class ExpertClaimService {
const blameFileContext = blameLean
? this.blameFileContextForExpert(blameLean)
: {};
const configuredPriceCap = getClaimV2TotalPaymentCapToman();
const priceCap =
configuredPriceCap !== null && claimPriceCapAppliesToBlame(linkedBlame)
? configuredPriceCap
: null;
let videoCapture: ClaimDetailV2ResponseDto["videoCapture"] = undefined;
if (videoCaptureRow) {
@@ -5269,6 +5294,7 @@ export class ExpertClaimService {
? this.sanitizeVehicleInquiryForApi(vehiclePayload)
: undefined,
...blameFileContext,
priceCap,
blameRequestId: claim.blameRequestId?.toString(),
blameRequestNo: claim.blameRequestNo,
money: moneyPayload,

View File

@@ -268,7 +268,7 @@ export class ExpertClaimV2Controller {
@ApiOperation({
summary: "Submit expert damage assessment reply",
description:
"**Preconditions:** claim locked by this expert (`EXPERT_REVIEWING`). **Unlocks** the claim. Each `parts[]` line needs `partId` (from GET claim detail `damagedParts[].partId`), plus pricing, `daghi`, and optional `factorNeeded`. Every supplied monetary field (`price`, `salary`, `totalPayment`, and `daghi.price`) must be 100,000–10,000,000,000 **Toman**. **Cap:** sum of line `totalPayment` values ≤ 53,000,000 **Toman** (same limit as factor-validation totals across priced + factor lines). Clears any prior `evaluation.ownerInsurerApproval` / `ownerPricedPartsApproval`.\n\n" +
"**Preconditions:** claim locked by this expert (`EXPERT_REVIEWING`). **Unlocks** the claim. Each `parts[]` line needs `partId` (from GET claim detail `damagedParts[].partId`), plus pricing, `daghi`, and optional `factorNeeded`. Every supplied monetary field (`price`, `salary`, `totalPayment`, and `daghi.price`) must be 1,000,000–100,000,000,000 **Rial**. **V1-only cap:** for user-created V1 files, sum of line `totalPayment` values ≤ 530,000,000 **Rial** (same limit as factor-validation totals across priced + factor lines). V2–V6 files are uncapped. Claim detail exposes the effective `priceCap` (`null` when uncapped). 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" +
@@ -353,7 +353,7 @@ export class ExpertClaimV2Controller {
"**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`. **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 Toman**; otherwise `PRICE_CAP_ERROR` with message that the maximum acceptable total was exceeded.\n\n" +
"**V1-only cap (when every factor line is decided):** for user-created V1 files, sum of **all** reply lines (priced parts + factor lines) must be ≤ **530,000,000 Rial**; otherwise `PRICE_CAP_ERROR` is returned. V2–V6 files are uncapped.\n\n" +
"**Outcomes:**\n" +
"- **All approved:** `caseStatus=COMPLETED`, `claimStatus=APPROVED`, workflow `CLAIM_COMPLETED` — no owner signature. V5 instead waits for FileMaker approval.\n" +
"- **Any rejected (repriced):** same completion behavior for now (V5 waits for FileMaker approval).\n" +