feat(routes): Add independent routes for Invoices, Products, Warehouses modules

- Create invoices.controller.ts and invoices.routes.ts with singleton service
- Create products.service.ts, products.controller.ts, products.routes.ts
- Create warehouses.service.ts, warehouses.controller.ts, warehouses.routes.ts
- Register all routes in app.ts
- Use Zod validation schemas in all controllers
- Apply multi-tenant isolation via tenantId
- Update invoices.module.ts to use singleton pattern

All business modules now have API routes registered and build passes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-18 03:43:43 -06:00
parent d456ad4aca
commit a127a4a424
13 changed files with 1842 additions and 55 deletions

View File

@ -372,12 +372,10 @@ export function initializeModules(
}
// Initialize Invoices Module
// Note: Invoices now uses routes-based approach via invoices.routes.ts in app.ts
if (config.invoices?.enabled) {
const invoicesModule = new InvoicesModule({
dataSource,
basePath: config.invoices.basePath,
});
app.use(invoicesModule.router);
const invoicesModuleInstance = new InvoicesModule();
app.use(invoicesModuleInstance.router);
console.log('✅ Invoices module initialized');
}

View File

@ -24,6 +24,9 @@ import systemRoutes from './modules/system/system.routes.js';
import crmRoutes from './modules/crm/crm.routes.js';
import hrRoutes from './modules/hr/hr.routes.js';
import reportsRoutes from './modules/reports/reports.routes.js';
import invoicesRoutes from './modules/invoices/invoices.routes.js';
import productsRoutes from './modules/products/products.routes.js';
import warehousesRoutes from './modules/warehouses/warehouses.routes.js';
const app: Application = express();
@ -73,6 +76,9 @@ app.use(`${apiPrefix}/system`, systemRoutes);
app.use(`${apiPrefix}/crm`, crmRoutes);
app.use(`${apiPrefix}/hr`, hrRoutes);
app.use(`${apiPrefix}/reports`, reportsRoutes);
app.use(`${apiPrefix}/invoices`, invoicesRoutes);
app.use(`${apiPrefix}/products`, productsRoutes);
app.use(`${apiPrefix}/warehouses`, warehousesRoutes);
// 404 handler
app.use((_req: Request, res: Response) => {

View File

@ -1,5 +1,4 @@
export { InvoicesModule, InvoicesModuleOptions } from './invoices.module';
export * from './entities';
export { InvoicesService } from './services';
export { InvoicesController, PaymentsController } from './controllers';
export * from './dto';
export { InvoicesModule, invoicesModule } from './invoices.module.js';
export * from './entities/index.js';
export { InvoicesService, invoicesService } from './services/index.js';
export { invoicesController } from './invoices.controller.js';

View File

@ -0,0 +1,301 @@
import { Response, NextFunction } from 'express';
import { z } from 'zod';
import { invoicesService, CreateInvoiceDto, UpdateInvoiceDto, CreatePaymentDto } from './services/index.js';
import { ApiResponse, AuthenticatedRequest, ValidationError } from '../../shared/types/index.js';
// Validation schemas
const createInvoiceSchema = z.object({
invoiceType: z.enum(['sale', 'purchase', 'credit_note', 'debit_note']),
partnerId: z.string().uuid('Partner ID inválido'),
invoiceDate: z.string().optional(),
dueDate: z.string().optional(),
currency: z.string().length(3).default('MXN'),
subtotal: z.coerce.number().optional(),
taxAmount: z.coerce.number().optional(),
totalAmount: z.coerce.number().optional(),
notes: z.string().optional(),
});
const updateInvoiceSchema = z.object({
partnerId: z.string().uuid().optional(),
invoiceDate: z.string().optional(),
dueDate: z.string().optional(),
currency: z.string().length(3).optional(),
subtotal: z.coerce.number().optional(),
taxAmount: z.coerce.number().optional(),
totalAmount: z.coerce.number().optional(),
notes: z.string().optional().nullable(),
status: z.enum(['draft', 'validated', 'paid', 'cancelled']).optional(),
});
const querySchema = z.object({
invoiceType: z.enum(['sale', 'purchase', 'credit_note', 'debit_note']).optional(),
partnerId: z.string().uuid().optional(),
status: z.enum(['draft', 'validated', 'paid', 'cancelled']).optional(),
limit: z.coerce.number().int().positive().max(100).default(50),
offset: z.coerce.number().int().min(0).default(0),
});
const createPaymentSchema = z.object({
partnerId: z.string().uuid('Partner ID inválido'),
paymentType: z.enum(['received', 'made']),
paymentMethod: z.string().min(1, 'Método de pago requerido'),
amount: z.coerce.number().positive('Monto debe ser positivo'),
currency: z.string().length(3).default('MXN'),
paymentDate: z.string().optional(),
reference: z.string().optional(),
notes: z.string().optional(),
partnerName: z.string().optional(),
});
const paymentQuerySchema = z.object({
partnerId: z.string().uuid().optional(),
limit: z.coerce.number().int().positive().max(100).default(50),
offset: z.coerce.number().int().min(0).default(0),
});
class InvoicesController {
// ========== INVOICES ==========
async findAll(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const validation = querySchema.safeParse(req.query);
if (!validation.success) {
throw new ValidationError('Parámetros de consulta inválidos', validation.error.errors);
}
const result = await invoicesService.findAllInvoices({
tenantId,
...validation.data,
});
res.json({ success: true, ...result } as ApiResponse);
} catch (error) {
next(error);
}
}
async findById(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const invoice = await invoicesService.findInvoice(req.params.id, tenantId);
if (!invoice) {
return res.status(404).json({ success: false, error: 'Factura no encontrada' } as ApiResponse);
}
res.json({ success: true, data: invoice } as ApiResponse);
} catch (error) {
next(error);
}
}
async create(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
const userId = req.user?.userId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const validation = createInvoiceSchema.safeParse(req.body);
if (!validation.success) {
throw new ValidationError('Datos de factura inválidos', validation.error.errors);
}
const invoice = await invoicesService.createInvoice(tenantId, validation.data as CreateInvoiceDto, userId);
res.status(201).json({ success: true, data: invoice } as ApiResponse);
} catch (error) {
next(error);
}
}
async update(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
const userId = req.user?.userId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const validation = updateInvoiceSchema.safeParse(req.body);
if (!validation.success) {
throw new ValidationError('Datos de actualización inválidos', validation.error.errors);
}
const invoice = await invoicesService.updateInvoice(req.params.id, tenantId, validation.data as UpdateInvoiceDto, userId);
if (!invoice) {
return res.status(404).json({ success: false, error: 'Factura no encontrada' } as ApiResponse);
}
res.json({ success: true, data: invoice } as ApiResponse);
} catch (error) {
next(error);
}
}
async delete(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const deleted = await invoicesService.deleteInvoice(req.params.id, tenantId);
if (!deleted) {
return res.status(404).json({ success: false, error: 'Factura no encontrada' } as ApiResponse);
}
res.status(204).send();
} catch (error) {
next(error);
}
}
async validate(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
const userId = req.user?.userId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const invoice = await invoicesService.validateInvoice(req.params.id, tenantId, userId);
if (!invoice) {
return res.status(400).json({ success: false, error: 'No se puede validar la factura' } as ApiResponse);
}
res.json({ success: true, data: invoice } as ApiResponse);
} catch (error) {
next(error);
}
}
async cancel(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
const userId = req.user?.userId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const invoice = await invoicesService.cancelInvoice(req.params.id, tenantId, userId);
if (!invoice) {
return res.status(400).json({ success: false, error: 'No se puede cancelar la factura' } as ApiResponse);
}
res.json({ success: true, data: invoice } as ApiResponse);
} catch (error) {
next(error);
}
}
// ========== PAYMENTS ==========
async findAllPayments(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const validation = paymentQuerySchema.safeParse(req.query);
if (!validation.success) {
throw new ValidationError('Parámetros de consulta inválidos', validation.error.errors);
}
const { partnerId, limit, offset } = validation.data;
const result = await invoicesService.findAllPayments(tenantId, partnerId, limit, offset);
res.json({ success: true, ...result } as ApiResponse);
} catch (error) {
next(error);
}
}
async findPaymentById(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const payment = await invoicesService.findPayment(req.params.id, tenantId);
if (!payment) {
return res.status(404).json({ success: false, error: 'Pago no encontrado' } as ApiResponse);
}
res.json({ success: true, data: payment } as ApiResponse);
} catch (error) {
next(error);
}
}
async createPayment(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
const userId = req.user?.userId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const validation = createPaymentSchema.safeParse(req.body);
if (!validation.success) {
throw new ValidationError('Datos de pago inválidos', validation.error.errors);
}
const payment = await invoicesService.createPayment(tenantId, validation.data as CreatePaymentDto, userId);
res.status(201).json({ success: true, data: payment } as ApiResponse);
} catch (error) {
next(error);
}
}
async confirmPayment(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
const userId = req.user?.userId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const payment = await invoicesService.confirmPayment(req.params.id, tenantId, userId);
if (!payment) {
return res.status(400).json({ success: false, error: 'No se puede confirmar el pago' } as ApiResponse);
}
res.json({ success: true, data: payment } as ApiResponse);
} catch (error) {
next(error);
}
}
async cancelPayment(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
const userId = req.user?.userId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const payment = await invoicesService.cancelPayment(req.params.id, tenantId, userId);
if (!payment) {
return res.status(400).json({ success: false, error: 'No se puede cancelar el pago' } as ApiResponse);
}
res.json({ success: true, data: payment } as ApiResponse);
} catch (error) {
next(error);
}
}
}
export const invoicesController = new InvoicesController();

View File

@ -1,42 +1,34 @@
import { Router } from 'express';
import { DataSource } from 'typeorm';
import { InvoicesService } from './services';
import { InvoicesController, PaymentsController } from './controllers';
import { Invoice, Payment } from './entities';
export interface InvoicesModuleOptions {
dataSource: DataSource;
basePath?: string;
}
import { invoicesService } from './services/index.js';
import { Invoice, Payment, InvoiceItem, PaymentAllocation } from './entities/index.js';
/**
* Invoices Module - Provides invoice and payment management functionality
*
* This module is kept for backwards compatibility but the recommended
* approach is to use the routes directly via invoices.routes.ts
*/
export class InvoicesModule {
public router: Router;
public invoicesService: InvoicesService;
private dataSource: DataSource;
private basePath: string;
constructor(options: InvoicesModuleOptions) {
this.dataSource = options.dataSource;
this.basePath = options.basePath || '';
constructor() {
this.router = Router();
this.initializeServices();
this.initializeRoutes();
}
private initializeServices(): void {
const invoiceRepository = this.dataSource.getRepository(Invoice);
const paymentRepository = this.dataSource.getRepository(Payment);
this.invoicesService = new InvoicesService(invoiceRepository, paymentRepository);
}
private initializeRoutes(): void {
const invoicesController = new InvoicesController(this.invoicesService);
const paymentsController = new PaymentsController(this.invoicesService);
this.router.use(`${this.basePath}/invoices`, invoicesController.router);
this.router.use(`${this.basePath}/payments`, paymentsController.router);
/**
* Get service instance
*/
getService() {
return invoicesService;
}
/**
* Get all entities managed by this module
*/
static getEntities(): Function[] {
return [Invoice, Payment];
return [Invoice, InvoiceItem, Payment, PaymentAllocation];
}
}
// Export module instance
export const invoicesModule = new InvoicesModule();

View File

@ -0,0 +1,64 @@
import { Router } from 'express';
import { invoicesController } from './invoices.controller.js';
import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js';
const router = Router();
// All routes require authentication
router.use(authenticate);
// ========== PAYMENTS (must be before /:id to avoid conflicts) ==========
// List payments
router.get('/payments', (req, res, next) => invoicesController.findAllPayments(req, res, next));
// Get payment by ID
router.get('/payments/:id', (req, res, next) => invoicesController.findPaymentById(req, res, next));
// Create payment
router.post('/payments', requireRoles('admin', 'manager', 'accountant', 'super_admin'), (req, res, next) =>
invoicesController.createPayment(req, res, next)
);
// Confirm payment
router.post('/payments/:id/confirm', requireRoles('admin', 'manager', 'accountant', 'super_admin'), (req, res, next) =>
invoicesController.confirmPayment(req, res, next)
);
// Cancel payment
router.post('/payments/:id/cancel', requireRoles('admin', 'manager', 'accountant', 'super_admin'), (req, res, next) =>
invoicesController.cancelPayment(req, res, next)
);
// ========== INVOICES ==========
// List invoices
router.get('/', (req, res, next) => invoicesController.findAll(req, res, next));
// Get invoice by ID
router.get('/:id', (req, res, next) => invoicesController.findById(req, res, next));
// Create invoice
router.post('/', requireRoles('admin', 'manager', 'accountant', 'super_admin'), (req, res, next) =>
invoicesController.create(req, res, next)
);
// Update invoice
router.patch('/:id', requireRoles('admin', 'manager', 'accountant', 'super_admin'), (req, res, next) =>
invoicesController.update(req, res, next)
);
// Delete invoice
router.delete('/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
invoicesController.delete(req, res, next)
);
// Validate invoice (change status to validated)
router.post('/:id/validate', requireRoles('admin', 'manager', 'accountant', 'super_admin'), (req, res, next) =>
invoicesController.validate(req, res, next)
);
// Cancel invoice
router.post('/:id/cancel', requireRoles('admin', 'manager', 'accountant', 'super_admin'), (req, res, next) =>
invoicesController.cancel(req, res, next)
);
export default router;

View File

@ -1,6 +1,6 @@
import { Repository, FindOptionsWhere } from 'typeorm';
import { Invoice, Payment } from '../entities';
import { CreateInvoiceDto, UpdateInvoiceDto, CreatePaymentDto } from '../dto';
import { FindOptionsWhere, IsNull } from 'typeorm';
import { AppDataSource } from '../../../config/typeorm.js';
import { Invoice, Payment, InvoiceStatus } from '../entities/index.js';
export interface InvoiceSearchParams {
tenantId: string;
@ -11,31 +11,87 @@ export interface InvoiceSearchParams {
offset?: number;
}
export interface CreateInvoiceDto {
invoiceType: 'sale' | 'purchase' | 'credit_note' | 'debit_note';
partnerId: string;
invoiceDate?: string;
dueDate?: string;
currency?: string;
subtotal?: number;
taxAmount?: number;
totalAmount?: number;
notes?: string;
}
export interface UpdateInvoiceDto {
partnerId?: string;
invoiceDate?: string;
dueDate?: string;
currency?: string;
subtotal?: number;
taxAmount?: number;
totalAmount?: number;
notes?: string;
status?: InvoiceStatus;
}
export interface CreatePaymentDto {
partnerId: string;
paymentType: 'received' | 'made';
paymentMethod: string;
amount: number;
currency?: string;
paymentDate?: string;
reference?: string;
notes?: string;
partnerName?: string;
}
export class InvoicesService {
constructor(
private readonly invoiceRepository: Repository<Invoice>,
private readonly paymentRepository: Repository<Payment>
) {}
private get invoiceRepository() {
return AppDataSource.getRepository(Invoice);
}
private get paymentRepository() {
return AppDataSource.getRepository(Payment);
}
async findAllInvoices(params: InvoiceSearchParams): Promise<{ data: Invoice[]; total: number }> {
const { tenantId, invoiceType, partnerId, status, limit = 50, offset = 0 } = params;
const where: FindOptionsWhere<Invoice> = { tenantId };
const where: FindOptionsWhere<Invoice> = { tenantId, deletedAt: IsNull() };
if (invoiceType) where.invoiceType = invoiceType as any;
if (partnerId) where.partnerId = partnerId;
if (status) where.status = status as any;
const [data, total] = await this.invoiceRepository.findAndCount({ where, take: limit, skip: offset, order: { createdAt: 'DESC' } });
const [data, total] = await this.invoiceRepository.findAndCount({
where,
take: limit,
skip: offset,
order: { createdAt: 'DESC' },
relations: ['items'],
});
return { data, total };
}
async findInvoice(id: string, tenantId: string): Promise<Invoice | null> {
return this.invoiceRepository.findOne({ where: { id, tenantId } });
return this.invoiceRepository.findOne({
where: { id, tenantId, deletedAt: IsNull() },
relations: ['items'],
});
}
async createInvoice(tenantId: string, dto: CreateInvoiceDto, createdBy?: string): Promise<Invoice> {
const count = await this.invoiceRepository.count({ where: { tenantId, invoiceType: dto.invoiceType } });
const prefix = dto.invoiceType === 'sale' ? 'FAC' : dto.invoiceType === 'purchase' ? 'FP' : dto.invoiceType === 'credit_note' ? 'NC' : 'ND';
const invoiceNumber = `${prefix}-${String(count + 1).padStart(6, '0')}`;
const invoice = this.invoiceRepository.create({ ...dto, tenantId, invoiceNumber, createdBy, invoiceDate: dto.invoiceDate ? new Date(dto.invoiceDate) : new Date(), dueDate: dto.dueDate ? new Date(dto.dueDate) : undefined });
const invoice = this.invoiceRepository.create({
...dto,
tenantId,
invoiceNumber,
createdBy,
invoiceDate: dto.invoiceDate ? new Date(dto.invoiceDate) : new Date(),
dueDate: dto.dueDate ? new Date(dto.dueDate) : undefined,
status: 'draft',
});
return this.invoiceRepository.save(invoice);
}
@ -59,28 +115,58 @@ export class InvoicesService {
return this.invoiceRepository.save(invoice);
}
async cancelInvoice(id: string, tenantId: string, userId?: string): Promise<Invoice | null> {
const invoice = await this.findInvoice(id, tenantId);
if (!invoice || invoice.status === 'cancelled') return null;
invoice.status = 'cancelled';
invoice.updatedBy = userId ?? null;
return this.invoiceRepository.save(invoice);
}
async findAllPayments(tenantId: string, partnerId?: string, limit = 50, offset = 0): Promise<{ data: Payment[]; total: number }> {
const where: FindOptionsWhere<Payment> = { tenantId };
const where: FindOptionsWhere<Payment> = { tenantId, deletedAt: IsNull() };
if (partnerId) where.partnerId = partnerId;
const [data, total] = await this.paymentRepository.findAndCount({ where, take: limit, skip: offset, order: { createdAt: 'DESC' } });
const [data, total] = await this.paymentRepository.findAndCount({
where,
take: limit,
skip: offset,
order: { createdAt: 'DESC' },
});
return { data, total };
}
async findPayment(id: string, tenantId: string): Promise<Payment | null> {
return this.paymentRepository.findOne({ where: { id, tenantId } });
return this.paymentRepository.findOne({ where: { id, tenantId, deletedAt: IsNull() } });
}
async createPayment(tenantId: string, dto: CreatePaymentDto, createdBy?: string): Promise<Payment> {
const count = await this.paymentRepository.count({ where: { tenantId } });
const paymentNumber = `PAG-${String(count + 1).padStart(6, '0')}`;
const payment = this.paymentRepository.create({ ...dto, tenantId, paymentNumber, createdBy, paymentDate: dto.paymentDate ? new Date(dto.paymentDate) : new Date() });
const payment = this.paymentRepository.create({
...dto,
tenantId,
paymentNumber,
createdBy,
paymentDate: dto.paymentDate ? new Date(dto.paymentDate) : new Date(),
status: 'draft',
});
return this.paymentRepository.save(payment);
}
async confirmPayment(id: string, tenantId: string, userId?: string): Promise<Payment | null> {
async confirmPayment(id: string, tenantId: string, _userId?: string): Promise<Payment | null> {
const payment = await this.findPayment(id, tenantId);
if (!payment || payment.status !== 'draft') return null;
payment.status = 'confirmed';
return this.paymentRepository.save(payment);
}
async cancelPayment(id: string, tenantId: string, _userId?: string): Promise<Payment | null> {
const payment = await this.findPayment(id, tenantId);
if (!payment || payment.status === 'cancelled') return null;
payment.status = 'cancelled';
return this.paymentRepository.save(payment);
}
}
// ===== Export Singleton Instance =====
export const invoicesService = new InvoicesService();

View File

@ -0,0 +1,346 @@
import { Response, NextFunction } from 'express';
import { z } from 'zod';
import { productsService, CreateProductDto, UpdateProductDto, CreateCategoryDto, UpdateCategoryDto } from './products.service.js';
import { ApiResponse, AuthenticatedRequest, ValidationError } from '../../shared/types/index.js';
// Validation schemas
const createProductSchema = z.object({
sku: z.string().min(1, 'SKU es requerido').max(50),
name: z.string().min(1, 'Nombre es requerido').max(200),
description: z.string().optional(),
shortName: z.string().max(50).optional(),
barcode: z.string().max(50).optional(),
categoryId: z.string().uuid().optional(),
productType: z.enum(['product', 'service', 'consumable', 'kit']).default('product'),
salePrice: z.coerce.number().min(0).optional(),
costPrice: z.coerce.number().min(0).optional(),
currency: z.string().length(3).default('MXN'),
taxRate: z.coerce.number().min(0).max(100).optional(),
isActive: z.boolean().default(true),
isSellable: z.boolean().default(true),
isPurchasable: z.boolean().default(true),
});
const updateProductSchema = z.object({
sku: z.string().min(1).max(50).optional(),
name: z.string().min(1).max(200).optional(),
description: z.string().optional().nullable(),
shortName: z.string().max(50).optional().nullable(),
barcode: z.string().max(50).optional().nullable(),
categoryId: z.string().uuid().optional().nullable(),
productType: z.enum(['product', 'service', 'consumable', 'kit']).optional(),
salePrice: z.coerce.number().min(0).optional(),
costPrice: z.coerce.number().min(0).optional(),
currency: z.string().length(3).optional(),
taxRate: z.coerce.number().min(0).max(100).optional(),
isActive: z.boolean().optional(),
isSellable: z.boolean().optional(),
isPurchasable: z.boolean().optional(),
});
const querySchema = z.object({
search: z.string().optional(),
categoryId: z.string().uuid().optional(),
productType: z.enum(['product', 'service', 'consumable', 'kit']).optional(),
isActive: z.coerce.boolean().optional(),
isSellable: z.coerce.boolean().optional(),
isPurchasable: z.coerce.boolean().optional(),
limit: z.coerce.number().int().positive().max(100).default(50),
offset: z.coerce.number().int().min(0).default(0),
});
const createCategorySchema = z.object({
code: z.string().min(1, 'Código es requerido').max(30),
name: z.string().min(1, 'Nombre es requerido').max(100),
description: z.string().optional(),
parentId: z.string().uuid().optional(),
isActive: z.boolean().default(true),
});
const updateCategorySchema = z.object({
code: z.string().min(1).max(30).optional(),
name: z.string().min(1).max(100).optional(),
description: z.string().optional().nullable(),
parentId: z.string().uuid().optional().nullable(),
isActive: z.boolean().optional(),
});
const categoryQuerySchema = z.object({
search: z.string().optional(),
parentId: z.string().uuid().optional(),
isActive: z.coerce.boolean().optional(),
limit: z.coerce.number().int().positive().max(100).default(50),
offset: z.coerce.number().int().min(0).default(0),
});
class ProductsControllerClass {
// ========== PRODUCTS ==========
async findAll(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const validation = querySchema.safeParse(req.query);
if (!validation.success) {
throw new ValidationError('Parámetros de consulta inválidos', validation.error.errors);
}
const result = await productsService.findAll({ tenantId, ...validation.data });
res.json({ success: true, ...result } as ApiResponse);
} catch (error) {
next(error);
}
}
async findById(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const product = await productsService.findOne(req.params.id, tenantId);
if (!product) {
return res.status(404).json({ success: false, error: 'Producto no encontrado' } as ApiResponse);
}
res.json({ success: true, data: product } as ApiResponse);
} catch (error) {
next(error);
}
}
async findBySku(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const product = await productsService.findBySku(req.params.sku, tenantId);
if (!product) {
return res.status(404).json({ success: false, error: 'Producto no encontrado' } as ApiResponse);
}
res.json({ success: true, data: product } as ApiResponse);
} catch (error) {
next(error);
}
}
async findByBarcode(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const product = await productsService.findByBarcode(req.params.barcode, tenantId);
if (!product) {
return res.status(404).json({ success: false, error: 'Producto no encontrado' } as ApiResponse);
}
res.json({ success: true, data: product } as ApiResponse);
} catch (error) {
next(error);
}
}
async create(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
const userId = req.user?.userId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const validation = createProductSchema.safeParse(req.body);
if (!validation.success) {
throw new ValidationError('Datos de producto inválidos', validation.error.errors);
}
const product = await productsService.create(tenantId, validation.data as CreateProductDto, userId);
res.status(201).json({ success: true, data: product } as ApiResponse);
} catch (error) {
next(error);
}
}
async update(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
const userId = req.user?.userId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const validation = updateProductSchema.safeParse(req.body);
if (!validation.success) {
throw new ValidationError('Datos de actualización inválidos', validation.error.errors);
}
const product = await productsService.update(req.params.id, tenantId, validation.data as UpdateProductDto, userId);
if (!product) {
return res.status(404).json({ success: false, error: 'Producto no encontrado' } as ApiResponse);
}
res.json({ success: true, data: product } as ApiResponse);
} catch (error) {
next(error);
}
}
async delete(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const deleted = await productsService.delete(req.params.id, tenantId);
if (!deleted) {
return res.status(404).json({ success: false, error: 'Producto no encontrado' } as ApiResponse);
}
res.status(204).send();
} catch (error) {
next(error);
}
}
async getSellable(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const { limit, offset } = querySchema.parse(req.query);
const result = await productsService.getSellableProducts(tenantId, limit, offset);
res.json({ success: true, ...result } as ApiResponse);
} catch (error) {
next(error);
}
}
async getPurchasable(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const { limit, offset } = querySchema.parse(req.query);
const result = await productsService.getPurchasableProducts(tenantId, limit, offset);
res.json({ success: true, ...result } as ApiResponse);
} catch (error) {
next(error);
}
}
// ========== CATEGORIES ==========
async findAllCategories(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const validation = categoryQuerySchema.safeParse(req.query);
if (!validation.success) {
throw new ValidationError('Parámetros de consulta inválidos', validation.error.errors);
}
const result = await productsService.findAllCategories({ tenantId, ...validation.data });
res.json({ success: true, ...result } as ApiResponse);
} catch (error) {
next(error);
}
}
async findCategoryById(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const category = await productsService.findCategory(req.params.id, tenantId);
if (!category) {
return res.status(404).json({ success: false, error: 'Categoría no encontrada' } as ApiResponse);
}
res.json({ success: true, data: category } as ApiResponse);
} catch (error) {
next(error);
}
}
async createCategory(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
const userId = req.user?.userId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const validation = createCategorySchema.safeParse(req.body);
if (!validation.success) {
throw new ValidationError('Datos de categoría inválidos', validation.error.errors);
}
const category = await productsService.createCategory(tenantId, validation.data as CreateCategoryDto, userId);
res.status(201).json({ success: true, data: category } as ApiResponse);
} catch (error) {
next(error);
}
}
async updateCategory(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
const userId = req.user?.userId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const validation = updateCategorySchema.safeParse(req.body);
if (!validation.success) {
throw new ValidationError('Datos de actualización inválidos', validation.error.errors);
}
const category = await productsService.updateCategory(req.params.id, tenantId, validation.data as UpdateCategoryDto, userId);
if (!category) {
return res.status(404).json({ success: false, error: 'Categoría no encontrada' } as ApiResponse);
}
res.json({ success: true, data: category } as ApiResponse);
} catch (error) {
next(error);
}
}
async deleteCategory(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const deleted = await productsService.deleteCategory(req.params.id, tenantId);
if (!deleted) {
return res.status(404).json({ success: false, error: 'Categoría no encontrada' } as ApiResponse);
}
res.status(204).send();
} catch (error) {
next(error);
}
}
}
export const productsController = new ProductsControllerClass();

View File

@ -0,0 +1,67 @@
import { Router } from 'express';
import { productsController } from './products.controller.js';
import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js';
const router = Router();
// All routes require authentication
router.use(authenticate);
// ========== CATEGORIES (must be before :id routes) ==========
// List categories
router.get('/categories', (req, res, next) => productsController.findAllCategories(req, res, next));
// Get category by ID
router.get('/categories/:id', (req, res, next) => productsController.findCategoryById(req, res, next));
// Create category
router.post('/categories', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
productsController.createCategory(req, res, next)
);
// Update category
router.patch('/categories/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
productsController.updateCategory(req, res, next)
);
// Delete category
router.delete('/categories/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
productsController.deleteCategory(req, res, next)
);
// ========== CONVENIENCE ROUTES ==========
// Get sellable products
router.get('/sellable', (req, res, next) => productsController.getSellable(req, res, next));
// Get purchasable products
router.get('/purchasable', (req, res, next) => productsController.getPurchasable(req, res, next));
// Get product by SKU
router.get('/sku/:sku', (req, res, next) => productsController.findBySku(req, res, next));
// Get product by Barcode
router.get('/barcode/:barcode', (req, res, next) => productsController.findByBarcode(req, res, next));
// ========== PRODUCTS ==========
// List products
router.get('/', (req, res, next) => productsController.findAll(req, res, next));
// Get product by ID
router.get('/:id', (req, res, next) => productsController.findById(req, res, next));
// Create product
router.post('/', requireRoles('admin', 'manager', 'inventory', 'super_admin'), (req, res, next) =>
productsController.create(req, res, next)
);
// Update product
router.patch('/:id', requireRoles('admin', 'manager', 'inventory', 'super_admin'), (req, res, next) =>
productsController.update(req, res, next)
);
// Delete product
router.delete('/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
productsController.delete(req, res, next)
);
export default router;

View File

@ -0,0 +1,261 @@
import { FindOptionsWhere, ILike, IsNull } from 'typeorm';
import { AppDataSource } from '../../config/typeorm.js';
import { Product } from './entities/product.entity.js';
import { ProductCategory } from './entities/product-category.entity.js';
export interface ProductSearchParams {
tenantId: string;
search?: string;
categoryId?: string;
productType?: 'product' | 'service' | 'consumable' | 'kit';
isActive?: boolean;
isSellable?: boolean;
isPurchasable?: boolean;
limit?: number;
offset?: number;
}
export interface CategorySearchParams {
tenantId: string;
search?: string;
parentId?: string;
isActive?: boolean;
limit?: number;
offset?: number;
}
export interface CreateProductDto {
sku: string;
name: string;
description?: string;
shortName?: string;
barcode?: string;
categoryId?: string;
productType?: 'product' | 'service' | 'consumable' | 'kit';
salePrice?: number;
costPrice?: number;
currency?: string;
taxRate?: number;
isActive?: boolean;
isSellable?: boolean;
isPurchasable?: boolean;
}
export interface UpdateProductDto {
sku?: string;
name?: string;
description?: string | null;
shortName?: string | null;
barcode?: string | null;
categoryId?: string | null;
productType?: 'product' | 'service' | 'consumable' | 'kit';
salePrice?: number;
costPrice?: number;
currency?: string;
taxRate?: number;
isActive?: boolean;
isSellable?: boolean;
isPurchasable?: boolean;
}
export interface CreateCategoryDto {
code: string;
name: string;
description?: string;
parentId?: string;
isActive?: boolean;
}
export interface UpdateCategoryDto {
code?: string;
name?: string;
description?: string | null;
parentId?: string | null;
isActive?: boolean;
}
class ProductsServiceClass {
private get productRepository() {
return AppDataSource.getRepository(Product);
}
private get categoryRepository() {
return AppDataSource.getRepository(ProductCategory);
}
// ==================== Products ====================
async findAll(params: ProductSearchParams): Promise<{ data: Product[]; total: number }> {
const {
tenantId,
search,
categoryId,
productType,
isActive,
isSellable,
isPurchasable,
limit = 50,
offset = 0,
} = params;
const where: FindOptionsWhere<Product>[] = [];
const baseWhere: FindOptionsWhere<Product> = { tenantId, deletedAt: IsNull() };
if (categoryId) {
baseWhere.categoryId = categoryId;
}
if (productType) {
baseWhere.productType = productType;
}
if (isActive !== undefined) {
baseWhere.isActive = isActive;
}
if (isSellable !== undefined) {
baseWhere.isSellable = isSellable;
}
if (isPurchasable !== undefined) {
baseWhere.isPurchasable = isPurchasable;
}
if (search) {
where.push(
{ ...baseWhere, name: ILike(`%${search}%`) },
{ ...baseWhere, sku: ILike(`%${search}%`) },
{ ...baseWhere, barcode: ILike(`%${search}%`) }
);
} else {
where.push(baseWhere);
}
const [data, total] = await this.productRepository.findAndCount({
where,
relations: ['category'],
take: limit,
skip: offset,
order: { name: 'ASC' },
});
return { data, total };
}
async findOne(id: string, tenantId: string): Promise<Product | null> {
return this.productRepository.findOne({
where: { id, tenantId, deletedAt: IsNull() },
relations: ['category'],
});
}
async findBySku(sku: string, tenantId: string): Promise<Product | null> {
return this.productRepository.findOne({
where: { sku, tenantId, deletedAt: IsNull() },
relations: ['category'],
});
}
async findByBarcode(barcode: string, tenantId: string): Promise<Product | null> {
return this.productRepository.findOne({
where: { barcode, tenantId, deletedAt: IsNull() },
relations: ['category'],
});
}
async create(tenantId: string, dto: CreateProductDto, createdBy?: string): Promise<Product> {
const product = this.productRepository.create({
...dto,
tenantId,
createdBy,
});
return this.productRepository.save(product);
}
async update(id: string, tenantId: string, dto: UpdateProductDto, updatedBy?: string): Promise<Product | null> {
const product = await this.findOne(id, tenantId);
if (!product) return null;
Object.assign(product, { ...dto, updatedBy });
return this.productRepository.save(product);
}
async delete(id: string, tenantId: string): Promise<boolean> {
const result = await this.productRepository.softDelete({ id, tenantId });
return (result.affected ?? 0) > 0;
}
async getSellableProducts(tenantId: string, limit = 50, offset = 0): Promise<{ data: Product[]; total: number }> {
return this.findAll({ tenantId, isSellable: true, isActive: true, limit, offset });
}
async getPurchasableProducts(tenantId: string, limit = 50, offset = 0): Promise<{ data: Product[]; total: number }> {
return this.findAll({ tenantId, isPurchasable: true, isActive: true, limit, offset });
}
// ==================== Categories ====================
async findAllCategories(params: CategorySearchParams): Promise<{ data: ProductCategory[]; total: number }> {
const { tenantId, search, parentId, isActive, limit = 50, offset = 0 } = params;
const where: FindOptionsWhere<ProductCategory> = { tenantId, deletedAt: IsNull() };
if (parentId) {
where.parentId = parentId;
}
if (isActive !== undefined) {
where.isActive = isActive;
}
if (search) {
const [data, total] = await this.categoryRepository.findAndCount({
where: [
{ ...where, name: ILike(`%${search}%`) },
{ ...where, code: ILike(`%${search}%`) },
],
take: limit,
skip: offset,
order: { name: 'ASC' },
});
return { data, total };
}
const [data, total] = await this.categoryRepository.findAndCount({
where,
take: limit,
skip: offset,
order: { sortOrder: 'ASC', name: 'ASC' },
});
return { data, total };
}
async findCategory(id: string, tenantId: string): Promise<ProductCategory | null> {
return this.categoryRepository.findOne({
where: { id, tenantId, deletedAt: IsNull() },
});
}
async createCategory(tenantId: string, dto: CreateCategoryDto, _createdBy?: string): Promise<ProductCategory> {
const category = this.categoryRepository.create({
...dto,
tenantId,
});
return this.categoryRepository.save(category);
}
async updateCategory(id: string, tenantId: string, dto: UpdateCategoryDto, _updatedBy?: string): Promise<ProductCategory | null> {
const category = await this.findCategory(id, tenantId);
if (!category) return null;
Object.assign(category, dto);
return this.categoryRepository.save(category);
}
async deleteCategory(id: string, tenantId: string): Promise<boolean> {
const result = await this.categoryRepository.softDelete({ id, tenantId });
return (result.affected ?? 0) > 0;
}
}
// Export singleton instance
export const productsService = new ProductsServiceClass();

View File

@ -0,0 +1,338 @@
import { Response, NextFunction } from 'express';
import { z } from 'zod';
import { warehousesService, CreateWarehouseDto, UpdateWarehouseDto, CreateLocationDto, UpdateLocationDto } from './warehouses.service.js';
import { ApiResponse, AuthenticatedRequest, ValidationError } from '../../shared/types/index.js';
// Validation schemas
const createWarehouseSchema = z.object({
code: z.string().min(1, 'Código es requerido').max(20),
name: z.string().min(1, 'Nombre es requerido').max(100),
address: z.string().max(200).optional(),
city: z.string().max(100).optional(),
state: z.string().max(100).optional(),
country: z.string().max(3).default('MEX'),
postalCode: z.string().max(20).optional(),
phone: z.string().max(30).optional(),
email: z.string().email().max(255).optional(),
isActive: z.boolean().default(true),
isDefault: z.boolean().default(false),
});
const updateWarehouseSchema = z.object({
code: z.string().min(1).max(20).optional(),
name: z.string().min(1).max(100).optional(),
address: z.string().max(200).optional().nullable(),
city: z.string().max(100).optional().nullable(),
state: z.string().max(100).optional().nullable(),
country: z.string().max(3).optional(),
postalCode: z.string().max(20).optional().nullable(),
phone: z.string().max(30).optional().nullable(),
email: z.string().email().max(255).optional().nullable(),
isActive: z.boolean().optional(),
isDefault: z.boolean().optional(),
});
const querySchema = z.object({
search: z.string().optional(),
isActive: z.coerce.boolean().optional(),
limit: z.coerce.number().int().positive().max(100).default(50),
offset: z.coerce.number().int().min(0).default(0),
});
const createLocationSchema = z.object({
warehouseId: z.string().uuid('Warehouse ID inválido'),
code: z.string().min(1, 'Código es requerido').max(30),
name: z.string().min(1, 'Nombre es requerido').max(100),
parentId: z.string().uuid().optional(),
locationType: z.enum(['zone', 'aisle', 'rack', 'shelf', 'bin']).default('shelf'),
barcode: z.string().max(50).optional(),
isActive: z.boolean().default(true),
});
const updateLocationSchema = z.object({
code: z.string().min(1).max(30).optional(),
name: z.string().min(1).max(100).optional(),
parentId: z.string().uuid().optional().nullable(),
locationType: z.enum(['zone', 'aisle', 'rack', 'shelf', 'bin']).optional(),
barcode: z.string().max(50).optional().nullable(),
isActive: z.boolean().optional(),
});
const locationQuerySchema = z.object({
warehouseId: z.string().uuid().optional(),
parentId: z.string().uuid().optional(),
locationType: z.enum(['zone', 'aisle', 'rack', 'shelf', 'bin']).optional(),
isActive: z.coerce.boolean().optional(),
limit: z.coerce.number().int().positive().max(100).default(50),
offset: z.coerce.number().int().min(0).default(0),
});
class WarehousesControllerClass {
// ========== WAREHOUSES ==========
async findAll(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const validation = querySchema.safeParse(req.query);
if (!validation.success) {
throw new ValidationError('Parámetros de consulta inválidos', validation.error.errors);
}
const result = await warehousesService.findAll({ tenantId, ...validation.data });
res.json({ success: true, ...result } as ApiResponse);
} catch (error) {
next(error);
}
}
async findById(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const warehouse = await warehousesService.findOne(req.params.id, tenantId);
if (!warehouse) {
return res.status(404).json({ success: false, error: 'Almacén no encontrado' } as ApiResponse);
}
res.json({ success: true, data: warehouse } as ApiResponse);
} catch (error) {
next(error);
}
}
async findByCode(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const warehouse = await warehousesService.findByCode(req.params.code, tenantId);
if (!warehouse) {
return res.status(404).json({ success: false, error: 'Almacén no encontrado' } as ApiResponse);
}
res.json({ success: true, data: warehouse } as ApiResponse);
} catch (error) {
next(error);
}
}
async getDefault(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const warehouse = await warehousesService.getDefault(tenantId);
if (!warehouse) {
return res.status(404).json({ success: false, error: 'No hay almacén por defecto' } as ApiResponse);
}
res.json({ success: true, data: warehouse } as ApiResponse);
} catch (error) {
next(error);
}
}
async getActive(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const warehouses = await warehousesService.getActive(tenantId);
res.json({ success: true, data: warehouses, total: warehouses.length } as ApiResponse);
} catch (error) {
next(error);
}
}
async create(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
const userId = req.user?.userId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const validation = createWarehouseSchema.safeParse(req.body);
if (!validation.success) {
throw new ValidationError('Datos de almacén inválidos', validation.error.errors);
}
const warehouse = await warehousesService.create(tenantId, validation.data as CreateWarehouseDto, userId);
res.status(201).json({ success: true, data: warehouse } as ApiResponse);
} catch (error) {
next(error);
}
}
async update(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
const userId = req.user?.userId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const validation = updateWarehouseSchema.safeParse(req.body);
if (!validation.success) {
throw new ValidationError('Datos de actualización inválidos', validation.error.errors);
}
const warehouse = await warehousesService.update(req.params.id, tenantId, validation.data as UpdateWarehouseDto, userId);
if (!warehouse) {
return res.status(404).json({ success: false, error: 'Almacén no encontrado' } as ApiResponse);
}
res.json({ success: true, data: warehouse } as ApiResponse);
} catch (error) {
next(error);
}
}
async delete(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const deleted = await warehousesService.delete(req.params.id, tenantId);
if (!deleted) {
return res.status(404).json({ success: false, error: 'Almacén no encontrado' } as ApiResponse);
}
res.status(204).send();
} catch (error) {
next(error);
}
}
// ========== LOCATIONS ==========
async findAllLocations(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const validation = locationQuerySchema.safeParse(req.query);
if (!validation.success) {
throw new ValidationError('Parámetros de consulta inválidos', validation.error.errors);
}
const result = await warehousesService.findAllLocations({ tenantId, ...validation.data });
res.json({ success: true, ...result } as ApiResponse);
} catch (error) {
next(error);
}
}
async findLocationById(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const location = await warehousesService.findLocation(req.params.id, tenantId);
if (!location) {
return res.status(404).json({ success: false, error: 'Ubicación no encontrada' } as ApiResponse);
}
res.json({ success: true, data: location } as ApiResponse);
} catch (error) {
next(error);
}
}
async getWarehouseLocations(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const locations = await warehousesService.getLocationsByWarehouse(req.params.warehouseId, tenantId);
res.json({ success: true, data: locations, total: locations.length } as ApiResponse);
} catch (error) {
next(error);
}
}
async createLocation(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
const userId = req.user?.userId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const validation = createLocationSchema.safeParse(req.body);
if (!validation.success) {
throw new ValidationError('Datos de ubicación inválidos', validation.error.errors);
}
const location = await warehousesService.createLocation(tenantId, validation.data as CreateLocationDto, userId);
res.status(201).json({ success: true, data: location } as ApiResponse);
} catch (error) {
next(error);
}
}
async updateLocation(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
const userId = req.user?.userId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const validation = updateLocationSchema.safeParse(req.body);
if (!validation.success) {
throw new ValidationError('Datos de actualización inválidos', validation.error.errors);
}
const location = await warehousesService.updateLocation(req.params.id, tenantId, validation.data as UpdateLocationDto, userId);
if (!location) {
return res.status(404).json({ success: false, error: 'Ubicación no encontrada' } as ApiResponse);
}
res.json({ success: true, data: location } as ApiResponse);
} catch (error) {
next(error);
}
}
async deleteLocation(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tenantId = req.user?.tenantId;
if (!tenantId) {
return res.status(400).json({ success: false, error: 'Tenant ID required' } as ApiResponse);
}
const deleted = await warehousesService.deleteLocation(req.params.id, tenantId);
if (!deleted) {
return res.status(404).json({ success: false, error: 'Ubicación no encontrada' } as ApiResponse);
}
res.status(204).send();
} catch (error) {
next(error);
}
}
}
export const warehousesController = new WarehousesControllerClass();

View File

@ -0,0 +1,67 @@
import { Router } from 'express';
import { warehousesController } from './warehouses.controller.js';
import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js';
const router = Router();
// All routes require authentication
router.use(authenticate);
// ========== LOCATIONS (before :id routes to avoid conflicts) ==========
// List all locations
router.get('/locations', (req, res, next) => warehousesController.findAllLocations(req, res, next));
// Get location by ID
router.get('/locations/:id', (req, res, next) => warehousesController.findLocationById(req, res, next));
// Create location
router.post('/locations', requireRoles('admin', 'manager', 'inventory', 'super_admin'), (req, res, next) =>
warehousesController.createLocation(req, res, next)
);
// Update location
router.patch('/locations/:id', requireRoles('admin', 'manager', 'inventory', 'super_admin'), (req, res, next) =>
warehousesController.updateLocation(req, res, next)
);
// Delete location
router.delete('/locations/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
warehousesController.deleteLocation(req, res, next)
);
// ========== CONVENIENCE ROUTES ==========
// Get default warehouse
router.get('/default', (req, res, next) => warehousesController.getDefault(req, res, next));
// Get active warehouses
router.get('/active', (req, res, next) => warehousesController.getActive(req, res, next));
// Get warehouse by code
router.get('/code/:code', (req, res, next) => warehousesController.findByCode(req, res, next));
// Get locations for a specific warehouse
router.get('/:warehouseId/locations', (req, res, next) => warehousesController.getWarehouseLocations(req, res, next));
// ========== WAREHOUSES ==========
// List warehouses
router.get('/', (req, res, next) => warehousesController.findAll(req, res, next));
// Get warehouse by ID
router.get('/:id', (req, res, next) => warehousesController.findById(req, res, next));
// Create warehouse
router.post('/', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
warehousesController.create(req, res, next)
);
// Update warehouse
router.patch('/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
warehousesController.update(req, res, next)
);
// Delete warehouse
router.delete('/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
warehousesController.delete(req, res, next)
);
export default router;

View File

@ -0,0 +1,262 @@
import { FindOptionsWhere, ILike, IsNull } from 'typeorm';
import { AppDataSource } from '../../config/typeorm.js';
import { Warehouse } from './entities/warehouse.entity.js';
import { WarehouseLocation } from './entities/warehouse-location.entity.js';
export interface WarehouseSearchParams {
tenantId: string;
search?: string;
isActive?: boolean;
limit?: number;
offset?: number;
}
export interface LocationSearchParams {
warehouseId?: string;
parentId?: string;
locationType?: 'zone' | 'aisle' | 'rack' | 'shelf' | 'bin';
isActive?: boolean;
limit?: number;
offset?: number;
}
export interface CreateWarehouseDto {
code: string;
name: string;
address?: string;
city?: string;
state?: string;
country?: string;
postalCode?: string;
phone?: string;
email?: string;
isActive?: boolean;
isDefault?: boolean;
}
export interface UpdateWarehouseDto {
code?: string;
name?: string;
address?: string | null;
city?: string | null;
state?: string | null;
country?: string | null;
postalCode?: string | null;
phone?: string | null;
email?: string | null;
isActive?: boolean;
isDefault?: boolean;
}
export interface CreateLocationDto {
warehouseId: string;
code: string;
name: string;
parentId?: string;
locationType?: 'zone' | 'aisle' | 'rack' | 'shelf' | 'bin';
barcode?: string;
isActive?: boolean;
}
export interface UpdateLocationDto {
code?: string;
name?: string;
parentId?: string | null;
locationType?: 'zone' | 'aisle' | 'rack' | 'shelf' | 'bin';
barcode?: string | null;
isActive?: boolean;
}
class WarehousesServiceClass {
private get warehouseRepository() {
return AppDataSource.getRepository(Warehouse);
}
private get locationRepository() {
return AppDataSource.getRepository(WarehouseLocation);
}
// ==================== Warehouses ====================
async findAll(params: WarehouseSearchParams): Promise<{ data: Warehouse[]; total: number }> {
const { tenantId, search, isActive, limit = 50, offset = 0 } = params;
const where: FindOptionsWhere<Warehouse> = { tenantId, deletedAt: IsNull() };
if (isActive !== undefined) {
where.isActive = isActive;
}
if (search) {
const [data, total] = await this.warehouseRepository.findAndCount({
where: [
{ ...where, name: ILike(`%${search}%`) },
{ ...where, code: ILike(`%${search}%`) },
],
take: limit,
skip: offset,
order: { name: 'ASC' },
});
return { data, total };
}
const [data, total] = await this.warehouseRepository.findAndCount({
where,
take: limit,
skip: offset,
order: { isDefault: 'DESC', name: 'ASC' },
});
return { data, total };
}
async findOne(id: string, tenantId: string): Promise<Warehouse | null> {
return this.warehouseRepository.findOne({
where: { id, tenantId, deletedAt: IsNull() },
});
}
async findByCode(code: string, tenantId: string): Promise<Warehouse | null> {
return this.warehouseRepository.findOne({
where: { code, tenantId, deletedAt: IsNull() },
});
}
async getDefault(tenantId: string): Promise<Warehouse | null> {
return this.warehouseRepository.findOne({
where: { tenantId, isDefault: true, deletedAt: IsNull() },
});
}
async getActive(tenantId: string): Promise<Warehouse[]> {
return this.warehouseRepository.find({
where: { tenantId, isActive: true, deletedAt: IsNull() },
order: { isDefault: 'DESC', name: 'ASC' },
});
}
async create(tenantId: string, dto: CreateWarehouseDto, _createdBy?: string): Promise<Warehouse> {
// If this is set as default, unset other defaults
if (dto.isDefault) {
await this.warehouseRepository.update(
{ tenantId, isDefault: true },
{ isDefault: false }
);
}
const warehouse = this.warehouseRepository.create({
...dto,
tenantId,
});
return this.warehouseRepository.save(warehouse);
}
async update(id: string, tenantId: string, dto: UpdateWarehouseDto, _updatedBy?: string): Promise<Warehouse | null> {
const warehouse = await this.findOne(id, tenantId);
if (!warehouse) return null;
// If setting as default, unset other defaults
if (dto.isDefault && !warehouse.isDefault) {
await this.warehouseRepository.update(
{ tenantId, isDefault: true },
{ isDefault: false }
);
}
Object.assign(warehouse, dto);
return this.warehouseRepository.save(warehouse);
}
async delete(id: string, tenantId: string): Promise<boolean> {
const result = await this.warehouseRepository.softDelete({ id, tenantId });
return (result.affected ?? 0) > 0;
}
// ==================== Locations ====================
// Note: Locations don't have tenantId directly, they belong to a Warehouse which has tenantId
async findAllLocations(params: LocationSearchParams & { tenantId: string }): Promise<{ data: WarehouseLocation[]; total: number }> {
const { tenantId, warehouseId, parentId, locationType, isActive, limit = 50, offset = 0 } = params;
// Build query to join with warehouse for tenant filtering
const queryBuilder = this.locationRepository
.createQueryBuilder('location')
.leftJoinAndSelect('location.warehouse', 'warehouse')
.where('warehouse.tenantId = :tenantId', { tenantId })
.andWhere('location.deletedAt IS NULL');
if (warehouseId) {
queryBuilder.andWhere('location.warehouseId = :warehouseId', { warehouseId });
}
if (parentId) {
queryBuilder.andWhere('location.parentId = :parentId', { parentId });
}
if (locationType) {
queryBuilder.andWhere('location.locationType = :locationType', { locationType });
}
if (isActive !== undefined) {
queryBuilder.andWhere('location.isActive = :isActive', { isActive });
}
queryBuilder.orderBy('location.code', 'ASC');
queryBuilder.skip(offset).take(limit);
const [data, total] = await queryBuilder.getManyAndCount();
return { data, total };
}
async findLocation(id: string, tenantId: string): Promise<WarehouseLocation | null> {
return this.locationRepository
.createQueryBuilder('location')
.leftJoinAndSelect('location.warehouse', 'warehouse')
.where('location.id = :id', { id })
.andWhere('warehouse.tenantId = :tenantId', { tenantId })
.andWhere('location.deletedAt IS NULL')
.getOne();
}
async createLocation(_tenantId: string, dto: CreateLocationDto, _createdBy?: string): Promise<WarehouseLocation> {
const location = this.locationRepository.create({
warehouseId: dto.warehouseId,
code: dto.code,
name: dto.name,
parentId: dto.parentId,
locationType: dto.locationType || 'shelf',
barcode: dto.barcode,
isActive: dto.isActive ?? true,
});
return this.locationRepository.save(location);
}
async updateLocation(id: string, tenantId: string, dto: UpdateLocationDto, _updatedBy?: string): Promise<WarehouseLocation | null> {
const location = await this.findLocation(id, tenantId);
if (!location) return null;
Object.assign(location, dto);
return this.locationRepository.save(location);
}
async deleteLocation(id: string, tenantId: string): Promise<boolean> {
const location = await this.findLocation(id, tenantId);
if (!location) return false;
const result = await this.locationRepository.softDelete({ id });
return (result.affected ?? 0) > 0;
}
async getLocationsByWarehouse(warehouseId: string, tenantId: string): Promise<WarehouseLocation[]> {
return this.locationRepository
.createQueryBuilder('location')
.leftJoin('location.warehouse', 'warehouse')
.where('location.warehouseId = :warehouseId', { warehouseId })
.andWhere('warehouse.tenantId = :tenantId', { tenantId })
.andWhere('location.isActive = :isActive', { isActive: true })
.andWhere('location.deletedAt IS NULL')
.orderBy('location.code', 'ASC')
.getMany();
}
}
// Export singleton instance
export const warehousesService = new WarehousesServiceClass();