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:
parent
d456ad4aca
commit
a127a4a424
@ -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');
|
||||
}
|
||||
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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';
|
||||
|
||||
301
src/modules/invoices/invoices.controller.ts
Normal file
301
src/modules/invoices/invoices.controller.ts
Normal 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();
|
||||
@ -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();
|
||||
|
||||
64
src/modules/invoices/invoices.routes.ts
Normal file
64
src/modules/invoices/invoices.routes.ts
Normal 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;
|
||||
@ -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();
|
||||
|
||||
346
src/modules/products/products.controller.ts
Normal file
346
src/modules/products/products.controller.ts
Normal 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();
|
||||
67
src/modules/products/products.routes.ts
Normal file
67
src/modules/products/products.routes.ts
Normal 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;
|
||||
261
src/modules/products/products.service.ts
Normal file
261
src/modules/products/products.service.ts
Normal 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();
|
||||
338
src/modules/warehouses/warehouses.controller.ts
Normal file
338
src/modules/warehouses/warehouses.controller.ts
Normal 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();
|
||||
67
src/modules/warehouses/warehouses.routes.ts
Normal file
67
src/modules/warehouses/warehouses.routes.ts
Normal 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;
|
||||
262
src/modules/warehouses/warehouses.service.ts
Normal file
262
src/modules/warehouses/warehouses.service.ts
Normal 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();
|
||||
Loading…
Reference in New Issue
Block a user