Files
yara724api/src/expert-insurer/expert-insurer.controller.ts
SepehrYahyaee 01f8a5b12c YARA-1078
2026-08-16 12:35:18 +03:30

380 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
BadRequestException,
Body,
Controller,
Get,
Param,
Post,
Put,
Query,
UnauthorizedException,
UseGuards,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiBody,
ApiOperation,
ApiParam,
ApiQuery,
ApiResponse,
ApiTags,
} from "@nestjs/swagger";
import { Types } from "mongoose";
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 { FileRating } from "src/request-management/entities/schema/request-management.schema";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { ExpertInsurerService } from "./expert-insurer.service";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
import {
UnifiedFileStatusReportDto,
UnifiedFileStatusReportQueryDto,
} from "src/common/dto/unified-file-status-report.dto";
import { CreateBranchDto } from "src/client/dto/create-branch.dto";
import {
CreateBlameExpertByInsurerDto,
CreateClaimExpertByInsurerDto,
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")
@ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.COMPANY)
export class ExpertInsurerController {
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)" })
async getInsuranceBranches(
@CurrentUser() insurer,
@Query("search") search?: string,
@Query("from") from?: string,
@Query("to") to?: string,
@Query("isActive") isActive?: string,
) {
if (!insurer) throw new UnauthorizedException("Could not identify the current user.");
return await this.expertInsurerService.retrieveInsuranceBranches(
insurer.clientKey,
{ search, from, to, isActive },
);
}
@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);
}
@Put("branches/:branchId/status")
@ApiParam({ name: "branchId" })
@ApiBody({
schema: {
type: "object",
properties: { isActive: { type: "boolean" } },
required: ["isActive"],
},
})
async setBranchStatus(
@CurrentUser() insurer,
@Param("branchId") branchId: string,
@Body("isActive") isActive: unknown,
) {
if (!insurer) throw new UnauthorizedException("Could not identify the current user.");
let active: boolean;
if (typeof isActive === "boolean") {
active = isActive;
} else {
const normalized = String(isActive ?? "").trim().toLowerCase();
if (!["true", "false", "1", "0", "yes", "no"].includes(normalized)) {
throw new BadRequestException("isActive must be a boolean");
}
active = ["true", "1", "yes"].includes(normalized);
}
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.");
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.");
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.");
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.");
return this.expertInsurerService.addFileReviewer(insurer.clientKey, body);
}
@ApiQuery({ name: "page", type: Number })
@ApiQuery({ name: "response_count", type: Number })
@Get("experts/list")
async getAllExperts(
@Query("page") page: number,
@Query("response_count") count: number,
@CurrentUser() actor,
) {
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: `{ 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).",
})
@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);
}
@Get("report/unified-file-statuses")
@ApiOperation({
summary: "Unified file status catalog + counts (blame + claim)",
description:
"Calculatable unified statuses and per-status counts for this insurer's files. Filter lists with `unifiedStatus` and `fileType`. Optional `from` / `to` ISO dates narrow the counted portfolio.",
})
@ApiResponse({ status: 200, type: UnifiedFileStatusReportDto })
async getUnifiedFileStatusReport(
@CurrentUser() actor,
@Query() query: UnifiedFileStatusReportQueryDto,
): Promise<UnifiedFileStatusReportDto> {
return await this.expertInsurerService.getInsurerUnifiedFileStatusReport(actor, query);
}
@Get("files/:publicId/timeline")
@ApiParam({ name: "publicId" })
@ApiOperation({
summary: "Activity timeline for a case",
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);
}
@Get("files/:publicId")
@ApiParam({ name: "publicId" })
async getFileDetailsByPublicId(@CurrentUser() insurer, @Param("publicId") publicId: string) {
return await this.expertInsurerService.retrieveFileDetailsByPublicId(insurer.clientKey, publicId);
}
@ApiBody({
description:
"One insurer rating for the shared publicId; persisted on claim and/or blame case documents when both exist.",
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 },
},
})
@ApiParam({ name: "publicId" })
@Put("files/:publicId/rating")
async rateExpertsByPublicId(
@CurrentUser() insurer,
@Param("publicId") publicId: string,
@Body() rating: FileRating,
) {
return await this.expertInsurerService.rateExpertByPublicId(publicId, rating, insurer.clientKey);
}
@Get("report/status-counts")
@ApiOperation({
summary: "Legacy status counts (mapped from unified statuses)",
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)" })
async getInsurerStatusReport(
@CurrentUser() actor,
@Query("from") from?: string,
@Query("to") to?: string,
) {
return await this.expertInsurerService.getInsurerFileStatusCounts(actor, from, to);
}
// ─── Expert detail ────────────────────────────────────────────────────────
@ApiParam({ name: "expertId" })
@ApiOperation({
summary: "Files handled by one roster expert (summary rows)",
description:
"Resolves id against this insurer's `expert` then `damage-expert` roster. Blame branch runs when the id is on the expert roster (field experts). Each item is a small summary (`kind`, ids, statuses, dates)—not full file payloads. Claim matches use final or draft damage-expert reply.",
})
@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);
}
}