- Partners: Moved services to services/ directory, consolidated duplicates, kept singleton pattern version with ranking service - Products: Moved service to services/, removed duplicate class-based version, kept singleton with deletedAt filtering - Reports: Moved service to services/, kept raw SQL version for active controller - Warehouses: Moved service to services/, removed duplicate class-based version, kept singleton with proper tenant isolation All modules now follow consistent structure: - services/*.service.ts for business logic - services/index.ts for exports - Controllers import from ./services/index.js Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
300 lines
8.5 KiB
TypeScript
300 lines
8.5 KiB
TypeScript
import { Repository, IsNull } from 'typeorm';
|
|
import { AppDataSource } from '../../../config/typeorm.js';
|
|
import { Warehouse } from '../../warehouses/entities/warehouse.entity.js';
|
|
import { Location } from '../entities/location.entity.js';
|
|
import { StockQuant } from '../entities/stock-quant.entity.js';
|
|
import { NotFoundError, ValidationError, ConflictError } from '../../../shared/types/index.js';
|
|
import { logger } from '../../../shared/utils/logger.js';
|
|
|
|
// ===== Interfaces =====
|
|
|
|
export interface CreateWarehouseDto {
|
|
companyId?: string;
|
|
name: string;
|
|
code: string;
|
|
description?: string;
|
|
addressLine1?: string;
|
|
city?: string;
|
|
state?: string;
|
|
postalCode?: string;
|
|
isDefault?: boolean;
|
|
}
|
|
|
|
export interface UpdateWarehouseDto {
|
|
name?: string;
|
|
description?: string;
|
|
addressLine1?: string;
|
|
city?: string;
|
|
state?: string;
|
|
postalCode?: string;
|
|
isDefault?: boolean;
|
|
isActive?: boolean;
|
|
}
|
|
|
|
export interface WarehouseFilters {
|
|
companyId?: string;
|
|
isActive?: boolean;
|
|
page?: number;
|
|
limit?: number;
|
|
}
|
|
|
|
export interface WarehouseWithRelations extends Warehouse {
|
|
companyName?: string;
|
|
}
|
|
|
|
// ===== Service Class =====
|
|
|
|
class WarehousesService {
|
|
private warehouseRepository: Repository<Warehouse>;
|
|
private locationRepository: Repository<Location>;
|
|
private stockQuantRepository: Repository<StockQuant>;
|
|
|
|
constructor() {
|
|
this.warehouseRepository = AppDataSource.getRepository(Warehouse);
|
|
this.locationRepository = AppDataSource.getRepository(Location);
|
|
this.stockQuantRepository = AppDataSource.getRepository(StockQuant);
|
|
}
|
|
|
|
async findAll(
|
|
tenantId: string,
|
|
filters: WarehouseFilters = {}
|
|
): Promise<{ data: WarehouseWithRelations[]; total: number }> {
|
|
try {
|
|
const { companyId, isActive, page = 1, limit = 50 } = filters;
|
|
const skip = (page - 1) * limit;
|
|
|
|
const queryBuilder = this.warehouseRepository
|
|
.createQueryBuilder('warehouse')
|
|
.leftJoinAndSelect('warehouse.company', 'company')
|
|
.where('warehouse.tenantId = :tenantId', { tenantId });
|
|
|
|
if (companyId) {
|
|
queryBuilder.andWhere('warehouse.companyId = :companyId', { companyId });
|
|
}
|
|
|
|
if (isActive !== undefined) {
|
|
queryBuilder.andWhere('warehouse.isActive = :isActive', { isActive });
|
|
}
|
|
|
|
const total = await queryBuilder.getCount();
|
|
|
|
const warehouses = await queryBuilder
|
|
.orderBy('warehouse.name', 'ASC')
|
|
.skip(skip)
|
|
.take(limit)
|
|
.getMany();
|
|
|
|
const data: WarehouseWithRelations[] = warehouses.map(w => ({
|
|
...w,
|
|
companyName: w.company?.name,
|
|
}));
|
|
|
|
logger.debug('Warehouses retrieved', { tenantId, count: data.length, total });
|
|
|
|
return { data, total };
|
|
} catch (error) {
|
|
logger.error('Error retrieving warehouses', {
|
|
error: (error as Error).message,
|
|
tenantId,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async findById(id: string, tenantId: string): Promise<WarehouseWithRelations> {
|
|
try {
|
|
const warehouse = await this.warehouseRepository
|
|
.createQueryBuilder('warehouse')
|
|
.leftJoinAndSelect('warehouse.company', 'company')
|
|
.where('warehouse.id = :id', { id })
|
|
.andWhere('warehouse.tenantId = :tenantId', { tenantId })
|
|
.getOne();
|
|
|
|
if (!warehouse) {
|
|
throw new NotFoundError('Almacén no encontrado');
|
|
}
|
|
|
|
return {
|
|
...warehouse,
|
|
companyName: warehouse.company?.name,
|
|
};
|
|
} catch (error) {
|
|
logger.error('Error finding warehouse', {
|
|
error: (error as Error).message,
|
|
id,
|
|
tenantId,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async create(dto: CreateWarehouseDto, tenantId: string, userId: string): Promise<Warehouse> {
|
|
try {
|
|
// Check unique code within company
|
|
const existing = await this.warehouseRepository.findOne({
|
|
where: {
|
|
companyId: dto.companyId,
|
|
code: dto.code,
|
|
},
|
|
});
|
|
|
|
if (existing) {
|
|
throw new ConflictError(`Ya existe un almacén con código ${dto.code} en esta empresa`);
|
|
}
|
|
|
|
// If is_default, clear other defaults for company
|
|
if (dto.isDefault) {
|
|
await this.warehouseRepository.update(
|
|
{ companyId: dto.companyId, tenantId },
|
|
{ isDefault: false }
|
|
);
|
|
}
|
|
|
|
const warehouseData: Partial<Warehouse> = {
|
|
tenantId,
|
|
companyId: dto.companyId,
|
|
name: dto.name,
|
|
code: dto.code,
|
|
description: dto.description,
|
|
addressLine1: dto.addressLine1,
|
|
city: dto.city,
|
|
state: dto.state,
|
|
postalCode: dto.postalCode,
|
|
isDefault: dto.isDefault || false,
|
|
createdBy: userId,
|
|
};
|
|
const warehouse = this.warehouseRepository.create(warehouseData as Warehouse);
|
|
|
|
await this.warehouseRepository.save(warehouse);
|
|
|
|
logger.info('Warehouse created', {
|
|
warehouseId: warehouse.id,
|
|
tenantId,
|
|
name: warehouse.name,
|
|
createdBy: userId,
|
|
});
|
|
|
|
return warehouse;
|
|
} catch (error) {
|
|
logger.error('Error creating warehouse', {
|
|
error: (error as Error).message,
|
|
tenantId,
|
|
dto,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async update(id: string, dto: UpdateWarehouseDto, tenantId: string, userId: string): Promise<Warehouse> {
|
|
try {
|
|
const existing = await this.findById(id, tenantId);
|
|
|
|
// If setting as default, clear other defaults
|
|
if (dto.isDefault) {
|
|
await this.warehouseRepository
|
|
.createQueryBuilder()
|
|
.update(Warehouse)
|
|
.set({ isDefault: false })
|
|
.where('companyId = :companyId', { companyId: existing.companyId })
|
|
.andWhere('tenantId = :tenantId', { tenantId })
|
|
.andWhere('id != :id', { id })
|
|
.execute();
|
|
}
|
|
|
|
if (dto.name !== undefined) existing.name = dto.name;
|
|
if (dto.description !== undefined) existing.description = dto.description;
|
|
if (dto.addressLine1 !== undefined) existing.addressLine1 = dto.addressLine1;
|
|
if (dto.city !== undefined) existing.city = dto.city;
|
|
if (dto.state !== undefined) existing.state = dto.state;
|
|
if (dto.postalCode !== undefined) existing.postalCode = dto.postalCode;
|
|
if (dto.isDefault !== undefined) existing.isDefault = dto.isDefault;
|
|
if (dto.isActive !== undefined) existing.isActive = dto.isActive;
|
|
|
|
existing.updatedBy = userId;
|
|
|
|
await this.warehouseRepository.save(existing);
|
|
|
|
logger.info('Warehouse updated', {
|
|
warehouseId: id,
|
|
tenantId,
|
|
updatedBy: userId,
|
|
});
|
|
|
|
return await this.findById(id, tenantId);
|
|
} catch (error) {
|
|
logger.error('Error updating warehouse', {
|
|
error: (error as Error).message,
|
|
id,
|
|
tenantId,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async delete(id: string, tenantId: string): Promise<void> {
|
|
try {
|
|
await this.findById(id, tenantId);
|
|
|
|
// Check if warehouse has locations with stock
|
|
const hasStock = await this.stockQuantRepository
|
|
.createQueryBuilder('sq')
|
|
.innerJoin('sq.location', 'location')
|
|
.where('location.warehouseId = :warehouseId', { warehouseId: id })
|
|
.andWhere('sq.quantity > 0')
|
|
.getCount();
|
|
|
|
if (hasStock > 0) {
|
|
throw new ConflictError('No se puede eliminar un almacén que tiene stock');
|
|
}
|
|
|
|
await this.warehouseRepository.delete({ id, tenantId });
|
|
|
|
logger.info('Warehouse deleted', {
|
|
warehouseId: id,
|
|
tenantId,
|
|
});
|
|
} catch (error) {
|
|
logger.error('Error deleting warehouse', {
|
|
error: (error as Error).message,
|
|
id,
|
|
tenantId,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async getLocations(warehouseId: string, tenantId: string): Promise<Location[]> {
|
|
await this.findById(warehouseId, tenantId);
|
|
|
|
return this.locationRepository.find({
|
|
where: {
|
|
warehouseId,
|
|
tenantId,
|
|
},
|
|
order: { name: 'ASC' },
|
|
});
|
|
}
|
|
|
|
async getStock(warehouseId: string, tenantId: string): Promise<any[]> {
|
|
await this.findById(warehouseId, tenantId);
|
|
|
|
const stock = await this.stockQuantRepository
|
|
.createQueryBuilder('sq')
|
|
.innerJoinAndSelect('sq.product', 'product')
|
|
.innerJoinAndSelect('sq.location', 'location')
|
|
.where('location.warehouseId = :warehouseId', { warehouseId })
|
|
.orderBy('product.name', 'ASC')
|
|
.addOrderBy('location.name', 'ASC')
|
|
.getMany();
|
|
|
|
return stock.map(sq => ({
|
|
...sq,
|
|
productName: sq.product?.name,
|
|
productCode: sq.product?.code,
|
|
locationName: sq.location?.name,
|
|
}));
|
|
}
|
|
}
|
|
|
|
export const warehousesService = new WarehousesService();
|