Compare commits

..

26 Commits

Author SHA1 Message Date
50ea476fa5 Merge pull request 'main' (#228) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#228
2026-07-28 16:59:02 +03:30
8609dbc377 merge upstream 2026-07-28 16:58:26 +03:30
0019a22ccd claim things fixed 2026-07-28 16:58:03 +03:30
8aae4b75e1 Merge pull request 'YARA-1135' (#227) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#227
2026-07-28 16:44:09 +03:30
SepehrYahyaee
891b1b2bbc YARA-1135 2026-07-28 16:43:29 +03:30
7ed102c6ef Merge pull request 'YARA-1148: add CALL_CENTER to LocalActorAuthGuard role allowlist' (#226) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#226
2026-07-28 15:44:00 +03:30
SepehrYahyaee
ec0d03fadf YARA-1148: add CALL_CENTER to LocalActorAuthGuard role allowlist 2026-07-28 15:40:11 +03:30
75adb2a3d2 Merge pull request 'main' (#225) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#225
2026-07-28 15:01:31 +03:30
SepehrYahyaee
4cf0461a48 YARA-1147 2026-07-28 15:00:09 +03:30
d7e6784746 Merge pull request 'main' (#1) from Yara724/api:main into main
Reviewed-on: s.yahyaee/yara724-api#1
2026-07-28 12:47:43 +03:30
d119cd64aa Merge pull request 'main' (#224) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#224
2026-07-28 12:25:22 +03:30
SepehrYahyaee
021d749962 Added permission for file maker and reviewers to access branches 2026-07-28 12:24:12 +03:30
SepehrYahyaee
61684156c6 YARA-1133 2026-07-27 14:00:54 +03:30
639e6ceebb Merge pull request 'YARA-1165' (#223) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#223
2026-07-27 11:46:36 +03:30
SepehrYahyaee
588a92c4b4 YARA-1165 2026-07-27 11:46:03 +03:30
f2e20b32eb Merge pull request 'YARA-1164' (#222) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#222
2026-07-27 11:39:39 +03:30
SepehrYahyaee
c94dd27a96 YARA-1164 2026-07-27 11:38:54 +03:30
6e1405c309 Merge pull request 'main' (#221) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#221
2026-07-27 10:39:57 +03:30
f053ee1348 merge upstream 2026-07-27 10:39:17 +03:30
182db56e15 the serial letter normalization added to the code to normalize the Heh , Ya , Kaf and etc to the correct character 2026-07-27 10:38:35 +03:30
9b348d567d Merge pull request 'YARA-1162' (#220) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#220
2026-07-27 10:16:22 +03:30
SepehrYahyaee
e4c3b7a16a YARA-1162 2026-07-27 10:15:46 +03:30
23fca04705 Merge pull request 'YARA-1154' (#219) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#219
2026-07-27 09:39:08 +03:30
SepehrYahyaee
4b9d946bfd YARA-1154 2026-07-27 09:31:07 +03:30
c85503e598 Merge pull request 'YARA-1136' (#218) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#218
2026-07-26 11:35:59 +03:30
SepehrYahyaee
9828aee8af YARA-1136 2026-07-26 11:35:21 +03:30
43 changed files with 2073 additions and 95 deletions

View File

@@ -0,0 +1,86 @@
#!/usr/bin/env node
/**
* Quick manual verification for plate normalization.
*
* Usage: node scripts/verify-plate-normalization.mjs
*
* Demonstrates that:
* 339ه77 (ه + U+200D) -> 339ه77 -> letter ه -> code 15
* 339ي77 (Arabic ي) -> 339ی77 -> letter ی -> code 16
*/
// ---------- paste the pure function here so no build needed ----------
function normalizePlateText(text) {
if (!text) return text;
let result = String(text);
result = result.normalize("NFKC");
result = result.replace(/[\u200C\u200D\u200E\u200F\uFEFF]/g, "");
result = result.replace(/\u064A/g, "\u06CC"); // Arabic ي → Persian ی
result = result.trim();
return result;
}
// ---------- the canonical plate-letter → code mapping ----------
const PLATE_LETTER_MAP = {
الف: 1, ب: 2, پ: 3, ج: 4, د: 5,
س: 6, ص: 7, ط: 8, ع: 9, ق: 10,
ل: 11, م: 12, ن: 13, و: 14, ه: 15,
ی: 16, ک: 17, ژ: 18, ت: 19, ث: 20,
ز: 21, ش: 22, ف: 23, گ: 24,
};
function extractAndMap(plateString) {
const normalized = normalizePlateText(plateString);
// Simple parser: digits | letter | digits (left+right digits, 1 letter)
const match = normalized.match(/^(\d+)([^\d]+)(\d+)$/);
if (!match) return { input: plateString, normalized, error: "no match" };
const [, leftDigits, letterRaw, rightDigits] = match;
const letter = normalizePlateText(letterRaw);
const code = PLATE_LETTER_MAP[letter];
return {
input: plateString,
normalized,
leftDigits,
letter,
rightDigits,
code,
ok: code !== undefined,
};
}
// ---------- test cases ----------
const cases = [
{ desc: "ه + ZWJ (the original bug)", plate: "339ه\u200D77" },
{ desc: "Arabic ي → Persian ی", plate: "339ي77" },
{ desc: "ه + ZWNJ", plate: "339ه\u200C77" },
{ desc: "RTL mark around letter", plate: "339\u200Fه\u200E77" },
{ desc: "BOM + ZWJ combined", plate: "\uFEFF339ه\u200D77" },
{ desc: "clean letter (no junk)", plate: "339ه77" },
{ desc: "full plate: الف", plate: "11الف22" },
{ desc: "full plate: ی", plate: "55ی99" },
];
console.log("Plate Normalization Verification\n");
console.log("=".repeat(72));
let allPassed = true;
for (const { desc, plate } of cases) {
const r = extractAndMap(plate);
const status = r.ok ? "PASS" : "FAIL";
if (!r.ok) allPassed = false;
console.log(`\n[${status}] ${desc}`);
console.log(` Input: ${JSON.stringify(plate)}`);
console.log(` Normalized: ${JSON.stringify(r.normalized)}`);
if (r.letter) {
console.log(` Letter: ${r.letter} (U+${r.letter.charCodeAt(0).toString(16).toUpperCase().padStart(4, "0")})`);
}
console.log(` Code: ${r.code ?? "undefined"}`);
}
console.log("\n" + "=".repeat(72));
console.log(allPassed ? "\n All tests PASSED ✓" : "\n Some tests FAILED ✗");
process.exit(allPassed ? 0 : 1);

View File

@@ -9,4 +9,5 @@ export enum RoleEnum {
FILE_MAKER = "file_maker", FILE_MAKER = "file_maker",
FILE_REVIEWER = "file_reviewer", FILE_REVIEWER = "file_reviewer",
SUPER_ADMIN = "super_admin", SUPER_ADMIN = "super_admin",
CALL_CENTER = "call_center",
} }

View File

@@ -29,6 +29,7 @@ import { FieldExpertDbService } from "src/users/entities/db-service/field-expert
import { RegistrarDbService } from "src/users/entities/db-service/registrar.db.service"; import { RegistrarDbService } from "src/users/entities/db-service/registrar.db.service";
import { FileMakerDbService } from "src/users/entities/db-service/file-maker.db.service"; import { FileMakerDbService } from "src/users/entities/db-service/file-maker.db.service";
import { FileReviewerDbService } from "src/users/entities/db-service/file-reviewer.db.service"; import { FileReviewerDbService } from "src/users/entities/db-service/file-reviewer.db.service";
import { CallCenterAgentDbService } from "src/users/entities/db-service/call-center-agent.db.service";
import { HashService } from "src/utils/hash/hash.service"; import { HashService } from "src/utils/hash/hash.service";
import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service"; import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service";
import { SuperAdminDbService } from "src/super-admin/entities/db-service/super-admin.db.service"; import { SuperAdminDbService } from "src/super-admin/entities/db-service/super-admin.db.service";
@@ -58,6 +59,7 @@ export class ActorAuthService {
private readonly fileMakerDbService: FileMakerDbService, private readonly fileMakerDbService: FileMakerDbService,
private readonly fileReviewerDbService: FileReviewerDbService, private readonly fileReviewerDbService: FileReviewerDbService,
private readonly superAdminDbService: SuperAdminDbService, private readonly superAdminDbService: SuperAdminDbService,
private readonly callCenterAgentDbService: CallCenterAgentDbService,
) {} ) {}
// TODO convrt to class for dynamic controller // TODO convrt to class for dynamic controller
@@ -123,6 +125,14 @@ export class ActorAuthService {
}); });
else res = await this.superAdminDbService.findByLoginIdentifier(username); else res = await this.superAdminDbService.findByLoginIdentifier(username);
break; break;
case RoleEnum.CALL_CENTER:
if (username == null && userId)
res = await this.callCenterAgentDbService.findOne({
_id: new Types.ObjectId(userId),
});
else
res = await this.callCenterAgentDbService.findByLoginIdentifier(username);
break;
default: default:
return null; return null;
} }

View File

@@ -46,6 +46,7 @@ export class LocalActorAuthGuard implements CanActivate {
RoleEnum.FILE_MAKER, RoleEnum.FILE_MAKER,
RoleEnum.FILE_REVIEWER, RoleEnum.FILE_REVIEWER,
RoleEnum.SUPER_ADMIN, RoleEnum.SUPER_ADMIN,
RoleEnum.CALL_CENTER,
].includes(payload.role) ].includes(payload.role)
) { ) {
throw new UnauthorizedException("User role is not authorized"); throw new UnauthorizedException("User role is not authorized");

View File

@@ -57,6 +57,7 @@ import { JwtModule } from "@nestjs/jwt";
import { MediaPolicyModule } from "src/media-policy/media-policy.module"; import { MediaPolicyModule } from "src/media-policy/media-policy.module";
import { FanavaranAuditModule } from "src/fanavaran/fanavaran-audit.module"; import { FanavaranAuditModule } from "src/fanavaran/fanavaran-audit.module";
import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module"; import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module";
import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normalizer.module";
@Module({ @Module({
imports: [ imports: [
@@ -67,6 +68,7 @@ import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module";
}), }),
FanavaranAuditModule, FanavaranAuditModule,
FanavaranLookupModule, FanavaranLookupModule,
PlateNormalizerModule,
PublicIdModule, PublicIdModule,
UsersModule, UsersModule,
RequestManagementModule, RequestManagementModule,

View File

@@ -156,6 +156,7 @@ import {
resolvePartCaptureIndex, resolvePartCaptureIndex,
} from "src/helpers/outer-damage-parts"; } from "src/helpers/outer-damage-parts";
import { normalizeMoneyAmountString } from "src/utils/unicode-digits"; import { normalizeMoneyAmountString } from "src/utils/unicode-digits";
import { PlateNormalizerService } from "src/utils/plate-normalizer/plate-normalizer.service";
import { import {
CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS, CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS,
@@ -352,6 +353,7 @@ export class ClaimRequestManagementService {
private readonly fanavaranLookupService: FanavaranLookupService, private readonly fanavaranLookupService: FanavaranLookupService,
private readonly fileMakerDbService: FileMakerDbService, private readonly fileMakerDbService: FileMakerDbService,
private readonly fieldExpertDbService: FieldExpertDbService, private readonly fieldExpertDbService: FieldExpertDbService,
private readonly plateNormalizer: PlateNormalizerService,
) {} ) {}
private requiredDocumentKeysV2(isCarBody: boolean): string[] { private requiredDocumentKeysV2(isCarBody: boolean): string[] {
@@ -503,7 +505,7 @@ export class ClaimRequestManagementService {
const ir = Number(irRaw); const ir = Number(irRaw);
const leftDigits = Number(leftRaw); const leftDigits = Number(leftRaw);
const centerDigits = Number(centerRaw); const centerDigits = Number(centerRaw);
const centerAlphabet = String(alphaRaw || "").trim(); const centerAlphabet = this.plateNormalizer.normalizePlateText(String(alphaRaw || ""));
if ( if (
!Number.isFinite(ir) || !Number.isFinite(ir) ||
!Number.isFinite(leftDigits) || !Number.isFinite(leftDigits) ||
@@ -530,11 +532,11 @@ export class ClaimRequestManagementService {
Number.isFinite(Number(p.leftDigits)) && Number.isFinite(Number(p.leftDigits)) &&
Number.isFinite(Number(p.centerDigits)) && Number.isFinite(Number(p.centerDigits)) &&
Number.isFinite(Number(p.ir)) && Number.isFinite(Number(p.ir)) &&
String(p.centerAlphabet || "").trim() this.plateNormalizer.normalizePlateText(String(p.centerAlphabet || ""))
) { ) {
return { return {
leftDigits: Number(p.leftDigits), leftDigits: Number(p.leftDigits),
centerAlphabet: String(p.centerAlphabet).trim(), centerAlphabet: this.plateNormalizer.normalizePlateText(String(p.centerAlphabet)),
centerDigits: Number(p.centerDigits), centerDigits: Number(p.centerDigits),
ir: Number(p.ir), ir: Number(p.ir),
}; };
@@ -3602,9 +3604,10 @@ export class ClaimRequestManagementService {
if (centerAlphabet == null) return null; if (centerAlphabet == null) return null;
const raw = String(centerAlphabet).trim(); const raw = String(centerAlphabet).trim();
if (!raw) return null; if (!raw) return null;
const asNumber = Number(raw); const normalized = this.plateNormalizer.normalizePlateText(raw);
const asNumber = Number(normalized);
if (Number.isFinite(asNumber)) return asNumber; if (Number.isFinite(asNumber)) return asNumber;
return FANAVARAN_PLATE_LETTER_CODE[raw] ?? null; return this.plateNormalizer.resolvePlateLetterCode(normalized, FANAVARAN_PLATE_LETTER_CODE);
} }
private formatFanavaranPlateNo( private formatFanavaranPlateNo(
@@ -3886,7 +3889,7 @@ export class ClaimRequestManagementService {
return { return {
BeginDate: insurance.startDate ?? null, BeginDate: insurance.startDate ?? null,
BuiltYear: BuiltYear:
Number(inquiryMapped.ModelField ?? inquiryMapped.ModelCii) || null, Number(inquiryMapped.ModelField ?? inquiryMapped.ModelCii) || Number(inquiryMapped.PrdDate) || null,
ChassisNo: ChassisNo:
inquiryMapped.ChassisNumberField ?? inquiryMapped.ChassisNumberField ??
inquiryMapped.ShsNum ?? inquiryMapped.ShsNum ??
@@ -5063,6 +5066,25 @@ export class ClaimRequestManagementService {
warning, warning,
fileName: candidate.fileName, fileName: candidate.fileName,
}); });
// Schedule a retry for this individual file via autoSubmitFanavaranAttachment,
// which handles idempotency (won't re-upload if already recorded as submitted)
// and has its own inner retry/back-off via scheduleFanavaranRetry.
await this.scheduleFanavaranRetry(
claimCaseId,
"attachments",
() =>
this.autoSubmitFanavaranAttachment(
claimCaseId,
{
path: candidate.path,
fileName: candidate.fileName,
source: candidate.source,
},
{ clientKey, auditSource: FanavaranAuditSource.AUTO_SUBMIT },
),
`${logPrefix} [${i + 1}/${pending.length}] retry`,
);
} }
if (i < pending.length - 1) { if (i < pending.length - 1) {
@@ -9664,6 +9686,7 @@ export class ClaimRequestManagementService {
accepted: boolean; accepted: boolean;
phase?: "PRICED_PARTS_FOR_FACTORS" | "FINAL_APPROVAL"; phase?: "PRICED_PARTS_FOR_FACTORS" | "FINAL_APPROVAL";
fanavaran?: FanavaranAutoSubmitResult; fanavaran?: FanavaranAutoSubmitResult;
fanavaranExpertise?: FanavaranExpertiseSubmitResult;
}> { }> {
if (!Types.ObjectId.isValid(claimRequestId)) { if (!Types.ObjectId.isValid(claimRequestId)) {
throw new BadRequestException("Invalid claim request id"); throw new BadRequestException("Invalid claim request id");
@@ -9937,6 +9960,7 @@ export class ClaimRequestManagementService {
accepted: true, accepted: true,
phase: "FINAL_APPROVAL", phase: "FINAL_APPROVAL",
fanavaran, fanavaran,
fanavaranExpertise,
}; };
} }
@@ -10817,11 +10841,6 @@ export class ClaimRequestManagementService {
const shebaNumber = claimCase.money?.sheba; const shebaNumber = claimCase.money?.sheba;
const nationalCode = claimCase.money?.nationalCodeOfInsurer; const nationalCode = claimCase.money?.nationalCodeOfInsurer;
if (!shebaNumber || !nationalCode) {
throw new BadRequestException(
"Bank information is missing on the claim. Complete run-inquiries for both parties first.",
);
}
const updatePayload: any = { const updatePayload: any = {
"damage.otherParts": otherParts.length > 0 ? otherParts : undefined, "damage.otherParts": otherParts.length > 0 ? otherParts : undefined,
@@ -10860,14 +10879,22 @@ export class ClaimRequestManagementService {
claimRequestId: updatedClaim._id.toString(), claimRequestId: updatedClaim._id.toString(),
publicId: updatedClaim.publicId, publicId: updatedClaim.publicId,
otherParts, otherParts,
shebaNumber: String(shebaNumber).replace( ...(shebaNumber
/^(.{4})(.*)(.{4})$/, ? {
"IR$1************$3", shebaNumber: String(shebaNumber).replace(
), /^(.{4})(.*)(.{4})$/,
nationalCodeOfOwner: String(nationalCode).replace( "IR$1************$3",
/^(.{2})(.*)(.{2})$/, ),
"$1******$3", }
), : {}),
...(nationalCode
? {
nationalCodeOfOwner: String(nationalCode).replace(
/^(.{2})(.*)(.{2})$/,
"$1******$3",
),
}
: {}),
currentStep: ClaimWorkflowStep.CAPTURE_PART_DAMAGES, currentStep: ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
nextStep: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, nextStep: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
message: message:

View File

@@ -106,14 +106,16 @@ export class SelectOtherPartsV2ResponseDto {
@ApiProperty({ @ApiProperty({
description: 'Sheba number (masked for security)', description: 'Sheba number (masked for security)',
example: 'IR12************1234', example: 'IR12************1234',
required: false,
}) })
shebaNumber: string; shebaNumber?: string;
@ApiProperty({ @ApiProperty({
description: 'National code of owner (masked)', description: 'National code of owner (masked)',
example: '12******90', example: '12******90',
required: false,
}) })
nationalCodeOfOwner: string; nationalCodeOfOwner?: string;
@ApiProperty({ @ApiProperty({
description: 'Current workflow step', description: 'Current workflow step',

View File

@@ -265,6 +265,21 @@ export class ClaimCase {
@Prop({ type: Types.ObjectId, index: true }) @Prop({ type: Types.ObjectId, index: true })
fileMakerApprovalActorId?: Types.ObjectId; fileMakerApprovalActorId?: Types.ObjectId;
/**
* V5 split flow: number of times the FileMaker has rejected this claim.
* Capped at 2; on the 3rd attempt the reject endpoint returns 422 and
* the FileMaker must approve instead.
*/
@Prop({ type: Number, default: 0 })
fileMakerRejectionCount?: number;
/**
* V5 split flow: reason text from the most recent FileMaker rejection.
* Overwritten on each rejection.
*/
@Prop({ type: String })
fileMakerRejectionReason?: string;
/** /**
* Legacy fields kept optional to simplify progressive migration. * Legacy fields kept optional to simplify progressive migration.
* If you choose to migrate later, we can remove these. * If you choose to migrate later, we can remove these.

View File

@@ -4,9 +4,12 @@ import {
Body, Body,
Controller, Controller,
Get, Get,
HttpException,
InternalServerErrorException,
Param, Param,
Patch, Patch,
Post, Post,
Put,
Query, Query,
UploadedFile, UploadedFile,
UseGuards, UseGuards,
@@ -50,6 +53,7 @@ import {
CapturePartV2ResponseDto, CapturePartV2ResponseDto,
} from "./dto/capture-part-v2.dto"; } from "./dto/capture-part-v2.dto";
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto"; import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
import { Role } from "src/common/auth/enums";
/** /**
* Expert-initiated claim flow that mirrors the normal user claim API * Expert-initiated claim flow that mirrors the normal user claim API
@@ -68,7 +72,7 @@ import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-
@Controller("v2/expert-initiated/claim-request-management") @Controller("v2/expert-initiated/claim-request-management")
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard) @UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.FIELD_EXPERT) @Roles(RoleEnum.FIELD_EXPERT, RoleEnum.FILE_MAKER, RoleEnum.FILE_REVIEWER)
export class ExpertInitiatedClaimMirrorController { export class ExpertInitiatedClaimMirrorController {
constructor( constructor(
private readonly claimRequestManagementService: ClaimRequestManagementService, private readonly claimRequestManagementService: ClaimRequestManagementService,
@@ -78,7 +82,8 @@ export class ExpertInitiatedClaimMirrorController {
@Post("create-from-blame/:blameRequestId") @Post("create-from-blame/:blameRequestId")
@ApiParam({ name: "blameRequestId" }) @ApiParam({ name: "blameRequestId" })
@ApiOperation({ @ApiOperation({
summary: "[Expert mirror] Create claim from expert-initiated IN_PERSON blame", summary:
"[Expert mirror] Create claim from expert-initiated IN_PERSON blame",
description: description:
"Blame must be COMPLETED. Creates a ClaimCase owned by the damaged (non-guilty) party.", "Blame must be COMPLETED. Creates a ClaimCase owned by the damaged (non-guilty) party.",
}) })
@@ -170,8 +175,7 @@ export class ExpertInitiatedClaimMirrorController {
}) })
@ApiBody({ @ApiBody({
type: SelectOuterPartsV2Dto, type: SelectOuterPartsV2Dto,
description: description: "Selected vehicle type + selected outer part IDs from catalog",
"Selected vehicle type + selected outer part IDs from catalog",
examples: { examples: {
example1: { example1: {
summary: "Sedan - minor front damage", summary: "Sedan - minor front damage",
@@ -208,7 +212,10 @@ export class ExpertInitiatedClaimMirrorController {
description: "Outer parts selected successfully", description: "Outer parts selected successfully",
type: SelectOuterPartsV2ResponseDto, type: SelectOuterPartsV2ResponseDto,
}) })
@ApiResponse({ status: 400, description: "Invalid workflow step or validation failed" }) @ApiResponse({
status: 400,
description: "Invalid workflow step or validation failed",
})
@ApiResponse({ status: 404, description: "Claim case not found" }) @ApiResponse({ status: 404, description: "Claim case not found" })
@ApiResponse({ status: 409, description: "Outer parts already selected" }) @ApiResponse({ status: 409, description: "Outer parts already selected" })
async selectOuterParts( async selectOuterParts(
@@ -293,9 +300,15 @@ Optional: upload car green card file in the same step.
description: "Other parts and bank information saved successfully", description: "Other parts and bank information saved successfully",
type: SelectOtherPartsV2ResponseDto, type: SelectOtherPartsV2ResponseDto,
}) })
@ApiResponse({ status: 400, description: "Invalid workflow step or validation failed" }) @ApiResponse({
status: 400,
description: "Invalid workflow step or validation failed",
})
@ApiResponse({ status: 404, description: "Claim case not found" }) @ApiResponse({ status: 404, description: "Claim case not found" })
@ApiResponse({ status: 409, description: "Bank information already submitted" }) @ApiResponse({
status: 409,
description: "Bank information already submitted",
})
async selectOtherParts( async selectOtherParts(
@Param("claimRequestId") claimRequestId: string, @Param("claimRequestId") claimRequestId: string,
@Body() body: SelectOtherPartsV2Dto, @Body() body: SelectOtherPartsV2Dto,
@@ -516,6 +529,81 @@ Returns status of each item (uploaded/captured or not).
); );
} }
// ─── Owner signature on expert pricing ───────────────────────────────────────
@Put("claim-sign/:claimRequestId")
@ApiParam({ name: "claimRequestId" })
@ApiConsumes("multipart/form-data")
@ApiBody({
description: "Signature file, agreement, and branch",
schema: {
type: "object",
required: ["sign", "agree", "branchId"],
properties: {
sign: {
type: "string",
format: "binary",
description: "Signature image",
},
agree: {
type: "boolean",
description: "true to accept, false to reject",
},
branchId: { type: "string", description: "Insurer branch ID" },
},
},
})
@ApiOperation({
summary:
"Owner signature on expert pricing (Flow 3 — expert acts on behalf of user)",
description:
"Field expert submits the damaged party's signature during the final approval stage. " +
"Delegates to the same service method as the user sign endpoint; the expert's " +
"identity is resolved to the claim owner via `resolveClaimEffectiveUserId`.",
})
@UseInterceptors(
FileInterceptor("sign", {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({
destination: "./files/claim-sign",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
const base = file.originalname.split(/[.,\s-]/)[0] || "sign";
callback(null, `${base}-${unique}${ex}`);
},
}),
}),
)
async submitOwnerSign(
@Param("claimRequestId") claimRequestId: string,
@Body("agree") agree: string | boolean,
@Body("branchId") branchId: string,
@CurrentUser() expert: any,
@UploadedFile() sign: Express.Multer.File,
) {
await this.mediaPolicyService.assertForClaim(sign, claimRequestId, "image");
const agreed =
typeof agree === "string"
? agree === "true" || agree === "1"
: Boolean(agree);
try {
return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2(
claimRequestId,
agreed,
typeof branchId === "string" ? branchId : "",
sign,
expert.sub,
expert,
);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to submit signature",
);
}
}
@Patch("car-capture/:claimRequestId") @Patch("car-capture/:claimRequestId")
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@UseInterceptors( @UseInterceptors(

View File

@@ -1871,6 +1871,7 @@ export class ExpertBlameService {
link: this.smsOrchestrationService.buildBlamePartyLink( link: this.smsOrchestrationService.buildBlamePartyLink(
requestIdToken, requestIdToken,
role, role,
"v1",
), ),
}); });
} }
@@ -2065,6 +2066,7 @@ export class ExpertBlameService {
link: this.smsOrchestrationService.buildBlamePartyLink( link: this.smsOrchestrationService.buildBlamePartyLink(
requestIdToken, requestIdToken,
linkRole, linkRole,
"v1",
), ),
}); });
} }

View File

@@ -77,6 +77,19 @@ export class ClaimListItemV2Dto {
example: true, example: true,
}) })
requiresFileMakerApproval?: boolean; requiresFileMakerApproval?: boolean;
@ApiPropertyOptional({
description:
"V5 only. Number of times the FileMaker has rejected this claim (02). When this reaches 2 the FileMaker can only approve.",
example: 1,
})
fileMakerRejectionCount?: number;
@ApiPropertyOptional({
description: "V5 only. Reason text supplied with the most recent FileMaker rejection.",
example: "Damage assessment is incorrect — please re-evaluate parts X and Y",
})
fileMakerRejectionReason?: string;
} }
export class GetClaimListV2ResponseDto { export class GetClaimListV2ResponseDto {

View File

@@ -22,4 +22,5 @@ export class BusinessRuleException extends HttpException {
export const BusinessErrorCode = { export const BusinessErrorCode = {
DAMAGE_EXPERT_RESEND_LIMIT_EXCEEDED: "DAMAGE_EXPERT_RESEND_LIMIT_EXCEEDED", DAMAGE_EXPERT_RESEND_LIMIT_EXCEEDED: "DAMAGE_EXPERT_RESEND_LIMIT_EXCEEDED",
FILE_MAKER_REJECTION_LIMIT_EXCEEDED: "FILE_MAKER_REJECTION_LIMIT_EXCEEDED",
} as const; } as const;

View File

@@ -2429,24 +2429,20 @@ export class ExpertClaimService {
await this.claimRequestManagementService.autoSubmitToFanavaranV2OnClaimCompleted( await this.claimRequestManagementService.autoSubmitToFanavaranV2OnClaimCompleted(
claimRequestId, claimRequestId,
); );
const fanavaranExpertise = // Expertise (stage 4) is intentionally NOT submitted here.
await this.claimRequestManagementService.autoSubmitFanavaranExpertiseOnExpertReply( // It is only triggered after the owner's final approval signature
claimRequestId, // (submitOwnerInsurerApprovalSignV2), because until that point the
); // expert review is not considered final — the user can still object.
return { return {
message: this.appendFanavaranAutoSubmitToMessage( message: this.appendFanavaranAutoSubmitToMessage(
this.appendFanavaranAutoSubmitToMessage( "Factors were reviewed with expert repricing on rejected lines. The claim is completed without an owner signature (temporary policy; may require owner acceptance later).",
"Factors were reviewed with expert repricing on rejected lines. The claim is completed without an owner signature (temporary policy; may require owner acceptance later).", fanavaran,
fanavaran,
),
fanavaranExpertise,
), ),
claimRequestId, claimRequestId,
claimStatus: ClaimStatus.APPROVED, claimStatus: ClaimStatus.APPROVED,
caseStatus: ClaimCaseStatus.COMPLETED, caseStatus: ClaimCaseStatus.COMPLETED,
outcome: "REJECTED_REPRICED_AUTO_COMPLETED", outcome: "REJECTED_REPRICED_AUTO_COMPLETED",
fanavaran, fanavaran,
fanavaranExpertise,
}; };
} }
@@ -2463,25 +2459,21 @@ export class ExpertClaimService {
await this.claimRequestManagementService.autoSubmitToFanavaranV2OnClaimCompleted( await this.claimRequestManagementService.autoSubmitToFanavaranV2OnClaimCompleted(
claimRequestId, claimRequestId,
); );
const fanavaranExpertise = // Expertise (stage 4) is intentionally NOT submitted here.
await this.claimRequestManagementService.autoSubmitFanavaranExpertiseOnExpertReply( // It is only triggered after the owner's final approval signature
claimRequestId, // (submitOwnerInsurerApprovalSignV2), because until that point the
); // expert review is not considered final — the user can still object.
return { return {
message: this.appendFanavaranAutoSubmitToMessage( message: this.appendFanavaranAutoSubmitToMessage(
this.appendFanavaranAutoSubmitToMessage( "All factors were approved by the expert. The claim is completed without an additional owner signature.",
"All factors were approved by the expert. The claim is completed without an additional owner signature.", fanavaran,
fanavaran,
),
fanavaranExpertise,
), ),
claimRequestId, claimRequestId,
claimStatus: ClaimStatus.APPROVED, claimStatus: ClaimStatus.APPROVED,
caseStatus: ClaimCaseStatus.COMPLETED, caseStatus: ClaimCaseStatus.COMPLETED,
outcome: "ALL_APPROVED_AUTO_COMPLETED", outcome: "ALL_APPROVED_AUTO_COMPLETED",
fanavaran, fanavaran,
fanavaranExpertise,
}; };
} }
@@ -3164,7 +3156,7 @@ export class ExpertClaimService {
receptor: ownerPhoneResend, receptor: ownerPhoneResend,
fileKind: "claim", fileKind: "claim",
publicId: claim.publicId, publicId: claim.publicId,
link: this.smsOrchestrationService.buildClaimLink(String(claim._id)), link: this.smsOrchestrationService.buildClaimLink(String(claim._id), "v1"),
}); });
} }
@@ -3462,7 +3454,7 @@ export class ExpertClaimService {
fileKind: "claim", fileKind: "claim",
publicId: claim.publicId, publicId: claim.publicId,
expertLastName, expertLastName,
link: this.smsOrchestrationService.buildClaimLink(String(claim._id)), link: this.smsOrchestrationService.buildClaimLink(String(claim._id), "v1"),
}); });
} }
@@ -4148,12 +4140,14 @@ export class ExpertClaimService {
/** /**
* Claim list for FILE_REVIEWER. * Claim list for FILE_REVIEWER.
* *
* Visibility rules: * Visibility rules (both V4 and V5 files):
* - WAITING_FOR_FILE_REVIEWER files made by a FileMaker that are either: * - WAITING_FOR_FILE_REVIEWER files made by a FileMaker that are either:
* (a) not yet assigned to any reviewer, OR * (a) not yet assigned to any reviewer, OR
* (b) already assigned to THIS reviewer * (b) already assigned to THIS reviewer
* - WAITING_FOR_EXPERT files that are assigned to THIS reviewer * - Files already assigned to THIS reviewer (any post-assignment status)
* (they completed the field work and the file is now in damage-expert queue) *
* V4: isMadeByFileMaker=true, requiresFileMakerApproval=false
* V5: isMadeByFileMaker=true, requiresFileMakerApproval=true
* *
* All scoped to this reviewer's insurance company via blame party clientId. * All scoped to this reviewer's insurance company via blame party clientId.
*/ */
@@ -4164,7 +4158,7 @@ export class ExpertClaimService {
const clientKey = requireActorClientKey(actor); const clientKey = requireActorClientKey(actor);
const reviewerOid = new Types.ObjectId(actor.sub); const reviewerOid = new Types.ObjectId(actor.sub);
// Find V4 blame files visible to this reviewer // Find V4 and V5 blame files visible to this reviewer
const blames = (await this.blameRequestDbService.find( const blames = (await this.blameRequestDbService.find(
{ {
isMadeByFileMaker: true, isMadeByFileMaker: true,
@@ -4186,7 +4180,7 @@ export class ExpertClaimService {
{ {
lean: true, lean: true,
select: select:
"_id type parties blameStatus status expert.decision assignedFileReviewerId", "_id type parties blameStatus status expert.decision assignedFileReviewerId requiresFileMakerApproval",
}, },
)) as any[]; )) as any[];
@@ -4255,6 +4249,9 @@ export class ExpertClaimService {
(!!blame?.isMadeByFileMaker && (!!blame?.isMadeByFileMaker &&
String(blame?.status ?? "") === "WAITING_FOR_EXPERT"), String(blame?.status ?? "") === "WAITING_FOR_EXPERT"),
assignedToMe: !!assignedToMe, assignedToMe: !!assignedToMe,
requiresFileMakerApproval: !!(c as any).requiresFileMakerApproval,
fileMakerRejectionCount: (c as any).fileMakerRejectionCount ?? 0,
fileMakerRejectionReason: (c as any).fileMakerRejectionReason ?? undefined,
}; };
}) as ClaimListItemV2Dto[]; }) as ClaimListItemV2Dto[];
@@ -4262,8 +4259,9 @@ export class ExpertClaimService {
} }
/** /**
* Claim list for FILE_MAKER — returns only the claims linked to blame files * Claim list for FILE_MAKER — returns claims linked to blame files they
* they personally created (V4 flow). * personally created (V4 and V5 flows).
* V4: requiresFileMakerApproval=false · V5: requiresFileMakerApproval=true
*/ */
private async getFileMakerClaimListV2( private async getFileMakerClaimListV2(
actor: any, actor: any,
@@ -4273,7 +4271,7 @@ export class ExpertClaimService {
const makerBlames = (await this.blameRequestDbService.find( const makerBlames = (await this.blameRequestDbService.find(
{ isMadeByFileMaker: true, initiatedByFieldExpertId: makerOid }, { isMadeByFileMaker: true, initiatedByFieldExpertId: makerOid },
{ lean: true, select: "_id type parties blameStatus status expert.decision assignedFileReviewerId" }, { lean: true, select: "_id type parties blameStatus status expert.decision assignedFileReviewerId requiresFileMakerApproval" },
)) as any[]; )) as any[];
if (makerBlames.length === 0) { if (makerBlames.length === 0) {
@@ -4281,9 +4279,10 @@ export class ExpertClaimService {
} }
const blameIds = makerBlames.map((b) => b._id); const blameIds = makerBlames.map((b) => b._id);
// Include all claims linked to this maker's blames — both V4 (requiresFileMakerApproval=false)
// and V5 (requiresFileMakerApproval=true).
const claims = (await this.claimCaseDbService.find({ const claims = (await this.claimCaseDbService.find({
blameRequestId: { $in: blameIds }, blameRequestId: { $in: blameIds },
requiresFileMakerApproval: true,
})) as any[]; })) as any[];
const blameById = new Map<string, any>( const blameById = new Map<string, any>(
@@ -4326,6 +4325,8 @@ export class ExpertClaimService {
createdAt: c.createdAt, createdAt: c.createdAt,
awaitingFactorValidation: claimIsAwaitingExpertFactorValidationV2(c), awaitingFactorValidation: claimIsAwaitingExpertFactorValidationV2(c),
requiresFileMakerApproval: !!(c as any).requiresFileMakerApproval, requiresFileMakerApproval: !!(c as any).requiresFileMakerApproval,
fileMakerRejectionCount: (c as any).fileMakerRejectionCount ?? 0,
fileMakerRejectionReason: (c as any).fileMakerRejectionReason ?? undefined,
needsFileReviewerCompletion: needsFileReviewerCompletion:
String(blame?.status ?? "") === "WAITING_FOR_FILE_REVIEWER" || String(blame?.status ?? "") === "WAITING_FOR_FILE_REVIEWER" ||
(!!blame?.isMadeByFileMaker && (!!blame?.isMadeByFileMaker &&

View File

@@ -4,6 +4,7 @@ import { ClientModule } from "src/client/client.module";
import { PlateDbService } from "src/plates/entites/db-service/plate.db.service"; import { PlateDbService } from "src/plates/entites/db-service/plate.db.service";
import { SandHubModule } from "src/sand-hub/sand-hub.module"; import { SandHubModule } from "src/sand-hub/sand-hub.module";
import { UsersModule } from "src/users/users.module"; import { UsersModule } from "src/users/users.module";
import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normalizer.module";
import { PlatesModel, PlatesSchema } from "./entites/schema/plate.schema"; import { PlatesModel, PlatesSchema } from "./entites/schema/plate.schema";
import { PlatesService } from "./plates.service"; import { PlatesService } from "./plates.service";
@@ -12,6 +13,7 @@ import { PlatesService } from "./plates.service";
UsersModule, UsersModule,
SandHubModule, SandHubModule,
ClientModule, ClientModule,
PlateNormalizerModule,
MongooseModule.forFeature([ MongooseModule.forFeature([
{ name: PlatesModel.name, schema: PlatesSchema }, { name: PlatesModel.name, schema: PlatesSchema },
]), ]),

View File

@@ -7,20 +7,23 @@ import {
import { Types } from "mongoose"; import { Types } from "mongoose";
import { PlateDbService } from "src/plates/entites/db-service/plate.db.service"; import { PlateDbService } from "src/plates/entites/db-service/plate.db.service";
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto"; import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
import { PlateNormalizerService } from "src/utils/plate-normalizer/plate-normalizer.service";
@Injectable() @Injectable()
export class PlatesService { export class PlatesService {
constructor( constructor(
private readonly plateDbService: PlateDbService, private readonly plateDbService: PlateDbService,
private readonly plateNormalizer: PlateNormalizerService,
) {} ) {}
async addPlate(currentUser, body: AddPlateDto) { async addPlate(currentUser, body: AddPlateDto) {
const { plate, nationalCodeOfInsurer } = body; const { plate, nationalCodeOfInsurer } = body;
const normalizedAlphabet = this.plateNormalizer.normalizePlateText(String(plate.centerAlphabet || ""));
const duplicate = await this.plateDbService.findOne({ const duplicate = await this.plateDbService.findOne({
userId: currentUser, userId: currentUser,
leftDigits: plate.leftDigits, leftDigits: plate.leftDigits,
centerAlphabet: plate.centerAlphabet, centerAlphabet: normalizedAlphabet,
centerDigits: plate.centerDigits, centerDigits: plate.centerDigits,
ir: plate.ir, ir: plate.ir,
nationalCodeOfInsurer, nationalCodeOfInsurer,
@@ -31,6 +34,7 @@ export class PlatesService {
const newPlateData = await this.plateDbService.create({ const newPlateData = await this.plateDbService.create({
...plate, ...plate,
centerAlphabet: normalizedAlphabet,
userId: currentUser, userId: currentUser,
nationalCodeOfInsurer: body.nationalCodeOfInsurer, nationalCodeOfInsurer: body.nationalCodeOfInsurer,
}); });

View File

@@ -0,0 +1,154 @@
import {
Body,
Controller,
Get,
Param,
Post,
UseGuards,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiBody,
ApiOperation,
ApiParam,
ApiResponse,
ApiTags,
} from "@nestjs/swagger";
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
import { RolesGuard } from "src/auth/guards/role.guard";
import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { RequestManagementService } from "./request-management.service";
import { RunCallCenterInquiryV6Dto } from "./dto/run-call-center-inquiry-v6.dto";
/**
* V6 call-center blame API.
*
* A call-center agent takes the guilty party's details over the phone
* (plate, national code, birthday), runs the insurance inquiry, then sends
* the blame link via SMS. The user (guilty party) opens the link and
* completes the form through the standard v2 user flow — except the
* initial-form / inquiry step is skipped (`skipInitialFormStep=true`) because
* the agent already did it.
*
* For THIRD_PARTY files: only the guilty party's data is collected here.
* The damaged party sees their own portion of the page and fills their info
* as normal after the blame link is opened.
*/
@ApiTags("call-center-blame (v6)")
@Controller("v6/call-center-blame")
@ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.CALL_CENTER)
export class CallCenterBlameV6Controller {
constructor(
private readonly requestManagementService: RequestManagementService,
) {}
@Get("my-files")
@ApiOperation({ summary: "[V6] List blame files created by this call-center agent" })
@ApiResponse({ status: 200, description: "List of blame files started by this agent" })
getMyFiles(@CurrentUser() agent: any) {
return this.requestManagementService.getMyCallCenterFilesV6(agent);
}
@Post("create")
@ApiOperation({
summary: "[V6] Create a call-centerinitiated blame file",
description:
"Creates a LINK blame file. The call-center agent collects the guilty " +
"party's plate + national-code over the phone, calls run-inquiry to store " +
"the results, then calls send-link to SMS the blame URL to the user. " +
"The user opens the link and fills the form via the normal v2 flow; the " +
"initial-form/inquiry step is automatically skipped because the agent " +
"already ran it.",
})
@ApiBody({
schema: {
type: "object",
required: ["type"],
properties: {
type: {
type: "string",
enum: ["THIRD_PARTY", "CAR_BODY"],
example: "THIRD_PARTY",
},
},
},
})
createFile(
@CurrentUser() agent: any,
@Body("type") type: "THIRD_PARTY" | "CAR_BODY",
) {
return this.requestManagementService.createCallCenterInitiatedBlameV6(agent, { type });
}
@Post("run-inquiry/:requestId")
@ApiOperation({
summary: "[V6] Run plate + insurance inquiry for the guilty party",
description:
"Call after `create`. The agent supplies the plate and national-code data " +
"collected from the caller. Plate + third-party block inquiry is executed " +
"and the results are stored on the blame document. " +
"For THIRD_PARTY files only the guilty party (first party) is inquired here; " +
"the damaged party's inquiry is handled later by the user via the link. " +
"Sheba (IBAN) is not collected here — the user adds it themselves.",
})
@ApiParam({ name: "requestId", description: "Blame request ID from `create`" })
@ApiBody({ type: RunCallCenterInquiryV6Dto })
runInquiry(
@CurrentUser() agent: any,
@Param("requestId") requestId: string,
@Body() dto: RunCallCenterInquiryV6Dto,
) {
return this.requestManagementService.runCallCenterInquiryV6(agent, requestId, dto);
}
@Post("send-link/:requestId")
@ApiOperation({
summary: "[V6] Send blame link to the guilty party via SMS",
description:
"Call after `run-inquiry`. Provide the guilty party's phone number; " +
"the service registers the user if needed, stores them as the first party, " +
"and sends the blame invite link via SMS. The user opens the link and " +
"completes the blame form via the standard v2 user flow (initial-form step skipped).",
})
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiBody({
schema: {
type: "object",
required: ["phoneNumber"],
properties: {
phoneNumber: {
type: "string",
example: "09121234567",
description: "Mobile number of the guilty party",
},
},
},
})
sendLink(
@CurrentUser() agent: any,
@Param("requestId") requestId: string,
@Body("phoneNumber") phoneNumber: string,
) {
return this.requestManagementService.sendCallCenterLinkV6(agent, requestId, { phoneNumber });
}
@Get("blame/:requestId")
@ApiOperation({
summary: "[V6] Get a single blame file created by this agent",
description:
"Returns the current status, workflow step, and party data for one blame " +
"file started by this call-center agent. Useful for checking whether the " +
"user has opened the link and progressed through the form.",
})
@ApiParam({ name: "requestId", description: "Blame request ID" })
getBlame(
@CurrentUser() agent: any,
@Param("requestId") requestId: string,
) {
return this.requestManagementService.getCallCenterBlameDetailV6(agent, requestId);
}
}

View File

@@ -0,0 +1,85 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import {
IsBoolean,
IsNotEmpty,
IsOptional,
IsString,
ValidateNested,
} from "class-validator";
import { Type } from "class-transformer";
class PlateV6Dto {
@ApiProperty({ example: "44", description: "Left two digits" })
@IsString()
@IsNotEmpty()
leftDigits: string;
@ApiProperty({ example: "ب", description: "Center alphabet letter" })
@IsString()
@IsNotEmpty()
centerAlphabet: string;
@ApiProperty({ example: "111", description: "Center three digits" })
@IsString()
@IsNotEmpty()
centerDigits: string;
@ApiProperty({ example: "22", description: "Right two digits (Iran region code)" })
@IsString()
@IsNotEmpty()
ir: string;
}
/**
* Inquiry body for the V6 call-center flow.
* Same as V3 but without `sheba` — the user adds their own IBAN later via the link.
*/
export class RunCallCenterInquiryV6Dto {
@ApiProperty({
type: PlateV6Dto,
description: "Plate segments — Tejarat block / third-party inquiry.",
})
@ValidateNested()
@Type(() => PlateV6Dto)
plate: PlateV6Dto;
@ApiProperty({ example: "1234567890", description: "National code of the policyholder (insurer)" })
@IsString()
@IsNotEmpty()
nationalCodeOfInsurer: string;
@ApiProperty({ example: "1234567890", description: "National code of the driver" })
@IsString()
@IsNotEmpty()
nationalCodeOfDriver: string;
@ApiProperty({ example: true, description: "Whether the driver is the same person as the insurer" })
@IsBoolean()
driverIsInsurer: boolean;
@ApiProperty({ example: 13780624, description: "Insurer birth date (Jalali)" })
insurerBirthday: number | string;
@ApiPropertyOptional({
example: 13780624,
description: "Driver birth date (Jalali). Required when driverIsInsurer is false.",
})
@IsOptional()
driverBirthday?: number | string | null;
@ApiPropertyOptional({
example: "123456789",
description: "Driver license (required when driverIsInsurer is false).",
})
@IsOptional()
@IsString()
driverLicense?: string;
@ApiPropertyOptional({
example: "123456789",
description: "Insurer license (required when driverIsInsurer is true).",
})
@IsOptional()
@IsString()
insurerLicense?: string;
}

View File

@@ -4,6 +4,7 @@ import {
IsNotEmpty, IsNotEmpty,
IsOptional, IsOptional,
IsString, IsString,
MaxLength,
ValidateNested, ValidateNested,
} from "class-validator"; } from "class-validator";
import { Type } from "class-transformer"; import { Type } from "class-transformer";
@@ -89,3 +90,66 @@ export class RunInquiriesV3Dto {
@IsString() @IsString()
insurerLicense?: string; insurerLicense?: string;
} }
/**
* Body for `POST run-inquiries-vin/:requestId`.
* Identical to RunInquiriesV3Dto but uses `vin` (17-char chassis number) instead of `plate`.
* First call = guilty party (+ auto claim). Second call = damaged party (THIRD_PARTY only).
*/
export class RunInquiriesVinV3Dto {
@ApiProperty({
example: "NAAM01E15HK123456",
description: "17-character VIN / chassis number (شماره شاسی)",
maxLength: 17,
})
@IsString()
@IsNotEmpty()
@MaxLength(17)
vin: string;
@ApiProperty({ example: "1234567890", description: "National code of the policyholder (insurer)" })
@IsString()
@IsNotEmpty()
nationalCodeOfInsurer: string;
@ApiProperty({ example: "1234567890", description: "National code of the driver" })
@IsString()
@IsNotEmpty()
nationalCodeOfDriver: string;
@ApiProperty({ example: true, description: "Whether the driver is the same person as the insurer" })
@IsBoolean()
driverIsInsurer: boolean;
@ApiProperty({ example: 13780624, description: "Insurer birth date (Jalali)" })
insurerBirthday: number | string;
@ApiPropertyOptional({ example: 13780624, description: "Driver birth date (Jalali). Required when driverIsInsurer is false." })
@IsOptional()
driverBirthday?: number | string | null;
@ApiPropertyOptional({
example: "IR123456789012345678901234",
description:
"Sheba (IBAN). Required on the damaged party call (CAR_BODY first call; THIRD_PARTY second call).",
})
@IsOptional()
@IsString()
sheba?: string;
@ApiPropertyOptional({
example: "123456789",
description: "Driver license (required when driverIsInsurer is false).",
})
@IsOptional()
@IsString()
driverLicense?: string;
@ApiPropertyOptional({
example: "123456789",
description: "Insurer license (required when driverIsInsurer is true).",
})
@IsOptional()
@IsString()
insurerLicense?: string;
}

View File

@@ -131,6 +131,26 @@ export class BlameRequest {
*/ */
@Prop({ type: Types.ObjectId }) @Prop({ type: Types.ObjectId })
assignedFileReviewerId?: Types.ObjectId; assignedFileReviewerId?: Types.ObjectId;
/**
* V6 call-center flow: true when this blame was started by a call-center agent
* on behalf of the guilty party who called in.
*/
@Prop({ default: false })
callCenterInitiated?: boolean;
/**
* V6 call-center flow: the call-center agent who created this file.
*/
@Prop({ type: Types.ObjectId })
initiatedByCallCenterId?: Types.ObjectId;
/**
* V6 call-center flow: when true the user-side blame page should skip the
* inquiry / initial-form step (the call-center agent already ran the inquiry).
*/
@Prop({ default: false })
skipInitialFormStep?: boolean;
} }
export type BlameRequestDocument = HydratedDocument<BlameRequest>; export type BlameRequestDocument = HydratedDocument<BlameRequest>;

View File

@@ -31,7 +31,7 @@ import { RoleEnum } from "src/Types&Enums/role.enum";
import { CreationMethod } from "./entities/schema/request-management.schema"; import { CreationMethod } from "./entities/schema/request-management.schema";
import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto"; import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto";
import { SendPartyOtpDto, VerifyPartyOtpDto } from "./dto/party-otp.dto"; import { SendPartyOtpDto, VerifyPartyOtpDto } from "./dto/party-otp.dto";
import { RunInquiriesV3Dto } from "./dto/run-inquiries-v3.dto"; import { RunInquiriesV3Dto, RunInquiriesVinV3Dto } from "./dto/run-inquiries-v3.dto";
import { import {
CarBodyFormDto, CarBodyFormDto,
DescriptionDto, DescriptionDto,
@@ -181,6 +181,23 @@ export class ExpertInitiatedBlameV3MirrorController {
return this.requestManagementService.runInquiriesV3(requestId, expert, dto); return this.requestManagementService.runInquiriesV3(requestId, expert, dto);
} }
@Post("run-inquiries-vin/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: RunInquiriesVinV3Dto })
@ApiOperation({
summary: "Run VIN/chassis inquiries for the current party (V3)",
description:
"VIN alternative to run-inquiries. Uses ESG chassis lookup instead of plate-based inquiry. " +
"1st call = guilty party (+ auto claim). 2nd call = damaged party (THIRD_PARTY, after guilty sign).",
})
async runInquiriesVin(
@CurrentUser() expert: any,
@Param("requestId") requestId: string,
@Body() dto: RunInquiriesVinV3Dto,
) {
return this.requestManagementService.runInquiriesVinV3(requestId, expert, dto);
}
@Post("add-detail-location/:requestId") @Post("add-detail-location/:requestId")
@ApiParam({ name: "requestId" }) @ApiParam({ name: "requestId" })
@ApiBody({ type: LocationDto }) @ApiBody({ type: LocationDto })

View File

@@ -27,6 +27,7 @@ import { MediaPolicyService } from "src/media-policy/media-policy.service";
import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service"; import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto"; import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
import { InitialFormVinDto } from "./dto/create-request-management.dto";
import { import {
BlameConfessionDtoV2, BlameConfessionDtoV2,
CarBodyFormDto, CarBodyFormDto,
@@ -181,6 +182,29 @@ export class ExpertInitiatedBlameMirrorController {
); );
} }
@Post("/run-inquiries-vin/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: InitialFormVinDto })
@ApiOperation({
summary: "[Expert mirror] Initial form via VIN/chassis inquiry for current party",
description:
"VIN alternative to run-inquiries. Uses ESG chassis lookup instead of plate-based inquiry. " +
"Fills the FIRST or SECOND party insurance/vehicle data depending on the current workflow step.",
})
async initialFormVin(
@Param("requestId") requestId: string,
@Body() body: InitialFormVinDto,
@CurrentUser() expert: any,
@Body("partyRole") partyRole?: string,
) {
return this.requestManagementService.initialFormVinV2(
requestId,
body,
expert,
partyRole,
);
}
@ApiBody({ @ApiBody({
schema: { schema: {
type: "object", type: "object",

View File

@@ -30,7 +30,7 @@ import { RoleEnum } from "src/Types&Enums/role.enum";
import { CreationMethod } from "./entities/schema/request-management.schema"; import { CreationMethod } from "./entities/schema/request-management.schema";
import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto"; import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto";
import { SendPartyOtpDto, VerifyPartyOtpDto } from "./dto/party-otp.dto"; import { SendPartyOtpDto, VerifyPartyOtpDto } from "./dto/party-otp.dto";
import { RunInquiriesV3Dto } from "./dto/run-inquiries-v3.dto"; import { RunInquiriesV3Dto, RunInquiriesVinV3Dto } from "./dto/run-inquiries-v3.dto";
import { import {
CarBodyFormDto, CarBodyFormDto,
DescriptionDto, DescriptionDto,
@@ -193,6 +193,27 @@ export class FileMakerBlameV4Controller {
); );
} }
@Post("run-inquiries-vin/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: RunInquiriesVinV3Dto })
@ApiOperation({
summary: "Run VIN/chassis inquiries for the current party (V4)",
description:
"VIN alternative to run-inquiries. Uses ESG chassis lookup instead of plate-based inquiry. " +
"1st call = guilty party (+ auto claim). 2nd call = damaged party (THIRD_PARTY, after guilty sign).",
})
async runInquiriesVin(
@CurrentUser() fileMaker: any,
@Param("requestId") requestId: string,
@Body() dto: RunInquiriesVinV3Dto,
) {
return this.requestManagementService.runInquiriesVinV3(
requestId,
fileMaker,
dto,
);
}
// ─── Party details ──────────────────────────────────────────────────────────── // ─── Party details ────────────────────────────────────────────────────────────
@Post("add-detail-location/:requestId") @Post("add-detail-location/:requestId")

View File

@@ -30,7 +30,7 @@ import { RoleEnum } from "src/Types&Enums/role.enum";
import { CreationMethod } from "./entities/schema/request-management.schema"; import { CreationMethod } from "./entities/schema/request-management.schema";
import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto"; import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto";
import { SendPartyOtpDto, VerifyPartyOtpDto } from "./dto/party-otp.dto"; import { SendPartyOtpDto, VerifyPartyOtpDto } from "./dto/party-otp.dto";
import { RunInquiriesV3Dto } from "./dto/run-inquiries-v3.dto"; import { RunInquiriesV3Dto, RunInquiriesVinV3Dto } from "./dto/run-inquiries-v3.dto";
import { import {
CarBodyFormDto, CarBodyFormDto,
DescriptionDto, DescriptionDto,
@@ -192,6 +192,27 @@ export class FileMakerBlameV5Controller {
); );
} }
@Post("run-inquiries-vin/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: RunInquiriesVinV3Dto })
@ApiOperation({
summary: "Run VIN/chassis inquiries for the current party (V5)",
description:
"VIN alternative to run-inquiries. Uses ESG chassis lookup instead of plate-based inquiry. " +
"1st call = guilty party (+ auto claim). 2nd call = damaged party (THIRD_PARTY, after guilty sign).",
})
async runInquiriesVin(
@CurrentUser() fileMaker: any,
@Param("requestId") requestId: string,
@Body() dto: RunInquiriesVinV3Dto,
) {
return this.requestManagementService.runInquiriesVinV3(
requestId,
fileMaker,
dto,
);
}
// ─── Party details ──────────────────────────────────────────────────────────── // ─── Party details ────────────────────────────────────────────────────────────
@Post("add-detail-location/:requestId") @Post("add-detail-location/:requestId")

View File

@@ -10,6 +10,7 @@ import {
ApiBody, ApiBody,
ApiOperation, ApiOperation,
ApiParam, ApiParam,
ApiResponse,
ApiTags, ApiTags,
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import { IsOptional, IsString } from "class-validator"; import { IsOptional, IsString } from "class-validator";
@@ -91,9 +92,28 @@ export class FileMakerClaimApprovalV5Controller {
@ApiOperation({ @ApiOperation({
summary: "Reject the completed claim back to FileReviewer (FileMaker V5)", summary: "Reject the completed claim back to FileReviewer (FileMaker V5)",
description: description:
"Rejects a claim that is in WAITING_FOR_FILE_MAKER_APPROVAL status. " + "Rejects a claim that is in `WAITING_FOR_FILE_MAKER_APPROVAL` status. " +
"Moves claim to FILE_MAKER_REJECTED so the FileReviewer can re-lock, " + "Moves the claim back to `WAITING_FOR_DAMAGE_EXPERT` and the linked blame back to " +
"adjust the damage assessment, and restart user interaction as needed.", "`WAITING_FOR_FILE_REVIEWER` so the FileReviewer can re-lock, adjust the damage " +
"assessment, and restart user interaction as needed.\n\n" +
"**Rejection limit:** the FileMaker may reject at most **2 times** per claim. " +
"On the 3rd attempt the endpoint returns **422** with " +
"`errorCode: FILE_MAKER_REJECTION_LIMIT_EXCEEDED` — the FileMaker must approve instead. " +
"The current count is returned as `fileMakerRejectionCount` in the response and in " +
"`GET v2/expert-claim/requests`.",
})
@ApiResponse({
status: 422,
description:
"Business rule violation: rejection limit exceeded. " +
"`errorCode: FILE_MAKER_REJECTION_LIMIT_EXCEEDED` — the claim has already been rejected twice.",
schema: {
example: {
errorCode: "FILE_MAKER_REJECTION_LIMIT_EXCEEDED",
message:
"This claim has already been rejected twice. You must approve it now — no further rejections are allowed.",
},
},
}) })
async reject( async reject(
@Param("claimRequestId") claimRequestId: string, @Param("claimRequestId") claimRequestId: string,

View File

@@ -1,11 +1,15 @@
import { extname } from "node:path"; import { extname } from "node:path";
import { import {
BadRequestException,
Body, Body,
Controller, Controller,
Get, Get,
HttpException,
InternalServerErrorException,
Param, Param,
Patch, Patch,
Post, Post,
Put,
UploadedFile, UploadedFile,
UseGuards, UseGuards,
UseInterceptors, UseInterceptors,
@@ -316,6 +320,73 @@ export class FileReviewerBlameV4Controller {
); );
} }
// ─── Owner signature on expert pricing ───────────────────────────────────────
@Put("claim-sign/:claimRequestId")
@ApiParam({ name: "claimRequestId" })
@ApiConsumes("multipart/form-data")
@ApiBody({
description: "Signature file, agreement, and branch",
schema: {
type: "object",
required: ["sign", "agree", "branchId"],
properties: {
sign: { type: "string", format: "binary", description: "Signature image" },
agree: { type: "boolean", description: "true to accept, false to reject" },
branchId: { type: "string", description: "Insurer branch ID" },
},
},
})
@ApiOperation({
summary: "Owner signature on expert pricing (V4 — FileReviewer acts on behalf of user)",
description:
"FileReviewer submits the damaged party's signature during the final approval stage. " +
"Delegates to the same service method as the user sign endpoint; the FileReviewer's " +
"identity is resolved to the claim owner via `resolveClaimEffectiveUserId`.",
})
@UseInterceptors(
FileInterceptor("sign", {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({
destination: "./files/claim-sign",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
const base = file.originalname.split(/[.,\s-]/)[0] || "sign";
callback(null, `${base}-${unique}${ex}`);
},
}),
}),
)
async submitOwnerSign(
@Param("claimRequestId") claimRequestId: string,
@Body("agree") agree: string | boolean,
@Body("branchId") branchId: string,
@CurrentUser() fileReviewer: any,
@UploadedFile() sign: Express.Multer.File,
) {
await this.mediaPolicyService.assertForClaim(sign, claimRequestId, "image");
const agreed =
typeof agree === "string"
? agree === "true" || agree === "1"
: Boolean(agree);
try {
return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2(
claimRequestId,
agreed,
typeof branchId === "string" ? branchId : "",
sign,
fileReviewer.sub,
fileReviewer,
);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to submit signature",
);
}
}
// ─── Walk-around video ──────────────────────────────────────────────────────── // ─── Walk-around video ────────────────────────────────────────────────────────
@Patch("car-capture/:claimRequestId") @Patch("car-capture/:claimRequestId")

View File

@@ -3,9 +3,12 @@ import {
Body, Body,
Controller, Controller,
Get, Get,
HttpException,
InternalServerErrorException,
Param, Param,
Patch, Patch,
Post, Post,
Put,
UploadedFile, UploadedFile,
UseGuards, UseGuards,
UseInterceptors, UseInterceptors,
@@ -315,6 +318,73 @@ export class FileReviewerBlameV5Controller {
); );
} }
// ─── Owner signature on expert pricing ───────────────────────────────────────
@Put("claim-sign/:claimRequestId")
@ApiParam({ name: "claimRequestId" })
@ApiConsumes("multipart/form-data")
@ApiBody({
description: "Signature file, agreement, and branch",
schema: {
type: "object",
required: ["sign", "agree", "branchId"],
properties: {
sign: { type: "string", format: "binary", description: "Signature image" },
agree: { type: "boolean", description: "true to accept, false to reject" },
branchId: { type: "string", description: "Insurer branch ID" },
},
},
})
@ApiOperation({
summary: "Owner signature on expert pricing (V5 — FileReviewer acts on behalf of user)",
description:
"FileReviewer submits the damaged party's signature during the final approval stage. " +
"Delegates to the same service method as the user sign endpoint; the FileReviewer's " +
"identity is resolved to the claim owner via `resolveClaimEffectiveUserId`.",
})
@UseInterceptors(
FileInterceptor("sign", {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({
destination: "./files/claim-sign",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
const base = file.originalname.split(/[.,\s-]/)[0] || "sign";
callback(null, `${base}-${unique}${ex}`);
},
}),
}),
)
async submitOwnerSign(
@Param("claimRequestId") claimRequestId: string,
@Body("agree") agree: string | boolean,
@Body("branchId") branchId: string,
@CurrentUser() fileReviewer: any,
@UploadedFile() sign: Express.Multer.File,
) {
await this.mediaPolicyService.assertForClaim(sign, claimRequestId, "image");
const agreed =
typeof agree === "string"
? agree === "true" || agree === "1"
: Boolean(agree);
try {
return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2(
claimRequestId,
agreed,
typeof branchId === "string" ? branchId : "",
sign,
fileReviewer.sub,
fileReviewer,
);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to submit signature",
);
}
}
// ─── Walk-around video ──────────────────────────────────────────────────────── // ─── Walk-around video ────────────────────────────────────────────────────────
@Patch("car-capture/:claimRequestId") @Patch("car-capture/:claimRequestId")

View File

@@ -7,6 +7,7 @@ import {
import { Types } from "mongoose"; import { Types } from "mongoose";
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service"; import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
import { SandHubService } from "src/sand-hub/sand-hub.service"; import { SandHubService } from "src/sand-hub/sand-hub.service";
import { PlateNormalizerService } from "src/utils/plate-normalizer/plate-normalizer.service";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { BlameRequestDbService } from "./entities/db-service/blame-request.db.service"; import { BlameRequestDbService } from "./entities/db-service/blame-request.db.service";
import { PartyRole } from "./entities/schema/partyRole.enum"; import { PartyRole } from "./entities/schema/partyRole.enum";
@@ -33,6 +34,7 @@ export class InquiryRefreshService {
private readonly blameRequestDbService: BlameRequestDbService, private readonly blameRequestDbService: BlameRequestDbService,
private readonly claimCaseDbService: ClaimCaseDbService, private readonly claimCaseDbService: ClaimCaseDbService,
private readonly sandHubService: SandHubService, private readonly sandHubService: SandHubService,
private readonly plateNormalizer: PlateNormalizerService,
) {} ) {}
async reinquiryInquiries( async reinquiryInquiries(
@@ -592,7 +594,7 @@ export class InquiryRefreshService {
centerDigits !== undefined && centerDigits !== undefined &&
ir !== undefined ir !== undefined
) { ) {
const plateLetter = String(centerAlphabet).trim(); const plateLetter = this.plateNormalizer.normalizePlateText(String(centerAlphabet));
const parsed: PlateParts = { const parsed: PlateParts = {
leftDigits: Number(leftDigits), leftDigits: Number(leftDigits),
centerAlphabet: plateLetter, centerAlphabet: plateLetter,
@@ -622,8 +624,7 @@ export class InquiryRefreshService {
const ir = Number(irRaw); const ir = Number(irRaw);
const leftDigits = Number(leftRaw); const leftDigits = Number(leftRaw);
const centerDigits = Number(centerRaw); const centerDigits = Number(centerRaw);
const centerAlphabet = String(alphaRaw || "").trim(); const centerAlphabet = this.plateNormalizer.normalizePlateText(String(alphaRaw || ""));
if ( if (
!Number.isFinite(ir) || !Number.isFinite(ir) ||
!Number.isFinite(leftDigits) || !Number.isFinite(leftDigits) ||

View File

@@ -28,6 +28,7 @@ import { MediaPolicyService } from "src/media-policy/media-policy.service";
import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service"; import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto"; import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
import { InitialFormVinDto } from "./dto/create-request-management.dto";
import { import {
CarBodyFormDto, CarBodyFormDto,
DescriptionDto, DescriptionDto,
@@ -165,6 +166,29 @@ export class RegistrarBlameMirrorController {
); );
} }
@Post("/initial-form-vin/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: InitialFormVinDto })
@ApiOperation({
summary: "[Registrar mirror] Initial form via VIN/chassis inquiry for current party",
description:
"VIN alternative to initial-form. Uses ESG chassis lookup instead of plate-based inquiry. " +
"Fills the FIRST or SECOND party insurance/vehicle data depending on the current workflow step.",
})
async initialFormVin(
@Param("requestId") requestId: string,
@Body() body: InitialFormVinDto,
@CurrentUser() registrar: any,
@Body("partyRole") partyRole?: string,
) {
return this.requestManagementService.initialFormVinV2(
requestId,
body,
registrar,
partyRole,
);
}
@ApiBody({ @ApiBody({
schema: { schema: {
type: "object", type: "object",

View File

@@ -12,6 +12,7 @@ import { UsersModule } from "src/users/users.module";
import { CronModule } from "src/utils/cron/cron.module"; import { CronModule } from "src/utils/cron/cron.module";
import { PublicIdModule } from "src/utils/public-id/public-id.module"; import { PublicIdModule } from "src/utils/public-id/public-id.module";
import { HashModule } from "src/utils/hash/hash.module"; import { HashModule } from "src/utils/hash/hash.module";
import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normalizer.module";
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module"; import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
import { WorkflowStepManagementModule } from "src/workflow-step-management/workflow-step-management.module"; import { WorkflowStepManagementModule } from "src/workflow-step-management/workflow-step-management.module";
import { AuthModule } from "src/auth/auth.module"; import { AuthModule } from "src/auth/auth.module";
@@ -50,6 +51,7 @@ import { FileReviewerBlameV5Controller } from "./file-reviewer-blame-v5.controll
import { FileMakerClaimApprovalV5Controller } from "./file-maker-claim-approval-v5.controller"; import { FileMakerClaimApprovalV5Controller } from "./file-maker-claim-approval-v5.controller";
import { InquiryRefreshController } from "./inquiry-refresh.controller"; import { InquiryRefreshController } from "./inquiry-refresh.controller";
import { InquiryRefreshService } from "./inquiry-refresh.service"; import { InquiryRefreshService } from "./inquiry-refresh.service";
import { CallCenterBlameV6Controller } from "./call-center-blame-v6.controller";
@Module({ @Module({
imports: [ imports: [
@@ -59,6 +61,7 @@ import { InquiryRefreshService } from "./inquiry-refresh.service";
SmsOrchestrationModule, SmsOrchestrationModule,
PublicIdModule, PublicIdModule,
HashModule, HashModule,
PlateNormalizerModule,
WorkflowStepManagementModule, WorkflowStepManagementModule,
PlatesModule, PlatesModule,
MulterModule.register({ MulterModule.register({
@@ -92,6 +95,7 @@ import { InquiryRefreshService } from "./inquiry-refresh.service";
FileReviewerBlameV5Controller, FileReviewerBlameV5Controller,
FileMakerClaimApprovalV5Controller, FileMakerClaimApprovalV5Controller,
InquiryRefreshController, InquiryRefreshController,
CallCenterBlameV6Controller,
], ],
providers: [ providers: [
RequestManagementService, RequestManagementService,

View File

@@ -42,7 +42,7 @@ import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatu
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum"; import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
import { resolveClaimOwnerFieldsFromBlame } from "src/helpers/blame-damaged-party"; import { resolveClaimOwnerFieldsFromBlame } from "src/helpers/blame-damaged-party";
import { ExpertAccidentFieldsDto } from "./dto/expert-accident-fields.dto"; import { ExpertAccidentFieldsDto } from "./dto/expert-accident-fields.dto";
import { RunInquiriesV3Dto } from "./dto/run-inquiries-v3.dto"; import { RunInquiriesV3Dto, RunInquiriesVinV3Dto } from "./dto/run-inquiries-v3.dto";
import { SandHubService } from "src/sand-hub/sand-hub.service"; import { SandHubService } from "src/sand-hub/sand-hub.service";
import { ReqBlameStatus } from "src/Types&Enums/blame-request-management/status.enum"; import { ReqBlameStatus } from "src/Types&Enums/blame-request-management/status.enum";
import { StepsEnum } from "src/Types&Enums/blame-request-management/steps.enum"; import { StepsEnum } from "src/Types&Enums/blame-request-management/steps.enum";
@@ -84,6 +84,7 @@ import { WorkflowStepDbService } from "src/workflow-step-management/entities/db-
import { WorkflowStepModel } from "src/workflow-step-management/entities/schema/workflow-step.schema"; import { WorkflowStepModel } from "src/workflow-step-management/entities/schema/workflow-step.schema";
import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatus.enum"; import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatus.enum";
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum"; import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
import { BusinessRuleException, BusinessErrorCode } from "src/expert-claim/exceptions/business-rule.exception";
import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service"; import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service";
import { import {
ExpertFileActivityType, ExpertFileActivityType,
@@ -2319,6 +2320,7 @@ export class RequestManagementService {
const url = this.smsOrchestrationService.buildInviteLink( const url = this.smsOrchestrationService.buildInviteLink(
frontendRoute, frontendRoute,
requestId, requestId,
"v1",
); );
await this.smsOrchestrationService.sendInviteLink( await this.smsOrchestrationService.sendInviteLink(
secondPartyPhone, secondPartyPhone,
@@ -2372,6 +2374,7 @@ export class RequestManagementService {
const url = this.smsOrchestrationService.buildInviteLink( const url = this.smsOrchestrationService.buildInviteLink(
frontendRoute, frontendRoute,
requestId, requestId,
"v1",
); );
await this.smsOrchestrationService.sendInviteLink( await this.smsOrchestrationService.sendInviteLink(
phoneNumber, phoneNumber,
@@ -3485,6 +3488,7 @@ export class RequestManagementService {
const URL = this.smsOrchestrationService.buildInviteLink( const URL = this.smsOrchestrationService.buildInviteLink(
frontendRoutes, frontendRoutes,
requestId, requestId,
"v1",
); );
await this.smsOrchestrationService.sendInviteLink( await this.smsOrchestrationService.sendInviteLink(
phoneNumber, phoneNumber,
@@ -4927,6 +4931,7 @@ export class RequestManagementService {
const firstLink = this.smsOrchestrationService.buildBlamePartyLink( const firstLink = this.smsOrchestrationService.buildBlamePartyLink(
requestId, requestId,
"FIRST", "FIRST",
"v2",
); );
const smsSent = await this.smsOrchestrationService.sendFieldExpertLink({ const smsSent = await this.smsOrchestrationService.sendFieldExpertLink({
receptor: phone, receptor: phone,
@@ -8365,6 +8370,7 @@ export class RequestManagementService {
targetPhone === request?.parties[0]?.person.phoneNumber targetPhone === request?.parties[0]?.person.phoneNumber
? "FIRST" ? "FIRST"
: "SECOND", : "SECOND",
"v1",
); );
await this.smsOrchestrationService.sendThirdPartyDamagedPartyClaimLinkNotice( await this.smsOrchestrationService.sendThirdPartyDamagedPartyClaimLinkNotice(
{ {
@@ -9148,6 +9154,399 @@ export class RequestManagementService {
}; };
} }
/**
* VIN variant of `runInquiriesV3`.
* Identical flow, but calls `getPolicyByChassisInquiry` instead of
* `getTejaratBlockInquiry` for the primary policy lookup.
*/
async runInquiriesVinV3(
requestId: string,
actor: any,
dto: RunInquiriesVinV3Dto,
): Promise<{
blameRequestId: string;
partyRole: PartyRole;
claimRequestId?: string;
claimPublicId?: string;
message: string;
}> {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Blame request not found");
this.assertBlameV3ExpertInPerson(req);
await this.verifyExpertAccessForBlameV2(req, actor);
const partyRole = this.resolveV3InquiryPartyRole(req);
if (!partyRole) {
throw new ConflictException("All party inquiries are already complete.");
}
if (partyRole === PartyRole.FIRST) {
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
if (firstIdx === -1)
throw new BadRequestException("First party not found");
const firstParty = req.parties[firstIdx];
if (!firstParty?.person?.userId) {
throw new BadRequestException(
"Guilty party OTP must be verified before running inquiries.",
);
}
if (req.type === BlameRequestType.CAR_BODY) {
const form = firstParty.carBodyFirstForm as any;
if (form?.car == null && form?.object == null) {
throw new BadRequestException(
"Submit car-body-form before running inquiries (CAR_BODY).",
);
}
}
await this.runPartyInquiriesVinV3Internal(
req,
dto,
PartyRole.FIRST,
firstParty,
);
if (req.type === BlameRequestType.CAR_BODY) {
await this.validateShebaV3(
dto as any,
firstParty.person?.clientId?.toString(),
);
}
this.markPartyInquiriesCompleteOnBlame(req, PartyRole.FIRST, actor);
await (req as any).save();
const nationalCode =
dto.nationalCodeOfInsurer || dto.nationalCodeOfDriver || "";
const newClaim = await this.createV3ClaimFromBlame(req, actor, {
sheba: req.type === BlameRequestType.CAR_BODY ? dto.sheba : undefined,
nationalCode:
req.type === BlameRequestType.CAR_BODY ? nationalCode : undefined,
interimThirdParty: req.type === BlameRequestType.THIRD_PARTY,
});
return {
blameRequestId: requestId,
partyRole: PartyRole.FIRST,
claimRequestId: String(newClaim._id),
claimPublicId: req.publicId,
message:
"Guilty-party inquiries complete and claim created. Proceed with location, description, voice, and signature.",
};
}
// Damaged party (THIRD_PARTY only)
if (!this.partyHasSigned(req, PartyRole.FIRST)) {
throw new BadRequestException(
"Guilty party must sign before running damaged-party inquiries.",
);
}
const secondIdx = this.getPartyIndex(req, PartyRole.SECOND);
if (secondIdx === -1 || !req.parties[secondIdx]?.person?.userId) {
throw new BadRequestException(
"Damaged party OTP must be verified before running inquiries.",
);
}
const secondParty = req.parties[secondIdx];
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
const firstUserId =
firstIdx !== -1 ? req.parties[firstIdx]?.person?.userId : null;
const secondUserId = secondParty.person?.userId;
if (
firstUserId &&
secondUserId &&
String(firstUserId) === String(secondUserId)
) {
throw new BadRequestException(
"Guilty and damaged parties are bound to the same user account. " +
"Re-verify the second party OTP using a different phone number.",
);
}
await this.runPartyInquiriesVinV3Internal(
req,
dto,
PartyRole.SECOND,
secondParty,
);
await this.validateShebaV3(
dto as any,
secondParty.person?.clientId?.toString(),
);
this.ensureV3GuiltyPartyDecision(req);
if (typeof (req as any).markModified === "function") {
(req as any).markModified("parties");
}
await (req as any).save();
await this.syncV3ClaimDamagedPartyFromBlame(req, dto as any);
this.markPartyInquiriesCompleteOnBlame(req, PartyRole.SECOND, actor);
await (req as any).save();
return {
blameRequestId: requestId,
partyRole: PartyRole.SECOND,
message:
"Damaged-party inquiries complete. Proceed with location, description, voice, and signature.",
};
}
/**
* VIN variant of `runPartyInquiriesV3Internal`.
* Calls `getPolicyByChassisInquiry` (ESG chassis lookup) instead of
* `getTejaratBlockInquiry` (plate-based). All other inquiries (personal,
* driving licence, car-body for CAR_BODY) are unchanged.
*/
private async runPartyInquiriesVinV3Internal(
req: any,
partyData: RunInquiriesVinV3Dto,
partyRole: PartyRole,
party: any,
): Promise<{ clientId?: string }> {
const roleLabel = partyRole === PartyRole.FIRST ? "FIRST" : "SECOND";
let clientId: string | undefined;
const inquiryOptions = (cid?: string) =>
cid ? { clientId: cid } : undefined;
let inquiryRaw: any;
let inquiryMapped: any;
try {
const inquiry = await this.sandHubService.getPolicyByChassisInquiry(
partyData.vin,
inquiryOptions(party?.person?.clientId?.toString()),
);
inquiryRaw = inquiry.raw;
inquiryMapped = inquiry.mapped;
this.recordPartyCaseInquiryStatus(req, "thirdParty", partyRole, true, {
source: "ESG_VIN_INQUIRY",
raw: inquiryRaw,
mapped: inquiryMapped,
});
} catch (err: any) {
this.logger.error(
`[V3-VIN] vin inquiry failed for ${roleLabel} party (request=${req._id}): ${err?.message || err}`,
);
this.recordPartyCaseInquiryStatus(
req,
"thirdParty",
partyRole,
false,
{},
err,
);
throw new BadRequestException(
`${roleLabel} party VIN inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`,
);
}
if (inquiryMapped?.Error) {
throw new BadRequestException(
inquiryMapped.Error.Message ||
`${roleLabel} party VIN inquiry returned an error`,
);
}
const clientName = inquiryMapped?.CompanyName;
const companyCode = inquiryMapped?.CompanyCode;
if (!clientName) {
throw new BadRequestException(
`CompanyName missing from ${roleLabel} party VIN inquiry response`,
);
}
const client = await this.clientService.findOrCreateClientByCompanyCode(
companyCode,
clientName,
);
if (!client) {
throw new BadRequestException(
`CompanyCode missing or invalid in ${roleLabel} party VIN inquiry response`,
);
}
const resolvedClientId = (client as any)?._id ?? (client as any)?._doc?._id;
clientId = resolvedClientId ? String(resolvedClientId) : undefined;
if (!party.person) party.person = {} as any;
party.person.clientId = resolvedClientId;
party.person.nationalCodeOfInsurer = partyData.nationalCodeOfInsurer;
party.person.nationalCodeOfDriver = partyData.nationalCodeOfDriver;
party.person.driverIsInsurer = partyData.driverIsInsurer;
party.person.insurerBirthday = partyData.insurerBirthday;
party.person.driverBirthday =
partyData.driverBirthday ??
(partyData.driverIsInsurer ? String(partyData.insurerBirthday) : null);
if (partyData.insurerLicense)
party.person.insurerLicense = partyData.insurerLicense;
if (partyData.driverLicense)
party.person.driverLicense = partyData.driverLicense;
if (!party.vehicle) party.vehicle = {} as any;
party.vehicle.plateId = partyData.vin; // store VIN as vehicle identifier
party.vehicle.name = inquiryMapped?.MapTypNam;
party.vehicle.type = `${inquiryMapped?.UsageField ?? ""} / ${inquiryMapped?.UsageName ?? inquiryMapped?.MapUsageName ?? "-"}`;
party.vehicle.inquiry = {
source: "ESG_VIN_INQUIRY",
raw: inquiryRaw,
mapped: inquiryMapped,
};
if (!party.insurance) party.insurance = {} as any;
party.insurance.policyNumber = inquiryMapped?.PrntPlcyCmpDocNo;
party.insurance.company = clientName;
party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl;
party.insurance.startDate = inquiryMapped?.IssueDate;
party.insurance.endDate = inquiryMapped?.EndDate;
if (
req.type === BlameRequestType.CAR_BODY &&
partyRole === PartyRole.FIRST
) {
try {
const carBodyInfo = await this.sandHubService.getTejaratCarBodyInquiry(
{
nationalCodeOfInsurer: partyData.nationalCodeOfInsurer,
plate: partyData.vin as any, // VIN used as identifier for CAR_BODY
},
clientId ? { clientId } : undefined,
);
this.recordPartyCaseInquiryStatus(req, "carBody", partyRole, true, {
source: "TEJARAT_CAR_BODY_VIN_INQUIRY",
raw: carBodyInfo.raw,
mapped: carBodyInfo.mapped,
});
party.vehicle.inquiry = {
...party.vehicle.inquiry,
carBody: {
source: "TEJARAT_CAR_BODY_VIN_INQUIRY",
raw: carBodyInfo.raw,
mapped: carBodyInfo.mapped,
},
};
const m = carBodyInfo.mapped;
(party.insurance as any).carBodyInsurance = {
policyNumber: m.policyNumber ?? null,
companyId: m.companyId ?? null,
companyName: m.CompanyName ?? null,
};
const cbCompanyCode = m.companyId ?? m.CompanyCode;
const cbCompanyName = m.CompanyName ?? m.companyPersianName;
if (cbCompanyCode && cbCompanyName) {
const cbClient =
await this.clientService.findOrCreateClientByCompanyCode(
Number(cbCompanyCode),
String(cbCompanyName),
);
const cbClientId =
(cbClient as any)?._id ?? (cbClient as any)?._doc?._id;
if (cbClientId) {
party.person.clientId = cbClientId;
clientId = String(cbClientId);
}
}
} catch (err: any) {
this.logger.error(
`[V3-VIN] car body inquiry failed for request=${req._id}: ${err?.message || err}`,
);
this.recordPartyCaseInquiryStatus(
req,
"carBody",
partyRole,
false,
{},
err,
);
throw new BadRequestException(
`CAR_BODY VIN inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`,
);
}
}
const nationalCode =
partyData.nationalCodeOfInsurer || partyData.nationalCodeOfDriver;
const birthDate = partyData.insurerBirthday ?? partyData.driverBirthday;
if (nationalCode && birthDate != null) {
try {
const personalInquiry = await this.sandHubService.getPersonalInquiry(
nationalCode,
birthDate as string | number,
clientId ? { clientId } : undefined,
);
this.recordPartyCaseInquiryStatus(
req,
"person",
partyRole,
true,
personalInquiry,
);
} catch (err: any) {
this.logger.error(
`[V3-VIN] personal inquiry failed for ${roleLabel} party request=${req._id}: ${err?.message || err}`,
);
this.recordPartyCaseInquiryStatus(
req,
"person",
partyRole,
false,
{},
err,
);
throw new BadRequestException(
`${roleLabel} party personal identity inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`,
);
}
}
const licenseNationalCode = partyData.driverIsInsurer
? partyData.nationalCodeOfInsurer
: partyData.nationalCodeOfDriver;
const licenseNumber = partyData.driverIsInsurer
? partyData.insurerLicense
: partyData.driverLicense;
if (!licenseNumber) {
this.recordPartyCaseInquiryStatus(
req,
"drivingLicence",
partyRole,
false,
{ skipped: true, reason: "No license number provided" },
);
} else {
try {
const licenseInquiry = await this.sandHubService.getDrivingLicenseInfo(
licenseNationalCode,
licenseNumber,
clientId ? { clientId } : undefined,
);
this.recordPartyCaseInquiryStatus(
req,
"drivingLicence",
partyRole,
true,
licenseInquiry,
);
} catch (err: any) {
this.logger.error(
`[V3-VIN] driving license inquiry failed for ${roleLabel} party request=${req._id}: ${err?.message || err}`,
);
this.recordPartyCaseInquiryStatus(
req,
"drivingLicence",
partyRole,
false,
{},
err,
);
throw new BadRequestException(
`${roleLabel} party driving license inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`,
);
}
}
return { clientId };
}
private assertBlameV3PartyDetailPhase(req: any, partyRole: PartyRole): void { private assertBlameV3PartyDetailPhase(req: any, partyRole: PartyRole): void {
this.assertBlameV3ExpertInPerson(req); this.assertBlameV3ExpertInPerson(req);
if (partyRole === PartyRole.FIRST) { if (partyRole === PartyRole.FIRST) {
@@ -9368,16 +9767,26 @@ export class RequestManagementService {
}, },
}; };
if (typeof (req as any).markModified === "function") {
(req as any).markModified("expert");
}
if (!Array.isArray(req.history)) req.history = []; if (!Array.isArray(req.history)) req.history = [];
req.history.push({ // Avoid duplicate history entries on idempotent retries.
type: "V3_ACCIDENT_FIELDS_SAVED", const alreadyRecorded = (req.history as any[]).some(
actor: { (e: any) => e?.type === "V3_ACCIDENT_FIELDS_SAVED",
actorId: new Types.ObjectId(String(expert.sub)), );
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), if (!alreadyRecorded) {
actorType: "field_expert", req.history.push({
}, type: "V3_ACCIDENT_FIELDS_SAVED",
metadata: { accidentWay: fields.accidentWay }, actor: {
} as any); actorId: new Types.ObjectId(String(expert.sub)),
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
},
metadata: { accidentWay: fields.accidentWay },
} as any);
}
await (req as any).save(); await (req as any).save();
@@ -9952,7 +10361,17 @@ export class RequestManagementService {
this.assertFileMakerApprovalAccess(claim, fileMaker); this.assertFileMakerApprovalAccess(claim, fileMaker);
if (claim.status !== ClaimCaseStatus.WAITING_FOR_FILE_MAKER_APPROVAL) { // After 2 rejections the claim is in WAITING_FOR_DAMAGE_EXPERT (forced-approve
// path); any other status is still an error.
const rejectionCount = (claim as any).fileMakerRejectionCount ?? 0;
const forcedApprove =
rejectionCount >= 2 &&
claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT;
if (
claim.status !== ClaimCaseStatus.WAITING_FOR_FILE_MAKER_APPROVAL &&
!forcedApprove
) {
throw new BadRequestException( throw new BadRequestException(
`Claim must be in WAITING_FOR_FILE_MAKER_APPROVAL status to approve. Current: ${claim.status}`, `Claim must be in WAITING_FOR_FILE_MAKER_APPROVAL status to approve. Current: ${claim.status}`,
); );
@@ -9996,11 +10415,14 @@ export class RequestManagementService {
} }
/** /**
* V5: FileMaker rejects the completed claim back to expert review. * V5: FileMaker rejects the completed claim back to FileReviewer for correction.
* *
* Moves claim from WAITING_FOR_FILE_MAKER_APPROVAL → WAITING_FOR_DAMAGE_EXPERT * Allowed at most 2 times per claim lifetime. On the 3rd attempt the endpoint
* so the FileReviewer can re-lock it and adjust the damage assessment or * returns 422 FILE_MAKER_REJECTION_LIMIT_EXCEEDED and the FileMaker must approve.
* restart the back-and-forth with the user. *
* Transition (both blame + claim are updated atomically):
* claim: WAITING_FOR_FILE_MAKER_APPROVAL → WAITING_FOR_DAMAGE_EXPERT
* blame: (any status) → WAITING_FOR_FILE_REVIEWER
*/ */
async fileMakerRejectV5( async fileMakerRejectV5(
fileMaker: any, fileMaker: any,
@@ -10010,6 +10432,7 @@ export class RequestManagementService {
claimRequestId: string; claimRequestId: string;
publicId: string; publicId: string;
status: string; status: string;
fileMakerRejectionCount: number;
message: string; message: string;
}> { }> {
const claim = await this.claimCaseDbService.findById(claimRequestId); const claim = await this.claimCaseDbService.findById(claimRequestId);
@@ -10023,14 +10446,26 @@ export class RequestManagementService {
); );
} }
const currentRejections = (claim as any).fileMakerRejectionCount ?? 0;
if (currentRejections >= 2) {
throw new BusinessRuleException(
BusinessErrorCode.FILE_MAKER_REJECTION_LIMIT_EXCEEDED,
"This claim has already been rejected twice. You must approve it now — no further rejections are allowed.",
);
}
const newRejectionCount = currentRejections + 1;
const actorName = `${fileMaker.firstName || ""} ${fileMaker.lastName || ""}`.trim(); const actorName = `${fileMaker.firstName || ""} ${fileMaker.lastName || ""}`.trim();
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: { $set: {
status: ClaimCaseStatus.FILE_MAKER_REJECTED, status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
claimStatus: ClaimStatus.NEEDS_REVISION, claimStatus: ClaimStatus.NEEDS_REVISION,
fileMakerRejectionCount: newRejectionCount,
fileMakerRejectionReason: reason ?? null,
"workflow.currentStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, "workflow.currentStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
"workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, "workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
"workflow.locked": false,
}, },
$push: { $push: {
history: { history: {
@@ -10041,17 +10476,43 @@ export class RequestManagementService {
actorType: "file_maker", actorType: "file_maker",
}, },
timestamp: new Date(), timestamp: new Date(),
metadata: { reason: reason ?? null }, metadata: { reason: reason ?? null, rejectionCount: newRejectionCount },
}, },
}, },
}); });
// Reset blame back to WAITING_FOR_FILE_REVIEWER so the reviewer's list
// query (which looks for that status + assignedFileReviewerId) surfaces it.
if (claim.blameRequestId) {
await this.blameRequestDbService.findByIdAndUpdate(
String(claim.blameRequestId),
{
$set: { status: CaseStatus.WAITING_FOR_FILE_REVIEWER },
$push: {
history: {
type: "V5_FILE_MAKER_REJECTED_BLAME_RESET",
actor: {
actorId: new Types.ObjectId(fileMaker.sub),
actorName,
actorType: "file_maker",
},
timestamp: new Date(),
metadata: { claimRequestId, reason: reason ?? null },
},
},
},
);
}
return { return {
claimRequestId, claimRequestId,
publicId: claim.publicId, publicId: claim.publicId,
status: ClaimCaseStatus.FILE_MAKER_REJECTED, status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
fileMakerRejectionCount: newRejectionCount,
message: message:
"Claim rejected by FileMaker. FileReviewer can re-lock the claim and adjust the damage assessment or restart the user interaction.", newRejectionCount >= 2
? "Claim rejected by FileMaker (2nd rejection — next action must be approval). FileReviewer can re-lock and adjust the assessment."
: "Claim rejected by FileMaker. FileReviewer can re-lock the claim and adjust the damage assessment or restart the user interaction.",
}; };
} }
@@ -10075,4 +10536,251 @@ export class RequestManagementService {
); );
} }
} }
// ── V6 call-center flow ────────────────────────────────────────────────
/**
* V6: Create a LINK blame file initiated by a call-center agent.
* Always LINK + CUSTOMER-filled (user fills the form after receiving the SMS).
* `skipInitialFormStep` is set so the user-side page skips the inquiry step
* (the agent already ran it via runCallCenterInquiryV6).
*/
async createCallCenterInitiatedBlameV6(
agent: any,
dto: { type: "THIRD_PARTY" | "CAR_BODY" },
): Promise<{ requestId: string; publicId: string }> {
if (agent?.role !== RoleEnum.CALL_CENTER) {
throw new ForbiddenException("Only call-center agents can use this endpoint.");
}
const agentId = new Types.ObjectId(agent.sub);
const type =
dto.type === "CAR_BODY"
? BlameRequestType.CAR_BODY
: BlameRequestType.THIRD_PARTY;
const firstStep = await this.getWorkflowStep({ stepNumber: 1 });
if (!firstStep?.stepKey) {
throw new InternalServerErrorException(
"Workflow stepNumber=1 not configured in step manager",
);
}
const nextStepFromManager = firstStep.nextPossibleSteps?.[0];
const nextStep =
type === BlameRequestType.CAR_BODY
? (WorkflowStep.CAR_BODY_ACCIDENT_TYPE as WorkflowStep)
: (nextStepFromManager as WorkflowStep);
if (!nextStep) {
throw new InternalServerErrorException(
"Workflow stepNumber=1 has no nextPossibleSteps configured",
);
}
const publicId = await this.publicIdService.generateRequestPublicId();
const created = await this.blameRequestDbService.create({
publicId,
requestNo: publicId,
type,
parties: [{ role: PartyRole.FIRST, person: {} }],
workflow: {
currentStep: firstStep.stepKey as WorkflowStep,
nextStep,
completedSteps: [firstStep.stepKey as WorkflowStep],
locked: false,
},
history: [],
callCenterInitiated: true,
initiatedByCallCenterId: agentId,
creationMethod: CreationMethod.LINK,
filledBy: FilledBy.CUSTOMER,
// User-side page should skip the inquiry/initial-form step
skipInitialFormStep: true,
});
const requestId = String((created as any)._id);
if (!Array.isArray((created as any).history)) (created as any).history = [];
(created as any).history.push({
type: "FILE_CREATED_BY_CALL_CENTER",
actor: {
actorId: agentId,
actorName: `${agent.firstName || ""} ${agent.lastName || ""}`.trim(),
actorType: RoleEnum.CALL_CENTER,
},
metadata: { creationMethod: CreationMethod.LINK, type: dto.type },
});
await (created as any).save();
return { requestId, publicId: (created as any).publicId };
}
/**
* V6: Run plate + personal inquiry for the guilty party on behalf of the caller.
* Stores the inquiry results on the blame's first party (same as V3 guilty-party path)
* but does NOT create a claim — the user will do that after filling the form via the link.
* Note: sheba is intentionally absent — the user provides their own IBAN later.
*/
async runCallCenterInquiryV6(
agent: any,
requestId: string,
dto: Omit<RunInquiriesV3Dto, "sheba">,
): Promise<{ blameRequestId: string; message: string }> {
if (agent?.role !== RoleEnum.CALL_CENTER) {
throw new ForbiddenException("Only call-center agents can use this endpoint.");
}
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Blame request not found");
if (!req.callCenterInitiated || String(req.initiatedByCallCenterId) !== String(agent.sub)) {
throw new ForbiddenException("You can only access files that you have initiated.");
}
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
if (firstIdx === -1) throw new BadRequestException("First party not found");
const firstParty = req.parties[firstIdx];
await this.runPartyInquiriesV3Internal(req, dto, PartyRole.FIRST, firstParty);
this.markPartyInquiriesCompleteOnBlame(req, PartyRole.FIRST, agent);
if (!Array.isArray((req as any).history)) (req as any).history = [];
(req as any).history.push({
type: "CALL_CENTER_INQUIRY_COMPLETED",
actor: {
actorId: new Types.ObjectId(agent.sub),
actorName: `${agent.firstName || ""} ${agent.lastName || ""}`.trim(),
actorType: RoleEnum.CALL_CENTER,
},
metadata: { partyRole: PartyRole.FIRST },
});
await (req as any).save();
return {
blameRequestId: requestId,
message: "Guilty-party inquiry complete. You can now send the blame link to the user.",
};
}
/**
* V6: Send the blame link to the guilty party's phone number.
* Registers/looks up the user by phone and stores them as the first party, then
* sends the SMS link so the user can fill in the rest of the form via the v2 flow.
*/
async sendCallCenterLinkV6(
agent: any,
requestId: string,
dto: { phoneNumber: string },
): Promise<{ sent: boolean; sentTo: { role: string; phoneNumber: string; smsSent: boolean; linkUrl: string }[] }> {
if (agent?.role !== RoleEnum.CALL_CENTER) {
throw new ForbiddenException("Only call-center agents can use this endpoint.");
}
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
if (!req.callCenterInitiated || String(req.initiatedByCallCenterId) !== String(agent.sub)) {
throw new ForbiddenException("You can only access files that you have initiated.");
}
if (!process.env.URL) {
throw new InternalServerErrorException("URL environment variable is not configured");
}
const phone = (dto?.phoneNumber || "").trim();
if (!phone) throw new BadRequestException("phoneNumber is required");
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
if (firstIdx === -1) throw new BadRequestException("First party not found on request");
const userId = await this.getOrCreateUserByPhoneNumber(phone);
if (!req.parties[firstIdx].person) req.parties[firstIdx].person = {} as any;
req.parties[firstIdx].person.phoneNumber = normalizeIranMobile(phone) ?? phone;
req.parties[firstIdx].person.userId = userId;
const expertName = `${agent?.lastName || ""}`.trim() || "اپراتور";
const firstLink = this.smsOrchestrationService.buildBlamePartyLink(requestId, "FIRST", "v6");
const smsSent = await this.smsOrchestrationService.sendFieldExpertLink({
receptor: phone,
type: req.type,
expertLastName: expertName,
link: firstLink,
});
const sentTo = [{ role: PartyRole.FIRST, phoneNumber: phone, smsSent, linkUrl: firstLink }];
if (!Array.isArray((req as any).history)) (req as any).history = [];
(req as any).history.push({
type: "CALL_CENTER_LINK_SENT",
actor: {
actorId: new Types.ObjectId(agent.sub),
actorName: `${agent.firstName || ""} ${agent.lastName || ""}`.trim(),
actorType: RoleEnum.CALL_CENTER,
},
metadata: { sentTo },
});
await (req as any).save();
return { sent: smsSent, sentTo };
}
/**
* V6: Get a single blame file detail for the call-center agent who created it.
*/
async getCallCenterBlameDetailV6(agent: any, requestId: string): Promise<any> {
if (agent?.role !== RoleEnum.CALL_CENTER) {
throw new ForbiddenException("Only call-center agents can use this endpoint.");
}
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Blame request not found");
if (!req.callCenterInitiated || String(req.initiatedByCallCenterId) !== String(agent.sub)) {
throw new ForbiddenException("You can only access files that you have initiated.");
}
return {
_id: (req as any)._id,
publicId: (req as any).publicId,
requestNo: (req as any).requestNo,
type: (req as any).type,
status: (req as any).status,
blameStatus: (req as any).blameStatus,
workflow: (req as any).workflow,
skipInitialFormStep: (req as any).skipInitialFormStep,
parties: ((req as any).parties ?? []).map((p: any) => ({
role: p.role,
person: {
phoneNumber: p.person?.phoneNumber,
nationalCodeOfInsurer: p.person?.nationalCodeOfInsurer,
clientId: p.person?.clientId,
},
insurance: p.insurance
? {
company: p.insurance.company,
policyNumber: p.insurance.policyNumber,
startDate: p.insurance.startDate,
endDate: p.insurance.endDate,
}
: undefined,
vehicle: p.vehicle
? { plateId: p.vehicle.plateId, name: p.vehicle.name }
: undefined,
})),
createdAt: (req as any).createdAt,
};
}
/**
* V6: List blame files created by this call-center agent.
*/
async getMyCallCenterFilesV6(agent: any): Promise<any[]> {
if (agent?.role !== RoleEnum.CALL_CENTER) {
throw new ForbiddenException("Only call-center agents can use this endpoint.");
}
const agentId = new Types.ObjectId(agent.sub);
const files = await this.blameRequestDbService.find({
callCenterInitiated: true,
initiatedByCallCenterId: agentId,
});
return (files || []).map((f: any) => ({
_id: f._id,
publicId: f.publicId,
requestNo: f.requestNo,
type: f.type,
status: f.status,
blameStatus: f.blameStatus,
workflow: f.workflow,
skipInitialFormStep: f.skipInitialFormStep,
createdAt: f.createdAt,
}));
}
} }

View File

@@ -5,6 +5,7 @@ import { createHttpModuleOptions } from "src/core/config/http-proxy.factory";
import { MongooseModule } from "@nestjs/mongoose"; import { MongooseModule } from "@nestjs/mongoose";
import { ClientModule } from "src/client/client.module"; import { ClientModule } from "src/client/client.module";
import { SystemSettingsModule } from "src/system-settings/system-settings.module"; import { SystemSettingsModule } from "src/system-settings/system-settings.module";
import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normalizer.module";
import { SandHubDbService } from "src/sand-hub/entity/db-service/sand-hub.db.service"; import { SandHubDbService } from "src/sand-hub/entity/db-service/sand-hub.db.service";
import { SandHubModel, SandHubSchema } from "./entity/schema/sand-hub.schema"; import { SandHubModel, SandHubSchema } from "./entity/schema/sand-hub.schema";
import { SandHubService } from "./sand-hub.service"; import { SandHubService } from "./sand-hub.service";
@@ -17,6 +18,7 @@ import { SandHubService } from "./sand-hub.service";
useFactory: createHttpModuleOptions, useFactory: createHttpModuleOptions,
}), }),
SystemSettingsModule, SystemSettingsModule,
PlateNormalizerModule,
ClientModule, ClientModule,
MongooseModule.forFeature([ MongooseModule.forFeature([
{ name: SandHubModel.name, schema: SandHubSchema }, { name: SandHubModel.name, schema: SandHubSchema },

View File

@@ -9,6 +9,9 @@ describe("SandHubService inquiry mocks", () => {
isInquiryLive: jest.fn(), isInquiryLive: jest.fn(),
getMockCompanyContext: jest.fn(), getMockCompanyContext: jest.fn(),
}; };
const plateNormalizer = {
normalizePlateText: (text: string) => text,
};
let service: SandHubService; let service: SandHubService;
@@ -30,6 +33,7 @@ describe("SandHubService inquiry mocks", () => {
httpService as any, httpService as any,
sandHubDbService as any, sandHubDbService as any,
externalInquirySettings as unknown as ExternalInquirySettingsService, externalInquirySettings as unknown as ExternalInquirySettingsService,
plateNormalizer as any,
); );
externalInquirySettings.isInquiryLive.mockResolvedValue(false); externalInquirySettings.isInquiryLive.mockResolvedValue(false);
externalInquirySettings.getMockCompanyContext.mockResolvedValue({ externalInquirySettings.getMockCompanyContext.mockResolvedValue({

View File

@@ -12,6 +12,7 @@ import {
import { SandHubDbService } from "src/sand-hub/entity/db-service/sand-hub.db.service"; import { SandHubDbService } from "src/sand-hub/entity/db-service/sand-hub.db.service";
import { SandHubModel } from "src/sand-hub/entity/schema/sand-hub.schema"; import { SandHubModel } from "src/sand-hub/entity/schema/sand-hub.schema";
import { ExternalInquirySettingsService } from "src/client/external-inquiry-settings.service"; import { ExternalInquirySettingsService } from "src/client/external-inquiry-settings.service";
import { PlateNormalizerService } from "src/utils/plate-normalizer/plate-normalizer.service";
import type { ExternalInquiryType } from "src/common/types/external-inquiry.types"; import type { ExternalInquiryType } from "src/common/types/external-inquiry.types";
import type { MockInquiryCompanyContext } from "src/common/types/external-inquiry.types"; import type { MockInquiryCompanyContext } from "src/common/types/external-inquiry.types";
import { SandHubDetailDto, SandHubInquiryOptions } from "./dto/sand-hub.dto"; import { SandHubDetailDto, SandHubInquiryOptions } from "./dto/sand-hub.dto";
@@ -39,6 +40,7 @@ export class SandHubService {
private readonly httpService: HttpService, private readonly httpService: HttpService,
private readonly sandHubDbService: SandHubDbService, private readonly sandHubDbService: SandHubDbService,
private readonly externalInquirySettings: ExternalInquirySettingsService, private readonly externalInquirySettings: ExternalInquirySettingsService,
private readonly plateNormalizer: PlateNormalizerService,
) {} ) {}
private clientRefFrom(options?: SandHubInquiryOptions): string | undefined { private clientRefFrom(options?: SandHubInquiryOptions): string | undefined {
@@ -162,8 +164,8 @@ export class SandHubService {
nationalCodeOfInsurer: nationalCode, nationalCodeOfInsurer: nationalCode,
plate: { plate: {
leftDigits: Number(payload.part1 ?? payload.leftTwoDigits ?? 16), leftDigits: Number(payload.part1 ?? payload.leftTwoDigits ?? 16),
centerAlphabet: String( centerAlphabet: this.plateNormalizer.normalizePlateText(
payload.part2 ?? payload.serialLetter ?? "12", String(payload.part2 ?? payload.serialLetter ?? "12"),
), ),
centerDigits: Number(payload.part3 ?? payload.threeDigits ?? 498), centerDigits: Number(payload.part3 ?? payload.threeDigits ?? 498),
ir: Number(payload.part4 ?? payload.rightTwoDigits ?? 60), ir: Number(payload.part4 ?? payload.rightTwoDigits ?? 60),

View File

@@ -37,20 +37,25 @@ export class SmsOrchestrationService implements OnModuleInit {
); );
} }
buildInviteLink(frontendRoute: string, requestId: string): string { buildInviteLink(
return `${process.env.URL}/${process.env.USER_BASE_PATH}/${frontendRoute}?token=${requestId}`; frontendRoute: string,
requestId: string,
versionPrefix: string,
): string {
return `${process.env.URL}/${versionPrefix}/${frontendRoute}?token=${requestId}`;
} }
buildBlamePartyLink( buildBlamePartyLink(
requestId: string, requestId: string,
partyRole: "FIRST" | "SECOND", partyRole: "FIRST" | "SECOND",
versionPrefix: string,
): string { ): string {
const route = partyRole === "SECOND" ? "user2" : "user"; const route = partyRole === "SECOND" ? "user2" : "user";
return `${process.env.URL}/${process.env.USER_BASE_PATH}/${route}?token=${requestId}`; return `${process.env.URL}/${versionPrefix}/${route}?token=${requestId}`;
} }
buildClaimLink(claimRequestId: string): string { buildClaimLink(claimRequestId: string, versionPrefix: string): string {
return `${process.env.URL}/${process.env.USER_BASE_PATH}/caseClaim?token=${claimRequestId}`; return `${process.env.URL}/${versionPrefix}/caseClaim?token=${claimRequestId}`;
} }
async sendInviteLink( async sendInviteLink(

View File

@@ -74,3 +74,37 @@ export class CreateRegistrarAdminDto {
@IsNotEmpty() @IsNotEmpty()
clientId: string; clientId: string;
} }
export class CreateCallCenterAgentDto {
@ApiProperty({ example: "agent@insurer.ir" })
@IsEmail()
email: string;
@ApiProperty({ example: "CallCenter@724" })
@IsString()
@IsNotEmpty()
password: string;
@ApiProperty({ example: "Sara" })
@IsString()
@IsNotEmpty()
firstName: string;
@ApiProperty({ example: "Hosseini" })
@IsString()
@IsNotEmpty()
lastName: string;
@ApiProperty({
example: "664a1b2c3d4e5f6789012345",
description: "Mongo ObjectId of the insurer client this agent belongs to.",
})
@IsString()
@IsNotEmpty()
clientId: string;
@ApiPropertyOptional({ example: "09121234567" })
@IsOptional()
@IsString()
mobile?: string;
}

View File

@@ -26,6 +26,7 @@ import {
CreateSuperAdminDto, CreateSuperAdminDto,
CreateFieldExpertAdminDto, CreateFieldExpertAdminDto,
CreateRegistrarAdminDto, CreateRegistrarAdminDto,
CreateCallCenterAgentDto,
} from "./dto/super-admin.dto"; } from "./dto/super-admin.dto";
import { ClientDto } from "src/client/dto/create-client.dto"; import { ClientDto } from "src/client/dto/create-client.dto";
import { import {
@@ -151,6 +152,13 @@ export class SuperAdminController {
return this.superAdminService.createRegistrar(body); return this.superAdminService.createRegistrar(body);
} }
@Post("create-call-center-agent")
@ApiOperation({ summary: "Create a call-center agent for a given client" })
@ApiBody({ type: CreateCallCenterAgentDto })
createCallCenterAgent(@Body() body: CreateCallCenterAgentDto) {
return this.superAdminService.createCallCenterAgent(body);
}
// ── System settings ────────────────────────────────────────────────────── // ── System settings ──────────────────────────────────────────────────────
@Get("system-settings") @Get("system-settings")

View File

@@ -19,10 +19,12 @@ import {
CreateSuperAdminDto, CreateSuperAdminDto,
CreateFieldExpertAdminDto, CreateFieldExpertAdminDto,
CreateRegistrarAdminDto, CreateRegistrarAdminDto,
CreateCallCenterAgentDto,
} from "./dto/super-admin.dto"; } from "./dto/super-admin.dto";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
import { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.service"; import { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.service";
import { RegistrarDbService } from "src/users/entities/db-service/registrar.db.service"; import { RegistrarDbService } from "src/users/entities/db-service/registrar.db.service";
import { CallCenterAgentDbService } from "src/users/entities/db-service/call-center-agent.db.service";
@Injectable() @Injectable()
export class SuperAdminService { export class SuperAdminService {
@@ -33,6 +35,7 @@ export class SuperAdminService {
private readonly actorAuthService: ActorAuthService, private readonly actorAuthService: ActorAuthService,
private readonly fieldExpertDbService: FieldExpertDbService, private readonly fieldExpertDbService: FieldExpertDbService,
private readonly registrarDbService: RegistrarDbService, private readonly registrarDbService: RegistrarDbService,
private readonly callCenterAgentDbService: CallCenterAgentDbService,
) {} ) {}
/** List all super-admin accounts (sans password). */ /** List all super-admin accounts (sans password). */
@@ -99,4 +102,28 @@ export class SuperAdminService {
async createRegistrar(body: CreateRegistrarAdminDto) { async createRegistrar(body: CreateRegistrarAdminDto) {
return this.actorAuthService.createRegistrarMock(body); return this.actorAuthService.createRegistrarMock(body);
} }
async createCallCenterAgent(body: CreateCallCenterAgentDto) {
const email = body.email.toLowerCase().trim();
const existing = await this.callCenterAgentDbService.findOne({ email });
if (existing) {
throw new ConflictException(
"A call-center agent with this email already exists.",
);
}
const password = await this.hashService.hash(body.password);
const created = await this.callCenterAgentDbService.create({
email,
password,
firstName: body.firstName,
lastName: body.lastName,
clientKey: new Types.ObjectId(body.clientId),
mobile: body.mobile,
role: RoleEnum.CALL_CENTER,
});
const { password: _pw, ...rest } = (created as any).toObject
? (created as any).toObject()
: (created as any);
return rest;
}
} }

View File

@@ -0,0 +1,50 @@
import { Injectable } from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose";
import { FilterQuery, Model, Types } from "mongoose";
import { CallCenterAgentModel } from "../schema/call-center-agent.schema";
@Injectable()
export class CallCenterAgentDbService {
constructor(
@InjectModel(CallCenterAgentModel.name)
private readonly model: Model<CallCenterAgentModel>,
) {}
async create(
data: Partial<CallCenterAgentModel> & { password: string },
): Promise<CallCenterAgentModel> {
return this.model.create(data);
}
async findOne(
filter: FilterQuery<CallCenterAgentModel>,
): Promise<CallCenterAgentModel | null> {
return this.model.findOne(filter);
}
async findById(id: string): Promise<CallCenterAgentModel | null> {
return this.model.findOne({ _id: new Types.ObjectId(id) });
}
async findByLoginIdentifier(
identifier: string,
): Promise<CallCenterAgentModel | null> {
const id = identifier.trim();
const or: FilterQuery<CallCenterAgentModel>[] = [
{ email: id },
{ username: id },
];
if (/^\d{10}$/.test(id)) {
or.push({ nationalCode: id });
}
return this.model.findOne({ $or: or });
}
async findAll(
filter: FilterQuery<CallCenterAgentModel>,
): Promise<(CallCenterAgentModel & { _id: Types.ObjectId })[]> {
return this.model.find(filter).lean() as Promise<
(CallCenterAgentModel & { _id: Types.ObjectId })[]
>;
}
}

View File

@@ -0,0 +1,61 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { Types } from "mongoose";
import { RoleEnum } from "src/Types&Enums/role.enum";
/**
* Call-center agent actor. Works under an insurance company (clientKey) and
* can start V6 LINK blame files on behalf of callers.
*/
@Schema({
collection: "call-center-agents",
versionKey: false,
timestamps: true,
})
export class CallCenterAgentModel {
_id?: Types.ObjectId;
@Prop({ required: true })
firstName: string;
@Prop({ required: true })
lastName: string;
@Prop({ type: String, unique: true, sparse: true, required: false })
email?: string;
@Prop({ type: String })
username?: string;
@Prop({ type: String, index: true, sparse: true })
nationalCode?: string;
@Prop({ type: Types.ObjectId, index: true })
clientKey?: Types.ObjectId;
@Prop({ required: true })
password: string;
@Prop({ required: false })
mobile?: string;
@Prop({ required: false })
phone?: string;
@Prop({ default: RoleEnum.CALL_CENTER })
role: RoleEnum;
@Prop({ type: "string", default: "" })
otp: string;
createdAt: Date;
}
export const CallCenterAgentDbSchema =
SchemaFactory.createForClass(CallCenterAgentModel);
CallCenterAgentDbSchema.pre("save", function (next) {
if (!this.username) {
this.username = (this as any).email || (this as any).nationalCode;
}
next();
});

View File

@@ -11,6 +11,7 @@ import { UserDbService } from "./entities/db-service/user.db.service";
import { ExpertFileActivityDbService } from "./entities/db-service/expert-file-activity.db.service"; import { ExpertFileActivityDbService } from "./entities/db-service/expert-file-activity.db.service";
import { FileMakerDbService } from "./entities/db-service/file-maker.db.service"; import { FileMakerDbService } from "./entities/db-service/file-maker.db.service";
import { FileReviewerDbService } from "./entities/db-service/file-reviewer.db.service"; import { FileReviewerDbService } from "./entities/db-service/file-reviewer.db.service";
import { CallCenterAgentDbService } from "./entities/db-service/call-center-agent.db.service";
import { import {
DamageExpertDbSchema, DamageExpertDbSchema,
DamageExpertModel, DamageExpertModel,
@@ -41,6 +42,10 @@ import {
FileReviewerDbSchema, FileReviewerDbSchema,
FileReviewerModel, FileReviewerModel,
} from "./entities/schema/file-reviewer.schema"; } from "./entities/schema/file-reviewer.schema";
import {
CallCenterAgentDbSchema,
CallCenterAgentModel,
} from "./entities/schema/call-center-agent.schema";
@Module({ @Module({
imports: [ imports: [
@@ -54,6 +59,7 @@ import {
{ name: ExpertFileActivity.name, schema: ExpertFileActivitySchema }, { name: ExpertFileActivity.name, schema: ExpertFileActivitySchema },
{ name: FileMakerModel.name, schema: FileMakerDbSchema }, { name: FileMakerModel.name, schema: FileMakerDbSchema },
{ name: FileReviewerModel.name, schema: FileReviewerDbSchema }, { name: FileReviewerModel.name, schema: FileReviewerDbSchema },
{ name: CallCenterAgentModel.name, schema: CallCenterAgentDbSchema },
]), ]),
HashModule, HashModule,
], ],
@@ -68,6 +74,7 @@ import {
ExpertFileActivityDbService, ExpertFileActivityDbService,
FileMakerDbService, FileMakerDbService,
FileReviewerDbService, FileReviewerDbService,
CallCenterAgentDbService,
], ],
exports: [ exports: [
UserDbService, UserDbService,
@@ -79,6 +86,7 @@ import {
ExpertFileActivityDbService, ExpertFileActivityDbService,
FileMakerDbService, FileMakerDbService,
FileReviewerDbService, FileReviewerDbService,
CallCenterAgentDbService,
], ],
}) })
export class UsersModule {} export class UsersModule {}

View File

@@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { PlateNormalizerService } from "./plate-normalizer.service";
@Module({
providers: [PlateNormalizerService],
exports: [PlateNormalizerService],
})
export class PlateNormalizerModule {}

View File

@@ -0,0 +1,79 @@
import { Injectable, Logger } from "@nestjs/common";
/**
* Characters stripped from plate text during normalization.
* These invisible Unicode code-points can sneak in via copy-paste
* or OCR and break exact-match lookups against the plate letter map.
*/
const INVISIBLE_CHARS = [
"\u200C", // Zero-Width Non-Joiner
"\u200D", // Zero-Width Joiner
"\u200E", // Left-to-Right Mark
"\u200F", // Right-to-Left Mark
"\uFEFF", // BOM / Zero-Width No-Break Space
];
const INVISIBLE_RE = new RegExp(`[${INVISIBLE_CHARS.join("")}]`, "g");
/**
* Normalize a Persian license-plate character (or short text) so that
* visually-identical Unicode variants collapse to the canonical form
* used in the plate letter mapping.
*
* Steps (in order):
* 1. NFKC normalization — folds compatibility equivalents
* 2. Strip invisible Unicode characters (ZWJ, ZWNJ, LTR/RTL marks, BOM)
* 3. Convert Arabic ي (U+064A) → Persian ی (U+06CC)
* 4. Trim leading/trailing whitespace
*/
export function normalizePlateText(text: string): string {
if (!text) return text;
let result = String(text);
// 1. Unicode NFKC normalization
result = result.normalize("NFKC");
// 2. Remove invisible characters
result = result.replace(INVISIBLE_RE, "");
// 3. Arabic ي → Persian ی
result = result.replace(/\u064A/g, "\u06CC");
// 4. Trim whitespace
result = result.trim();
return result;
}
@Injectable()
export class PlateNormalizerService {
private readonly logger = new Logger(PlateNormalizerService.name);
/** @see module-level {@link normalizePlateText} */
normalizePlateText(text: string): string {
return normalizePlateText(text);
}
/**
* Normalize a plate letter and, if it doesn't resolve in the given
* mapping, log the raw Unicode code-points for debugging.
*
* @returns the numeric code from `mapping`, or `null` when the letter
* is unknown after normalization.
*/
resolvePlateLetterCode(
letter: string,
mapping: Record<string, number>,
): number | null {
const normalized = this.normalizePlateText(letter);
const code = mapping[normalized];
if (code !== undefined) return code;
this.logger.debug(
`Unknown plate letter: ${normalized} [${Array.from(normalized).map((c) => `U+${c.charCodeAt(0).toString(16).toUpperCase().padStart(4, "0")}`).join(", ")}]`,
);
return null;
}
}

View File

@@ -0,0 +1,131 @@
import { PlateNormalizerService, normalizePlateText } from "./plate-normalizer.service";
describe("normalizePlateText (pure function)", () => {
it("returns unchanged text for a clean plate letter", () => {
expect(normalizePlateText("ه")).toBe("ه");
expect(normalizePlateText("الف")).toBe("الف");
});
it("strips Zero-Width Joiner (U+200D)", () => {
// ه + ZWJ — the exact scenario from the issue
const input = "ه\u200D";
expect(normalizePlateText(input)).toBe("ه");
});
it("strips Zero-Width Non-Joiner (U+200C)", () => {
expect(normalizePlateText("ه\u200C")).toBe("ه");
});
it("strips Left-to-Right Mark (U+200E)", () => {
expect(normalizePlateText("ه\u200E")).toBe("ه");
});
it("strips Right-to-Left Mark (U+200F)", () => {
expect(normalizePlateText("ه\u200F")).toBe("ه");
});
it("strips BOM (U+FEFF)", () => {
expect(normalizePlateText("\uFEFFه")).toBe("ه");
});
it("converts Arabic ي (U+064A) to Persian ی (U+06CC)", () => {
expect(normalizePlateText("\u064A")).toBe("\u06CC");
});
it("does not alter already-Persian ی (U+06CC)", () => {
expect(normalizePlateText("\u06CC")).toBe("\u06CC");
});
it("handles a full plate string: 339ه\u200D77 → 339ه77", () => {
expect(normalizePlateText("339ه\u200D77")).toBe("339ه77");
});
it("handles multiple invisible chars interleaved", () => {
const input = "\u200F339\u200Cه\u200D\uFEFF77\u200E";
expect(normalizePlateText(input)).toBe("339ه77");
});
it("trims whitespace", () => {
expect(normalizePlateText(" ه ")).toBe("ه");
});
it("applies NFKC normalization", () => {
// Full-width digit (U+FF13) → ASCII 3
expect(normalizePlateText("")).toBe("3");
});
it("returns empty string for empty input", () => {
expect(normalizePlateText("")).toBe("");
});
});
describe("PlateNormalizerService", () => {
let service: PlateNormalizerService;
beforeEach(() => {
service = new PlateNormalizerService();
});
describe("normalizePlateText", () => {
it("delegates to the pure function", () => {
expect(service.normalizePlateText("ه\u200D")).toBe("ه");
});
});
describe("resolvePlateLetterCode", () => {
const PLATE_LETTER_MAP: Record<string, number> = {
"\u0627\u0644\u0641": 1, // الف
"\u0628": 2,
"\u067E": 3,
"\u062C": 4,
"\u062F": 5,
"\u0633": 6,
"\u0635": 7,
"\u0637": 8,
"\u0639": 9,
"\u0642": 10,
"\u0644": 11,
"\u0645": 12,
"\u0646": 13,
"\u0648": 14,
"\u0647": 15, // ه
"\u06CC": 16, // ی
"\u06A9": 17,
"\u0698": 18,
"\u062A": 19,
"\u062B": 20,
"\u0632": 21,
"\u0634": 22,
"\u0641": 23,
"\u06AF": 24,
};
it("returns 15 for clean ه", () => {
expect(service.resolvePlateLetterCode("ه", PLATE_LETTER_MAP)).toBe(15);
});
it("returns 15 for ه + ZWJ (the bug scenario)", () => {
expect(service.resolvePlateLetterCode("ه\u200D", PLATE_LETTER_MAP)).toBe(
15,
);
});
it("returns 16 for Arabic ي (mapped to Persian ی)", () => {
expect(
service.resolvePlateLetterCode("\u064A", PLATE_LETTER_MAP),
).toBe(16);
});
it("returns null for an unknown letter and logs it", () => {
const spy = jest.spyOn(
(service as any).logger,
"debug",
);
expect(service.resolvePlateLetterCode("؟", PLATE_LETTER_MAP)).toBeNull();
expect(spy).toHaveBeenCalledWith(
expect.stringContaining("Unknown plate letter"),
);
spy.mockRestore();
});
});
});