diff --git a/src/client/dto/create-branch.dto.ts b/src/client/dto/create-branch.dto.ts index fa704ea..ad9b5ce 100644 --- a/src/client/dto/create-branch.dto.ts +++ b/src/client/dto/create-branch.dto.ts @@ -1,5 +1,11 @@ import { ApiProperty } from "@nestjs/swagger"; -import { IsString, IsNotEmpty, IsOptional } from "class-validator"; +import { + IsBoolean, + IsDateString, + IsNotEmpty, + IsOptional, + IsString, +} from "class-validator"; export class CreateBranchDto { @ApiProperty({ example: "شهرک غرب" }) @@ -31,4 +37,18 @@ export class CreateBranchDto { @IsOptional() @IsString() phoneNumber?: string; + + @ApiProperty({ required: false, example: true, default: true }) + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @ApiProperty({ + required: false, + example: "2025-01-01T00:00:00.000Z", + description: "Branch activity start datetime (ISO string)", + }) + @IsOptional() + @IsDateString() + activityStartDate?: string; } diff --git a/src/client/entities/db-service/branch.db.service.ts b/src/client/entities/db-service/branch.db.service.ts index 66b79ed..8e40fa3 100644 --- a/src/client/entities/db-service/branch.db.service.ts +++ b/src/client/entities/db-service/branch.db.service.ts @@ -25,7 +25,71 @@ export class BranchDbService { async findAll(insuranceId: string): Promise { return await this.branchModel.find({ clientKey: new Types.ObjectId(insuranceId), - }, { _id: 1, name: 1, code: 1, city: 1, state: 1, address: 1, phoneNumber: 1, createdAtFa: 1, updatedAtFa: 1 }); + }); + } + + async findAllWithFilters( + insuranceId: string, + opts?: { + search?: string; + from?: Date; + to?: Date; + isActive?: boolean; + }, + ): Promise { + const filter: any = { clientKey: new Types.ObjectId(insuranceId) }; + + if (typeof opts?.isActive === "boolean") { + filter.isActive = opts.isActive; + } + + if (opts?.from || opts?.to) { + filter.$or = [ + { + createdAt: { + ...(opts.from ? { $gte: opts.from } : {}), + ...(opts.to ? { $lte: opts.to } : {}), + }, + }, + { + activityStartDate: { + ...(opts.from ? { $gte: opts.from } : {}), + ...(opts.to ? { $lte: opts.to } : {}), + }, + }, + ]; + } + + if (opts?.search?.trim()) { + const q = opts.search.trim(); + const rx = new RegExp(q, "i"); + filter.$and = [ + ...(filter.$and || []), + { + $or: [ + { name: rx }, + { code: rx }, + { city: rx }, + { state: rx }, + { address: rx }, + { phoneNumber: rx }, + ], + }, + ]; + } + + return this.branchModel.find(filter).lean(); + } + + async findByIds(ids: Types.ObjectId[]) { + return this.branchModel + .find({ _id: { $in: ids } }) + .select({ _id: 1, name: 1, code: 1, city: 1, state: 1, address: 1, phoneNumber: 1, isActive: 1, activityStartDate: 1, createdAt: 1, updatedAt: 1 }) + .lean(); + } + + async findByIdAndUpdate(id: string, update: any): Promise { + return this.branchModel.findByIdAndUpdate(id, update, { new: true }).lean(); } async findById(id: string): Promise { diff --git a/src/client/entities/schema/branch.schema.ts b/src/client/entities/schema/branch.schema.ts index 86d9b44..2f4ffe3 100644 --- a/src/client/entities/schema/branch.schema.ts +++ b/src/client/entities/schema/branch.schema.ts @@ -25,6 +25,12 @@ export class BranchModel { @Prop() phoneNumber?: string; + + @Prop({ type: Boolean, default: true }) + isActive?: boolean; + + @Prop({ type: Date }) + activityStartDate?: Date; } export const BranchSchema = SchemaFactory.createForClass(BranchModel); diff --git a/src/expert-insurer/expert-insurer.controller.ts b/src/expert-insurer/expert-insurer.controller.ts index f0193be..7282b06 100644 --- a/src/expert-insurer/expert-insurer.controller.ts +++ b/src/expert-insurer/expert-insurer.controller.ts @@ -41,12 +41,35 @@ export class ExpertInsurerController { constructor(private readonly expertInsurerService: ExpertInsurerService) {} @Get("branches") - async getInsuranceBranches(@CurrentUser() insurer) { + @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 }, ); } @@ -66,6 +89,29 @@ export class ExpertInsurerController { ); } + @Put("branches/:branchId/status") + @ApiParam({ name: "branchId" }) + @ApiQuery({ name: "isActive", type: Boolean }) + async setBranchStatus( + @CurrentUser() insurer, + @Param("branchId") branchId: string, + @Query("isActive") isActive: string, + ) { + if (!insurer) { + throw new UnauthorizedException("Could not identify the current user."); + } + const normalized = String(isActive).trim().toLowerCase(); + if (!["true", "false", "1", "0", "yes", "no"].includes(normalized)) { + throw new BadRequestException("isActive must be true/false"); + } + const active = ["true", "1", "yes"].includes(normalized); + return this.expertInsurerService.setBranchActive( + insurer.clientKey, + branchId, + active, + ); + } + @Post("experts/blame") @ApiBody({ type: CreateBlameExpertByInsurerDto }) async addBlameExpert( diff --git a/src/expert-insurer/expert-insurer.service.ts b/src/expert-insurer/expert-insurer.service.ts index cb33bdd..135a7b5 100644 --- a/src/expert-insurer/expert-insurer.service.ts +++ b/src/expert-insurer/expert-insurer.service.ts @@ -1207,12 +1207,66 @@ export class ExpertInsurerService { const newBranch = await this.branchDbService.create({ ...branchDto, + isActive: branchDto.isActive ?? true, + activityStartDate: branchDto.activityStartDate + ? new Date(branchDto.activityStartDate) + : undefined, clientKey: clientId, }); return newBranch; } + private parseOptionalBoolean(value?: string): boolean | undefined { + if (value == null || value === "") return undefined; + const normalized = String(value).trim().toLowerCase(); + if (["true", "1", "yes"].includes(normalized)) return true; + if (["false", "0", "no"].includes(normalized)) return false; + throw new BadRequestException("Invalid boolean value for 'isActive'"); + } + + private buildHandlingBranchStatusSets() { + const blameInHandling = new Set([ + CaseStatus.OPEN, + CaseStatus.WAITING_FOR_SECOND_PARTY, + CaseStatus.WAITING_FOR_EXPERT, + CaseStatus.WAITING_FOR_DOCUMENT_RESEND, + CaseStatus.WAITING_FOR_SIGNATURES, + ]); + const claimInHandling = new Set([ + ClaimCaseStatus.CREATED, + ClaimCaseStatus.SELECTING_OUTER_PARTS, + ClaimCaseStatus.SELECTING_OTHER_PARTS, + ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS, + ClaimCaseStatus.CAPTURING_PART_DAMAGES, + ClaimCaseStatus.WAITING_FOR_USER_RESEND, + ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT, + ClaimCaseStatus.EXPERT_REVIEWING, + ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL, + ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN, + ClaimCaseStatus.INSURER_REVIEW_MIXED_FACTORS_PENDING, + ClaimCaseStatus.OWNER_REPAIR_FACTOR_UPLOAD_PENDING, + ClaimCaseStatus.EXPERT_VALIDATING_REPAIR_FACTORS, + ]); + return { blameInHandling, claimInHandling }; + } + + async setBranchActive( + clientKey: string, + branchId: string, + isActive: boolean, + ) { + const clientId = this.getClientId(clientKey); + await this.assertBranchBelongsToClient(branchId, clientId); + const updated = await this.branchDbService.findByIdAndUpdate(branchId, { + $set: { isActive }, + }); + if (!updated) { + throw new NotFoundException("Branch not found"); + } + return updated; + } + private async assertBranchBelongsToClient( branchId: string, clientId: Types.ObjectId, @@ -1445,8 +1499,108 @@ export class ExpertInsurerService { }; } - async retrieveInsuranceBranches(insuranceId: string) { - return this.branchDbService.findAll(insuranceId); + async retrieveInsuranceBranches( + insuranceId: string, + opts?: { + search?: string; + from?: string; + to?: string; + isActive?: string; + }, + ) { + const clientObjectId = this.getClientId(insuranceId); + const { fromDate, toDate } = this.parseDateRange(opts?.from, opts?.to); + const isActiveFilter = this.parseOptionalBoolean(opts?.isActive); + const branches = await this.branchDbService.findAllWithFilters(String(clientObjectId), { + search: opts?.search, + from: fromDate, + to: toDate, + isActive: isActiveFilter, + }); + + const branchIdSet = new Set(branches.map((b: any) => String(b._id))); + const ckFilter = this.clientKeyScopeFilter(clientObjectId); + const [experts, damageExperts, blameFiles, claimFiles] = await Promise.all([ + this.expertDbService.findAll(ckFilter as never), + this.damageExpertDbService.findAll(ckFilter as never), + this.getClientBlameFiles(clientObjectId), + this.getClientClaimFiles(clientObjectId), + ]); + + const activeExpertsByBranch = new Map>(); + const expertBranchById = new Map(); + const claimExpertBranchById = new Map(); + for (const e of experts as any[]) { + const bid = e?.branchId ? String(e.branchId) : ""; + if (!bid || !branchIdSet.has(bid)) continue; + const eid = String(e?._id || ""); + if (!eid) continue; + expertBranchById.set(eid, bid); + if (!activeExpertsByBranch.has(bid)) activeExpertsByBranch.set(bid, new Set()); + activeExpertsByBranch.get(bid)!.add(eid); + } + for (const e of damageExperts as any[]) { + const bid = e?.branchId ? String(e.branchId) : ""; + if (!bid || !branchIdSet.has(bid)) continue; + const eid = String(e?._id || ""); + if (!eid) continue; + claimExpertBranchById.set(eid, bid); + if (!activeExpertsByBranch.has(bid)) activeExpertsByBranch.set(bid, new Set()); + activeExpertsByBranch.get(bid)!.add(eid); + } + + const completedByBranch = new Map>(); + const handlingByBranch = new Map>(); + const { blameInHandling, claimInHandling } = this.buildHandlingBranchStatusSets(); + const addPublicId = (map: Map>, branchId: string, fileKey: string) => { + if (!map.has(branchId)) map.set(branchId, new Set()); + map.get(branchId)!.add(fileKey); + }; + + for (const b of blameFiles as any[]) { + const expertId = String(b?.expert?.assignedExpertId || ""); + if (!expertId) continue; + const bid = expertBranchById.get(expertId); + if (!bid) continue; + const fileKey = String(b?.publicId || b?._id || ""); + if (!fileKey) continue; + const status = String(b?.status || ""); + if (status === CaseStatus.COMPLETED) addPublicId(completedByBranch, bid, fileKey); + if (blameInHandling.has(status)) addPublicId(handlingByBranch, bid, fileKey); + } + + for (const c of claimFiles as any[]) { + const expertId = String(this.claimDamageExpertActorId(c) || ""); + if (!expertId) continue; + const bid = claimExpertBranchById.get(expertId); + if (!bid) continue; + const fileKey = String(c?.publicId || c?._id || ""); + if (!fileKey) continue; + const status = String(c?.status || ""); + if (status === ClaimCaseStatus.COMPLETED) addPublicId(completedByBranch, bid, fileKey); + if (claimInHandling.has(status)) addPublicId(handlingByBranch, bid, fileKey); + } + + const list = branches.map((branch: any) => { + const bid = String(branch._id); + return { + ...branch, + totalActiveExperts: activeExpertsByBranch.get(bid)?.size ?? 0, + totalFinishedFiles: completedByBranch.get(bid)?.size ?? 0, + totalFilesInHandling: handlingByBranch.get(bid)?.size ?? 0, + }; + }); + + return { + total: list.length, + filters: { + search: opts?.search ?? null, + from: fromDate ?? null, + to: toDate ?? null, + isActive: isActiveFilter ?? null, + }, + branches: list, + }; } private parseDateRange(from?: string, to?: string) {