YARA-1078

This commit is contained in:
SepehrYahyaee
2026-08-16 12:35:18 +03:30
parent 875b52d761
commit 01f8a5b12c
6 changed files with 548 additions and 295 deletions

View File

@@ -39,6 +39,13 @@ import {
CreateFileMakerByInsurerDto,
CreateFileReviewerByInsurerDto,
} from "./dto/create-insurer-expert.dto";
import {
InsurerStatisticsDto,
InsurerTopExpertsDto,
InsurerTopFileDto,
InsurerWorkLogResponseDto,
} from "./dto/insurer-reports.dto";
import { ReportsService } from "src/reports/reports.service";
@Controller("expert-insurer")
@ApiTags("expert-insurer-panel")
@@ -46,25 +53,18 @@ import {
@UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.COMPANY)
export class ExpertInsurerController {
constructor(private readonly expertInsurerService: ExpertInsurerService) {}
constructor(
private readonly expertInsurerService: ExpertInsurerService,
private readonly reportsService: ReportsService,
) {}
// ─── Branch management ────────────────────────────────────────────────────
@Get("branches")
@ApiQuery({ name: "search", required: false, type: String })
@ApiQuery({
name: "from",
required: false,
description: "Optional start datetime (ISO string)",
})
@ApiQuery({
name: "to",
required: false,
description: "Optional end datetime (ISO string)",
})
@ApiQuery({
name: "isActive",
required: false,
description: "Filter active state (true/false)",
})
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" })
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
@ApiQuery({ name: "isActive", required: false, description: "Filter active state (true/false)" })
async getInsuranceBranches(
@CurrentUser() insurer,
@Query("search") search?: string,
@@ -72,9 +72,7 @@ export class ExpertInsurerController {
@Query("to") to?: string,
@Query("isActive") isActive?: string,
) {
if (!insurer) {
throw new UnauthorizedException("Could not identify the current user.");
}
if (!insurer) throw new UnauthorizedException("Could not identify the current user.");
return await this.expertInsurerService.retrieveInsuranceBranches(
insurer.clientKey,
{ search, from, to, isActive },
@@ -83,18 +81,9 @@ export class ExpertInsurerController {
@Post("branches")
@ApiBody({ type: CreateBranchDto })
async addBranch(
@CurrentUser() insurer,
@Body() createBranchDto: CreateBranchDto,
) {
if (!insurer) {
throw new UnauthorizedException("Could not identify the current user.");
}
return await this.expertInsurerService.addBranch(
insurer.clientKey,
createBranchDto,
);
async addBranch(@CurrentUser() insurer, @Body() createBranchDto: CreateBranchDto) {
if (!insurer) throw new UnauthorizedException("Could not identify the current user.");
return await this.expertInsurerService.addBranch(insurer.clientKey, createBranchDto);
}
@Put("branches/:branchId/status")
@@ -111,10 +100,7 @@ export class ExpertInsurerController {
@Param("branchId") branchId: string,
@Body("isActive") isActive: unknown,
) {
if (!insurer) {
throw new UnauthorizedException("Could not identify the current user.");
}
// Accept native boolean (JSON body) or string coercion (legacy query/form usage)
if (!insurer) throw new UnauthorizedException("Could not identify the current user.");
let active: boolean;
if (typeof isActive === "boolean") {
active = isActive;
@@ -125,60 +111,38 @@ export class ExpertInsurerController {
}
active = ["true", "1", "yes"].includes(normalized);
}
return this.expertInsurerService.setBranchActive(
insurer.clientKey,
branchId,
active,
);
return this.expertInsurerService.setBranchActive(insurer.clientKey, branchId, active);
}
// ─── Expert management ────────────────────────────────────────────────────
@Post("experts/blame")
@ApiBody({ type: CreateBlameExpertByInsurerDto })
async addBlameExpert(
@CurrentUser() insurer,
@Body() body: CreateBlameExpertByInsurerDto,
) {
if (!insurer) {
throw new UnauthorizedException("Could not identify the current user.");
}
async addBlameExpert(@CurrentUser() insurer, @Body() body: CreateBlameExpertByInsurerDto) {
if (!insurer) throw new UnauthorizedException("Could not identify the current user.");
return this.expertInsurerService.addBlameExpert(insurer.clientKey, body);
}
@Post("experts/claim")
@ApiBody({ type: CreateClaimExpertByInsurerDto })
async addClaimExpert(
@CurrentUser() insurer,
@Body() body: CreateClaimExpertByInsurerDto,
) {
if (!insurer) {
throw new UnauthorizedException("Could not identify the current user.");
}
async addClaimExpert(@CurrentUser() insurer, @Body() body: CreateClaimExpertByInsurerDto) {
if (!insurer) throw new UnauthorizedException("Could not identify the current user.");
return this.expertInsurerService.addClaimExpert(insurer.clientKey, body);
}
@Post("experts/file-maker")
@ApiBody({ type: CreateFileMakerByInsurerDto })
@ApiOperation({ summary: "Create a FileMaker account under this insurer" })
async addFileMaker(
@CurrentUser() insurer,
@Body() body: CreateFileMakerByInsurerDto,
) {
if (!insurer) {
throw new UnauthorizedException("Could not identify the current user.");
}
async addFileMaker(@CurrentUser() insurer, @Body() body: CreateFileMakerByInsurerDto) {
if (!insurer) throw new UnauthorizedException("Could not identify the current user.");
return this.expertInsurerService.addFileMaker(insurer.clientKey, body);
}
@Post("experts/file-reviewer")
@ApiBody({ type: CreateFileReviewerByInsurerDto })
@ApiOperation({ summary: "Create a FileReviewer account under this insurer" })
async addFileReviewer(
@CurrentUser() insurer,
@Body() body: CreateFileReviewerByInsurerDto,
) {
if (!insurer) {
throw new UnauthorizedException("Could not identify the current user.");
}
async addFileReviewer(@CurrentUser() insurer, @Body() body: CreateFileReviewerByInsurerDto) {
if (!insurer) throw new UnauthorizedException("Could not identify the current user.");
return this.expertInsurerService.addFileReviewer(insurer.clientKey, body);
}
@@ -190,37 +154,140 @@ export class ExpertInsurerController {
@Query("response_count") count: number,
@CurrentUser() actor,
) {
return await this.expertInsurerService.retrieveAllExpertsOfClient(
actor,
page,
count,
);
return await this.expertInsurerService.retrieveAllExpertsOfClient(actor, page, count);
}
// ─── Reports: statistics ──────────────────────────────────────────────────
@Get("statistics")
@ApiOperation({
summary: "نمایش کلی — KPI cards for insurer reports page",
description:
"Returns `totalFilesReviewed` (distinct tenant claim files with ≥1 expert CHECKED activity), " +
"`averageUserRatingPercentage` (mean of progressSpeed + registrationEase + overallEvaluation across rated files, normalised to 0–100), " +
"`inPersonAccompaniedCount` (blame files where expertInitiated=true AND creationMethod=IN_PERSON), " +
"`filesCreatedThisMonth`, `totalFiles`, `objectionPercentage`, `insurerToBotRatingPercentage`. " +
"⚠️ `userRatingPercentage` and `filesWithUserRatingPercentage` are the share of files *that have any user rating* — NOT a satisfaction score. Use `averageUserRatingPercentage` for رضایت کاربران.",
})
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string) — restricts counted claim portfolio" })
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
@ApiResponse({ status: 200, type: InsurerStatisticsDto })
async getExpertStatistics(
@CurrentUser() actor,
@Query("from") from?: string,
@Query("to") to?: string,
) {
return await this.expertInsurerService.getExpertStatisticsReport(actor, { from, to });
}
// ─── Reports: top files ───────────────────────────────────────────────────
@Get("top-files")
@ApiOperation({
summary: "Top 10 highest-rated claim files for this insurer",
description:
"Sorted by combined insurer + user rating blend (getCombinedFileScore). " +
"Returns slim DTO only: publicId, createdAt, combinedScore, userRating.{comment, overallEvaluation}. " +
"Use publicId to build مشاهده پرونده links.",
})
@ApiQuery({ name: "from", required: false, description: "Restrict to files created on or after this ISO date" })
@ApiQuery({ name: "to", required: false, description: "Restrict to files created on or before this ISO date" })
@ApiResponse({ status: 200, type: [InsurerTopFileDto] })
async getTopFiles(
@CurrentUser() insurer,
@Query("from") from?: string,
@Query("to") to?: string,
) {
return await this.expertInsurerService.getTopFilesForClient(insurer.clientKey, { from, to });
}
// ─── Reports: top experts (canonical + alias) ─────────────────────────────
@Get("experts/top")
@ApiOperation({
summary: "Top blame vs claim experts for this insurer",
description:
"Response has two arrays: `blameExperts` (roster from expert / blame files) and `claimExperts` (damage-expert roster / claim files). Each item includes `overallAverageRating` derived from ratings stored on those files plus user ratings where present.",
"Response: `{ blameExperts: [], claimExperts: [] }`. " +
"Each item: `_id`, `fullName`, `expertKind`, `overallAverageRating`, `requestStats`. " +
"Sorted by overallAverageRating descending; up to 10 per group. " +
"Optional `from` / `to` accepted for forward-compat (currently accepted but roster is full-history).",
})
async getTopExperts(@CurrentUser() actor) {
return await this.expertInsurerService.getTopExpertsForClient(actor);
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" })
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
@ApiResponse({ status: 200, type: InsurerTopExpertsDto })
async getTopExperts(
@CurrentUser() actor,
@Query("from") from?: string,
@Query("to") to?: string,
) {
return await this.expertInsurerService.getTopExpertsForClient(actor, { from, to });
}
/**
* Alias: some BFF/frontend paths call /top-experts instead of /experts/top.
* Both routes point to the same handler.
*/
@Get("top-experts")
@ApiOperation({
summary: "Alias for GET experts/top — top blame + claim experts",
description: "Same response as `GET experts/top`. Provided for front-end compatibility.",
})
@ApiQuery({ name: "from", required: false })
@ApiQuery({ name: "to", required: false })
@ApiResponse({ status: 200, type: InsurerTopExpertsDto })
async getTopExpertsAlias(
@CurrentUser() actor,
@Query("from") from?: string,
@Query("to") to?: string,
) {
return await this.expertInsurerService.getTopExpertsForClient(actor, { from, to });
}
// ─── Reports: work-log (delegates to ReportsService) ─────────────────────
@Get("expert-work-log")
@ApiOperation({
summary: "نمایش جدولی — per-expert work log (blame panel + damage experts)",
description:
"Rows for every tenant expert in the `expert` (blame) and `damage-expert` (claim) collections — same scope as GET experts/list, field-expert excluded. " +
"Columns: expertId, fullName, expertKind, totalHandled (files in HANDLED state up to `to`/now), " +
"currentlyChecking (CHECKED but not yet HANDLED up to `to`/now), " +
"distinctFilesCheckedInPeriod (distinct files with ≥1 CHECKED event inside from–to window; all-time when omitted). " +
"Optional `expertKind` filter: `expert` = blame panel, `damage_expert` = claim damage experts, `all` (default).",
})
@ApiQuery({ name: "expertKind", required: false, enum: ["all", "expert", "damage_expert"], description: "Filter by expert kind" })
@ApiQuery({ name: "from", required: false, description: "Start of date window (ISO string)" })
@ApiQuery({ name: "to", required: false, description: "End of date window (ISO string)" })
@ApiQuery({ name: "page", required: false, type: Number, description: "Page (1-based)" })
@ApiQuery({ name: "limit", required: false, type: Number, description: "Items per page" })
@ApiResponse({ status: 200, type: InsurerWorkLogResponseDto })
async getExpertWorkLog(
@CurrentUser() actor,
@Query("expertKind") expertKind?: "all" | "expert" | "damage_expert",
@Query("from") from?: string,
@Query("to") to?: string,
@Query("page") page?: string,
@Query("limit") limit?: string,
) {
return this.reportsService.getInsurerExpertWorkLog(actor, {
expertKind,
from,
to,
page: page ? parseInt(page, 10) : undefined,
limit: limit ? parseInt(limit, 10) : undefined,
});
}
// ─── Files ────────────────────────────────────────────────────────────────
@Get("files")
@ApiOperation({
summary: "List insurer files (blame + claim merged by publicId)",
description:
"Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`, `unifiedStatus`, `fileType` (THIRD_PARTY | CAR_BODY).",
})
async getAllFiles(
@CurrentUser() insurer,
@Query() query: ListQueryV2Dto,
) {
return await this.expertInsurerService.retrieveAllFilesOfClient(
insurer.clientKey,
query,
);
async getAllFiles(@CurrentUser() insurer, @Query() query: ListQueryV2Dto) {
return await this.expertInsurerService.retrieveAllFilesOfClient(insurer.clientKey, query);
}
@Get("report/unified-file-statuses")
@@ -234,10 +301,7 @@ export class ExpertInsurerController {
@CurrentUser() actor,
@Query() query: UnifiedFileStatusReportQueryDto,
): Promise<UnifiedFileStatusReportDto> {
return await this.expertInsurerService.getInsurerUnifiedFileStatusReport(
actor,
query,
);
return await this.expertInsurerService.getInsurerUnifiedFileStatusReport(actor, query);
}
@Get("files/:publicId/timeline")
@@ -247,26 +311,14 @@ export class ExpertInsurerController {
description:
"Returns a chronological list of all history events for the blame and/or claim associated with the given publicId. Each event has: source, type, timestamp, actor, metadata.",
})
async getFileTimeline(
@CurrentUser() insurer,
@Param("publicId") publicId: string,
) {
return await this.expertInsurerService.getFileTimeline(
insurer.clientKey,
publicId,
);
async getFileTimeline(@CurrentUser() insurer, @Param("publicId") publicId: string) {
return await this.expertInsurerService.getFileTimeline(insurer.clientKey, publicId);
}
@Get("files/:publicId")
@ApiParam({ name: "publicId" })
async getFileDetailsByPublicId(
@CurrentUser() insurer,
@Param("publicId") publicId: string,
) {
return await this.expertInsurerService.retrieveFileDetailsByPublicId(
insurer.clientKey,
publicId,
);
async getFileDetailsByPublicId(@CurrentUser() insurer, @Param("publicId") publicId: string) {
return await this.expertInsurerService.retrieveFileDetailsByPublicId(insurer.clientKey, publicId);
}
@ApiBody({
@@ -275,56 +327,14 @@ export class ExpertInsurerController {
schema: {
type: "object",
properties: {
collisionMethodAccuracy: {
type: "number",
minimum: 0,
maximum: 5,
example: 4,
description: "تشخیص درست نحوه برخورد",
},
evaluationTimeliness: {
type: "number",
minimum: 0,
maximum: 5,
example: 3,
description: "زمان ارزیابی",
},
accidentCauseAccuracy: {
type: "number",
minimum: 0,
maximum: 5,
example: 5,
description: "تشخیص درست علت تصادف",
},
guiltyVehicleIdentification: {
type: "number",
minimum: 0,
maximum: 5,
example: 4,
description: "تشخیص درست وسیله نقلیه مقصر",
},
botRating: {
type: "number",
minimum: 0,
maximum: 5,
example: 4,
description: "برای امتیاز دادن به عملکرد بات",
},
},
required: [
"collisionMethodAccuracy",
"evaluationTimeliness",
"accidentCauseAccuracy",
"guiltyVehicleIdentification",
"botRating",
],
example: {
collisionMethodAccuracy: 4,
evaluationTimeliness: 3,
accidentCauseAccuracy: 5,
guiltyVehicleIdentification: 4,
botRating: 4,
collisionMethodAccuracy: { type: "number", minimum: 0, maximum: 5, example: 4, description: "تشخیص درست نحوه برخورد" },
evaluationTimeliness: { type: "number", minimum: 0, maximum: 5, example: 3, description: "زمان ارزیابی" },
accidentCauseAccuracy: { type: "number", minimum: 0, maximum: 5, example: 5, description: "تشخیص درست علت تصادف" },
guiltyVehicleIdentification: { type: "number", minimum: 0, maximum: 5, example: 4, description: "تشخیص درست وسیله نقلیه مقصر" },
botRating: { type: "number", minimum: 0, maximum: 5, example: 4, description: "برای امتیاز دادن به عملکرد بات" },
},
required: ["collisionMethodAccuracy", "evaluationTimeliness", "accidentCauseAccuracy", "guiltyVehicleIdentification", "botRating"],
example: { collisionMethodAccuracy: 4, evaluationTimeliness: 3, accidentCauseAccuracy: 5, guiltyVehicleIdentification: 4, botRating: 4 },
},
})
@ApiParam({ name: "publicId" })
@@ -334,23 +344,7 @@ export class ExpertInsurerController {
@Param("publicId") publicId: string,
@Body() rating: FileRating,
) {
return await this.expertInsurerService.rateExpertByPublicId(
publicId,
rating,
insurer.clientKey,
);
}
@Get("top-files")
async getTopFiles(@CurrentUser() insurer) {
return await this.expertInsurerService.getTopFilesForClient(
insurer.clientKey,
);
}
@Get("statistics")
async getExpertStatistics(@CurrentUser() actor) {
return await this.expertInsurerService.getExpertStatisticsReport(actor);
return await this.expertInsurerService.rateExpertByPublicId(publicId, rating, insurer.clientKey);
}
@Get("report/status-counts")
@@ -359,28 +353,18 @@ export class ExpertInsurerController {
deprecated: true,
description: "Prefer GET report/unified-file-statuses for full calculatable blame+claim statuses.",
})
@ApiQuery({
name: "from",
required: false,
description: "Optional start datetime (ISO string)",
})
@ApiQuery({
name: "to",
required: false,
description: "Optional end datetime (ISO string)",
})
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" })
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
async getInsurerStatusReport(
@CurrentUser() actor,
@Query("from") from?: string,
@Query("to") to?: string,
) {
return await this.expertInsurerService.getInsurerFileStatusCounts(
actor,
from,
to,
);
return await this.expertInsurerService.getInsurerFileStatusCounts(actor, from, to);
}
// ─── Expert detail ────────────────────────────────────────────────────────
@ApiParam({ name: "expertId" })
@ApiOperation({
summary: "Files handled by one roster expert (summary rows)",
@@ -389,13 +373,7 @@ export class ExpertInsurerController {
})
@Get("/:expertId")
async requestDetail(@CurrentUser() insurer, @Param("expertId") id: string) {
if (!Types.ObjectId.isValid(id)) {
throw new BadRequestException("Invalid expert ID");
}
return await this.expertInsurerService.getAllFilesForInsurerExpert(
id,
insurer.clientKey,
);
if (!Types.ObjectId.isValid(id)) throw new BadRequestException("Invalid expert ID");
return await this.expertInsurerService.getAllFilesForInsurerExpert(id, insurer.clientKey);
}
}