forked from Yara724/api
fix: align inquiry errors and expert review rules
This commit is contained in:
@@ -4,13 +4,63 @@ import {
|
||||
} from "./inquiry-error";
|
||||
|
||||
describe("inquiry error messages", () => {
|
||||
it("turns ESG not-found responses into a contextual plate message", () => {
|
||||
it("preserves a Persian ESG not-found response", () => {
|
||||
expect(
|
||||
getInquiryErrorMessage(
|
||||
{ success: false, message: "موردی یافت نشد" },
|
||||
"thirdPartyPlate",
|
||||
),
|
||||
).toBe("بیمهنامه شخص ثالثی مطابق پلاک و کد ملی واردشده یافت نشد.");
|
||||
).toBe("موردی یافت نشد");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["RECORD_NOT_FOUND", "رکوردی یافت نشد", "Provider request failed"],
|
||||
[
|
||||
"INQUIRY_NO_MATCH",
|
||||
"نتیجهای مطابق با اطلاعات وارد شده یافت نشد",
|
||||
"Inquiry returned no matching result",
|
||||
],
|
||||
])(
|
||||
"prefers ESG messageFa for %s over the technical message",
|
||||
(code, messageFa, message) => {
|
||||
expect(
|
||||
getInquiryErrorMessage(
|
||||
{
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
messageFa,
|
||||
providerMessage: message,
|
||||
providerCode: code,
|
||||
},
|
||||
attemptSummary: {
|
||||
attempts: [{ code, message, messageFa }],
|
||||
},
|
||||
},
|
||||
"thirdPartyPlate",
|
||||
),
|
||||
).toBe(messageFa);
|
||||
},
|
||||
);
|
||||
|
||||
it("finds messageFa inside an HTTP response envelope", () => {
|
||||
expect(
|
||||
getInquiryErrorMessage(
|
||||
{
|
||||
response: {
|
||||
status: 404,
|
||||
data: {
|
||||
error: {
|
||||
code: "RECORD_NOT_FOUND",
|
||||
message: "Provider request failed",
|
||||
messageFa: "رکوردی یافت نشد",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"thirdPartyPlate",
|
||||
),
|
||||
).toBe("رکوردی یافت نشد");
|
||||
});
|
||||
|
||||
it("distinguishes VIN and car-body not-found failures", () => {
|
||||
|
||||
@@ -87,7 +87,25 @@ export function inquiryErrorStatus(error: unknown): number | undefined {
|
||||
}
|
||||
|
||||
export function extractInquiryProviderMessage(error: unknown): string {
|
||||
for (const record of errorRecords(error)) {
|
||||
const records = errorRecords(error);
|
||||
|
||||
// The normalized ESG/Parsian envelope carries the safe user-facing text in
|
||||
// messageFa while `message` and `providerMessage` may remain technical.
|
||||
// Search every envelope level for that explicit Persian field before
|
||||
// considering generic message fields on an outer object.
|
||||
for (const record of records) {
|
||||
for (const key of [
|
||||
"messageFa",
|
||||
"MessageFa",
|
||||
"messageFA",
|
||||
"persianMessage",
|
||||
] as const) {
|
||||
const message = cleanMessage(record[key]);
|
||||
if (message) return message;
|
||||
}
|
||||
}
|
||||
|
||||
for (const record of records) {
|
||||
for (const key of ["message", "Message", "detail", "title"] as const) {
|
||||
const message = cleanMessage(record[key]);
|
||||
if (message) return message;
|
||||
@@ -128,13 +146,21 @@ export function isInquiryTimeout(error: unknown): boolean {
|
||||
const hasPersian = (value: string): boolean => /[\u0600-\u06ff]/.test(value);
|
||||
|
||||
const isNotFound = (error: unknown, message: string): boolean => {
|
||||
const root = asRecord(error);
|
||||
const responseData = asRecord(asRecord(root?.response)?.data);
|
||||
const code = String(root?.code ?? responseData?.code ?? "").toUpperCase();
|
||||
const codes = errorRecords(error).flatMap((record) =>
|
||||
[record.code, record.providerCode]
|
||||
.map((code) => String(code ?? "").toUpperCase())
|
||||
.filter(Boolean),
|
||||
);
|
||||
return (
|
||||
inquiryErrorStatus(error) === 404 ||
|
||||
["NOT_FOUND", "POLICY_NOT_FOUND", "NO_POLICY", "RECORD_NOT_FOUND"].includes(
|
||||
code,
|
||||
codes.some((code) =>
|
||||
[
|
||||
"NOT_FOUND",
|
||||
"POLICY_NOT_FOUND",
|
||||
"NO_POLICY",
|
||||
"RECORD_NOT_FOUND",
|
||||
"INQUIRY_NO_MATCH",
|
||||
].includes(code),
|
||||
) ||
|
||||
/\bnot[ -]?found\b|\bno (?:active |relevant )?(?:record|policy|item)\b|record\.not\.found|موردی یافت نشد|یافت نشد|پیدا نشد|فاقد بیمه(?:| )?نامه/i.test(
|
||||
message,
|
||||
@@ -175,6 +201,11 @@ export function getInquiryErrorMessage(
|
||||
): string {
|
||||
const providerMessage = extractInquiryProviderMessage(error);
|
||||
|
||||
// Persian text supplied by the provider is already the intended client
|
||||
// message. Preserve it verbatim instead of replacing it with a local
|
||||
// contextual fallback such as "inquiry not found".
|
||||
if (providerMessage && hasPersian(providerMessage)) return providerMessage;
|
||||
|
||||
if (isNotFound(error, providerMessage)) return NOT_FOUND_MESSAGES[context];
|
||||
|
||||
if (
|
||||
@@ -208,10 +239,6 @@ export function getInquiryErrorMessage(
|
||||
return "سرویس استعلام در دسترس نیست. لطفاً کمی بعد دوباره تلاش کنید.";
|
||||
}
|
||||
|
||||
// A provider's specific Persian validation/business message is already safe
|
||||
// and more useful than replacing it with a broad local validation message.
|
||||
if (providerMessage && hasPersian(providerMessage)) return providerMessage;
|
||||
|
||||
if (isInvalidInput(providerMessage) || inquiryErrorStatus(error) === 422) {
|
||||
return INVALID_MESSAGES[context];
|
||||
}
|
||||
|
||||
@@ -8,13 +8,13 @@ export const REPAIR_LINE_AMOUNT_TOMAN = { // IT IS RIAL FROM NOW ON
|
||||
MAX: 530_000_000,
|
||||
} as const;
|
||||
|
||||
/** Max sum of all priced + factor lines in one expert reply / validation (Toman). */
|
||||
/** Max sum of all priced + factor lines in one V1 expert reply / validation (Rial). */
|
||||
export const CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN = REPAIR_LINE_AMOUNT_TOMAN.MAX;
|
||||
|
||||
const ENABLED_VALUES = new Set(["1", "true", "yes", "on", "enabled"]);
|
||||
|
||||
/**
|
||||
* Returns null when the claim v2 total cap is disabled.
|
||||
* Returns null when the V1 claim total cap is disabled.
|
||||
*
|
||||
* Set CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED=true to enforce the cap again.
|
||||
* Optionally set CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN to override the amount.
|
||||
|
||||
66
src/expert-blame/expert-blame.participants.spec.ts
Normal file
66
src/expert-blame/expert-blame.participants.spec.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||||
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
|
||||
import { ExpertBlameService } from "./expert-blame.service";
|
||||
|
||||
describe("ExpertBlameService participant detail contract", () => {
|
||||
it("returns normalized participants and their role assignments from the blame party", async () => {
|
||||
const service = new (ExpertBlameService as any)(
|
||||
...new Array(13).fill(undefined),
|
||||
) as any;
|
||||
const expertId = "66ec0e480e321873c0900001";
|
||||
|
||||
service.expireBlameCaseWorkflowLockV2IfStale = jest
|
||||
.fn()
|
||||
.mockResolvedValue(undefined);
|
||||
service.blameRequestDbService = {
|
||||
findByIdWithoutHistory: jest.fn().mockResolvedValue({
|
||||
_id: "66ec0e480e321873c0900002",
|
||||
type: BlameRequestType.THIRD_PARTY,
|
||||
status: CaseStatus.WAITING_FOR_EXPERT,
|
||||
expertInitiated: true,
|
||||
initiatedByFieldExpertId: expertId,
|
||||
workflow: {},
|
||||
parties: [
|
||||
{
|
||||
role: "FIRST",
|
||||
person: { fullName: "Legacy Party Name" },
|
||||
participants: [
|
||||
{
|
||||
participantId: "PERSON_1",
|
||||
nationalCode: "0012345678",
|
||||
birthday: "1370/01/01",
|
||||
unknown: true,
|
||||
},
|
||||
],
|
||||
participantRoles: {
|
||||
driver: "PERSON_1",
|
||||
vehicleOwner: "PERSON_1",
|
||||
thirdPartyPolicyholder: "PERSON_1",
|
||||
},
|
||||
vehicle: { inquiry: { raw: { large: true } } },
|
||||
},
|
||||
],
|
||||
createdAt: new Date("2026-09-19T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-09-19T00:00:00.000Z"),
|
||||
}),
|
||||
};
|
||||
|
||||
const result = await service.findOneV2("blame-1", { sub: expertId });
|
||||
const party = (result.parties as any[])[0];
|
||||
|
||||
expect(party.participants).toEqual([
|
||||
{
|
||||
participantId: "PERSON_1",
|
||||
nationalCode: "0012345678",
|
||||
birthday: "1370/01/01",
|
||||
},
|
||||
]);
|
||||
expect(party.participantRoles).toEqual({
|
||||
driver: "PERSON_1",
|
||||
vehicleOwner: "PERSON_1",
|
||||
thirdPartyPolicyholder: "PERSON_1",
|
||||
});
|
||||
expect(party.person.fullName).toBe("Legacy Party Name");
|
||||
expect(party.vehicle.inquiry).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -91,6 +91,7 @@ import {
|
||||
ExpertFileActivityType,
|
||||
ExpertFileKind,
|
||||
} from "src/users/entities/schema/expert-file-activity.schema";
|
||||
import { sanitizeStoredInquiryParticipants } from "src/request-management/inquiry-participant-resolver";
|
||||
|
||||
interface CheckedRequestEntry {
|
||||
CheckedRequest?: {
|
||||
@@ -1250,8 +1251,17 @@ export class ExpertBlameService {
|
||||
doc.createdAtFormatted = `${createdDate} ${createdTime}`;
|
||||
doc.updatedAtFormatted = `${updatedDate} ${updatedTime}`;
|
||||
|
||||
// Keep the normalized blame-party participant contract authoritative for
|
||||
// every expert detail response. Legacy person fields remain for old files,
|
||||
// but new UIs must read participants + participantRoles from the same party.
|
||||
doc.parties = parties.map((party: Record<string, unknown>) => ({
|
||||
...party,
|
||||
participants: sanitizeStoredInquiryParticipants(party.participants),
|
||||
participantRoles: party.participantRoles,
|
||||
}));
|
||||
|
||||
// Strip heavy SandHub inquiry blob
|
||||
for (const party of parties as Array<{
|
||||
for (const party of doc.parties as Array<{
|
||||
vehicle?: Record<string, unknown>;
|
||||
}>) {
|
||||
if (
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" +
|
||||
|
||||
25
src/helpers/claim-price-cap.spec.ts
Normal file
25
src/helpers/claim-price-cap.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { CreationMethod } from "src/request-management/entities/schema/request-management.schema";
|
||||
import { claimPriceCapAppliesToBlame } from "./claim-price-cap";
|
||||
|
||||
describe("claimPriceCapAppliesToBlame", () => {
|
||||
it.each([
|
||||
[{ creationMethod: CreationMethod.NORMAL }],
|
||||
[{}],
|
||||
])("applies to V1 user-created files (%p)", (blame) => {
|
||||
expect(claimPriceCapAppliesToBlame(blame)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ creationMethod: CreationMethod.LINK, expertInitiated: true }],
|
||||
[{ creationMethod: CreationMethod.IN_PERSON, expertInitiated: true }],
|
||||
[{ creationMethod: CreationMethod.IN_PERSON, registrarInitiated: true }],
|
||||
[{ creationMethod: CreationMethod.LINK, callCenterInitiated: true }],
|
||||
[{ creationMethod: CreationMethod.NORMAL, initiatedByCallCenterId: "agent" }],
|
||||
])("does not apply to non-V1 files (%p)", (blame) => {
|
||||
expect(claimPriceCapAppliesToBlame(blame)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not apply when the linked blame origin is unavailable", () => {
|
||||
expect(claimPriceCapAppliesToBlame(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
39
src/helpers/claim-price-cap.ts
Normal file
39
src/helpers/claim-price-cap.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { CreationMethod } from "src/request-management/entities/schema/request-management.schema";
|
||||
|
||||
export type ClaimPriceCapBlameOrigin = {
|
||||
creationMethod?: CreationMethod | string | null;
|
||||
expertInitiated?: boolean | null;
|
||||
registrarInitiated?: boolean | null;
|
||||
callCenterInitiated?: boolean | null;
|
||||
initiatedByFieldExpertId?: unknown;
|
||||
initiatedByRegistrarId?: unknown;
|
||||
initiatedByCallCenterId?: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* The total-payment cap is a V1 business rule. V1 files are created directly
|
||||
* by a user (`NORMAL`); expert/registrar LINK or IN_PERSON files and V6
|
||||
* call-center files must not inherit it. Missing creationMethod is treated as
|
||||
* NORMAL for older user-created records, while initiator markers take
|
||||
* precedence so legacy non-V1 records cannot be misclassified.
|
||||
*/
|
||||
export function claimPriceCapAppliesToBlame(
|
||||
blame?: ClaimPriceCapBlameOrigin | null,
|
||||
): boolean {
|
||||
if (!blame) return false;
|
||||
|
||||
const hasNonV1Initiator =
|
||||
blame.expertInitiated === true ||
|
||||
blame.registrarInitiated === true ||
|
||||
blame.callCenterInitiated === true ||
|
||||
blame.initiatedByFieldExpertId != null ||
|
||||
blame.initiatedByRegistrarId != null ||
|
||||
blame.initiatedByCallCenterId != null;
|
||||
|
||||
if (hasNonV1Initiator) return false;
|
||||
|
||||
return (
|
||||
blame.creationMethod == null ||
|
||||
blame.creationMethod === CreationMethod.NORMAL
|
||||
);
|
||||
}
|
||||
@@ -495,6 +495,26 @@ describe("inquiry participant resolver", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a mapped provider message when the current result is unusable", async () => {
|
||||
const currentPlate = {
|
||||
leftDigits: "44",
|
||||
centerAlphabet: "ب",
|
||||
centerDigits: "111",
|
||||
ir: "22",
|
||||
};
|
||||
|
||||
await expect(
|
||||
runCurrentPlateInquiry({
|
||||
currentPlate,
|
||||
query: async () => ({
|
||||
mapped: { Error: { Message: "رکوردی یافت نشد" } },
|
||||
}),
|
||||
isUsable: () => false,
|
||||
errorMessage: (value) => value.mapped.Error.Message,
|
||||
}),
|
||||
).rejects.toThrow("رکوردی یافت نشد");
|
||||
});
|
||||
|
||||
it("retains one failed current-plate attempt after a provider outage", async () => {
|
||||
const currentPlate = {
|
||||
leftDigits: "44",
|
||||
|
||||
@@ -446,6 +446,7 @@ export async function runCurrentPlateInquiry<T>(options: {
|
||||
vin?: string;
|
||||
query: (plate: InquiryVehicleInputDto["currentPlate"]) => Promise<T>;
|
||||
isUsable: (value: T) => boolean;
|
||||
errorMessage?: (value: T) => string | undefined;
|
||||
}): Promise<{
|
||||
value: T;
|
||||
plateKind: "CURRENT";
|
||||
@@ -479,7 +480,8 @@ export async function runCurrentPlateInquiry<T>(options: {
|
||||
usable: false,
|
||||
});
|
||||
const error = new BadRequestException(
|
||||
"بیمهنامه معتبر و فعالی مطابق مشخصات خودرو و کد ملی واردشده یافت نشد.",
|
||||
options.errorMessage?.(value) ||
|
||||
"بیمهنامه معتبر و فعالی مطابق مشخصات خودرو و کد ملی واردشده یافت نشد.",
|
||||
) as BadRequestException & { attempts?: typeof attempts };
|
||||
error.attempts = attempts;
|
||||
throw error;
|
||||
|
||||
@@ -78,6 +78,34 @@ describe("RequestManagementService policyholder inquiry routing", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates the mapped ESG Persian error for third-party insurance", async () => {
|
||||
const service = Object.create(RequestManagementService.prototype) as any;
|
||||
service.sandHubService = {
|
||||
getTejaratBlockInquiry: jest.fn().mockResolvedValue({
|
||||
raw: {
|
||||
success: false,
|
||||
error: {
|
||||
code: "RECORD_NOT_FOUND",
|
||||
message: "Provider request failed",
|
||||
messageFa: "رکوردی یافت نشد",
|
||||
},
|
||||
},
|
||||
mapped: { Error: { Message: "رکوردی یافت نشد" } },
|
||||
}),
|
||||
};
|
||||
const submission = service.normalizeInquiryInput(
|
||||
BlameRequestType.THIRD_PARTY,
|
||||
participantInput(BlameRequestType.THIRD_PARTY),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.getThirdPartyPlateInquiry(submission),
|
||||
).rejects.toThrow("رکوردی یافت نشد");
|
||||
expect(service.sandHubService.getTejaratBlockInquiry).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not fall back from the current plate for car-body insurance", async () => {
|
||||
const service = Object.create(RequestManagementService.prototype) as any;
|
||||
service.sandHubService = {
|
||||
|
||||
@@ -147,6 +147,13 @@ import {
|
||||
getInquiryErrorMessage,
|
||||
} from "src/common/utils/inquiry-error";
|
||||
|
||||
function mappedInquiryErrorMessage(value: any): string | undefined {
|
||||
const message = value?.mapped?.Error?.Message;
|
||||
return typeof message === "string" && message.trim()
|
||||
? message.trim()
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a compact Jalali date (number or string like 13780624) as YYYY/MM/DD.
|
||||
* Returns the original value as a string if it cannot be parsed.
|
||||
@@ -401,6 +408,7 @@ export class RequestManagementService {
|
||||
!value?.mapped?.Error &&
|
||||
!!value?.mapped?.CompanyName &&
|
||||
isMappedPolicyCurrent(value.mapped),
|
||||
errorMessage: mappedInquiryErrorMessage,
|
||||
});
|
||||
return {
|
||||
...result.value,
|
||||
@@ -435,6 +443,7 @@ export class RequestManagementService {
|
||||
value.mapped.CompanyName ||
|
||||
value.mapped.companyId
|
||||
),
|
||||
errorMessage: mappedInquiryErrorMessage,
|
||||
});
|
||||
return {
|
||||
...result.value,
|
||||
|
||||
@@ -155,28 +155,38 @@ describe("SandHubService inquiry mocks", () => {
|
||||
expect(result.mapped.PrntPlcyCmpDocNo).toBe("REAL-ESG-POLICY");
|
||||
});
|
||||
|
||||
it("preserves ESG not-found semantics as a Persian plate-specific error", async () => {
|
||||
it("preserves ESG messageFa for a plate inquiry", async () => {
|
||||
process.env.CLIENT_ID = "8";
|
||||
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
|
||||
jest.spyOn(service as any, "makeEsgRequest").mockResolvedValue({
|
||||
success: false,
|
||||
message: "موردی یافت نشد",
|
||||
error: {
|
||||
code: "RECORD_NOT_FOUND",
|
||||
message: "Provider request failed",
|
||||
messageFa: "رکوردی یافت نشد",
|
||||
providerMessage: "err.record.not.found",
|
||||
providerCode: "RECORD_NOT_FOUND",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.getTejaratBlockInquiry(userDetail, {
|
||||
enforceDeploymentClientMatch: true,
|
||||
});
|
||||
|
||||
expect(result.mapped.Error.Message).toBe(
|
||||
"بیمهنامه شخص ثالثی مطابق پلاک و کد ملی واردشده یافت نشد.",
|
||||
);
|
||||
expect(result.mapped.Error.Message).toBe("رکوردی یافت نشد");
|
||||
});
|
||||
|
||||
it("uses a VIN-specific message for the same ESG not-found response", async () => {
|
||||
it("preserves ESG messageFa for a VIN inquiry", async () => {
|
||||
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
|
||||
jest.spyOn(service as any, "makeEsgRequest").mockResolvedValue({
|
||||
success: false,
|
||||
message: "موردی یافت نشد",
|
||||
error: {
|
||||
code: "INQUIRY_NO_MATCH",
|
||||
message: "Inquiry returned no matching result",
|
||||
messageFa: "نتیجهای مطابق با اطلاعات وارد شده یافت نشد",
|
||||
providerMessage: "Inquiry returned no matching result",
|
||||
providerCode: "INQUIRY_NO_MATCH",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.getPolicyByChassisInquiry({
|
||||
@@ -185,7 +195,7 @@ describe("SandHubService inquiry mocks", () => {
|
||||
});
|
||||
|
||||
expect(result.mapped.Error.Message).toBe(
|
||||
"بیمهنامه شخص ثالثی مطابق شماره شاسی (VIN) و کد ملی واردشده یافت نشد.",
|
||||
"نتیجهای مطابق با اطلاعات وارد شده یافت نشد",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user