From a127a4a42466647fef0a48fcd50a59ff9ef13e0f Mon Sep 17 00:00:00 2001 From: rckrdmrd Date: Sun, 18 Jan 2026 03:43:43 -0600 Subject: [PATCH] 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 --- src/app.integration.ts | 8 +- src/app.ts | 6 + src/modules/invoices/index.ts | 9 +- src/modules/invoices/invoices.controller.ts | 301 +++++++++++++++ src/modules/invoices/invoices.module.ts | 50 ++- src/modules/invoices/invoices.routes.ts | 64 ++++ src/modules/invoices/services/index.ts | 118 +++++- src/modules/products/products.controller.ts | 346 ++++++++++++++++++ src/modules/products/products.routes.ts | 67 ++++ src/modules/products/products.service.ts | 261 +++++++++++++ .../warehouses/warehouses.controller.ts | 338 +++++++++++++++++ src/modules/warehouses/warehouses.routes.ts | 67 ++++ src/modules/warehouses/warehouses.service.ts | 262 +++++++++++++ 13 files changed, 1842 insertions(+), 55 deletions(-) create mode 100644 src/modules/invoices/invoices.controller.ts create mode 100644 src/modules/invoices/invoices.routes.ts create mode 100644 src/modules/products/products.controller.ts create mode 100644 src/modules/products/products.routes.ts create mode 100644 src/modules/products/products.service.ts create mode 100644 src/modules/warehouses/warehouses.controller.ts create mode 100644 src/modules/warehouses/warehouses.routes.ts create mode 100644 src/modules/warehouses/warehouses.service.ts diff --git a/src/app.integration.ts b/src/app.integration.ts index e48face..a2650dc 100644 --- a/src/app.integration.ts +++ b/src/app.integration.ts @@ -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'); } diff --git a/src/app.ts b/src/app.ts index d98076d..7abff0a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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) => { diff --git a/src/modules/invoices/index.ts b/src/modules/invoices/index.ts index 48f9e2f..27cce0a 100644 --- a/src/modules/invoices/index.ts +++ b/src/modules/invoices/index.ts @@ -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'; diff --git a/src/modules/invoices/invoices.controller.ts b/src/modules/invoices/invoices.controller.ts new file mode 100644 index 0000000..ac8b4e1 --- /dev/null +++ b/src/modules/invoices/invoices.controller.ts @@ -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(); diff --git a/src/modules/invoices/invoices.module.ts b/src/modules/invoices/invoices.module.ts index 08409ab..59c5fd7 100644 --- a/src/modules/invoices/invoices.module.ts +++ b/src/modules/invoices/invoices.module.ts @@ -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(); diff --git a/src/modules/invoices/invoices.routes.ts b/src/modules/invoices/invoices.routes.ts new file mode 100644 index 0000000..a597a35 --- /dev/null +++ b/src/modules/invoices/invoices.routes.ts @@ -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; diff --git a/src/modules/invoices/services/index.ts b/src/modules/invoices/services/index.ts index c2e8ce4..62668d8 100644 --- a/src/modules/invoices/services/index.ts +++ b/src/modules/invoices/services/index.ts @@ -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, - private readonly paymentRepository: Repository - ) {} + 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 = { tenantId }; + const where: FindOptionsWhere = { 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 { - 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 { 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 { + 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 = { tenantId }; + const where: FindOptionsWhere = { 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 { - 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 { 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 { + async confirmPayment(id: string, tenantId: string, _userId?: string): Promise { 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 { + 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(); diff --git a/src/modules/products/products.controller.ts b/src/modules/products/products.controller.ts new file mode 100644 index 0000000..a5fd0d1 --- /dev/null +++ b/src/modules/products/products.controller.ts @@ -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(); diff --git a/src/modules/products/products.routes.ts b/src/modules/products/products.routes.ts new file mode 100644 index 0000000..d0c1629 --- /dev/null +++ b/src/modules/products/products.routes.ts @@ -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; diff --git a/src/modules/products/products.service.ts b/src/modules/products/products.service.ts new file mode 100644 index 0000000..32e4b18 --- /dev/null +++ b/src/modules/products/products.service.ts @@ -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[] = []; + const baseWhere: FindOptionsWhere = { 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 { + return this.productRepository.findOne({ + where: { id, tenantId, deletedAt: IsNull() }, + relations: ['category'], + }); + } + + async findBySku(sku: string, tenantId: string): Promise { + return this.productRepository.findOne({ + where: { sku, tenantId, deletedAt: IsNull() }, + relations: ['category'], + }); + } + + async findByBarcode(barcode: string, tenantId: string): Promise { + return this.productRepository.findOne({ + where: { barcode, tenantId, deletedAt: IsNull() }, + relations: ['category'], + }); + } + + async create(tenantId: string, dto: CreateProductDto, createdBy?: string): Promise { + const product = this.productRepository.create({ + ...dto, + tenantId, + createdBy, + }); + return this.productRepository.save(product); + } + + async update(id: string, tenantId: string, dto: UpdateProductDto, updatedBy?: string): Promise { + 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 { + 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 = { 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 { + return this.categoryRepository.findOne({ + where: { id, tenantId, deletedAt: IsNull() }, + }); + } + + async createCategory(tenantId: string, dto: CreateCategoryDto, _createdBy?: string): Promise { + const category = this.categoryRepository.create({ + ...dto, + tenantId, + }); + return this.categoryRepository.save(category); + } + + async updateCategory(id: string, tenantId: string, dto: UpdateCategoryDto, _updatedBy?: string): Promise { + 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 { + const result = await this.categoryRepository.softDelete({ id, tenantId }); + return (result.affected ?? 0) > 0; + } +} + +// Export singleton instance +export const productsService = new ProductsServiceClass(); diff --git a/src/modules/warehouses/warehouses.controller.ts b/src/modules/warehouses/warehouses.controller.ts new file mode 100644 index 0000000..bbee5d9 --- /dev/null +++ b/src/modules/warehouses/warehouses.controller.ts @@ -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(); diff --git a/src/modules/warehouses/warehouses.routes.ts b/src/modules/warehouses/warehouses.routes.ts new file mode 100644 index 0000000..58a0f89 --- /dev/null +++ b/src/modules/warehouses/warehouses.routes.ts @@ -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; diff --git a/src/modules/warehouses/warehouses.service.ts b/src/modules/warehouses/warehouses.service.ts new file mode 100644 index 0000000..a42999d --- /dev/null +++ b/src/modules/warehouses/warehouses.service.ts @@ -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 = { 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 { + return this.warehouseRepository.findOne({ + where: { id, tenantId, deletedAt: IsNull() }, + }); + } + + async findByCode(code: string, tenantId: string): Promise { + return this.warehouseRepository.findOne({ + where: { code, tenantId, deletedAt: IsNull() }, + }); + } + + async getDefault(tenantId: string): Promise { + return this.warehouseRepository.findOne({ + where: { tenantId, isDefault: true, deletedAt: IsNull() }, + }); + } + + async getActive(tenantId: string): Promise { + return this.warehouseRepository.find({ + where: { tenantId, isActive: true, deletedAt: IsNull() }, + order: { isDefault: 'DESC', name: 'ASC' }, + }); + } + + async create(tenantId: string, dto: CreateWarehouseDto, _createdBy?: string): Promise { + // 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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();