erp-construccion-backend-v2/src/modules/construction/services/departamento.service.ts
Adrian Flores Cortes 6a71183121 feat(MAI-002,MAI-004,MAI-009): Add missing controllers and services
- construction/torre: Service + Controller for vertical buildings
- construction/nivel: Service + Controller for floor levels
- construction/departamento: Service + Controller for units
- purchase/purchase-order-construction: Controller for PO extensions
- purchase/supplier-construction: Controller for supplier extensions
- quality/checklist: Controller for inspection templates

All endpoints follow existing patterns with multi-tenancy support.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 17:39:51 -06:00

362 lines
10 KiB
TypeScript

/**
* 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<Departamento>,
) {}
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<Departamento | null> {
return this.repository.findOne({
where: {
id,
tenantId: ctx.tenantId,
},
relations: ['nivel', 'nivel.torre', 'prototipo'],
});
}
async findByCode(ctx: ServiceContext, nivelId: string, code: string): Promise<Departamento | null> {
return this.repository.findOne({
where: {
nivelId,
code,
tenantId: ctx.tenantId,
},
});
}
async findByNivel(ctx: ServiceContext, nivelId: string): Promise<Departamento[]> {
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<Departamento[]> {
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<Departamento[]> {
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<Departamento> {
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<Departamento | null> {
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<Departamento | null> {
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<Departamento | null> {
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<Departamento | null> {
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<Departamento | null> {
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<boolean> {
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;
}
}