Files
yara724api/src/expert-claim/expert-claim.v2.controller.ts
2026-06-18 18:15:20 +03:30

361 lines
16 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 {
Body,
Controller,
Get,
Header,
Param,
Headers,
Patch,
Post,
Put,
UseGuards,
Query,
Res,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiBody,
ApiOperation,
ApiParam,
ApiPropertyOptional,
ApiQuery,
ApiResponse,
ApiTags,
} from "@nestjs/swagger";
import { IsOptional, IsString } from "class-validator";
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 { ExpertFileAssignResultDto } from "src/common/dto/expert-file-assign-result.dto";
import { ExpertClaimService } from "./expert-claim.service";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
import { GetClaimListV2ResponseDto } from "./dto/claim-list-v2.dto";
import {
ClaimSubmitResendV2Dto,
SubmitExpertReplyV2Dto,
} from "./dto/expert-claim-v2.dto";
import { UpdateClaimDamagedPartsV2Dto } from "./dto/update-claim-damaged-parts-v2.dto";
import {
ClaimPriceDropContextV2Dto,
ClaimPriceDropResultV2Dto,
UpsertClaimPriceDropV2Dto,
} from "./dto/claim-price-drop-v2.dto";
import { FactorValidationV2Dto } from "./dto/factor-validation.dto";
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
import { OuterPartCatalogItemDto } from "src/claim-request-management/dto/select-outer-parts-v2.dto";
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
class InPersonVisitV2Dto {
@ApiPropertyOptional({ example: "Paint damage requires physical inspection" })
@IsOptional()
@IsString()
note?: string;
}
@ApiTags("expert-claim-panel (v2)")
@Controller("v2/expert-claim")
@ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.DAMAGE_EXPERT, RoleEnum.FIELD_EXPERT)
export class ExpertClaimV2Controller {
constructor(
private readonly expertClaimService: ExpertClaimService,
private readonly claimRequestManagementService: ClaimRequestManagementService,
) {}
@Get("report/status-counts")
@ApiOperation({
summary: "Count claim cases by grouped status bucket (this damage expert)",
description:
"Counts only files in the acting experts portfolio: expertFileActivities (checked or handled), current workflow lock, or a prior damageExpertReply / damageExpertReplyFinal by this expert. IN_PROGRESS groups user-phase statuses before submission is complete (CREATED through CAPTURING_PART_DAMAGES). Other keys match ClaimCaseStatus. Does not include the open tenant queue unless this expert has touched the file.",
})
async getStatusReportBucketsV2(@CurrentUser() actor: any) {
return await this.expertClaimService.getStatusReportBucketsV2(actor);
}
/**
* Same catalog as `GET v2/claim-request-management/outer-parts-catalog` so factor/resend payloads use identical part ids.
*/
@Get("outer-parts-catalog")
@ApiOperation({
summary: "Get outer parts catalog (V2)",
description:
"Returns outer-damage parts with id/key/side. Optional `carType` filter returns only that type catalog.",
})
@ApiResponse({
status: 200,
description: "Outer parts catalog",
type: [OuterPartCatalogItemDto],
})
async getOuterPartsCatalog(
@Query("carType") carType?: ClaimVehicleTypeV2,
): Promise<OuterPartCatalogItemDto[]> {
return this.claimRequestManagementService.getOuterPartsCatalogV2(carType);
}
@Get("branches")
@ApiOperation({
summary: "List insurer branches for this damage expert (V2)",
description:
"Returns branches for the insurance company bound to the expert JWT (`clientKey`). " +
"Same payload as v1 `GET expert-claim/branches/:insuranceId` — no client id in the URL.",
})
@ApiResponse({
status: 200,
description:
"Branch list (name, code, address, city, state, phoneNumber, isActive, …) for daghi / branch selection in expert replies",
})
async getInsuranceBranchesV2(@CurrentUser() actor: { clientKey?: string }) {
return await this.expertClaimService.retrieveInsuranceBranchesV2(actor);
}
@Get("requests")
@ApiOperation({
summary: "List available claim requests for damage expert",
description:
"Damage experts: tenant queue (`WAITING_FOR_DAMAGE_EXPERT`), locked/in-progress, factor validation. " +
"Field experts: all claims linked to blame files they initiated (`initiatedByFieldExpertId` or matching `blameRequestId`). Optional query: `search`, `sortBy`, `sortOrder`, `page`, `limit`.",
})
@ApiResponse({ status: 200, type: GetClaimListV2ResponseDto })
async getClaimListV2(
@CurrentUser() actor,
@Query() query: ListQueryV2Dto,
): Promise<GetClaimListV2ResponseDto> {
return await this.expertClaimService.getClaimListV2(actor, query);
}
@Get("request/:claimRequestId")
@ApiOperation({
summary: "Get claim request detail for damage expert",
description:
"Returns full claim details including captured images, required documents, damage selections, `evaluation.priceDrop` during damage review, `videoCapture` (from claim-video-capture via media.videoCaptureId), and `blameCase` (linked blameCases document with party video/voice URLs like expert-blame detail). Allowed when status is WAITING_FOR_DAMAGE_EXPERT (if locked, only the locking expert) or when awaiting factor validation.",
})
@ApiParam({ name: "claimRequestId" })
async getClaimDetailV2(
@Param("claimRequestId") claimRequestId: string,
@CurrentUser() actor,
) {
return await this.expertClaimService.getClaimDetailV2(
claimRequestId,
actor,
);
}
@Get("request/:claimRequestId/price-drop")
@ApiOperation({
summary: "Price-drop context for manual expert entry (V2)",
description:
"While the claim is locked in EXPERT_REVIEWING: severity labels (جزیی / متوسط / شدید), coefficient catalog, damaged parts with price-drop mapping, suggested Jalali year from linked blame plate inquiry (`ModelField` / `ModelCii`), and saved `evaluation.priceDrop` if any.",
})
@ApiParam({ name: "claimRequestId" })
@ApiResponse({ status: 200, type: ClaimPriceDropContextV2Dto })
async getPriceDropContextV2(
@Param("claimRequestId") claimRequestId: string,
@CurrentUser() actor,
) {
return await this.expertClaimService.getPriceDropContextV2(
claimRequestId,
actor,
);
}
@Put("request/:claimRequestId/price-drop")
@ApiOperation({
summary: "Calculate and save price drop (V2)",
description:
"Expert supplies car market price, optional `vehicle.carName` / `vehicle.carModel`, per-damaged-part severity (`partId` from claim detail), and optional `carModelYear` (defaults from blame inquiry). Persists `evaluation.priceDrop` using `(carPrice × yearCoefficient × sumOfCoefficients) / 400`.",
})
@ApiParam({ name: "claimRequestId" })
@ApiBody({ type: UpsertClaimPriceDropV2Dto })
@ApiResponse({ status: 200, type: ClaimPriceDropResultV2Dto })
async upsertPriceDropV2(
@Param("claimRequestId") claimRequestId: string,
@Body() body: UpsertClaimPriceDropV2Dto,
@CurrentUser() actor,
) {
return await this.expertClaimService.upsertPriceDropV2(
claimRequestId,
body,
actor,
);
}
@Post("assign/:claimRequestId")
@ApiOperation({
summary: "Check availability and assign claim to this damage expert",
description:
"Call before opening a case from the list. Returns `assigned` when the claim was free and is now locked to you, `already_assigned_to_you` when you already hold the lock, or **409** with `status=locked` when another expert is reviewing. Works for both the damage-review queue (`WAITING_FOR_DAMAGE_EXPERT`) and factor-validation queue.",
})
@ApiParam({ name: "claimRequestId" })
@ApiResponse({ status: 200, type: ExpertFileAssignResultDto })
@ApiResponse({
status: 409,
description: "Another expert is processing this claim",
type: ExpertFileAssignResultDto,
})
async assignClaimForReviewV2(
@Param("claimRequestId") claimRequestId: string,
@CurrentUser() actor,
) {
return await this.expertClaimService.assignClaimForReviewV2(
claimRequestId,
actor,
);
}
@Put("lock/:claimRequestId")
@ApiOperation({
summary: "Lock a claim request for review",
description:
"Lockable in two queues:\n" +
"1. **Damage review queue** — claim status `WAITING_FOR_DAMAGE_EXPERT`. Locking advances the claim to `status=EXPERT_REVIEWING`, `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_DAMAGE_ASSESSMENT`.\n" +
"2. **Factor validation queue** — claim status `EXPERT_VALIDATING_REPAIR_FACTORS` (or legacy `WAITING_FOR_INSURER_APPROVAL`) with `claimStatus=UNDER_REVIEW` and `workflow.currentStep=EXPERT_COST_EVALUATION`. Locking only sets the workflow lock fields; status/claimStatus/currentStep are left untouched so the claim stays in the factor-validation queue when the lock expires.\n\n" +
"Only one expert can hold the lock at a time. Re-locking by the same expert is idempotent. Same assignment logic as POST `assign/:claimRequestId`, but returns the legacy lock shape and maps conflicts to 400 instead of 409.",
deprecated: true,
})
@ApiParam({ name: "claimRequestId" })
async lockClaimRequestV2(
@Param("claimRequestId") claimRequestId: string,
@CurrentUser() actor,
) {
return await this.expertClaimService.lockClaimRequestV2(
claimRequestId,
actor,
);
}
@Put("reply/submit/:claimRequestId")
@ApiOperation({
summary: "Submit expert damage assessment reply",
description:
"**Preconditions:** claim locked by this expert (`EXPERT_REVIEWING`). **Unlocks** the claim. Each `parts[]` line needs `partId` (from GET claim detail `damagedParts[].partId`), plus pricing, `daghi`, and optional `factorNeeded`. **Cap:** sum of line `totalPayment` values ≤ 53,000,000 **Toman** (same limit as factor-validation totals across priced + factor lines). Clears any prior `evaluation.ownerInsurerApproval` / `ownerPricedPartsApproval`.\n\n" +
"**Frontend routing by `ClaimCaseStatus` (`status`):**\n" +
"- **All parts `factorNeeded`:** `OWNER_REPAIR_FACTOR_UPLOAD_PENDING`, `claimStatus=NEEDS_REVISION`, `workflow.currentStep=OWNER_UPLOAD_FACTOR_DOCUMENTS`, `workflow.nextStep=EXPERT_COST_EVALUATION` → owner uploads all factors; then `status` becomes **`EXPERT_VALIDATING_REPAIR_FACTORS`**, `claimStatus=UNDER_REVIEW`, `currentStep=EXPERT_COST_EVALUATION` for expert **validate-factors**.\n" +
"- **Mixed (some priced, some factorNeeded):** `INSURER_REVIEW_MIXED_FACTORS_PENDING`, `claimStatus=NEEDS_REVISION`, `currentStep=INSURER_REVIEW`, `nextStep=OWNER_UPLOAD_FACTOR_DOCUMENTS` → owner must call **owner-insurer-approval/sign** first (priced-line acceptance); `currentStep` then moves to `OWNER_UPLOAD_FACTOR_DOCUMENTS` (same case `status` until factors are done).\n" +
"- **No factors:** **`INSURER_REVIEW_AWAITING_OWNER_SIGN`**, `claimStatus=APPROVED`, `currentStep=INSURER_REVIEW`, `nextStep=CLAIM_COMPLETED` → owner final sign/reject only.\n\n" +
"**Legacy rows** may still use `WAITING_FOR_INSURER_APPROVAL` instead of the specific values above.\n\n" +
"**After the owner fulfilled an expert resend** (`damageExpertResend.fulfilledAt`), this expert **cannot** initiate **another resend**—use **reply/submit**, in-person visit, or **validate-factors** as appropriate.",
})
@ApiParam({ name: "claimRequestId" })
@ApiBody({ type: SubmitExpertReplyV2Dto })
async submitExpertReplyV2(
@Param("claimRequestId") claimRequestId: string,
@Body() body: SubmitExpertReplyV2Dto,
@CurrentUser() actor,
) {
return await this.expertClaimService.submitExpertReplyV2(
claimRequestId,
body,
actor,
);
}
@Put("reply/resend/:claimRequestId")
@ApiOperation({
summary: "Submit expert damage resend request",
description:
"Claim must be locked by this expert (`EXPERT_REVIEWING`). Sets `WAITING_FOR_USER_RESEND`, `USER_EXPERT_RESEND`, `NEEDS_REVISION`, clears the lock, and stores `evaluation.damageExpertResend`. " +
"Owner completes via document/capture endpoints or `POST .../expert-resend/acknowledge` when only instructions were given.\n\n" +
"**One resend per claim lifecycle:** if the owner has already fulfilled a resend (`damageExpertResend.fulfilledAt`), this endpoint returns **400**—the expert may only submit a priced reply or request in-person visit afterward.",
})
@ApiParam({ name: "claimRequestId" })
@ApiBody({ type: ClaimSubmitResendV2Dto })
async submitExpertResendV2(
@Param("claimRequestId") claimRequestId: string,
@Body() body: ClaimSubmitResendV2Dto,
@CurrentUser() actor,
) {
return await this.expertClaimService.submitResendDocsV2(
claimRequestId,
body,
actor,
);
}
@Patch(":claimRequestId/visit")
@ApiOperation({
summary: "Request in-person visit for claim",
description:
"Expert decides the user must come in person. Claim must be locked by this expert. Sets claimStatus to NEEDS_REVISION and unlocks the claim so the user is informed.",
})
@ApiParam({ name: "claimRequestId" })
@ApiBody({ type: InPersonVisitV2Dto, required: false })
async requestInPersonVisitV2(
@Param("claimRequestId") claimRequestId: string,
@Body() body: InPersonVisitV2Dto,
@CurrentUser() actor,
) {
return await this.expertClaimService.requestInPersonVisitV2(
claimRequestId,
actor,
body?.note,
);
}
@Patch("validate-factors/:claimRequestId")
@ApiOperation({
summary: "Validate uploaded repair factors (V2 ClaimCase)",
description:
"**Response:** `claimStatus` = `ClaimStatus` (e.g. APPROVED). `caseStatus` = `ClaimCaseStatus` (e.g. COMPLETED vs insurer-review) — they are not interchangeable.\n\n" +
"**Preconditions:** `status=EXPERT_VALIDATING_REPAIR_FACTORS` (or legacy `WAITING_FOR_INSURER_APPROVAL`), `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_COST_EVALUATION`, every `factorNeeded` line has `factorLink`.\n\n" +
"**Decisions:** each factor line gets `APPROVED` or `REJECTED`. **Every** decided line must include expert-entered `totalPayment` **or** both `price` and `salary` (factor photos are not read for amounts).\n\n" +
"**Cap (when every factor line is decided):** sum of **all** reply lines (priced parts + factor lines) must be ≤ **53,000,000 Toman**; otherwise `PRICE_CAP_ERROR` with message that the maximum acceptable total was exceeded.\n\n" +
"**Outcomes:**\n" +
"- **All approved:** `caseStatus=COMPLETED`, `claimStatus=APPROVED`, workflow `CLAIM_COMPLETED` — no owner signature.\n" +
"- **Any rejected (repriced):** same auto-complete for now (owner acceptance may be added later).\n" +
"- **Partial batch:** returns pending until every factor line has a non-pending decision (no cap error until the batch is complete).",
})
@ApiParam({ name: "claimRequestId" })
@ApiBody({ type: FactorValidationV2Dto })
async validateClaimFactorsV2(
@Param("claimRequestId") claimRequestId: string,
@Body() body: FactorValidationV2Dto,
@CurrentUser() actor,
) {
return await this.expertClaimService.validateClaimFactorsV2(
claimRequestId,
body,
actor,
);
}
@Patch("request/:claimRequestId/damaged-parts")
@ApiOperation({
summary: "Edit selected damaged parts (V2)",
description:
"Damage expert can modify selected damaged parts while claim is in EXPERT_REVIEWING and locked by the same expert.",
})
@ApiParam({ name: "claimRequestId" })
@ApiBody({ type: UpdateClaimDamagedPartsV2Dto })
async updateClaimDamagedPartsV2(
@Param("claimRequestId") claimRequestId: string,
@Body() body: UpdateClaimDamagedPartsV2Dto,
@CurrentUser() actor,
) {
return await this.expertClaimService.updateClaimDamagedPartsV2(
claimRequestId,
body,
actor,
);
}
@ApiQuery({
name: "query",
enum: ["car-capture", "accident"],
required: true,
})
@Get("stream/:id/video")
@Header("Accept-Ranges", "bytes")
@Header("Content-Type", "video/mp4")
async claimStream(
@Headers() headers,
@Param("id") id: string,
@Query("query") query: "car-capture" | "accident",
@Res() res,
) {
return this.expertClaimService.streamServiceV2(id, query, res, headers);
}
}