/** * DepartamentoService - Servicio de departamentos/unidades * * Gestión de departamentos/unidades dentro de niveles de construcción vertical. * * @module Construction (MAI-002) */ import { Repository } from 'typeorm'; import { Departamento } from '../entities/departamento.entity'; interface ServiceContext { tenantId: string; userId?: string; } export interface CreateDepartamentoDto { nivelId: string; prototipoId?: string; code: string; unitNumber: string; areaM2?: number; status?: string; priceBase?: number; priceFinal?: number; } export interface UpdateDepartamentoDto { prototipoId?: string; unitNumber?: string; areaM2?: number; status?: string; priceBase?: number; priceFinal?: number; } export interface SellDepartamentoDto { buyerId: string; priceFinal: number; saleDate?: Date; deliveryDate?: Date; } export interface DepartamentoFilters { nivelId?: string; torreId?: string; prototipoId?: string; status?: string; minPrice?: number; maxPrice?: number; search?: string; page?: number; limit?: number; } export class DepartamentoService { constructor( private readonly repository: Repository, ) {} async findAll( ctx: ServiceContext, filters: DepartamentoFilters = {}, ): Promise<{ data: Departamento[]; total: number; page: number; limit: number }> { const page = filters.page || 1; const limit = filters.limit || 50; const skip = (page - 1) * limit; const queryBuilder = this.repository .createQueryBuilder('d') .leftJoinAndSelect('d.nivel', 'nivel') .leftJoinAndSelect('nivel.torre', 'torre') .leftJoinAndSelect('d.prototipo', 'prototipo') .where('d.tenant_id = :tenantId', { tenantId: ctx.tenantId }) .andWhere('d.deleted_at IS NULL'); if (filters.nivelId) { queryBuilder.andWhere('d.nivel_id = :nivelId', { nivelId: filters.nivelId }); } if (filters.torreId) { queryBuilder.andWhere('nivel.torre_id = :torreId', { torreId: filters.torreId }); } if (filters.prototipoId) { queryBuilder.andWhere('d.prototipo_id = :prototipoId', { prototipoId: filters.prototipoId }); } if (filters.status) { queryBuilder.andWhere('d.status = :status', { status: filters.status }); } if (filters.minPrice !== undefined) { queryBuilder.andWhere('d.price_final >= :minPrice', { minPrice: filters.minPrice }); } if (filters.maxPrice !== undefined) { queryBuilder.andWhere('d.price_final <= :maxPrice', { maxPrice: filters.maxPrice }); } if (filters.search) { queryBuilder.andWhere( '(d.code ILIKE :search OR d.unit_number ILIKE :search)', { search: `%${filters.search}%` }, ); } const [data, total] = await queryBuilder .orderBy('d.code', 'ASC') .skip(skip) .take(limit) .getManyAndCount(); return { data, total, page, limit }; } async findById(ctx: ServiceContext, id: string): Promise { return this.repository.findOne({ where: { id, tenantId: ctx.tenantId, }, relations: ['nivel', 'nivel.torre', 'prototipo'], }); } async findByCode(ctx: ServiceContext, nivelId: string, code: string): Promise { return this.repository.findOne({ where: { nivelId, code, tenantId: ctx.tenantId, }, }); } async findByNivel(ctx: ServiceContext, nivelId: string): Promise { return this.repository .createQueryBuilder('d') .leftJoinAndSelect('d.prototipo', 'prototipo') .where('d.tenant_id = :tenantId', { tenantId: ctx.tenantId }) .andWhere('d.deleted_at IS NULL') .andWhere('d.nivel_id = :nivelId', { nivelId }) .orderBy('d.code', 'ASC') .getMany(); } async findByTorre(ctx: ServiceContext, torreId: string): Promise { return this.repository .createQueryBuilder('d') .leftJoinAndSelect('d.nivel', 'nivel') .leftJoinAndSelect('d.prototipo', 'prototipo') .where('d.tenant_id = :tenantId', { tenantId: ctx.tenantId }) .andWhere('d.deleted_at IS NULL') .andWhere('nivel.torre_id = :torreId', { torreId }) .orderBy('nivel.floor_number', 'ASC') .addOrderBy('d.code', 'ASC') .getMany(); } async findAvailable(ctx: ServiceContext, torreId?: string): Promise { const queryBuilder = this.repository .createQueryBuilder('d') .leftJoinAndSelect('d.nivel', 'nivel') .leftJoinAndSelect('nivel.torre', 'torre') .leftJoinAndSelect('d.prototipo', 'prototipo') .where('d.tenant_id = :tenantId', { tenantId: ctx.tenantId }) .andWhere('d.deleted_at IS NULL') .andWhere('d.status = :status', { status: 'available' }); if (torreId) { queryBuilder.andWhere('nivel.torre_id = :torreId', { torreId }); } return queryBuilder .orderBy('torre.code', 'ASC') .addOrderBy('nivel.floor_number', 'ASC') .addOrderBy('d.code', 'ASC') .getMany(); } async create(ctx: ServiceContext, dto: CreateDepartamentoDto): Promise { const existing = await this.findByCode(ctx, dto.nivelId, dto.code); if (existing) { throw new Error(`Departamento with code ${dto.code} already exists in this nivel`); } const departamento = this.repository.create({ tenantId: ctx.tenantId, createdBy: ctx.userId, nivelId: dto.nivelId, prototipoId: dto.prototipoId, code: dto.code, unitNumber: dto.unitNumber, areaM2: dto.areaM2, status: dto.status ?? 'available', priceBase: dto.priceBase, priceFinal: dto.priceFinal ?? dto.priceBase, }); return this.repository.save(departamento); } async update(ctx: ServiceContext, id: string, dto: UpdateDepartamentoDto): Promise { const departamento = await this.findById(ctx, id); if (!departamento) { return null; } if (departamento.status === 'sold' || departamento.status === 'delivered') { throw new Error('Cannot update a sold or delivered unit'); } Object.assign(departamento, { ...dto, updatedBy: ctx.userId, }); return this.repository.save(departamento); } async sell(ctx: ServiceContext, id: string, dto: SellDepartamentoDto): Promise { const departamento = await this.findById(ctx, id); if (!departamento) { return null; } if (departamento.status !== 'available' && departamento.status !== 'reserved') { throw new Error('Unit is not available for sale'); } departamento.buyerId = dto.buyerId; departamento.priceFinal = dto.priceFinal; departamento.saleDate = dto.saleDate ?? new Date(); if (dto.deliveryDate) { departamento.deliveryDate = dto.deliveryDate; } departamento.status = 'sold'; if (ctx.userId) { departamento.updatedBy = ctx.userId; } return this.repository.save(departamento); } async reserve(ctx: ServiceContext, id: string, buyerId: string): Promise { const departamento = await this.findById(ctx, id); if (!departamento) { return null; } if (departamento.status !== 'available') { throw new Error('Unit is not available for reservation'); } departamento.buyerId = buyerId; departamento.status = 'reserved'; if (ctx.userId) { departamento.updatedBy = ctx.userId; } return this.repository.save(departamento); } async cancelReservation(ctx: ServiceContext, id: string): Promise { const departamento = await this.findById(ctx, id); if (!departamento) { return null; } if (departamento.status !== 'reserved') { throw new Error('Unit is not reserved'); } departamento.buyerId = null as any; departamento.status = 'available'; if (ctx.userId) { departamento.updatedBy = ctx.userId; } return this.repository.save(departamento); } async markDelivered(ctx: ServiceContext, id: string, deliveryDate?: Date): Promise { const departamento = await this.findById(ctx, id); if (!departamento) { return null; } if (departamento.status !== 'sold') { throw new Error('Unit must be sold before delivery'); } departamento.deliveryDate = deliveryDate ?? new Date(); departamento.status = 'delivered'; if (ctx.userId) { departamento.updatedBy = ctx.userId; } return this.repository.save(departamento); } async getStatistics(ctx: ServiceContext, torreId?: string): Promise<{ totalUnits: number; available: number; reserved: number; sold: number; delivered: number; totalValue: number; soldValue: number; averagePrice: number; }> { const queryBuilder = this.repository .createQueryBuilder('d') .leftJoin('d.nivel', 'nivel') .where('d.tenant_id = :tenantId', { tenantId: ctx.tenantId }) .andWhere('d.deleted_at IS NULL'); if (torreId) { queryBuilder.andWhere('nivel.torre_id = :torreId', { torreId }); } const departamentos = await queryBuilder.getMany(); const available = departamentos.filter(d => d.status === 'available'); const reserved = departamentos.filter(d => d.status === 'reserved'); const sold = departamentos.filter(d => d.status === 'sold'); const delivered = departamentos.filter(d => d.status === 'delivered'); const totalValue = departamentos.reduce((sum, d) => sum + Number(d.priceFinal || 0), 0); const soldValue = [...sold, ...delivered].reduce((sum, d) => sum + Number(d.priceFinal || 0), 0); return { totalUnits: departamentos.length, available: available.length, reserved: reserved.length, sold: sold.length, delivered: delivered.length, totalValue, soldValue, averagePrice: departamentos.length > 0 ? totalValue / departamentos.length : 0, }; } async softDelete(ctx: ServiceContext, id: string): Promise { const departamento = await this.findById(ctx, id); if (!departamento) { return false; } if (departamento.status === 'sold' || departamento.status === 'delivered') { throw new Error('Cannot delete a sold or delivered unit'); } const result = await this.repository.update( { id, tenantId: ctx.tenantId }, { deletedAt: new Date(), deletedBy: ctx.userId }, ); return (result.affected ?? 0) > 0; } }