- 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>
362 lines
10 KiB
TypeScript
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;
|
|
}
|
|
}
|