[ERP-CONSTRUCCION] feat: Implement 6 core business modules
Branches: - 5 services: branch, schedule, inventory-settings, payment-terminal, user-assignment - Hierarchical management, schedules, terminals Products: - 6 services: category, product, price, supplier, attribute, variant - Hierarchical categories, multi-pricing, variants Projects: - 6 services: project, task, timesheet, milestone, member, stage - Kanban support, timesheet approval workflow Sales: - 2 services: quotation, sales-order - Full sales workflow with quotation-to-order conversion Invoices: - 2 services: invoice, payment - Complete invoicing with payment allocation Notifications: - 5 services: notification, preference, template, channel, in-app - Multi-channel support (email, sms, push, whatsapp, in-app) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
88e1c4e9b6
commit
8f8843cd10
@ -0,0 +1,195 @@
|
||||
/**
|
||||
* BranchInventorySettings Controller
|
||||
* API endpoints para gestión de configuración de inventario por sucursal
|
||||
*
|
||||
* @module Branches
|
||||
* @prefix /api/v1/branches/:branchId/inventory-settings
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import {
|
||||
BranchInventorySettingsService,
|
||||
CreateBranchInventorySettingsDto,
|
||||
UpdateBranchInventorySettingsDto,
|
||||
} from '../services/branch-inventory-settings.service';
|
||||
import { ServiceContext } from '../services/branch.service';
|
||||
|
||||
const router = Router({ mergeParams: true });
|
||||
const inventorySettingsService = new BranchInventorySettingsService();
|
||||
|
||||
/**
|
||||
* Helper to extract ServiceContext from request
|
||||
*/
|
||||
function getContext(req: Request): ServiceContext {
|
||||
return {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/branches/:branchId/inventory-settings
|
||||
* Obtiene la configuración de inventario de una sucursal
|
||||
*/
|
||||
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { branchId } = req.params;
|
||||
const settings = await inventorySettingsService.findByBranch(getContext(req), branchId);
|
||||
|
||||
if (!settings) {
|
||||
// Return effective settings with defaults if none configured
|
||||
const effective = await inventorySettingsService.getEffectiveSettings(getContext(req), branchId);
|
||||
return res.json({
|
||||
success: true,
|
||||
data: null,
|
||||
defaults: effective.defaults,
|
||||
message: 'No hay configuración específica, se usan valores por defecto',
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: settings });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/branches/:branchId/inventory-settings/effective
|
||||
* Obtiene la configuración efectiva (configurada o defaults)
|
||||
*/
|
||||
router.get('/effective', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { branchId } = req.params;
|
||||
const effective = await inventorySettingsService.getEffectiveSettings(getContext(req), branchId);
|
||||
|
||||
// Merge settings with defaults
|
||||
const mergedSettings = {
|
||||
...effective.defaults,
|
||||
...(effective.settings || {}),
|
||||
};
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: mergedSettings,
|
||||
hasCustomSettings: !!effective.settings,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/branches/:branchId/inventory-settings
|
||||
* Crea la configuración de inventario para una sucursal
|
||||
*/
|
||||
router.post('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { branchId } = req.params;
|
||||
const data: CreateBranchInventorySettingsDto = {
|
||||
...req.body,
|
||||
branchId,
|
||||
};
|
||||
|
||||
const settings = await inventorySettingsService.create(getContext(req), data);
|
||||
return res.status(201).json({ success: true, data: settings });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('already exist')) {
|
||||
return res.status(409).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/v1/branches/:branchId/inventory-settings
|
||||
* Crea o actualiza la configuración de inventario (upsert)
|
||||
*/
|
||||
router.put('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { branchId } = req.params;
|
||||
const data: UpdateBranchInventorySettingsDto = req.body;
|
||||
|
||||
const settings = await inventorySettingsService.upsertByBranch(getContext(req), branchId, data);
|
||||
return res.json({ success: true, data: settings });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/branches/:branchId/inventory-settings
|
||||
* Actualiza parcialmente la configuración de inventario
|
||||
*/
|
||||
router.patch('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { branchId } = req.params;
|
||||
const existing = await inventorySettingsService.findByBranch(getContext(req), branchId);
|
||||
|
||||
if (!existing) {
|
||||
return res.status(404).json({
|
||||
error: 'No hay configuración de inventario para esta sucursal. Use POST para crear.',
|
||||
});
|
||||
}
|
||||
|
||||
const data: UpdateBranchInventorySettingsDto = req.body;
|
||||
const settings = await inventorySettingsService.update(getContext(req), existing.id, data);
|
||||
|
||||
return res.json({ success: true, data: settings });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/branches/:branchId/inventory-settings
|
||||
* Elimina la configuración de inventario (vuelve a defaults)
|
||||
*/
|
||||
router.delete('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { branchId } = req.params;
|
||||
const deleted = await inventorySettingsService.deleteByBranch(getContext(req), branchId);
|
||||
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'No hay configuración de inventario para esta sucursal' });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Configuración de inventario eliminada, se usarán valores por defecto',
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@ -0,0 +1,312 @@
|
||||
/**
|
||||
* BranchPaymentTerminal Controller
|
||||
* API endpoints para gestión de terminales de pago por sucursal
|
||||
*
|
||||
* @module Branches
|
||||
* @prefix /api/v1/branches/:branchId/payment-terminals
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import {
|
||||
BranchPaymentTerminalService,
|
||||
CreateBranchPaymentTerminalDto,
|
||||
UpdateBranchPaymentTerminalDto,
|
||||
} from '../services/branch-payment-terminal.service';
|
||||
import { ServiceContext } from '../services/branch.service';
|
||||
import { HealthStatus } from '../entities/branch-payment-terminal.entity';
|
||||
|
||||
const router = Router({ mergeParams: true });
|
||||
const terminalService = new BranchPaymentTerminalService();
|
||||
|
||||
/**
|
||||
* Helper to extract ServiceContext from request
|
||||
*/
|
||||
function getContext(req: Request): ServiceContext {
|
||||
return {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/branches/:branchId/payment-terminals
|
||||
* Lista todas las terminales de pago de una sucursal
|
||||
*/
|
||||
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { branchId } = req.params;
|
||||
const { terminalProvider, isActive, healthStatus } = req.query;
|
||||
|
||||
const terminals = await terminalService.findAll(getContext(req), {
|
||||
branchId,
|
||||
terminalProvider: terminalProvider as any,
|
||||
isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined,
|
||||
healthStatus: healthStatus as HealthStatus,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: terminals,
|
||||
count: terminals.length,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/branches/:branchId/payment-terminals/primary
|
||||
* Obtiene la terminal primaria de una sucursal
|
||||
*/
|
||||
router.get('/primary', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { branchId } = req.params;
|
||||
const terminal = await terminalService.findPrimaryTerminal(getContext(req), branchId);
|
||||
|
||||
if (!terminal) {
|
||||
return res.status(404).json({ error: 'No hay terminal primaria configurada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: terminal });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/branches/:branchId/payment-terminals/health
|
||||
* Obtiene el resumen de salud de las terminales
|
||||
*/
|
||||
router.get('/health', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const summary = await terminalService.getHealthSummary(getContext(req));
|
||||
return res.json({ success: true, data: summary });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/branches/:branchId/payment-terminals/needs-check
|
||||
* Obtiene las terminales que necesitan health check
|
||||
*/
|
||||
router.get('/needs-check', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { maxAgeMinutes } = req.query;
|
||||
const terminals = await terminalService.getTerminalsNeedingHealthCheck(
|
||||
getContext(req),
|
||||
maxAgeMinutes ? parseInt(maxAgeMinutes as string) : undefined
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: terminals, count: terminals.length });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/branches/:branchId/payment-terminals/:id
|
||||
* Obtiene una terminal específica
|
||||
*/
|
||||
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const terminal = await terminalService.findById(getContext(req), req.params.id);
|
||||
if (!terminal) {
|
||||
return res.status(404).json({ error: 'Terminal no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: terminal });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/branches/:branchId/payment-terminals
|
||||
* Crea una nueva terminal de pago
|
||||
*/
|
||||
router.post('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { branchId } = req.params;
|
||||
const data: CreateBranchPaymentTerminalDto = {
|
||||
...req.body,
|
||||
branchId,
|
||||
};
|
||||
|
||||
// Validate required fields
|
||||
if (!data.terminalId || !data.terminalProvider || !data.provider) {
|
||||
return res.status(400).json({
|
||||
error: 'terminalId, terminalProvider y provider son requeridos',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if terminal ID already exists
|
||||
const existing = await terminalService.findByTerminalId(getContext(req), data.terminalId);
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: 'Ya existe una terminal con ese ID' });
|
||||
}
|
||||
|
||||
const terminal = await terminalService.create(getContext(req), data);
|
||||
return res.status(201).json({ success: true, data: terminal });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/branches/:branchId/payment-terminals/:id
|
||||
* Actualiza una terminal
|
||||
*/
|
||||
router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const data: UpdateBranchPaymentTerminalDto = req.body;
|
||||
const terminal = await terminalService.update(getContext(req), req.params.id, data);
|
||||
|
||||
if (!terminal) {
|
||||
return res.status(404).json({ error: 'Terminal no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: terminal });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/branches/:branchId/payment-terminals/:id/set-primary
|
||||
* Establece una terminal como primaria
|
||||
*/
|
||||
router.post('/:id/set-primary', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const terminal = await terminalService.setAsPrimary(getContext(req), req.params.id);
|
||||
if (!terminal) {
|
||||
return res.status(404).json({ error: 'Terminal no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: terminal,
|
||||
message: 'Terminal establecida como primaria',
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/branches/:branchId/payment-terminals/:id/health-check
|
||||
* Actualiza el estado de salud de una terminal
|
||||
*/
|
||||
router.post('/:id/health-check', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { healthStatus } = req.body;
|
||||
if (!healthStatus) {
|
||||
return res.status(400).json({ error: 'healthStatus es requerido' });
|
||||
}
|
||||
|
||||
const terminal = await terminalService.updateHealthStatus(
|
||||
getContext(req),
|
||||
req.params.id,
|
||||
healthStatus as HealthStatus
|
||||
);
|
||||
|
||||
if (!terminal) {
|
||||
return res.status(404).json({ error: 'Terminal no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: terminal });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/branches/:branchId/payment-terminals/:id/record-transaction
|
||||
* Registra una transacción en la terminal
|
||||
*/
|
||||
router.post('/:id/record-transaction', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const terminal = await terminalService.recordTransaction(getContext(req), req.params.id);
|
||||
if (!terminal) {
|
||||
return res.status(404).json({ error: 'Terminal no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: terminal });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/branches/:branchId/payment-terminals/:id
|
||||
* Elimina una terminal
|
||||
*/
|
||||
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const deleted = await terminalService.delete(getContext(req), req.params.id);
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Terminal no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, message: 'Terminal eliminada' });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
298
src/modules/branches/controllers/branch-schedule.controller.ts
Normal file
298
src/modules/branches/controllers/branch-schedule.controller.ts
Normal file
@ -0,0 +1,298 @@
|
||||
/**
|
||||
* BranchSchedule Controller
|
||||
* API endpoints para gestión de horarios de sucursales
|
||||
*
|
||||
* @module Branches
|
||||
* @prefix /api/v1/branches/:branchId/schedules
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import {
|
||||
BranchScheduleService,
|
||||
CreateBranchScheduleDto,
|
||||
UpdateBranchScheduleDto,
|
||||
} from '../services/branch-schedule.service';
|
||||
import { ServiceContext } from '../services/branch.service';
|
||||
|
||||
const router = Router({ mergeParams: true });
|
||||
const branchScheduleService = new BranchScheduleService();
|
||||
|
||||
/**
|
||||
* Helper to extract ServiceContext from request
|
||||
*/
|
||||
function getContext(req: Request): ServiceContext {
|
||||
return {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/branches/:branchId/schedules
|
||||
* Lista todos los horarios de una sucursal
|
||||
*/
|
||||
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { branchId } = req.params;
|
||||
const { scheduleType, dayOfWeek, isActive } = req.query;
|
||||
|
||||
const schedules = await branchScheduleService.findAll(getContext(req), {
|
||||
branchId,
|
||||
scheduleType: scheduleType as any,
|
||||
dayOfWeek: dayOfWeek ? parseInt(dayOfWeek as string) : undefined,
|
||||
isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: schedules,
|
||||
count: schedules.length,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/branches/:branchId/schedules/regular
|
||||
* Obtiene los horarios regulares de una sucursal
|
||||
*/
|
||||
router.get('/regular', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { branchId } = req.params;
|
||||
const schedules = await branchScheduleService.findRegularSchedule(getContext(req), branchId);
|
||||
|
||||
return res.json({ success: true, data: schedules, count: schedules.length });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/branches/:branchId/schedules/holidays
|
||||
* Obtiene los horarios de días festivos de una sucursal
|
||||
*/
|
||||
router.get('/holidays', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { branchId } = req.params;
|
||||
const schedules = await branchScheduleService.findHolidaySchedule(getContext(req), branchId);
|
||||
|
||||
return res.json({ success: true, data: schedules, count: schedules.length });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/branches/:branchId/schedules/for-date
|
||||
* Obtiene el horario efectivo para una fecha específica
|
||||
*/
|
||||
router.get('/for-date', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { branchId } = req.params;
|
||||
const { date } = req.query;
|
||||
|
||||
if (!date) {
|
||||
return res.status(400).json({ error: 'date es requerido (formato: YYYY-MM-DD)' });
|
||||
}
|
||||
|
||||
const schedule = await branchScheduleService.findScheduleForDate(
|
||||
getContext(req),
|
||||
branchId,
|
||||
new Date(date as string)
|
||||
);
|
||||
|
||||
if (!schedule) {
|
||||
return res.status(404).json({ error: 'No hay horario configurado para esta fecha' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: schedule });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/branches/:branchId/schedules/is-open
|
||||
* Verifica si la sucursal está abierta en un momento dado
|
||||
*/
|
||||
router.get('/is-open', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { branchId } = req.params;
|
||||
const { datetime } = req.query;
|
||||
|
||||
const checkDate = datetime ? new Date(datetime as string) : new Date();
|
||||
const isOpen = await branchScheduleService.isBranchOpenAt(getContext(req), branchId, checkDate);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
isOpen,
|
||||
checkedAt: checkDate.toISOString(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/branches/:branchId/schedules/:id
|
||||
* Obtiene un horario específico
|
||||
*/
|
||||
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const schedule = await branchScheduleService.findById(getContext(req), req.params.id);
|
||||
if (!schedule) {
|
||||
return res.status(404).json({ error: 'Horario no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: schedule });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/branches/:branchId/schedules
|
||||
* Crea un nuevo horario
|
||||
*/
|
||||
router.post('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { branchId } = req.params;
|
||||
const data: CreateBranchScheduleDto = {
|
||||
...req.body,
|
||||
branchId,
|
||||
};
|
||||
|
||||
// Validate required fields
|
||||
if (!data.name || !data.openTime || !data.closeTime) {
|
||||
return res.status(400).json({ error: 'name, openTime y closeTime son requeridos' });
|
||||
}
|
||||
|
||||
const schedule = await branchScheduleService.create(getContext(req), data);
|
||||
return res.status(201).json({ success: true, data: schedule });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/branches/:branchId/schedules/weekly
|
||||
* Crea o reemplaza el horario semanal completo
|
||||
*/
|
||||
router.post('/weekly', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { branchId } = req.params;
|
||||
const { weeklyHours } = req.body;
|
||||
|
||||
if (!weeklyHours || !Array.isArray(weeklyHours)) {
|
||||
return res.status(400).json({
|
||||
error: 'weeklyHours es requerido como array de { dayOfWeek, openTime, closeTime, shifts? }',
|
||||
});
|
||||
}
|
||||
|
||||
const schedules = await branchScheduleService.createWeeklySchedule(
|
||||
getContext(req),
|
||||
branchId,
|
||||
weeklyHours
|
||||
);
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: schedules,
|
||||
count: schedules.length,
|
||||
message: 'Horario semanal configurado',
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/branches/:branchId/schedules/:id
|
||||
* Actualiza un horario
|
||||
*/
|
||||
router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const data: UpdateBranchScheduleDto = req.body;
|
||||
const schedule = await branchScheduleService.update(getContext(req), req.params.id, data);
|
||||
|
||||
if (!schedule) {
|
||||
return res.status(404).json({ error: 'Horario no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: schedule });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/branches/:branchId/schedules/:id
|
||||
* Elimina un horario
|
||||
*/
|
||||
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const deleted = await branchScheduleService.delete(getContext(req), req.params.id);
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Horario no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, message: 'Horario eliminado' });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
315
src/modules/branches/controllers/branch.controller.ts
Normal file
315
src/modules/branches/controllers/branch.controller.ts
Normal file
@ -0,0 +1,315 @@
|
||||
/**
|
||||
* Branch Controller
|
||||
* API endpoints para gestión de sucursales
|
||||
*
|
||||
* @module Branches
|
||||
* @prefix /api/v1/branches
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import {
|
||||
BranchService,
|
||||
CreateBranchDto,
|
||||
UpdateBranchDto,
|
||||
ServiceContext,
|
||||
} from '../services/branch.service';
|
||||
|
||||
const router = Router();
|
||||
const branchService = new BranchService();
|
||||
|
||||
/**
|
||||
* Helper to extract ServiceContext from request
|
||||
*/
|
||||
function getContext(req: Request): ServiceContext {
|
||||
return {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/branches
|
||||
* Lista todas las sucursales del tenant
|
||||
*/
|
||||
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { branchType, isActive, city, state, parentId, page, limit } = req.query;
|
||||
|
||||
const result = await branchService.findAll(
|
||||
getContext(req),
|
||||
{
|
||||
branchType: branchType as any,
|
||||
isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined,
|
||||
city: city as string,
|
||||
state: state as string,
|
||||
parentId: parentId as string,
|
||||
},
|
||||
{
|
||||
page: page ? parseInt(page as string) : undefined,
|
||||
limit: limit ? parseInt(limit as string) : undefined,
|
||||
}
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/branches/statistics
|
||||
* Estadísticas de sucursales
|
||||
*/
|
||||
router.get('/statistics', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const stats = await branchService.getStatistics(getContext(req));
|
||||
return res.json({ success: true, data: stats });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/branches/hierarchy
|
||||
* Obtiene la jerarquía completa de sucursales
|
||||
*/
|
||||
router.get('/hierarchy', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const hierarchy = await branchService.getHierarchy(getContext(req));
|
||||
return res.json({ success: true, data: hierarchy });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/branches/roots
|
||||
* Obtiene las sucursales raíz (sin padre)
|
||||
*/
|
||||
router.get('/roots', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const roots = await branchService.findRootBranches(getContext(req));
|
||||
return res.json({ success: true, data: roots, count: roots.length });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/branches/main
|
||||
* Obtiene la sucursal principal/matriz
|
||||
*/
|
||||
router.get('/main', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const main = await branchService.findMainBranch(getContext(req));
|
||||
if (!main) {
|
||||
return res.status(404).json({ error: 'No hay sucursal principal configurada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: main });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/branches/nearby
|
||||
* Busca sucursales cercanas a una ubicación
|
||||
*/
|
||||
router.get('/nearby', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { lat, lng, radius } = req.query;
|
||||
|
||||
if (!lat || !lng) {
|
||||
return res.status(400).json({ error: 'lat y lng son requeridos' });
|
||||
}
|
||||
|
||||
const branches = await branchService.findByLocation(
|
||||
getContext(req),
|
||||
parseFloat(lat as string),
|
||||
parseFloat(lng as string),
|
||||
radius ? parseFloat(radius as string) : undefined
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: branches, count: branches.length });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/branches/:id
|
||||
* Obtiene una sucursal por ID
|
||||
*/
|
||||
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const branch = await branchService.findById(getContext(req), req.params.id);
|
||||
if (!branch) {
|
||||
return res.status(404).json({ error: 'Sucursal no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: branch });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/branches/:id/children
|
||||
* Obtiene las sucursales hijas de una sucursal
|
||||
*/
|
||||
router.get('/:id/children', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const children = await branchService.findChildren(getContext(req), req.params.id);
|
||||
return res.json({ success: true, data: children, count: children.length });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/branches
|
||||
* Crea una nueva sucursal
|
||||
*/
|
||||
router.post('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const data: CreateBranchDto = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!data.code || !data.name) {
|
||||
return res.status(400).json({ error: 'code y name son requeridos' });
|
||||
}
|
||||
|
||||
// Check if code already exists
|
||||
const existing = await branchService.findByCode(getContext(req), data.code);
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: 'Ya existe una sucursal con ese código' });
|
||||
}
|
||||
|
||||
const branch = await branchService.create(getContext(req), data);
|
||||
return res.status(201).json({ success: true, data: branch });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/branches/:id
|
||||
* Actualiza una sucursal
|
||||
*/
|
||||
router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const data: UpdateBranchDto = req.body;
|
||||
const branch = await branchService.update(getContext(req), req.params.id, data);
|
||||
|
||||
if (!branch) {
|
||||
return res.status(404).json({ error: 'Sucursal no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: branch });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/branches/:id/set-main
|
||||
* Establece una sucursal como principal/matriz
|
||||
*/
|
||||
router.post('/:id/set-main', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const branch = await branchService.setAsMain(getContext(req), req.params.id);
|
||||
if (!branch) {
|
||||
return res.status(404).json({ error: 'Sucursal no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: branch, message: 'Sucursal establecida como principal' });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/branches/:id
|
||||
* Elimina una sucursal (soft delete)
|
||||
*/
|
||||
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const deleted = await branchService.delete(getContext(req), req.params.id);
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Sucursal no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, message: 'Sucursal eliminada' });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('children')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
13
src/modules/branches/controllers/index.ts
Normal file
13
src/modules/branches/controllers/index.ts
Normal file
@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Branches Controllers Index
|
||||
* @module Branches
|
||||
*/
|
||||
|
||||
export { default as branchController } from './branch.controller';
|
||||
export { default as branchScheduleController } from './branch-schedule.controller';
|
||||
export { default as branchInventorySettingsController } from './branch-inventory-settings.controller';
|
||||
export { default as branchPaymentTerminalController } from './branch-payment-terminal.controller';
|
||||
export {
|
||||
default as userBranchAssignmentController,
|
||||
userBranchAssignmentRouter,
|
||||
} from './user-branch-assignment.controller';
|
||||
@ -0,0 +1,428 @@
|
||||
/**
|
||||
* UserBranchAssignment Controller
|
||||
* API endpoints para gestión de asignaciones de usuarios a sucursales
|
||||
*
|
||||
* @module Branches
|
||||
* @prefix /api/v1/branches/:branchId/assignments
|
||||
* @prefix /api/v1/user-branch-assignments (for user-centric operations)
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import {
|
||||
UserBranchAssignmentService,
|
||||
CreateUserBranchAssignmentDto,
|
||||
UpdateUserBranchAssignmentDto,
|
||||
} from '../services/user-branch-assignment.service';
|
||||
import { ServiceContext } from '../services/branch.service';
|
||||
|
||||
const router = Router({ mergeParams: true });
|
||||
const assignmentService = new UserBranchAssignmentService();
|
||||
|
||||
/**
|
||||
* Helper to extract ServiceContext from request
|
||||
*/
|
||||
function getContext(req: Request): ServiceContext {
|
||||
return {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/branches/:branchId/assignments
|
||||
* Lista todas las asignaciones de usuarios a una sucursal
|
||||
*/
|
||||
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { branchId } = req.params;
|
||||
const { assignmentType, branchRole, isActive } = req.query;
|
||||
|
||||
const assignments = await assignmentService.findAll(getContext(req), {
|
||||
branchId,
|
||||
assignmentType: assignmentType as any,
|
||||
branchRole: branchRole as any,
|
||||
isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: assignments,
|
||||
count: assignments.length,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/branches/:branchId/assignments/managers
|
||||
* Obtiene los gerentes de una sucursal
|
||||
*/
|
||||
router.get('/managers', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { branchId } = req.params;
|
||||
const managers = await assignmentService.getBranchManagers(getContext(req), branchId);
|
||||
|
||||
return res.json({ success: true, data: managers, count: managers.length });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/branches/:branchId/assignments/statistics
|
||||
* Obtiene estadísticas de asignaciones
|
||||
*/
|
||||
router.get('/statistics', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const stats = await assignmentService.getAssignmentStatistics(getContext(req));
|
||||
return res.json({ success: true, data: stats });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/branches/:branchId/assignments/check-user/:userId
|
||||
* Verifica si un usuario está asignado a una sucursal
|
||||
*/
|
||||
router.get('/check-user/:userId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { branchId, userId } = req.params;
|
||||
const isAssigned = await assignmentService.isUserAssignedToBranch(
|
||||
getContext(req),
|
||||
userId,
|
||||
branchId
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: { isAssigned, userId, branchId },
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/branches/:branchId/assignments/:id
|
||||
* Obtiene una asignación específica
|
||||
*/
|
||||
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const assignment = await assignmentService.findById(getContext(req), req.params.id);
|
||||
if (!assignment) {
|
||||
return res.status(404).json({ error: 'Asignación no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: assignment });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/branches/:branchId/assignments
|
||||
* Crea una nueva asignación de usuario a sucursal
|
||||
*/
|
||||
router.post('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { branchId } = req.params;
|
||||
const data: CreateUserBranchAssignmentDto = {
|
||||
...req.body,
|
||||
branchId,
|
||||
};
|
||||
|
||||
// Validate required fields
|
||||
if (!data.userId) {
|
||||
return res.status(400).json({ error: 'userId es requerido' });
|
||||
}
|
||||
|
||||
const assignment = await assignmentService.create(getContext(req), data);
|
||||
return res.status(201).json({ success: true, data: assignment });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/branches/:branchId/assignments/:id
|
||||
* Actualiza una asignación
|
||||
*/
|
||||
router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const data: UpdateUserBranchAssignmentDto = req.body;
|
||||
const assignment = await assignmentService.update(getContext(req), req.params.id, data);
|
||||
|
||||
if (!assignment) {
|
||||
return res.status(404).json({ error: 'Asignación no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: assignment });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/branches/:branchId/assignments/:id/set-primary
|
||||
* Establece una asignación como primaria
|
||||
*/
|
||||
router.post('/:id/set-primary', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const assignment = await assignmentService.setAsPrimary(getContext(req), req.params.id);
|
||||
if (!assignment) {
|
||||
return res.status(404).json({ error: 'Asignación no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: assignment,
|
||||
message: 'Asignación establecida como primaria',
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/branches/:branchId/assignments/:id/deactivate
|
||||
* Desactiva una asignación
|
||||
*/
|
||||
router.post('/:id/deactivate', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const assignment = await assignmentService.deactivate(getContext(req), req.params.id);
|
||||
if (!assignment) {
|
||||
return res.status(404).json({ error: 'Asignación no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: assignment, message: 'Asignación desactivada' });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/branches/:branchId/assignments/:id
|
||||
* Elimina una asignación
|
||||
*/
|
||||
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const deleted = await assignmentService.delete(getContext(req), req.params.id);
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Asignación no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, message: 'Asignación eliminada' });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
/**
|
||||
* User-centric router for branch assignments
|
||||
* Mounted at /api/v1/user-branch-assignments
|
||||
*/
|
||||
export const userBranchAssignmentRouter = Router();
|
||||
|
||||
/**
|
||||
* GET /api/v1/user-branch-assignments/user/:userId
|
||||
* Obtiene todas las asignaciones de un usuario
|
||||
*/
|
||||
userBranchAssignmentRouter.get(
|
||||
'/user/:userId',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { userId } = req.params;
|
||||
const assignments = await assignmentService.findByUser(getContext(req), userId);
|
||||
|
||||
return res.json({ success: true, data: assignments, count: assignments.length });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/v1/user-branch-assignments/user/:userId/primary
|
||||
* Obtiene la asignación primaria de un usuario
|
||||
*/
|
||||
userBranchAssignmentRouter.get(
|
||||
'/user/:userId/primary',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { userId } = req.params;
|
||||
const assignment = await assignmentService.findPrimaryAssignment(getContext(req), userId);
|
||||
|
||||
if (!assignment) {
|
||||
return res.status(404).json({ error: 'No hay asignación primaria para este usuario' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: assignment });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/v1/user-branch-assignments/user/:userId/active
|
||||
* Obtiene las asignaciones activas de un usuario
|
||||
*/
|
||||
userBranchAssignmentRouter.get(
|
||||
'/user/:userId/active',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { userId } = req.params;
|
||||
const { asOfDate } = req.query;
|
||||
|
||||
const assignments = await assignmentService.findActiveAssignments(
|
||||
getContext(req),
|
||||
userId,
|
||||
asOfDate ? new Date(asOfDate as string) : undefined
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: assignments, count: assignments.length });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/v1/user-branch-assignments/user/:userId/permissions/:branchId
|
||||
* Obtiene los permisos de un usuario en una sucursal
|
||||
*/
|
||||
userBranchAssignmentRouter.get(
|
||||
'/user/:userId/permissions/:branchId',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { userId, branchId } = req.params;
|
||||
const permissions = await assignmentService.getUserPermissionsForBranch(
|
||||
getContext(req),
|
||||
userId,
|
||||
branchId
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: permissions });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/v1/user-branch-assignments/transfer
|
||||
* Transfiere un usuario de una sucursal a otra
|
||||
*/
|
||||
userBranchAssignmentRouter.post(
|
||||
'/transfer',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { userId, fromBranchId, toBranchId, transferPrimary } = req.body;
|
||||
|
||||
if (!userId || !fromBranchId || !toBranchId) {
|
||||
return res.status(400).json({
|
||||
error: 'userId, fromBranchId y toBranchId son requeridos',
|
||||
});
|
||||
}
|
||||
|
||||
const assignment = await assignmentService.transferUserToBranch(
|
||||
getContext(req),
|
||||
userId,
|
||||
fromBranchId,
|
||||
toBranchId,
|
||||
transferPrimary !== false
|
||||
);
|
||||
|
||||
if (!assignment) {
|
||||
return res.status(400).json({ error: 'No se pudo completar la transferencia' });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: assignment,
|
||||
message: 'Usuario transferido exitosamente',
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
@ -0,0 +1,141 @@
|
||||
/**
|
||||
* BranchInventorySettings Service
|
||||
* Servicio para gestión de configuración de inventario por sucursal
|
||||
*
|
||||
* @module Branches
|
||||
*/
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import { AppDataSource } from '../../../shared/database/typeorm.config';
|
||||
import { BranchInventorySettings } from '../entities/branch-inventory-settings.entity';
|
||||
import { ServiceContext } from './branch.service';
|
||||
|
||||
export interface CreateBranchInventorySettingsDto {
|
||||
branchId: string;
|
||||
warehouseId?: string;
|
||||
defaultStockMin?: number;
|
||||
defaultStockMax?: number;
|
||||
autoReorderEnabled?: boolean;
|
||||
priceListId?: string;
|
||||
allowPriceOverride?: boolean;
|
||||
maxDiscountPercent?: number;
|
||||
taxConfig?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface UpdateBranchInventorySettingsDto {
|
||||
warehouseId?: string;
|
||||
defaultStockMin?: number;
|
||||
defaultStockMax?: number;
|
||||
autoReorderEnabled?: boolean;
|
||||
priceListId?: string;
|
||||
allowPriceOverride?: boolean;
|
||||
maxDiscountPercent?: number;
|
||||
taxConfig?: Record<string, any>;
|
||||
}
|
||||
|
||||
export class BranchInventorySettingsService {
|
||||
private repository: Repository<BranchInventorySettings>;
|
||||
|
||||
constructor() {
|
||||
this.repository = AppDataSource.getRepository(BranchInventorySettings);
|
||||
}
|
||||
|
||||
async findByBranch(_ctx: ServiceContext, branchId: string): Promise<BranchInventorySettings | null> {
|
||||
return this.repository.findOne({
|
||||
where: { branchId },
|
||||
relations: ['branch'],
|
||||
});
|
||||
}
|
||||
|
||||
async findById(_ctx: ServiceContext, id: string): Promise<BranchInventorySettings | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id },
|
||||
relations: ['branch'],
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(_ctx: ServiceContext): Promise<BranchInventorySettings[]> {
|
||||
return this.repository.find({
|
||||
relations: ['branch'],
|
||||
});
|
||||
}
|
||||
|
||||
async create(
|
||||
ctx: ServiceContext,
|
||||
data: CreateBranchInventorySettingsDto
|
||||
): Promise<BranchInventorySettings> {
|
||||
// Check if settings already exist for this branch
|
||||
const existing = await this.findByBranch(ctx, data.branchId);
|
||||
if (existing) {
|
||||
throw new Error('Inventory settings already exist for this branch');
|
||||
}
|
||||
|
||||
const settings = this.repository.create(data);
|
||||
return this.repository.save(settings);
|
||||
}
|
||||
|
||||
async update(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
data: UpdateBranchInventorySettingsDto
|
||||
): Promise<BranchInventorySettings | null> {
|
||||
const settings = await this.findById(ctx, id);
|
||||
if (!settings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Object.assign(settings, data);
|
||||
return this.repository.save(settings);
|
||||
}
|
||||
|
||||
async upsertByBranch(
|
||||
ctx: ServiceContext,
|
||||
branchId: string,
|
||||
data: UpdateBranchInventorySettingsDto
|
||||
): Promise<BranchInventorySettings> {
|
||||
let settings = await this.findByBranch(ctx, branchId);
|
||||
|
||||
if (settings) {
|
||||
Object.assign(settings, data);
|
||||
} else {
|
||||
settings = this.repository.create({
|
||||
branchId,
|
||||
...data,
|
||||
});
|
||||
}
|
||||
|
||||
return this.repository.save(settings);
|
||||
}
|
||||
|
||||
async delete(_ctx: ServiceContext, id: string): Promise<boolean> {
|
||||
const result = await this.repository.delete({ id });
|
||||
return result.affected ? result.affected > 0 : false;
|
||||
}
|
||||
|
||||
async deleteByBranch(_ctx: ServiceContext, branchId: string): Promise<boolean> {
|
||||
const result = await this.repository.delete({ branchId });
|
||||
return result.affected ? result.affected > 0 : false;
|
||||
}
|
||||
|
||||
async getEffectiveSettings(
|
||||
ctx: ServiceContext,
|
||||
branchId: string
|
||||
): Promise<{
|
||||
settings: BranchInventorySettings | null;
|
||||
defaults: UpdateBranchInventorySettingsDto;
|
||||
}> {
|
||||
const settings = await this.findByBranch(ctx, branchId);
|
||||
|
||||
// Default values if no settings configured
|
||||
const defaults: UpdateBranchInventorySettingsDto = {
|
||||
defaultStockMin: 0,
|
||||
defaultStockMax: 1000,
|
||||
autoReorderEnabled: false,
|
||||
allowPriceOverride: false,
|
||||
maxDiscountPercent: 0,
|
||||
taxConfig: {},
|
||||
};
|
||||
|
||||
return { settings, defaults };
|
||||
}
|
||||
}
|
||||
240
src/modules/branches/services/branch-payment-terminal.service.ts
Normal file
240
src/modules/branches/services/branch-payment-terminal.service.ts
Normal file
@ -0,0 +1,240 @@
|
||||
/**
|
||||
* BranchPaymentTerminal Service
|
||||
* Servicio para gestión de terminales de pago por sucursal
|
||||
*
|
||||
* @module Branches
|
||||
*/
|
||||
|
||||
import { Repository, FindOptionsWhere } from 'typeorm';
|
||||
import { AppDataSource } from '../../../shared/database/typeorm.config';
|
||||
import {
|
||||
BranchPaymentTerminal,
|
||||
TerminalProvider,
|
||||
HealthStatus,
|
||||
} from '../entities/branch-payment-terminal.entity';
|
||||
import { ServiceContext } from './branch.service';
|
||||
|
||||
export interface CreateBranchPaymentTerminalDto {
|
||||
branchId: string;
|
||||
terminalId: string;
|
||||
terminalName?: string;
|
||||
terminalProvider: TerminalProvider;
|
||||
provider: string;
|
||||
credentials?: Record<string, any>;
|
||||
isActive?: boolean;
|
||||
isPrimary?: boolean;
|
||||
dailyLimit?: number;
|
||||
transactionLimit?: number;
|
||||
config?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface UpdateBranchPaymentTerminalDto {
|
||||
terminalName?: string;
|
||||
terminalProvider?: TerminalProvider;
|
||||
provider?: string;
|
||||
credentials?: Record<string, any>;
|
||||
isActive?: boolean;
|
||||
isPrimary?: boolean;
|
||||
healthStatus?: HealthStatus;
|
||||
dailyLimit?: number;
|
||||
transactionLimit?: number;
|
||||
config?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface BranchPaymentTerminalFilters {
|
||||
branchId?: string;
|
||||
terminalProvider?: TerminalProvider;
|
||||
isActive?: boolean;
|
||||
healthStatus?: HealthStatus;
|
||||
}
|
||||
|
||||
export class BranchPaymentTerminalService {
|
||||
private repository: Repository<BranchPaymentTerminal>;
|
||||
|
||||
constructor() {
|
||||
this.repository = AppDataSource.getRepository(BranchPaymentTerminal);
|
||||
}
|
||||
|
||||
async findAll(
|
||||
ctx: ServiceContext,
|
||||
filters?: BranchPaymentTerminalFilters
|
||||
): Promise<BranchPaymentTerminal[]> {
|
||||
const where: FindOptionsWhere<BranchPaymentTerminal> = {
|
||||
tenantId: ctx.tenantId,
|
||||
};
|
||||
|
||||
if (filters?.branchId) {
|
||||
where.branchId = filters.branchId;
|
||||
}
|
||||
|
||||
if (filters?.terminalProvider) {
|
||||
where.terminalProvider = filters.terminalProvider;
|
||||
}
|
||||
|
||||
if (filters?.isActive !== undefined) {
|
||||
where.isActive = filters.isActive;
|
||||
}
|
||||
|
||||
if (filters?.healthStatus) {
|
||||
where.healthStatus = filters.healthStatus;
|
||||
}
|
||||
|
||||
return this.repository.find({
|
||||
where,
|
||||
order: { isPrimary: 'DESC', terminalName: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(ctx: ServiceContext, id: string): Promise<BranchPaymentTerminal | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id, tenantId: ctx.tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
async findByBranch(ctx: ServiceContext, branchId: string): Promise<BranchPaymentTerminal[]> {
|
||||
return this.repository.find({
|
||||
where: { branchId, tenantId: ctx.tenantId, isActive: true },
|
||||
order: { isPrimary: 'DESC', terminalName: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findPrimaryTerminal(
|
||||
ctx: ServiceContext,
|
||||
branchId: string
|
||||
): Promise<BranchPaymentTerminal | null> {
|
||||
return this.repository.findOne({
|
||||
where: { branchId, tenantId: ctx.tenantId, isPrimary: true, isActive: true },
|
||||
});
|
||||
}
|
||||
|
||||
async findByTerminalId(
|
||||
ctx: ServiceContext,
|
||||
terminalId: string
|
||||
): Promise<BranchPaymentTerminal | null> {
|
||||
return this.repository.findOne({
|
||||
where: { terminalId, tenantId: ctx.tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
async create(
|
||||
ctx: ServiceContext,
|
||||
data: CreateBranchPaymentTerminalDto
|
||||
): Promise<BranchPaymentTerminal> {
|
||||
// If this is the first terminal or marked as primary, ensure only one primary
|
||||
if (data.isPrimary) {
|
||||
await this.repository.update(
|
||||
{ branchId: data.branchId, tenantId: ctx.tenantId, isPrimary: true },
|
||||
{ isPrimary: false }
|
||||
);
|
||||
}
|
||||
|
||||
const terminal = this.repository.create({
|
||||
...data,
|
||||
tenantId: ctx.tenantId,
|
||||
healthStatus: 'unknown',
|
||||
});
|
||||
|
||||
return this.repository.save(terminal);
|
||||
}
|
||||
|
||||
async update(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
data: UpdateBranchPaymentTerminalDto
|
||||
): Promise<BranchPaymentTerminal | null> {
|
||||
const terminal = await this.findById(ctx, id);
|
||||
if (!terminal) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If setting as primary, unset other primaries for this branch
|
||||
if (data.isPrimary && !terminal.isPrimary) {
|
||||
await this.repository.update(
|
||||
{ branchId: terminal.branchId, tenantId: ctx.tenantId, isPrimary: true },
|
||||
{ isPrimary: false }
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(terminal, data);
|
||||
return this.repository.save(terminal);
|
||||
}
|
||||
|
||||
async delete(ctx: ServiceContext, id: string): Promise<boolean> {
|
||||
const result = await this.repository.delete({ id, tenantId: ctx.tenantId });
|
||||
return result.affected ? result.affected > 0 : false;
|
||||
}
|
||||
|
||||
async setAsPrimary(ctx: ServiceContext, id: string): Promise<BranchPaymentTerminal | null> {
|
||||
const terminal = await this.findById(ctx, id);
|
||||
if (!terminal) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Unset other primaries
|
||||
await this.repository.update(
|
||||
{ branchId: terminal.branchId, tenantId: ctx.tenantId, isPrimary: true },
|
||||
{ isPrimary: false }
|
||||
);
|
||||
|
||||
terminal.isPrimary = true;
|
||||
return this.repository.save(terminal);
|
||||
}
|
||||
|
||||
async updateHealthStatus(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
healthStatus: HealthStatus
|
||||
): Promise<BranchPaymentTerminal | null> {
|
||||
const terminal = await this.findById(ctx, id);
|
||||
if (!terminal) {
|
||||
return null;
|
||||
}
|
||||
|
||||
terminal.healthStatus = healthStatus;
|
||||
terminal.lastHealthCheckAt = new Date();
|
||||
return this.repository.save(terminal);
|
||||
}
|
||||
|
||||
async recordTransaction(ctx: ServiceContext, id: string): Promise<BranchPaymentTerminal | null> {
|
||||
const terminal = await this.findById(ctx, id);
|
||||
if (!terminal) {
|
||||
return null;
|
||||
}
|
||||
|
||||
terminal.lastTransactionAt = new Date();
|
||||
return this.repository.save(terminal);
|
||||
}
|
||||
|
||||
async getHealthSummary(ctx: ServiceContext): Promise<{
|
||||
total: number;
|
||||
healthy: number;
|
||||
degraded: number;
|
||||
offline: number;
|
||||
unknown: number;
|
||||
}> {
|
||||
const terminals = await this.repository.find({
|
||||
where: { tenantId: ctx.tenantId, isActive: true },
|
||||
});
|
||||
|
||||
return {
|
||||
total: terminals.length,
|
||||
healthy: terminals.filter(t => t.healthStatus === 'healthy').length,
|
||||
degraded: terminals.filter(t => t.healthStatus === 'degraded').length,
|
||||
offline: terminals.filter(t => t.healthStatus === 'offline').length,
|
||||
unknown: terminals.filter(t => t.healthStatus === 'unknown').length,
|
||||
};
|
||||
}
|
||||
|
||||
async getTerminalsNeedingHealthCheck(
|
||||
ctx: ServiceContext,
|
||||
maxAgeMinutes: number = 30
|
||||
): Promise<BranchPaymentTerminal[]> {
|
||||
const cutoff = new Date(Date.now() - maxAgeMinutes * 60 * 1000);
|
||||
|
||||
const terminals = await this.repository.find({
|
||||
where: { tenantId: ctx.tenantId, isActive: true },
|
||||
});
|
||||
|
||||
return terminals.filter(t => !t.lastHealthCheckAt || t.lastHealthCheckAt < cutoff);
|
||||
}
|
||||
}
|
||||
206
src/modules/branches/services/branch-schedule.service.ts
Normal file
206
src/modules/branches/services/branch-schedule.service.ts
Normal file
@ -0,0 +1,206 @@
|
||||
/**
|
||||
* BranchSchedule Service
|
||||
* Servicio para gestión de horarios de sucursales
|
||||
*
|
||||
* @module Branches
|
||||
*/
|
||||
|
||||
import { Repository, FindOptionsWhere } from 'typeorm';
|
||||
import { AppDataSource } from '../../../shared/database/typeorm.config';
|
||||
import { BranchSchedule, ScheduleType } from '../entities/branch-schedule.entity';
|
||||
import { ServiceContext } from './branch.service';
|
||||
|
||||
export interface CreateBranchScheduleDto {
|
||||
branchId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
scheduleType?: ScheduleType;
|
||||
dayOfWeek?: number;
|
||||
specificDate?: Date;
|
||||
openTime: string;
|
||||
closeTime: string;
|
||||
shifts?: Array<{ name: string; start: string; end: string }>;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateBranchScheduleDto {
|
||||
name?: string;
|
||||
description?: string;
|
||||
scheduleType?: ScheduleType;
|
||||
dayOfWeek?: number;
|
||||
specificDate?: Date;
|
||||
openTime?: string;
|
||||
closeTime?: string;
|
||||
shifts?: Array<{ name: string; start: string; end: string }>;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface BranchScheduleFilters {
|
||||
branchId?: string;
|
||||
scheduleType?: ScheduleType;
|
||||
dayOfWeek?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class BranchScheduleService {
|
||||
private repository: Repository<BranchSchedule>;
|
||||
|
||||
constructor() {
|
||||
this.repository = AppDataSource.getRepository(BranchSchedule);
|
||||
}
|
||||
|
||||
async findAll(_ctx: ServiceContext, filters?: BranchScheduleFilters): Promise<BranchSchedule[]> {
|
||||
const where: FindOptionsWhere<BranchSchedule> = {};
|
||||
|
||||
if (filters?.branchId) {
|
||||
where.branchId = filters.branchId;
|
||||
}
|
||||
|
||||
if (filters?.scheduleType) {
|
||||
where.scheduleType = filters.scheduleType;
|
||||
}
|
||||
|
||||
if (filters?.dayOfWeek !== undefined) {
|
||||
where.dayOfWeek = filters.dayOfWeek;
|
||||
}
|
||||
|
||||
if (filters?.isActive !== undefined) {
|
||||
where.isActive = filters.isActive;
|
||||
}
|
||||
|
||||
return this.repository.find({
|
||||
where,
|
||||
relations: ['branch'],
|
||||
order: { dayOfWeek: 'ASC', openTime: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(_ctx: ServiceContext, id: string): Promise<BranchSchedule | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id },
|
||||
relations: ['branch'],
|
||||
});
|
||||
}
|
||||
|
||||
async findByBranch(_ctx: ServiceContext, branchId: string): Promise<BranchSchedule[]> {
|
||||
return this.repository.find({
|
||||
where: { branchId, isActive: true },
|
||||
order: { dayOfWeek: 'ASC', openTime: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findRegularSchedule(_ctx: ServiceContext, branchId: string): Promise<BranchSchedule[]> {
|
||||
return this.repository.find({
|
||||
where: { branchId, scheduleType: 'regular', isActive: true },
|
||||
order: { dayOfWeek: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findHolidaySchedule(_ctx: ServiceContext, branchId: string): Promise<BranchSchedule[]> {
|
||||
return this.repository.find({
|
||||
where: { branchId, scheduleType: 'holiday', isActive: true },
|
||||
order: { specificDate: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findScheduleForDate(
|
||||
_ctx: ServiceContext,
|
||||
branchId: string,
|
||||
date: Date
|
||||
): Promise<BranchSchedule | null> {
|
||||
// First check for specific date (holiday or special)
|
||||
const specificSchedule = await this.repository.findOne({
|
||||
where: {
|
||||
branchId,
|
||||
specificDate: date,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (specificSchedule) {
|
||||
return specificSchedule;
|
||||
}
|
||||
|
||||
// Fall back to regular schedule for day of week
|
||||
const dayOfWeek = date.getDay();
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
branchId,
|
||||
scheduleType: 'regular',
|
||||
dayOfWeek,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async create(_ctx: ServiceContext, data: CreateBranchScheduleDto): Promise<BranchSchedule> {
|
||||
const schedule = this.repository.create(data);
|
||||
return this.repository.save(schedule);
|
||||
}
|
||||
|
||||
async update(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
data: UpdateBranchScheduleDto
|
||||
): Promise<BranchSchedule | null> {
|
||||
const schedule = await this.findById(ctx, id);
|
||||
if (!schedule) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Object.assign(schedule, data);
|
||||
return this.repository.save(schedule);
|
||||
}
|
||||
|
||||
async delete(_ctx: ServiceContext, id: string): Promise<boolean> {
|
||||
const result = await this.repository.delete({ id });
|
||||
return result.affected ? result.affected > 0 : false;
|
||||
}
|
||||
|
||||
async createWeeklySchedule(
|
||||
_ctx: ServiceContext,
|
||||
branchId: string,
|
||||
weeklyHours: Array<{
|
||||
dayOfWeek: number;
|
||||
openTime: string;
|
||||
closeTime: string;
|
||||
shifts?: Array<{ name: string; start: string; end: string }>;
|
||||
}>
|
||||
): Promise<BranchSchedule[]> {
|
||||
// Delete existing regular schedules for this branch
|
||||
await this.repository.delete({
|
||||
branchId,
|
||||
scheduleType: 'regular',
|
||||
});
|
||||
|
||||
// Create new schedules
|
||||
const schedules = weeklyHours.map(hours =>
|
||||
this.repository.create({
|
||||
branchId,
|
||||
name: `Regular - Day ${hours.dayOfWeek}`,
|
||||
scheduleType: 'regular',
|
||||
dayOfWeek: hours.dayOfWeek,
|
||||
openTime: hours.openTime,
|
||||
closeTime: hours.closeTime,
|
||||
shifts: hours.shifts || [],
|
||||
isActive: true,
|
||||
})
|
||||
);
|
||||
|
||||
return this.repository.save(schedules);
|
||||
}
|
||||
|
||||
async isBranchOpenAt(
|
||||
ctx: ServiceContext,
|
||||
branchId: string,
|
||||
dateTime: Date
|
||||
): Promise<boolean> {
|
||||
const schedule = await this.findScheduleForDate(ctx, branchId, dateTime);
|
||||
if (!schedule) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const timeStr = dateTime.toTimeString().slice(0, 5); // HH:MM format
|
||||
return timeStr >= schedule.openTime && timeStr <= schedule.closeTime;
|
||||
}
|
||||
}
|
||||
317
src/modules/branches/services/branch.service.ts
Normal file
317
src/modules/branches/services/branch.service.ts
Normal file
@ -0,0 +1,317 @@
|
||||
/**
|
||||
* Branch Service
|
||||
* Servicio para gestión de sucursales
|
||||
*
|
||||
* @module Branches
|
||||
*/
|
||||
|
||||
import { Repository, FindOptionsWhere, IsNull, Not } from 'typeorm';
|
||||
import { AppDataSource } from '../../../shared/database/typeorm.config';
|
||||
import { Branch, BranchType } from '../entities/branch.entity';
|
||||
|
||||
export interface ServiceContext {
|
||||
tenantId: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface CreateBranchDto {
|
||||
code: string;
|
||||
name: string;
|
||||
shortName?: string;
|
||||
branchType?: BranchType;
|
||||
parentId?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
managerId?: string;
|
||||
addressLine1?: string;
|
||||
addressLine2?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
postalCode?: string;
|
||||
country?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
geofenceRadius?: number;
|
||||
geofenceEnabled?: boolean;
|
||||
timezone?: string;
|
||||
currency?: string;
|
||||
isActive?: boolean;
|
||||
isMain?: boolean;
|
||||
operatingHours?: Record<string, { open: string; close: string }>;
|
||||
settings?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface UpdateBranchDto {
|
||||
name?: string;
|
||||
shortName?: string;
|
||||
branchType?: BranchType;
|
||||
parentId?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
managerId?: string;
|
||||
addressLine1?: string;
|
||||
addressLine2?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
postalCode?: string;
|
||||
country?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
geofenceRadius?: number;
|
||||
geofenceEnabled?: boolean;
|
||||
timezone?: string;
|
||||
currency?: string;
|
||||
isActive?: boolean;
|
||||
isMain?: boolean;
|
||||
operatingHours?: Record<string, { open: string; close: string }>;
|
||||
settings?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface BranchFilters {
|
||||
branchType?: BranchType;
|
||||
isActive?: boolean;
|
||||
city?: string;
|
||||
state?: string;
|
||||
parentId?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface PaginationOptions {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export class BranchService {
|
||||
private repository: Repository<Branch>;
|
||||
|
||||
constructor() {
|
||||
this.repository = AppDataSource.getRepository(Branch);
|
||||
}
|
||||
|
||||
async findAll(
|
||||
ctx: ServiceContext,
|
||||
filters?: BranchFilters,
|
||||
pagination?: PaginationOptions
|
||||
): Promise<PaginatedResult<Branch>> {
|
||||
const where: FindOptionsWhere<Branch> = {
|
||||
tenantId: ctx.tenantId,
|
||||
};
|
||||
|
||||
if (filters?.branchType) {
|
||||
where.branchType = filters.branchType;
|
||||
}
|
||||
|
||||
if (filters?.isActive !== undefined) {
|
||||
where.isActive = filters.isActive;
|
||||
}
|
||||
|
||||
if (filters?.city) {
|
||||
where.city = filters.city;
|
||||
}
|
||||
|
||||
if (filters?.state) {
|
||||
where.state = filters.state;
|
||||
}
|
||||
|
||||
if (filters?.parentId) {
|
||||
where.parentId = filters.parentId;
|
||||
}
|
||||
|
||||
const page = pagination?.page || 1;
|
||||
const limit = pagination?.limit || 50;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [data, total] = await this.repository.findAndCount({
|
||||
where,
|
||||
relations: ['parent', 'children'],
|
||||
order: { code: 'ASC' },
|
||||
skip,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async findById(ctx: ServiceContext, id: string): Promise<Branch | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id, tenantId: ctx.tenantId },
|
||||
relations: ['parent', 'children', 'userAssignments', 'schedules', 'paymentTerminals'],
|
||||
});
|
||||
}
|
||||
|
||||
async findByCode(ctx: ServiceContext, code: string): Promise<Branch | null> {
|
||||
return this.repository.findOne({
|
||||
where: { code, tenantId: ctx.tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
async findMainBranch(ctx: ServiceContext): Promise<Branch | null> {
|
||||
return this.repository.findOne({
|
||||
where: { tenantId: ctx.tenantId, isMain: true },
|
||||
});
|
||||
}
|
||||
|
||||
async findRootBranches(ctx: ServiceContext): Promise<Branch[]> {
|
||||
return this.repository.find({
|
||||
where: { tenantId: ctx.tenantId, parentId: IsNull() },
|
||||
relations: ['children'],
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findChildren(ctx: ServiceContext, parentId: string): Promise<Branch[]> {
|
||||
return this.repository.find({
|
||||
where: { tenantId: ctx.tenantId, parentId },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(ctx: ServiceContext, data: CreateBranchDto): Promise<Branch> {
|
||||
const branch = this.repository.create({
|
||||
...data,
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.userId,
|
||||
});
|
||||
|
||||
// Build hierarchy path if parent exists
|
||||
if (data.parentId) {
|
||||
const parent = await this.findById(ctx, data.parentId);
|
||||
if (parent) {
|
||||
branch.hierarchyPath = parent.hierarchyPath
|
||||
? `${parent.hierarchyPath}/${parent.id}`
|
||||
: parent.id;
|
||||
branch.hierarchyLevel = parent.hierarchyLevel + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return this.repository.save(branch);
|
||||
}
|
||||
|
||||
async update(ctx: ServiceContext, id: string, data: UpdateBranchDto): Promise<Branch | null> {
|
||||
const branch = await this.findById(ctx, id);
|
||||
if (!branch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If parent changed, update hierarchy
|
||||
if (data.parentId !== undefined && data.parentId !== branch.parentId) {
|
||||
if (data.parentId) {
|
||||
const newParent = await this.findById(ctx, data.parentId);
|
||||
if (newParent) {
|
||||
branch.hierarchyPath = newParent.hierarchyPath
|
||||
? `${newParent.hierarchyPath}/${newParent.id}`
|
||||
: newParent.id;
|
||||
branch.hierarchyLevel = newParent.hierarchyLevel + 1;
|
||||
}
|
||||
} else {
|
||||
branch.hierarchyPath = '';
|
||||
branch.hierarchyLevel = 0;
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(branch, data);
|
||||
if (ctx.userId) {
|
||||
branch.updatedBy = ctx.userId;
|
||||
}
|
||||
|
||||
return this.repository.save(branch);
|
||||
}
|
||||
|
||||
async delete(ctx: ServiceContext, id: string): Promise<boolean> {
|
||||
// Check if branch has children
|
||||
const children = await this.findChildren(ctx, id);
|
||||
if (children.length > 0) {
|
||||
throw new Error('Cannot delete branch with children. Delete children first.');
|
||||
}
|
||||
|
||||
const result = await this.repository.softDelete({ id, tenantId: ctx.tenantId });
|
||||
return result.affected ? result.affected > 0 : false;
|
||||
}
|
||||
|
||||
async setAsMain(ctx: ServiceContext, id: string): Promise<Branch | null> {
|
||||
// First, unset any current main branch
|
||||
await this.repository.update(
|
||||
{ tenantId: ctx.tenantId, isMain: true },
|
||||
{ isMain: false }
|
||||
);
|
||||
|
||||
// Set new main branch
|
||||
return this.update(ctx, id, { isMain: true });
|
||||
}
|
||||
|
||||
async getStatistics(ctx: ServiceContext): Promise<{
|
||||
total: number;
|
||||
active: number;
|
||||
inactive: number;
|
||||
byType: Record<string, number>;
|
||||
}> {
|
||||
const branches = await this.repository.find({
|
||||
where: { tenantId: ctx.tenantId },
|
||||
});
|
||||
|
||||
const byType: Record<string, number> = {};
|
||||
branches.forEach(branch => {
|
||||
byType[branch.branchType] = (byType[branch.branchType] || 0) + 1;
|
||||
});
|
||||
|
||||
return {
|
||||
total: branches.length,
|
||||
active: branches.filter(b => b.isActive).length,
|
||||
inactive: branches.filter(b => !b.isActive).length,
|
||||
byType,
|
||||
};
|
||||
}
|
||||
|
||||
async getHierarchy(ctx: ServiceContext): Promise<Branch[]> {
|
||||
const roots = await this.findRootBranches(ctx);
|
||||
|
||||
const loadChildren = async (branch: Branch): Promise<Branch> => {
|
||||
const children = await this.findChildren(ctx, branch.id);
|
||||
branch.children = await Promise.all(children.map(loadChildren));
|
||||
return branch;
|
||||
};
|
||||
|
||||
return Promise.all(roots.map(loadChildren));
|
||||
}
|
||||
|
||||
async findByLocation(
|
||||
ctx: ServiceContext,
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
radiusKm: number = 10
|
||||
): Promise<Branch[]> {
|
||||
// Simple distance calculation using Haversine formula approximation
|
||||
const latDelta = radiusKm / 111; // 1 degree latitude ~ 111 km
|
||||
const lonDelta = radiusKm / (111 * Math.cos(latitude * Math.PI / 180));
|
||||
|
||||
const branches = await this.repository.find({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
isActive: true,
|
||||
latitude: Not(IsNull()),
|
||||
longitude: Not(IsNull()),
|
||||
},
|
||||
});
|
||||
|
||||
return branches.filter(branch => {
|
||||
if (!branch.latitude || !branch.longitude) return false;
|
||||
const latDiff = Math.abs(Number(branch.latitude) - latitude);
|
||||
const lonDiff = Math.abs(Number(branch.longitude) - longitude);
|
||||
return latDiff <= latDelta && lonDiff <= lonDelta;
|
||||
});
|
||||
}
|
||||
}
|
||||
10
src/modules/branches/services/index.ts
Normal file
10
src/modules/branches/services/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Branches Services Index
|
||||
* @module Branches
|
||||
*/
|
||||
|
||||
export * from './branch.service';
|
||||
export * from './branch-schedule.service';
|
||||
export * from './branch-inventory-settings.service';
|
||||
export * from './branch-payment-terminal.service';
|
||||
export * from './user-branch-assignment.service';
|
||||
344
src/modules/branches/services/user-branch-assignment.service.ts
Normal file
344
src/modules/branches/services/user-branch-assignment.service.ts
Normal file
@ -0,0 +1,344 @@
|
||||
/**
|
||||
* UserBranchAssignment Service
|
||||
* Servicio para gestión de asignaciones de usuarios a sucursales
|
||||
*
|
||||
* @module Branches
|
||||
*/
|
||||
|
||||
import { Repository, FindOptionsWhere } from 'typeorm';
|
||||
import { AppDataSource } from '../../../shared/database/typeorm.config';
|
||||
import {
|
||||
UserBranchAssignment,
|
||||
AssignmentType,
|
||||
BranchRole,
|
||||
} from '../entities/user-branch-assignment.entity';
|
||||
import { ServiceContext } from './branch.service';
|
||||
|
||||
export interface CreateUserBranchAssignmentDto {
|
||||
userId: string;
|
||||
branchId: string;
|
||||
assignmentType?: AssignmentType;
|
||||
branchRole?: BranchRole;
|
||||
permissions?: string[];
|
||||
validFrom?: Date;
|
||||
validUntil?: Date;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateUserBranchAssignmentDto {
|
||||
assignmentType?: AssignmentType;
|
||||
branchRole?: BranchRole;
|
||||
permissions?: string[];
|
||||
validFrom?: Date;
|
||||
validUntil?: Date;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface UserBranchAssignmentFilters {
|
||||
userId?: string;
|
||||
branchId?: string;
|
||||
assignmentType?: AssignmentType;
|
||||
branchRole?: BranchRole;
|
||||
isActive?: boolean;
|
||||
includeExpired?: boolean;
|
||||
}
|
||||
|
||||
export class UserBranchAssignmentService {
|
||||
private repository: Repository<UserBranchAssignment>;
|
||||
|
||||
constructor() {
|
||||
this.repository = AppDataSource.getRepository(UserBranchAssignment);
|
||||
}
|
||||
|
||||
async findAll(
|
||||
ctx: ServiceContext,
|
||||
filters?: UserBranchAssignmentFilters
|
||||
): Promise<UserBranchAssignment[]> {
|
||||
const where: FindOptionsWhere<UserBranchAssignment> = {
|
||||
tenantId: ctx.tenantId,
|
||||
};
|
||||
|
||||
if (filters?.userId) {
|
||||
where.userId = filters.userId;
|
||||
}
|
||||
|
||||
if (filters?.branchId) {
|
||||
where.branchId = filters.branchId;
|
||||
}
|
||||
|
||||
if (filters?.assignmentType) {
|
||||
where.assignmentType = filters.assignmentType;
|
||||
}
|
||||
|
||||
if (filters?.branchRole) {
|
||||
where.branchRole = filters.branchRole;
|
||||
}
|
||||
|
||||
if (filters?.isActive !== undefined) {
|
||||
where.isActive = filters.isActive;
|
||||
}
|
||||
|
||||
return this.repository.find({
|
||||
where,
|
||||
relations: ['branch'],
|
||||
order: { assignmentType: 'ASC', createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(ctx: ServiceContext, id: string): Promise<UserBranchAssignment | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id, tenantId: ctx.tenantId },
|
||||
relations: ['branch'],
|
||||
});
|
||||
}
|
||||
|
||||
async findByUser(ctx: ServiceContext, userId: string): Promise<UserBranchAssignment[]> {
|
||||
return this.repository.find({
|
||||
where: { userId, tenantId: ctx.tenantId, isActive: true },
|
||||
relations: ['branch'],
|
||||
order: { assignmentType: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findByBranch(ctx: ServiceContext, branchId: string): Promise<UserBranchAssignment[]> {
|
||||
return this.repository.find({
|
||||
where: { branchId, tenantId: ctx.tenantId, isActive: true },
|
||||
relations: ['branch'],
|
||||
order: { branchRole: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findPrimaryAssignment(
|
||||
ctx: ServiceContext,
|
||||
userId: string
|
||||
): Promise<UserBranchAssignment | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
userId,
|
||||
tenantId: ctx.tenantId,
|
||||
assignmentType: 'primary',
|
||||
isActive: true,
|
||||
},
|
||||
relations: ['branch'],
|
||||
});
|
||||
}
|
||||
|
||||
async findActiveAssignments(
|
||||
ctx: ServiceContext,
|
||||
userId: string,
|
||||
asOfDate?: Date
|
||||
): Promise<UserBranchAssignment[]> {
|
||||
const now = asOfDate || new Date();
|
||||
|
||||
const assignments = await this.repository.find({
|
||||
where: {
|
||||
userId,
|
||||
tenantId: ctx.tenantId,
|
||||
isActive: true,
|
||||
},
|
||||
relations: ['branch'],
|
||||
});
|
||||
|
||||
// Filter by validity period
|
||||
return assignments.filter(a => {
|
||||
const validFrom = a.validFrom || new Date(0);
|
||||
const validUntil = a.validUntil || new Date('9999-12-31');
|
||||
return now >= validFrom && now <= validUntil;
|
||||
});
|
||||
}
|
||||
|
||||
async isUserAssignedToBranch(
|
||||
ctx: ServiceContext,
|
||||
userId: string,
|
||||
branchId: string
|
||||
): Promise<boolean> {
|
||||
const assignment = await this.repository.findOne({
|
||||
where: {
|
||||
userId,
|
||||
branchId,
|
||||
tenantId: ctx.tenantId,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!assignment) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check validity
|
||||
const now = new Date();
|
||||
const validFrom = assignment.validFrom || new Date(0);
|
||||
const validUntil = assignment.validUntil || new Date('9999-12-31');
|
||||
|
||||
return now >= validFrom && now <= validUntil;
|
||||
}
|
||||
|
||||
async create(
|
||||
ctx: ServiceContext,
|
||||
data: CreateUserBranchAssignmentDto
|
||||
): Promise<UserBranchAssignment> {
|
||||
// If setting as primary, unset other primary assignments for this user
|
||||
if (data.assignmentType === 'primary') {
|
||||
await this.repository.update(
|
||||
{
|
||||
userId: data.userId,
|
||||
tenantId: ctx.tenantId,
|
||||
assignmentType: 'primary',
|
||||
},
|
||||
{ assignmentType: 'secondary' }
|
||||
);
|
||||
}
|
||||
|
||||
const assignment = this.repository.create({
|
||||
...data,
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.userId,
|
||||
});
|
||||
|
||||
return this.repository.save(assignment);
|
||||
}
|
||||
|
||||
async update(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
data: UpdateUserBranchAssignmentDto
|
||||
): Promise<UserBranchAssignment | null> {
|
||||
const assignment = await this.findById(ctx, id);
|
||||
if (!assignment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If setting as primary, unset other primary assignments for this user
|
||||
if (data.assignmentType === 'primary' && assignment.assignmentType !== 'primary') {
|
||||
await this.repository.update(
|
||||
{
|
||||
userId: assignment.userId,
|
||||
tenantId: ctx.tenantId,
|
||||
assignmentType: 'primary',
|
||||
},
|
||||
{ assignmentType: 'secondary' }
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(assignment, data);
|
||||
return this.repository.save(assignment);
|
||||
}
|
||||
|
||||
async delete(ctx: ServiceContext, id: string): Promise<boolean> {
|
||||
const result = await this.repository.delete({ id, tenantId: ctx.tenantId });
|
||||
return result.affected ? result.affected > 0 : false;
|
||||
}
|
||||
|
||||
async deactivate(ctx: ServiceContext, id: string): Promise<UserBranchAssignment | null> {
|
||||
return this.update(ctx, id, { isActive: false });
|
||||
}
|
||||
|
||||
async setAsPrimary(ctx: ServiceContext, id: string): Promise<UserBranchAssignment | null> {
|
||||
const assignment = await this.findById(ctx, id);
|
||||
if (!assignment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Unset other primary assignments for this user
|
||||
await this.repository.update(
|
||||
{
|
||||
userId: assignment.userId,
|
||||
tenantId: ctx.tenantId,
|
||||
assignmentType: 'primary',
|
||||
},
|
||||
{ assignmentType: 'secondary' }
|
||||
);
|
||||
|
||||
assignment.assignmentType = 'primary';
|
||||
return this.repository.save(assignment);
|
||||
}
|
||||
|
||||
async getBranchManagers(ctx: ServiceContext, branchId: string): Promise<UserBranchAssignment[]> {
|
||||
return this.repository.find({
|
||||
where: {
|
||||
branchId,
|
||||
tenantId: ctx.tenantId,
|
||||
branchRole: 'manager',
|
||||
isActive: true,
|
||||
},
|
||||
relations: ['branch'],
|
||||
});
|
||||
}
|
||||
|
||||
async getUserPermissionsForBranch(
|
||||
ctx: ServiceContext,
|
||||
userId: string,
|
||||
branchId: string
|
||||
): Promise<string[]> {
|
||||
const assignments = await this.repository.find({
|
||||
where: {
|
||||
userId,
|
||||
branchId,
|
||||
tenantId: ctx.tenantId,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Merge all permissions from all assignments
|
||||
const permissions = new Set<string>();
|
||||
assignments.forEach(a => {
|
||||
(a.permissions || []).forEach(p => permissions.add(p));
|
||||
});
|
||||
|
||||
return Array.from(permissions);
|
||||
}
|
||||
|
||||
async getAssignmentStatistics(ctx: ServiceContext): Promise<{
|
||||
totalAssignments: number;
|
||||
activeAssignments: number;
|
||||
byType: Record<string, number>;
|
||||
byRole: Record<string, number>;
|
||||
}> {
|
||||
const assignments = await this.repository.find({
|
||||
where: { tenantId: ctx.tenantId },
|
||||
});
|
||||
|
||||
const byType: Record<string, number> = {};
|
||||
const byRole: Record<string, number> = {};
|
||||
|
||||
assignments.forEach(a => {
|
||||
byType[a.assignmentType] = (byType[a.assignmentType] || 0) + 1;
|
||||
if (a.branchRole) {
|
||||
byRole[a.branchRole] = (byRole[a.branchRole] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
totalAssignments: assignments.length,
|
||||
activeAssignments: assignments.filter(a => a.isActive).length,
|
||||
byType,
|
||||
byRole,
|
||||
};
|
||||
}
|
||||
|
||||
async transferUserToBranch(
|
||||
ctx: ServiceContext,
|
||||
userId: string,
|
||||
fromBranchId: string,
|
||||
toBranchId: string,
|
||||
transferPrimary: boolean = true
|
||||
): Promise<UserBranchAssignment | null> {
|
||||
// Deactivate assignments at old branch
|
||||
await this.repository.update(
|
||||
{
|
||||
userId,
|
||||
branchId: fromBranchId,
|
||||
tenantId: ctx.tenantId,
|
||||
},
|
||||
{ isActive: false }
|
||||
);
|
||||
|
||||
// Create new assignment at new branch
|
||||
return this.create(ctx, {
|
||||
userId,
|
||||
branchId: toBranchId,
|
||||
assignmentType: transferPrimary ? 'primary' : 'secondary',
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
7
src/modules/invoices/controllers/index.ts
Normal file
7
src/modules/invoices/controllers/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Invoices Controllers Index
|
||||
* @module Invoices
|
||||
*/
|
||||
|
||||
export { createInvoiceController } from './invoice.controller';
|
||||
export { createPaymentController } from './payment.controller';
|
||||
498
src/modules/invoices/controllers/invoice.controller.ts
Normal file
498
src/modules/invoices/controllers/invoice.controller.ts
Normal file
@ -0,0 +1,498 @@
|
||||
/**
|
||||
* InvoiceController - REST Controller for Invoices
|
||||
*
|
||||
* Endpoints for invoice management including CRUD and workflow operations.
|
||||
*
|
||||
* @module Invoices
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { InvoiceService, InvoiceFilters } from '../services';
|
||||
|
||||
export function createInvoiceController(dataSource: DataSource): Router {
|
||||
const router = Router();
|
||||
const service = new InvoiceService(dataSource);
|
||||
|
||||
// ==================== CRUD ENDPOINTS ====================
|
||||
|
||||
/**
|
||||
* GET /
|
||||
* List invoices with filters and pagination
|
||||
*/
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const filters: InvoiceFilters = {
|
||||
invoiceType: req.query.invoiceType as any,
|
||||
invoiceContext: req.query.invoiceContext as any,
|
||||
status: req.query.status as any,
|
||||
partnerId: req.query.partnerId as string,
|
||||
startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined,
|
||||
endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined,
|
||||
overdue: req.query.overdue === 'true',
|
||||
search: req.query.search as string,
|
||||
};
|
||||
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = parseInt(req.query.limit as string) || 20;
|
||||
|
||||
const result = await service.findAll(ctx, filters, page, limit);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /dashboard
|
||||
* Get dashboard statistics
|
||||
*/
|
||||
router.get('/dashboard', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const stats = await service.getDashboardStats(ctx);
|
||||
res.json(stats);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /summary
|
||||
* Get invoice summary
|
||||
*/
|
||||
router.get('/summary', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const filters: InvoiceFilters = {
|
||||
invoiceContext: req.query.invoiceContext as any,
|
||||
startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined,
|
||||
endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined,
|
||||
};
|
||||
|
||||
const summary = await service.getSummary(ctx, filters);
|
||||
res.json(summary);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /by-number/:invoiceNumber
|
||||
* Get invoice by number
|
||||
*/
|
||||
router.get('/by-number/:invoiceNumber', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const invoice = await service.findByNumber(ctx, req.params.invoiceNumber);
|
||||
if (!invoice) {
|
||||
res.status(404).json({ error: 'Factura no encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(invoice);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /:id
|
||||
* Get invoice by ID
|
||||
*/
|
||||
router.get('/:id', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const invoice = await service.findWithItems(ctx, req.params.id);
|
||||
if (!invoice) {
|
||||
res.status(404).json({ error: 'Factura no encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(invoice);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /
|
||||
* Create new invoice
|
||||
*/
|
||||
router.post('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const invoice = await service.create(ctx, req.body);
|
||||
res.status(201).json(invoice);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /:id
|
||||
* Update invoice
|
||||
*/
|
||||
router.put('/:id', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const invoice = await service.update(ctx, req.params.id, req.body);
|
||||
if (!invoice) {
|
||||
res.status(404).json({ error: 'Factura no encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(invoice);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /:id
|
||||
* Delete invoice (soft delete)
|
||||
*/
|
||||
router.delete('/:id', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const deleted = await service.softDelete(ctx, req.params.id);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Factura no encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== ITEM ENDPOINTS ====================
|
||||
|
||||
/**
|
||||
* POST /:id/items
|
||||
* Add items to invoice
|
||||
*/
|
||||
router.post('/:id/items', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const items = await service.addItems(ctx, req.params.id, req.body.items || [req.body]);
|
||||
res.status(201).json(items);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /items/:itemId
|
||||
* Update invoice item
|
||||
*/
|
||||
router.put('/items/:itemId', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const item = await service.updateItem(ctx, req.params.itemId, req.body);
|
||||
if (!item) {
|
||||
res.status(404).json({ error: 'Item no encontrado' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(item);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /items/:itemId
|
||||
* Remove invoice item
|
||||
*/
|
||||
router.delete('/items/:itemId', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const deleted = await service.removeItem(ctx, req.params.itemId);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Item no encontrado' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== WORKFLOW ENDPOINTS ====================
|
||||
|
||||
/**
|
||||
* POST /:id/validate
|
||||
* Validate invoice (draft -> validated)
|
||||
*/
|
||||
router.post('/:id/validate', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const invoice = await service.validate(ctx, req.params.id);
|
||||
if (!invoice) {
|
||||
res.status(404).json({ error: 'Factura no encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(invoice);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /:id/send
|
||||
* Send invoice (validated -> sent)
|
||||
*/
|
||||
router.post('/:id/send', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const invoice = await service.send(ctx, req.params.id);
|
||||
if (!invoice) {
|
||||
res.status(404).json({ error: 'Factura no encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(invoice);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /:id/record-payment
|
||||
* Record partial payment
|
||||
*/
|
||||
router.post('/:id/record-payment', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const { amount } = req.body;
|
||||
if (!amount || amount <= 0) {
|
||||
res.status(400).json({ error: 'Monto de pago invalido' });
|
||||
return;
|
||||
}
|
||||
|
||||
const invoice = await service.recordPartialPayment(ctx, req.params.id, amount);
|
||||
if (!invoice) {
|
||||
res.status(404).json({ error: 'Factura no encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(invoice);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /:id/mark-paid
|
||||
* Mark invoice as paid
|
||||
*/
|
||||
router.post('/:id/mark-paid', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const invoice = await service.markAsPaid(ctx, req.params.id, req.body.paymentReference);
|
||||
if (!invoice) {
|
||||
res.status(404).json({ error: 'Factura no encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(invoice);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /:id/void
|
||||
* Void invoice
|
||||
*/
|
||||
router.post('/:id/void', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const invoice = await service.void(ctx, req.params.id, req.body.reason);
|
||||
if (!invoice) {
|
||||
res.status(404).json({ error: 'Factura no encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(invoice);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /:id/cancel
|
||||
* Cancel invoice
|
||||
*/
|
||||
router.post('/:id/cancel', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const invoice = await service.cancel(ctx, req.params.id, req.body.reason);
|
||||
if (!invoice) {
|
||||
res.status(404).json({ error: 'Factura no encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(invoice);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /:id/refund
|
||||
* Create refund (credit note)
|
||||
*/
|
||||
router.post('/:id/refund', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const creditNote = await service.createRefund(ctx, req.params.id, req.body.reason);
|
||||
if (!creditNote) {
|
||||
res.status(404).json({ error: 'Factura no encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(201).json(creditNote);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== GENERATION ENDPOINTS ====================
|
||||
|
||||
/**
|
||||
* POST /generate/from-sales-order
|
||||
* Generate invoice from sales order
|
||||
*/
|
||||
router.post('/generate/from-sales-order', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const { salesOrderId, ...salesOrderData } = req.body;
|
||||
const invoice = await service.generateFromSalesOrder(ctx, salesOrderId, salesOrderData);
|
||||
res.status(201).json(invoice);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /generate/for-subscription
|
||||
* Generate invoice for subscription period
|
||||
*/
|
||||
router.post('/generate/for-subscription', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const invoice = await service.generateForSubscription(ctx, req.body);
|
||||
res.status(201).json(invoice);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== MAINTENANCE ENDPOINTS ====================
|
||||
|
||||
/**
|
||||
* POST /update-overdue
|
||||
* Update overdue status for all invoices
|
||||
*/
|
||||
router.post('/update-overdue', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const count = await service.updateOverdueStatus(ctx);
|
||||
res.json({ updated: count });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
357
src/modules/invoices/controllers/payment.controller.ts
Normal file
357
src/modules/invoices/controllers/payment.controller.ts
Normal file
@ -0,0 +1,357 @@
|
||||
/**
|
||||
* PaymentController - REST Controller for Payments
|
||||
*
|
||||
* Endpoints for payment management including CRUD, allocation, and workflow operations.
|
||||
*
|
||||
* @module Invoices
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { PaymentService, PaymentFilters } from '../services';
|
||||
|
||||
export function createPaymentController(dataSource: DataSource): Router {
|
||||
const router = Router();
|
||||
const service = new PaymentService(dataSource);
|
||||
|
||||
// ==================== CRUD ENDPOINTS ====================
|
||||
|
||||
/**
|
||||
* GET /
|
||||
* List payments with filters and pagination
|
||||
*/
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const filters: PaymentFilters = {
|
||||
paymentType: req.query.paymentType as any,
|
||||
partnerId: req.query.partnerId as string,
|
||||
status: req.query.status as any,
|
||||
paymentMethod: req.query.paymentMethod as string,
|
||||
startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined,
|
||||
endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined,
|
||||
search: req.query.search as string,
|
||||
};
|
||||
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = parseInt(req.query.limit as string) || 20;
|
||||
|
||||
const result = await service.findAll(ctx, filters, page, limit);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /summary
|
||||
* Get payment summary
|
||||
*/
|
||||
router.get('/summary', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const filters: PaymentFilters = {
|
||||
paymentType: req.query.paymentType as any,
|
||||
startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined,
|
||||
endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined,
|
||||
};
|
||||
|
||||
const summary = await service.getSummary(ctx, filters);
|
||||
res.json(summary);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /by-partner/:partnerId
|
||||
* Get payments by partner
|
||||
*/
|
||||
router.get('/by-partner/:partnerId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = parseInt(req.query.limit as string) || 20;
|
||||
|
||||
const result = await service.findByPartner(ctx, req.params.partnerId, page, limit);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /invoices-for-allocation/:partnerId
|
||||
* Get invoices available for allocation
|
||||
*/
|
||||
router.get('/invoices-for-allocation/:partnerId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const invoices = await service.getInvoicesForAllocation(ctx, req.params.partnerId);
|
||||
res.json(invoices);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /:id
|
||||
* Get payment by ID
|
||||
*/
|
||||
router.get('/:id', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const payment = await service.findWithAllocations(ctx, req.params.id);
|
||||
if (!payment) {
|
||||
res.status(404).json({ error: 'Pago no encontrado' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(payment);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /:id/unallocated
|
||||
* Get unallocated amount for a payment
|
||||
*/
|
||||
router.get('/:id/unallocated', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const unallocatedAmount = await service.getUnallocatedAmount(ctx, req.params.id);
|
||||
res.json({ unallocatedAmount });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /:id/allocations
|
||||
* Get allocations for a payment
|
||||
*/
|
||||
router.get('/:id/allocations', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const allocations = await service.getAllocations(ctx, req.params.id);
|
||||
res.json(allocations);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /
|
||||
* Create new payment
|
||||
*/
|
||||
router.post('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const payment = await service.create(ctx, req.body);
|
||||
res.status(201).json(payment);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /:id
|
||||
* Update payment
|
||||
*/
|
||||
router.put('/:id', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const payment = await service.update(ctx, req.params.id, req.body);
|
||||
if (!payment) {
|
||||
res.status(404).json({ error: 'Pago no encontrado' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(payment);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /:id
|
||||
* Delete payment (soft delete)
|
||||
*/
|
||||
router.delete('/:id', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const deleted = await service.softDelete(ctx, req.params.id);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Pago no encontrado' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== ALLOCATION ENDPOINTS ====================
|
||||
|
||||
/**
|
||||
* POST /:id/allocate
|
||||
* Allocate payment to invoices
|
||||
*/
|
||||
router.post('/:id/allocate', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const { allocations } = req.body;
|
||||
if (!allocations || !Array.isArray(allocations) || allocations.length === 0) {
|
||||
res.status(400).json({ error: 'Se requieren asignaciones' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await service.allocateToInvoices(ctx, req.params.id, allocations);
|
||||
res.status(201).json(result);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /allocations/:allocationId
|
||||
* Remove allocation
|
||||
*/
|
||||
router.delete('/allocations/:allocationId', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const deleted = await service.removeAllocation(ctx, req.params.allocationId);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Asignacion no encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== WORKFLOW ENDPOINTS ====================
|
||||
|
||||
/**
|
||||
* POST /:id/confirm
|
||||
* Confirm payment (draft -> confirmed)
|
||||
*/
|
||||
router.post('/:id/confirm', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const payment = await service.confirm(ctx, req.params.id);
|
||||
if (!payment) {
|
||||
res.status(404).json({ error: 'Pago no encontrado' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(payment);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /:id/reconcile
|
||||
* Reconcile payment (confirmed -> reconciled)
|
||||
*/
|
||||
router.post('/:id/reconcile', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const payment = await service.reconcile(ctx, req.params.id);
|
||||
if (!payment) {
|
||||
res.status(404).json({ error: 'Pago no encontrado' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(payment);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /:id/cancel
|
||||
* Cancel payment
|
||||
*/
|
||||
router.post('/:id/cancel', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const ctx = {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: (req as any).user?.id,
|
||||
};
|
||||
|
||||
const payment = await service.cancel(ctx, req.params.id, req.body.reason);
|
||||
if (!payment) {
|
||||
res.status(404).json({ error: 'Pago no encontrado' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(payment);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
13
src/modules/invoices/index.ts
Normal file
13
src/modules/invoices/index.ts
Normal file
@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Invoices Module Index
|
||||
* @module Invoices
|
||||
*/
|
||||
|
||||
// Entities
|
||||
export * from './entities';
|
||||
|
||||
// Services
|
||||
export * from './services';
|
||||
|
||||
// Controllers
|
||||
export * from './controllers';
|
||||
19
src/modules/invoices/services/index.ts
Normal file
19
src/modules/invoices/services/index.ts
Normal file
@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Invoices Services Index
|
||||
* @module Invoices
|
||||
*/
|
||||
|
||||
export { InvoiceService } from './invoice.service';
|
||||
export type {
|
||||
CreateInvoiceDto,
|
||||
CreateInvoiceItemDto,
|
||||
InvoiceFilters,
|
||||
} from './invoice.service';
|
||||
|
||||
export { PaymentService } from './payment.service';
|
||||
export type {
|
||||
CreatePaymentDto,
|
||||
PaymentFilters,
|
||||
PaymentType,
|
||||
PaymentStatus,
|
||||
} from './payment.service';
|
||||
983
src/modules/invoices/services/invoice.service.ts
Normal file
983
src/modules/invoices/services/invoice.service.ts
Normal file
@ -0,0 +1,983 @@
|
||||
/**
|
||||
* InvoiceService - Invoice Management Service
|
||||
*
|
||||
* Handles unified invoice management for both commercial and SaaS contexts.
|
||||
* Includes CRUD operations, invoice generation, and status workflow.
|
||||
*
|
||||
* @module Invoices
|
||||
*/
|
||||
|
||||
import { DataSource, Repository, IsNull } from 'typeorm';
|
||||
import {
|
||||
Invoice,
|
||||
InvoiceType,
|
||||
InvoiceStatus,
|
||||
InvoiceContext,
|
||||
InvoiceItem,
|
||||
} from '../entities';
|
||||
|
||||
interface ServiceContext {
|
||||
tenantId: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
interface PaginatedResult<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface CreateInvoiceDto {
|
||||
invoiceType?: InvoiceType;
|
||||
invoiceContext?: InvoiceContext;
|
||||
// Commercial fields
|
||||
salesOrderId?: string;
|
||||
purchaseOrderId?: string;
|
||||
partnerId?: string;
|
||||
partnerName?: string;
|
||||
partnerTaxId?: string;
|
||||
// SaaS billing fields
|
||||
subscriptionId?: string;
|
||||
periodStart?: Date;
|
||||
periodEnd?: Date;
|
||||
// Billing information
|
||||
billingName?: string;
|
||||
billingEmail?: string;
|
||||
billingAddress?: Record<string, any>;
|
||||
taxId?: string;
|
||||
// Dates
|
||||
invoiceDate?: Date;
|
||||
dueDate?: Date;
|
||||
// Payment
|
||||
currency?: string;
|
||||
exchangeRate?: number;
|
||||
paymentTermDays?: number;
|
||||
paymentMethod?: string;
|
||||
// Notes
|
||||
notes?: string;
|
||||
internalNotes?: string;
|
||||
// Items
|
||||
items?: CreateInvoiceItemDto[];
|
||||
}
|
||||
|
||||
export interface CreateInvoiceItemDto {
|
||||
productId?: string;
|
||||
lineNumber?: number;
|
||||
productSku?: string;
|
||||
productName: string;
|
||||
description?: string;
|
||||
satProductCode?: string;
|
||||
satUnitCode?: string;
|
||||
quantity: number;
|
||||
uom?: string;
|
||||
unitPrice: number;
|
||||
discountPercent?: number;
|
||||
taxRate?: number;
|
||||
withholdingRate?: number;
|
||||
}
|
||||
|
||||
export interface InvoiceFilters {
|
||||
invoiceType?: InvoiceType;
|
||||
invoiceContext?: InvoiceContext;
|
||||
status?: InvoiceStatus;
|
||||
partnerId?: string;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
overdue?: boolean;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
interface InvoiceSummary {
|
||||
totalInvoices: number;
|
||||
totalAmount: number;
|
||||
totalPaid: number;
|
||||
totalDue: number;
|
||||
byStatus: Partial<Record<InvoiceStatus, { count: number; amount: number }>>;
|
||||
}
|
||||
|
||||
interface DashboardStats {
|
||||
totalPending: number;
|
||||
totalOverdue: number;
|
||||
dueThisWeek: number;
|
||||
dueThisMonth: number;
|
||||
countPending: number;
|
||||
countOverdue: number;
|
||||
recentInvoices: Invoice[];
|
||||
}
|
||||
|
||||
export class InvoiceService {
|
||||
private repository: Repository<Invoice>;
|
||||
private itemRepository: Repository<InvoiceItem>;
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {
|
||||
this.repository = dataSource.getRepository(Invoice);
|
||||
this.itemRepository = dataSource.getRepository(InvoiceItem);
|
||||
}
|
||||
|
||||
// ==================== CRUD OPERATIONS ====================
|
||||
|
||||
/**
|
||||
* Find all invoices with pagination and filters
|
||||
*/
|
||||
async findAll(
|
||||
ctx: ServiceContext,
|
||||
filters: InvoiceFilters = {},
|
||||
page = 1,
|
||||
limit = 20
|
||||
): Promise<PaginatedResult<Invoice>> {
|
||||
const queryBuilder = this.repository
|
||||
.createQueryBuilder('invoice')
|
||||
.where('invoice.tenantId = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('invoice.deletedAt IS NULL');
|
||||
|
||||
if (filters.invoiceType) {
|
||||
queryBuilder.andWhere('invoice.invoiceType = :invoiceType', {
|
||||
invoiceType: filters.invoiceType,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.invoiceContext) {
|
||||
queryBuilder.andWhere('invoice.invoiceContext = :invoiceContext', {
|
||||
invoiceContext: filters.invoiceContext,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.status) {
|
||||
queryBuilder.andWhere('invoice.status = :status', { status: filters.status });
|
||||
}
|
||||
|
||||
if (filters.partnerId) {
|
||||
queryBuilder.andWhere('invoice.partnerId = :partnerId', {
|
||||
partnerId: filters.partnerId,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.startDate) {
|
||||
queryBuilder.andWhere('invoice.invoiceDate >= :startDate', {
|
||||
startDate: filters.startDate,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.endDate) {
|
||||
queryBuilder.andWhere('invoice.invoiceDate <= :endDate', {
|
||||
endDate: filters.endDate,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.overdue) {
|
||||
queryBuilder.andWhere('invoice.dueDate < :today', { today: new Date() });
|
||||
queryBuilder.andWhere('invoice.status IN (:...pendingStatuses)', {
|
||||
pendingStatuses: ['draft', 'validated', 'sent', 'partial'],
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.search) {
|
||||
queryBuilder.andWhere(
|
||||
'(invoice.invoiceNumber ILIKE :search OR invoice.partnerName ILIKE :search OR invoice.billingName ILIKE :search)',
|
||||
{ search: `%${filters.search}%` }
|
||||
);
|
||||
}
|
||||
|
||||
queryBuilder.orderBy('invoice.invoiceDate', 'DESC');
|
||||
|
||||
const total = await queryBuilder.getCount();
|
||||
const data = await queryBuilder
|
||||
.skip((page - 1) * limit)
|
||||
.take(limit)
|
||||
.getMany();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find invoice by ID
|
||||
*/
|
||||
async findById(ctx: ServiceContext, id: string): Promise<Invoice | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
id,
|
||||
tenantId: ctx.tenantId,
|
||||
deletedAt: IsNull(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find invoice with items
|
||||
*/
|
||||
async findWithItems(ctx: ServiceContext, id: string): Promise<Invoice | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
id,
|
||||
tenantId: ctx.tenantId,
|
||||
deletedAt: IsNull(),
|
||||
},
|
||||
relations: ['items'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find invoice by number
|
||||
*/
|
||||
async findByNumber(ctx: ServiceContext, invoiceNumber: string): Promise<Invoice | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
invoiceNumber,
|
||||
tenantId: ctx.tenantId,
|
||||
deletedAt: IsNull(),
|
||||
},
|
||||
relations: ['items'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new invoice
|
||||
*/
|
||||
async create(ctx: ServiceContext, data: CreateInvoiceDto): Promise<Invoice> {
|
||||
const invoiceNumber = await this.generateInvoiceNumber(ctx, data.invoiceType || 'sale');
|
||||
|
||||
const invoice = this.repository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
invoiceNumber,
|
||||
invoiceType: data.invoiceType || 'sale',
|
||||
invoiceContext: data.invoiceContext || 'commercial',
|
||||
salesOrderId: data.salesOrderId,
|
||||
purchaseOrderId: data.purchaseOrderId,
|
||||
partnerId: data.partnerId,
|
||||
partnerName: data.partnerName,
|
||||
partnerTaxId: data.partnerTaxId,
|
||||
subscriptionId: data.subscriptionId,
|
||||
periodStart: data.periodStart,
|
||||
periodEnd: data.periodEnd,
|
||||
billingName: data.billingName,
|
||||
billingEmail: data.billingEmail,
|
||||
billingAddress: data.billingAddress,
|
||||
taxId: data.taxId,
|
||||
invoiceDate: data.invoiceDate || new Date(),
|
||||
dueDate: data.dueDate || this.calculateDueDate(data.invoiceDate, data.paymentTermDays),
|
||||
currency: data.currency || 'MXN',
|
||||
exchangeRate: data.exchangeRate || 1,
|
||||
paymentTermDays: data.paymentTermDays || 30,
|
||||
paymentMethod: data.paymentMethod,
|
||||
notes: data.notes,
|
||||
internalNotes: data.internalNotes,
|
||||
status: 'draft',
|
||||
createdBy: ctx.userId,
|
||||
});
|
||||
|
||||
const savedInvoice = await this.repository.save(invoice);
|
||||
|
||||
// Add items if provided
|
||||
if (data.items && data.items.length > 0) {
|
||||
await this.addItems(ctx, savedInvoice.id, data.items);
|
||||
await this.recalculateTotals(ctx, savedInvoice.id);
|
||||
}
|
||||
|
||||
return this.findWithItems(ctx, savedInvoice.id) as Promise<Invoice>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update invoice
|
||||
*/
|
||||
async update(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
data: Partial<CreateInvoiceDto>
|
||||
): Promise<Invoice | null> {
|
||||
const invoice = await this.findById(ctx, id);
|
||||
if (!invoice) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (invoice.status !== 'draft') {
|
||||
throw new Error('Solo se pueden modificar facturas en borrador');
|
||||
}
|
||||
|
||||
const updateData: Partial<Invoice> = {};
|
||||
|
||||
if (data.partnerId !== undefined) updateData.partnerId = data.partnerId;
|
||||
if (data.partnerName !== undefined) updateData.partnerName = data.partnerName;
|
||||
if (data.partnerTaxId !== undefined) updateData.partnerTaxId = data.partnerTaxId;
|
||||
if (data.billingName !== undefined) updateData.billingName = data.billingName;
|
||||
if (data.billingEmail !== undefined) updateData.billingEmail = data.billingEmail;
|
||||
if (data.billingAddress !== undefined) updateData.billingAddress = data.billingAddress;
|
||||
if (data.taxId !== undefined) updateData.taxId = data.taxId;
|
||||
if (data.invoiceDate !== undefined) updateData.invoiceDate = data.invoiceDate;
|
||||
if (data.dueDate !== undefined) updateData.dueDate = data.dueDate;
|
||||
if (data.currency !== undefined) updateData.currency = data.currency;
|
||||
if (data.exchangeRate !== undefined) updateData.exchangeRate = data.exchangeRate;
|
||||
if (data.paymentTermDays !== undefined) updateData.paymentTermDays = data.paymentTermDays;
|
||||
if (data.paymentMethod !== undefined) updateData.paymentMethod = data.paymentMethod;
|
||||
if (data.notes !== undefined) updateData.notes = data.notes;
|
||||
if (data.internalNotes !== undefined) updateData.internalNotes = data.internalNotes;
|
||||
|
||||
updateData.updatedBy = ctx.userId;
|
||||
|
||||
Object.assign(invoice, updateData);
|
||||
await this.repository.save(invoice);
|
||||
|
||||
return this.findWithItems(ctx, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete invoice
|
||||
*/
|
||||
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
|
||||
const invoice = await this.findById(ctx, id);
|
||||
if (!invoice) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (invoice.status !== 'draft') {
|
||||
throw new Error('Solo se pueden eliminar facturas en borrador');
|
||||
}
|
||||
|
||||
invoice.deletedAt = new Date();
|
||||
await this.repository.save(invoice);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ==================== INVOICE ITEMS ====================
|
||||
|
||||
/**
|
||||
* Add items to invoice
|
||||
*/
|
||||
async addItems(
|
||||
ctx: ServiceContext,
|
||||
invoiceId: string,
|
||||
items: CreateInvoiceItemDto[]
|
||||
): Promise<InvoiceItem[]> {
|
||||
const invoice = await this.findById(ctx, invoiceId);
|
||||
if (!invoice) {
|
||||
throw new Error('Factura no encontrada');
|
||||
}
|
||||
|
||||
if (invoice.status !== 'draft') {
|
||||
throw new Error('Solo se pueden agregar items a facturas en borrador');
|
||||
}
|
||||
|
||||
const savedItems: InvoiceItem[] = [];
|
||||
let lineNumber = 1;
|
||||
|
||||
// Get existing max line number
|
||||
const existingItems = await this.itemRepository.find({
|
||||
where: { invoiceId },
|
||||
order: { lineNumber: 'DESC' },
|
||||
take: 1,
|
||||
});
|
||||
|
||||
if (existingItems.length > 0) {
|
||||
lineNumber = existingItems[0].lineNumber + 1;
|
||||
}
|
||||
|
||||
for (const itemData of items) {
|
||||
const item = this.itemRepository.create({
|
||||
invoiceId,
|
||||
productId: itemData.productId,
|
||||
lineNumber: itemData.lineNumber || lineNumber++,
|
||||
productSku: itemData.productSku,
|
||||
productName: itemData.productName,
|
||||
description: itemData.description,
|
||||
satProductCode: itemData.satProductCode,
|
||||
satUnitCode: itemData.satUnitCode,
|
||||
quantity: itemData.quantity,
|
||||
uom: itemData.uom || 'PZA',
|
||||
unitPrice: itemData.unitPrice,
|
||||
discountPercent: itemData.discountPercent || 0,
|
||||
taxRate: itemData.taxRate ?? 16.0,
|
||||
withholdingRate: itemData.withholdingRate || 0,
|
||||
});
|
||||
|
||||
// Calculate amounts
|
||||
const subtotal = item.quantity * item.unitPrice;
|
||||
const discountAmount = subtotal * (item.discountPercent / 100);
|
||||
const afterDiscount = subtotal - discountAmount;
|
||||
const taxAmount = afterDiscount * (item.taxRate / 100);
|
||||
const withholdingAmount = afterDiscount * (item.withholdingRate / 100);
|
||||
const total = afterDiscount + taxAmount - withholdingAmount;
|
||||
|
||||
item.subtotal = afterDiscount;
|
||||
item.discountAmount = discountAmount;
|
||||
item.taxAmount = taxAmount;
|
||||
item.withholdingAmount = withholdingAmount;
|
||||
item.total = total;
|
||||
|
||||
savedItems.push(await this.itemRepository.save(item));
|
||||
}
|
||||
|
||||
await this.recalculateTotals(ctx, invoiceId);
|
||||
return savedItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update invoice item
|
||||
*/
|
||||
async updateItem(
|
||||
ctx: ServiceContext,
|
||||
itemId: string,
|
||||
data: Partial<CreateInvoiceItemDto>
|
||||
): Promise<InvoiceItem | null> {
|
||||
const item = await this.itemRepository.findOne({
|
||||
where: { id: itemId },
|
||||
relations: ['invoice'],
|
||||
});
|
||||
|
||||
if (!item || item.invoice.tenantId !== ctx.tenantId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (item.invoice.status !== 'draft') {
|
||||
throw new Error('Solo se pueden modificar items de facturas en borrador');
|
||||
}
|
||||
|
||||
if (data.productName !== undefined) item.productName = data.productName;
|
||||
if (data.description !== undefined) item.description = data.description;
|
||||
if (data.quantity !== undefined) item.quantity = data.quantity;
|
||||
if (data.unitPrice !== undefined) item.unitPrice = data.unitPrice;
|
||||
if (data.discountPercent !== undefined) item.discountPercent = data.discountPercent;
|
||||
if (data.taxRate !== undefined) item.taxRate = data.taxRate;
|
||||
if (data.withholdingRate !== undefined) item.withholdingRate = data.withholdingRate;
|
||||
|
||||
// Recalculate amounts
|
||||
const subtotal = item.quantity * item.unitPrice;
|
||||
const discountAmount = subtotal * (item.discountPercent / 100);
|
||||
const afterDiscount = subtotal - discountAmount;
|
||||
const taxAmount = afterDiscount * (item.taxRate / 100);
|
||||
const withholdingAmount = afterDiscount * (item.withholdingRate / 100);
|
||||
const total = afterDiscount + taxAmount - withholdingAmount;
|
||||
|
||||
item.subtotal = afterDiscount;
|
||||
item.discountAmount = discountAmount;
|
||||
item.taxAmount = taxAmount;
|
||||
item.withholdingAmount = withholdingAmount;
|
||||
item.total = total;
|
||||
|
||||
const savedItem = await this.itemRepository.save(item);
|
||||
await this.recalculateTotals(ctx, item.invoiceId);
|
||||
|
||||
return savedItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove invoice item
|
||||
*/
|
||||
async removeItem(ctx: ServiceContext, itemId: string): Promise<boolean> {
|
||||
const item = await this.itemRepository.findOne({
|
||||
where: { id: itemId },
|
||||
relations: ['invoice'],
|
||||
});
|
||||
|
||||
if (!item || item.invoice.tenantId !== ctx.tenantId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (item.invoice.status !== 'draft') {
|
||||
throw new Error('Solo se pueden eliminar items de facturas en borrador');
|
||||
}
|
||||
|
||||
const invoiceId = item.invoiceId;
|
||||
await this.itemRepository.remove(item);
|
||||
await this.recalculateTotals(ctx, invoiceId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ==================== STATUS WORKFLOW ====================
|
||||
|
||||
/**
|
||||
* Validate invoice (draft -> validated)
|
||||
*/
|
||||
async validate(ctx: ServiceContext, id: string): Promise<Invoice | null> {
|
||||
return this.changeStatus(ctx, id, 'validated', ['draft']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send invoice (validated -> sent)
|
||||
*/
|
||||
async send(ctx: ServiceContext, id: string): Promise<Invoice | null> {
|
||||
return this.changeStatus(ctx, id, 'sent', ['validated']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record partial payment
|
||||
*/
|
||||
async recordPartialPayment(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
amount: number
|
||||
): Promise<Invoice | null> {
|
||||
const invoice = await this.findById(ctx, id);
|
||||
if (!invoice) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!['sent', 'partial', 'overdue'].includes(invoice.status)) {
|
||||
throw new Error('Estado invalido para registrar pago');
|
||||
}
|
||||
|
||||
const newAmountPaid = Number(invoice.amountPaid) + amount;
|
||||
const total = Number(invoice.total);
|
||||
|
||||
invoice.amountPaid = newAmountPaid;
|
||||
invoice.paidAmount = newAmountPaid;
|
||||
invoice.updatedBy = ctx.userId;
|
||||
|
||||
if (newAmountPaid >= total) {
|
||||
invoice.status = 'paid';
|
||||
invoice.paidAt = new Date();
|
||||
invoice.paymentDate = new Date();
|
||||
} else {
|
||||
invoice.status = 'partial';
|
||||
}
|
||||
|
||||
return this.repository.save(invoice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark as paid (sent/partial -> paid)
|
||||
*/
|
||||
async markAsPaid(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
paymentReference?: string
|
||||
): Promise<Invoice | null> {
|
||||
const invoice = await this.findById(ctx, id);
|
||||
if (!invoice) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!['sent', 'partial', 'overdue'].includes(invoice.status)) {
|
||||
throw new Error('Estado invalido para marcar como pagada');
|
||||
}
|
||||
|
||||
invoice.status = 'paid';
|
||||
invoice.amountPaid = invoice.total;
|
||||
invoice.paidAmount = invoice.total;
|
||||
invoice.paidAt = new Date();
|
||||
invoice.paymentDate = new Date();
|
||||
invoice.paymentReference = paymentReference || invoice.paymentReference;
|
||||
invoice.updatedBy = ctx.userId;
|
||||
|
||||
return this.repository.save(invoice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Void invoice
|
||||
*/
|
||||
async void(ctx: ServiceContext, id: string, reason?: string): Promise<Invoice | null> {
|
||||
const invoice = await this.findById(ctx, id);
|
||||
if (!invoice) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (invoice.status === 'paid') {
|
||||
throw new Error('No se puede anular una factura pagada');
|
||||
}
|
||||
|
||||
invoice.status = 'voided';
|
||||
invoice.internalNotes = `${invoice.internalNotes || ''}\n[ANULADA]: ${reason || 'Sin motivo especificado'}`;
|
||||
invoice.updatedBy = ctx.userId;
|
||||
|
||||
return this.repository.save(invoice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel invoice
|
||||
*/
|
||||
async cancel(ctx: ServiceContext, id: string, reason?: string): Promise<Invoice | null> {
|
||||
const invoice = await this.findById(ctx, id);
|
||||
if (!invoice) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (invoice.status === 'paid') {
|
||||
throw new Error('No se puede cancelar una factura pagada');
|
||||
}
|
||||
|
||||
invoice.status = 'cancelled';
|
||||
invoice.internalNotes = `${invoice.internalNotes || ''}\n[CANCELADA]: ${reason || 'Sin motivo especificado'}`;
|
||||
invoice.updatedBy = ctx.userId;
|
||||
|
||||
return this.repository.save(invoice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create refund (credit note)
|
||||
*/
|
||||
async createRefund(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
reason?: string
|
||||
): Promise<Invoice | null> {
|
||||
const originalInvoice = await this.findWithItems(ctx, id);
|
||||
if (!originalInvoice) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (originalInvoice.status !== 'paid') {
|
||||
throw new Error('Solo se pueden reembolsar facturas pagadas');
|
||||
}
|
||||
|
||||
// Create credit note
|
||||
const creditNoteData: CreateInvoiceDto = {
|
||||
invoiceType: 'credit_note',
|
||||
invoiceContext: originalInvoice.invoiceContext as InvoiceContext,
|
||||
partnerId: originalInvoice.partnerId ?? undefined,
|
||||
partnerName: originalInvoice.partnerName ?? undefined,
|
||||
partnerTaxId: originalInvoice.partnerTaxId ?? undefined,
|
||||
billingName: originalInvoice.billingName ?? undefined,
|
||||
billingEmail: originalInvoice.billingEmail ?? undefined,
|
||||
billingAddress: originalInvoice.billingAddress ?? undefined,
|
||||
currency: originalInvoice.currency,
|
||||
exchangeRate: originalInvoice.exchangeRate,
|
||||
notes: `Nota de credito por factura ${originalInvoice.invoiceNumber}. ${reason || ''}`,
|
||||
items: originalInvoice.items?.map((item) => ({
|
||||
productId: item.productId,
|
||||
productSku: item.productSku,
|
||||
productName: item.productName,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
uom: item.uom,
|
||||
unitPrice: item.unitPrice,
|
||||
discountPercent: item.discountPercent,
|
||||
taxRate: item.taxRate,
|
||||
withholdingRate: item.withholdingRate,
|
||||
})),
|
||||
};
|
||||
|
||||
const creditNote = await this.create(ctx, creditNoteData);
|
||||
|
||||
// Mark original as refunded
|
||||
originalInvoice.status = 'refunded';
|
||||
originalInvoice.updatedBy = ctx.userId;
|
||||
await this.repository.save(originalInvoice);
|
||||
|
||||
return creditNote;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and update overdue invoices
|
||||
*/
|
||||
async updateOverdueStatus(ctx: ServiceContext): Promise<number> {
|
||||
const today = new Date();
|
||||
|
||||
const result = await this.repository
|
||||
.createQueryBuilder()
|
||||
.update(Invoice)
|
||||
.set({ status: 'overdue' })
|
||||
.where('tenantId = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('status IN (:...statuses)', { statuses: ['sent', 'partial'] })
|
||||
.andWhere('dueDate < :today', { today })
|
||||
.andWhere('deletedAt IS NULL')
|
||||
.execute();
|
||||
|
||||
return result.affected || 0;
|
||||
}
|
||||
|
||||
// ==================== GENERATION ====================
|
||||
|
||||
/**
|
||||
* Generate invoice from sales order
|
||||
*/
|
||||
async generateFromSalesOrder(
|
||||
ctx: ServiceContext,
|
||||
salesOrderId: string,
|
||||
salesOrderData: {
|
||||
partnerId: string;
|
||||
partnerName: string;
|
||||
partnerTaxId?: string;
|
||||
items: CreateInvoiceItemDto[];
|
||||
currency?: string;
|
||||
paymentTermDays?: number;
|
||||
}
|
||||
): Promise<Invoice> {
|
||||
const invoiceData: CreateInvoiceDto = {
|
||||
invoiceType: 'sale',
|
||||
invoiceContext: 'commercial',
|
||||
salesOrderId,
|
||||
partnerId: salesOrderData.partnerId,
|
||||
partnerName: salesOrderData.partnerName,
|
||||
partnerTaxId: salesOrderData.partnerTaxId,
|
||||
currency: salesOrderData.currency,
|
||||
paymentTermDays: salesOrderData.paymentTermDays,
|
||||
items: salesOrderData.items,
|
||||
};
|
||||
|
||||
return this.create(ctx, invoiceData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate invoice for subscription period
|
||||
*/
|
||||
async generateForSubscription(
|
||||
ctx: ServiceContext,
|
||||
subscriptionData: {
|
||||
subscriptionId: string;
|
||||
billingName: string;
|
||||
billingEmail: string;
|
||||
taxId?: string;
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
items: CreateInvoiceItemDto[];
|
||||
currency?: string;
|
||||
}
|
||||
): Promise<Invoice> {
|
||||
const invoiceData: CreateInvoiceDto = {
|
||||
invoiceType: 'sale',
|
||||
invoiceContext: 'saas',
|
||||
subscriptionId: subscriptionData.subscriptionId,
|
||||
billingName: subscriptionData.billingName,
|
||||
billingEmail: subscriptionData.billingEmail,
|
||||
taxId: subscriptionData.taxId,
|
||||
periodStart: subscriptionData.periodStart,
|
||||
periodEnd: subscriptionData.periodEnd,
|
||||
currency: subscriptionData.currency,
|
||||
items: subscriptionData.items,
|
||||
};
|
||||
|
||||
return this.create(ctx, invoiceData);
|
||||
}
|
||||
|
||||
// ==================== REPORTS ====================
|
||||
|
||||
/**
|
||||
* Get invoice summary
|
||||
*/
|
||||
async getSummary(
|
||||
ctx: ServiceContext,
|
||||
filters: InvoiceFilters = {}
|
||||
): Promise<InvoiceSummary> {
|
||||
const queryBuilder = this.repository
|
||||
.createQueryBuilder('invoice')
|
||||
.where('invoice.tenantId = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('invoice.deletedAt IS NULL');
|
||||
|
||||
if (filters.invoiceContext) {
|
||||
queryBuilder.andWhere('invoice.invoiceContext = :context', {
|
||||
context: filters.invoiceContext,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.startDate) {
|
||||
queryBuilder.andWhere('invoice.invoiceDate >= :startDate', {
|
||||
startDate: filters.startDate,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.endDate) {
|
||||
queryBuilder.andWhere('invoice.invoiceDate <= :endDate', {
|
||||
endDate: filters.endDate,
|
||||
});
|
||||
}
|
||||
|
||||
const invoices = await queryBuilder.getMany();
|
||||
|
||||
const summary: InvoiceSummary = {
|
||||
totalInvoices: invoices.length,
|
||||
totalAmount: 0,
|
||||
totalPaid: 0,
|
||||
totalDue: 0,
|
||||
byStatus: {},
|
||||
};
|
||||
|
||||
for (const invoice of invoices) {
|
||||
const total = Number(invoice.total) || 0;
|
||||
const amountPaid = Number(invoice.amountPaid) || 0;
|
||||
|
||||
summary.totalAmount += total;
|
||||
summary.totalPaid += amountPaid;
|
||||
summary.totalDue += total - amountPaid;
|
||||
|
||||
if (!summary.byStatus[invoice.status]) {
|
||||
summary.byStatus[invoice.status] = { count: 0, amount: 0 };
|
||||
}
|
||||
summary.byStatus[invoice.status]!.count++;
|
||||
summary.byStatus[invoice.status]!.amount += total;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dashboard statistics
|
||||
*/
|
||||
async getDashboardStats(ctx: ServiceContext): Promise<DashboardStats> {
|
||||
const today = new Date();
|
||||
const endOfWeek = new Date(today);
|
||||
endOfWeek.setDate(today.getDate() + (7 - today.getDay()));
|
||||
const endOfMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0);
|
||||
|
||||
const baseQuery = this.repository
|
||||
.createQueryBuilder('invoice')
|
||||
.where('invoice.tenantId = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('invoice.status IN (:...statuses)', {
|
||||
statuses: ['draft', 'validated', 'sent', 'partial'],
|
||||
})
|
||||
.andWhere('invoice.deletedAt IS NULL');
|
||||
|
||||
// Total pending
|
||||
const totalPendingResult = await baseQuery
|
||||
.clone()
|
||||
.select('SUM(invoice.total - invoice.amountPaid)', 'total')
|
||||
.getRawOne();
|
||||
|
||||
// Total overdue
|
||||
const totalOverdueResult = await baseQuery
|
||||
.clone()
|
||||
.andWhere('invoice.dueDate < :today', { today })
|
||||
.select('SUM(invoice.total - invoice.amountPaid)', 'total')
|
||||
.getRawOne();
|
||||
|
||||
// Due this week
|
||||
const dueThisWeekResult = await baseQuery
|
||||
.clone()
|
||||
.andWhere('invoice.dueDate BETWEEN :today AND :endOfWeek', { today, endOfWeek })
|
||||
.select('SUM(invoice.total - invoice.amountPaid)', 'total')
|
||||
.getRawOne();
|
||||
|
||||
// Due this month
|
||||
const dueThisMonthResult = await baseQuery
|
||||
.clone()
|
||||
.andWhere('invoice.dueDate BETWEEN :today AND :endOfMonth', { today, endOfMonth })
|
||||
.select('SUM(invoice.total - invoice.amountPaid)', 'total')
|
||||
.getRawOne();
|
||||
|
||||
// Counts
|
||||
const countPending = await baseQuery.clone().getCount();
|
||||
const countOverdue = await baseQuery
|
||||
.clone()
|
||||
.andWhere('invoice.dueDate < :today', { today })
|
||||
.getCount();
|
||||
|
||||
// Recent invoices
|
||||
const recentInvoices = await this.repository.find({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
deletedAt: IsNull(),
|
||||
},
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 5,
|
||||
});
|
||||
|
||||
return {
|
||||
totalPending: parseFloat(totalPendingResult?.total) || 0,
|
||||
totalOverdue: parseFloat(totalOverdueResult?.total) || 0,
|
||||
dueThisWeek: parseFloat(dueThisWeekResult?.total) || 0,
|
||||
dueThisMonth: parseFloat(dueThisMonthResult?.total) || 0,
|
||||
countPending,
|
||||
countOverdue,
|
||||
recentInvoices,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== PRIVATE HELPERS ====================
|
||||
|
||||
private async changeStatus(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
newStatus: InvoiceStatus,
|
||||
allowedFromStatuses: InvoiceStatus[]
|
||||
): Promise<Invoice | null> {
|
||||
const invoice = await this.findById(ctx, id);
|
||||
if (!invoice) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!allowedFromStatuses.includes(invoice.status)) {
|
||||
throw new Error(
|
||||
`Transicion de estado invalida: de ${invoice.status} a ${newStatus}`
|
||||
);
|
||||
}
|
||||
|
||||
invoice.status = newStatus;
|
||||
invoice.updatedBy = ctx.userId;
|
||||
|
||||
return this.repository.save(invoice);
|
||||
}
|
||||
|
||||
private async generateInvoiceNumber(
|
||||
ctx: ServiceContext,
|
||||
invoiceType: InvoiceType
|
||||
): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const prefix = this.getInvoicePrefix(invoiceType);
|
||||
|
||||
const lastInvoice = await this.repository
|
||||
.createQueryBuilder('invoice')
|
||||
.where('invoice.tenantId = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('invoice.invoiceNumber LIKE :pattern', { pattern: `${prefix}-${year}%` })
|
||||
.orderBy('invoice.invoiceNumber', 'DESC')
|
||||
.getOne();
|
||||
|
||||
let sequence = 1;
|
||||
if (lastInvoice) {
|
||||
const match = lastInvoice.invoiceNumber.match(/(\d+)$/);
|
||||
if (match) {
|
||||
sequence = parseInt(match[1], 10) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return `${prefix}-${year}-${String(sequence).padStart(6, '0')}`;
|
||||
}
|
||||
|
||||
private getInvoicePrefix(invoiceType: InvoiceType): string {
|
||||
switch (invoiceType) {
|
||||
case 'sale':
|
||||
return 'FAC';
|
||||
case 'purchase':
|
||||
return 'FP';
|
||||
case 'credit_note':
|
||||
return 'NC';
|
||||
case 'debit_note':
|
||||
return 'ND';
|
||||
default:
|
||||
return 'INV';
|
||||
}
|
||||
}
|
||||
|
||||
private calculateDueDate(invoiceDate?: Date, paymentTermDays?: number): Date {
|
||||
const baseDate = invoiceDate || new Date();
|
||||
const days = paymentTermDays || 30;
|
||||
const dueDate = new Date(baseDate);
|
||||
dueDate.setDate(dueDate.getDate() + days);
|
||||
return dueDate;
|
||||
}
|
||||
|
||||
private async recalculateTotals(ctx: ServiceContext, invoiceId: string): Promise<void> {
|
||||
const items = await this.itemRepository.find({ where: { invoiceId } });
|
||||
|
||||
let subtotal = 0;
|
||||
let discountAmount = 0;
|
||||
let taxAmount = 0;
|
||||
let withholdingTax = 0;
|
||||
|
||||
for (const item of items) {
|
||||
subtotal += Number(item.subtotal) || 0;
|
||||
discountAmount += Number(item.discountAmount) || 0;
|
||||
taxAmount += Number(item.taxAmount) || 0;
|
||||
withholdingTax += Number(item.withholdingAmount) || 0;
|
||||
}
|
||||
|
||||
const total = subtotal + taxAmount - withholdingTax;
|
||||
|
||||
await this.repository.update(
|
||||
{ id: invoiceId, tenantId: ctx.tenantId },
|
||||
{
|
||||
subtotal,
|
||||
discountAmount,
|
||||
taxAmount,
|
||||
withholdingTax,
|
||||
total,
|
||||
updatedBy: ctx.userId,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
617
src/modules/invoices/services/payment.service.ts
Normal file
617
src/modules/invoices/services/payment.service.ts
Normal file
@ -0,0 +1,617 @@
|
||||
/**
|
||||
* PaymentService - Payment Management Service
|
||||
*
|
||||
* Handles payment processing and allocation to invoices.
|
||||
*
|
||||
* @module Invoices
|
||||
*/
|
||||
|
||||
import { DataSource, Repository, IsNull } from 'typeorm';
|
||||
import { Payment, PaymentAllocation, Invoice } from '../entities';
|
||||
|
||||
interface ServiceContext {
|
||||
tenantId: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
interface PaginatedResult<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export type PaymentType = 'received' | 'made';
|
||||
export type PaymentStatus = 'draft' | 'confirmed' | 'reconciled' | 'cancelled';
|
||||
|
||||
export interface CreatePaymentDto {
|
||||
paymentType?: PaymentType;
|
||||
partnerId: string;
|
||||
partnerName?: string;
|
||||
currency?: string;
|
||||
amount: number;
|
||||
exchangeRate?: number;
|
||||
paymentDate?: Date;
|
||||
paymentMethod: string;
|
||||
reference?: string;
|
||||
bankAccountId?: string;
|
||||
notes?: string;
|
||||
invoiceAllocations?: {
|
||||
invoiceId: string;
|
||||
amount: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface PaymentFilters {
|
||||
paymentType?: PaymentType;
|
||||
partnerId?: string;
|
||||
status?: PaymentStatus;
|
||||
paymentMethod?: string;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
interface PaymentSummary {
|
||||
totalPayments: number;
|
||||
totalAmount: number;
|
||||
byMethod: Record<string, { count: number; amount: number }>;
|
||||
byStatus: Partial<Record<PaymentStatus, { count: number; amount: number }>>;
|
||||
}
|
||||
|
||||
export class PaymentService {
|
||||
private repository: Repository<Payment>;
|
||||
private allocationRepository: Repository<PaymentAllocation>;
|
||||
private invoiceRepository: Repository<Invoice>;
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {
|
||||
this.repository = dataSource.getRepository(Payment);
|
||||
this.allocationRepository = dataSource.getRepository(PaymentAllocation);
|
||||
this.invoiceRepository = dataSource.getRepository(Invoice);
|
||||
}
|
||||
|
||||
// ==================== CRUD OPERATIONS ====================
|
||||
|
||||
/**
|
||||
* Find all payments with pagination and filters
|
||||
*/
|
||||
async findAll(
|
||||
ctx: ServiceContext,
|
||||
filters: PaymentFilters = {},
|
||||
page = 1,
|
||||
limit = 20
|
||||
): Promise<PaginatedResult<Payment>> {
|
||||
const queryBuilder = this.repository
|
||||
.createQueryBuilder('payment')
|
||||
.where('payment.tenantId = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('payment.deletedAt IS NULL');
|
||||
|
||||
if (filters.paymentType) {
|
||||
queryBuilder.andWhere('payment.paymentType = :paymentType', {
|
||||
paymentType: filters.paymentType,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.partnerId) {
|
||||
queryBuilder.andWhere('payment.partnerId = :partnerId', {
|
||||
partnerId: filters.partnerId,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.status) {
|
||||
queryBuilder.andWhere('payment.status = :status', { status: filters.status });
|
||||
}
|
||||
|
||||
if (filters.paymentMethod) {
|
||||
queryBuilder.andWhere('payment.paymentMethod = :paymentMethod', {
|
||||
paymentMethod: filters.paymentMethod,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.startDate) {
|
||||
queryBuilder.andWhere('payment.paymentDate >= :startDate', {
|
||||
startDate: filters.startDate,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.endDate) {
|
||||
queryBuilder.andWhere('payment.paymentDate <= :endDate', {
|
||||
endDate: filters.endDate,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.search) {
|
||||
queryBuilder.andWhere(
|
||||
'(payment.paymentNumber ILIKE :search OR payment.partnerName ILIKE :search OR payment.reference ILIKE :search)',
|
||||
{ search: `%${filters.search}%` }
|
||||
);
|
||||
}
|
||||
|
||||
queryBuilder.orderBy('payment.paymentDate', 'DESC');
|
||||
|
||||
const total = await queryBuilder.getCount();
|
||||
const data = await queryBuilder
|
||||
.skip((page - 1) * limit)
|
||||
.take(limit)
|
||||
.getMany();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find payment by ID
|
||||
*/
|
||||
async findById(ctx: ServiceContext, id: string): Promise<Payment | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
id,
|
||||
tenantId: ctx.tenantId,
|
||||
deletedAt: IsNull(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find payment with allocations
|
||||
*/
|
||||
async findWithAllocations(ctx: ServiceContext, id: string): Promise<Payment | null> {
|
||||
const payment = await this.findById(ctx, id);
|
||||
if (!payment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allocations = await this.allocationRepository.find({
|
||||
where: { paymentId: id },
|
||||
relations: ['invoice'],
|
||||
});
|
||||
|
||||
(payment as any).allocations = allocations;
|
||||
return payment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new payment
|
||||
*/
|
||||
async create(ctx: ServiceContext, data: CreatePaymentDto): Promise<Payment> {
|
||||
const paymentNumber = await this.generatePaymentNumber(ctx, data.paymentType || 'received');
|
||||
|
||||
const payment = this.repository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
paymentNumber,
|
||||
paymentType: data.paymentType || 'received',
|
||||
partnerId: data.partnerId,
|
||||
partnerName: data.partnerName || '',
|
||||
currency: data.currency || 'MXN',
|
||||
amount: data.amount,
|
||||
exchangeRate: data.exchangeRate || 1,
|
||||
paymentDate: data.paymentDate || new Date(),
|
||||
paymentMethod: data.paymentMethod,
|
||||
reference: data.reference,
|
||||
bankAccountId: data.bankAccountId,
|
||||
notes: data.notes,
|
||||
status: 'draft',
|
||||
createdBy: ctx.userId,
|
||||
});
|
||||
|
||||
const savedPayment = await this.repository.save(payment);
|
||||
|
||||
// Process allocations if provided
|
||||
if (data.invoiceAllocations && data.invoiceAllocations.length > 0) {
|
||||
await this.allocateToInvoices(ctx, savedPayment.id, data.invoiceAllocations);
|
||||
}
|
||||
|
||||
return this.findById(ctx, savedPayment.id) as Promise<Payment>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update payment
|
||||
*/
|
||||
async update(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
data: Partial<CreatePaymentDto>
|
||||
): Promise<Payment | null> {
|
||||
const payment = await this.findById(ctx, id);
|
||||
if (!payment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (payment.status !== 'draft') {
|
||||
throw new Error('Solo se pueden modificar pagos en borrador');
|
||||
}
|
||||
|
||||
if (data.partnerId !== undefined) payment.partnerId = data.partnerId;
|
||||
if (data.partnerName !== undefined) payment.partnerName = data.partnerName || '';
|
||||
if (data.amount !== undefined) payment.amount = data.amount;
|
||||
if (data.paymentDate !== undefined) payment.paymentDate = data.paymentDate;
|
||||
if (data.paymentMethod !== undefined) payment.paymentMethod = data.paymentMethod;
|
||||
if (data.reference !== undefined) payment.reference = data.reference || '';
|
||||
if (data.bankAccountId !== undefined) payment.bankAccountId = data.bankAccountId || '';
|
||||
if (data.notes !== undefined) payment.notes = data.notes || '';
|
||||
|
||||
return this.repository.save(payment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete payment
|
||||
*/
|
||||
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
|
||||
const payment = await this.findById(ctx, id);
|
||||
if (!payment) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payment.status !== 'draft') {
|
||||
throw new Error('Solo se pueden eliminar pagos en borrador');
|
||||
}
|
||||
|
||||
payment.deletedAt = new Date();
|
||||
await this.repository.save(payment);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ==================== ALLOCATION ====================
|
||||
|
||||
/**
|
||||
* Allocate payment to invoices
|
||||
*/
|
||||
async allocateToInvoices(
|
||||
ctx: ServiceContext,
|
||||
paymentId: string,
|
||||
allocations: { invoiceId: string; amount: number }[]
|
||||
): Promise<PaymentAllocation[]> {
|
||||
const payment = await this.findById(ctx, paymentId);
|
||||
if (!payment) {
|
||||
throw new Error('Pago no encontrado');
|
||||
}
|
||||
|
||||
if (payment.status === 'cancelled') {
|
||||
throw new Error('No se puede asignar a un pago cancelado');
|
||||
}
|
||||
|
||||
// Validate total allocation
|
||||
const totalAllocation = allocations.reduce((sum, a) => sum + a.amount, 0);
|
||||
const existingAllocations = await this.allocationRepository.find({
|
||||
where: { paymentId },
|
||||
});
|
||||
const existingTotal = existingAllocations.reduce(
|
||||
(sum, a) => sum + Number(a.amount),
|
||||
0
|
||||
);
|
||||
|
||||
if (existingTotal + totalAllocation > Number(payment.amount)) {
|
||||
throw new Error('El total de asignaciones excede el monto del pago');
|
||||
}
|
||||
|
||||
const savedAllocations: PaymentAllocation[] = [];
|
||||
|
||||
for (const allocation of allocations) {
|
||||
const invoice = await this.invoiceRepository.findOne({
|
||||
where: {
|
||||
id: allocation.invoiceId,
|
||||
tenantId: ctx.tenantId,
|
||||
deletedAt: IsNull(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!invoice) {
|
||||
throw new Error(`Factura ${allocation.invoiceId} no encontrada`);
|
||||
}
|
||||
|
||||
// Validate invoice status
|
||||
if (!['sent', 'partial', 'overdue'].includes(invoice.status)) {
|
||||
throw new Error(
|
||||
`Factura ${invoice.invoiceNumber} no puede recibir pagos en estado ${invoice.status}`
|
||||
);
|
||||
}
|
||||
|
||||
// Validate amount
|
||||
const amountDue = Number(invoice.total) - Number(invoice.amountPaid);
|
||||
if (allocation.amount > amountDue) {
|
||||
throw new Error(
|
||||
`El monto de asignacion excede el saldo pendiente de la factura ${invoice.invoiceNumber}`
|
||||
);
|
||||
}
|
||||
|
||||
// Create allocation
|
||||
const paymentAllocation = this.allocationRepository.create({
|
||||
paymentId,
|
||||
invoiceId: allocation.invoiceId,
|
||||
amount: allocation.amount,
|
||||
allocationDate: new Date(),
|
||||
createdBy: ctx.userId,
|
||||
});
|
||||
|
||||
savedAllocations.push(await this.allocationRepository.save(paymentAllocation));
|
||||
|
||||
// Update invoice
|
||||
const newAmountPaid = Number(invoice.amountPaid) + allocation.amount;
|
||||
invoice.amountPaid = newAmountPaid;
|
||||
invoice.paidAmount = newAmountPaid;
|
||||
|
||||
if (newAmountPaid >= Number(invoice.total)) {
|
||||
invoice.status = 'paid';
|
||||
invoice.paidAt = new Date();
|
||||
invoice.paymentDate = new Date();
|
||||
} else {
|
||||
invoice.status = 'partial';
|
||||
}
|
||||
|
||||
invoice.updatedBy = ctx.userId;
|
||||
await this.invoiceRepository.save(invoice);
|
||||
}
|
||||
|
||||
return savedAllocations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove allocation
|
||||
*/
|
||||
async removeAllocation(ctx: ServiceContext, allocationId: string): Promise<boolean> {
|
||||
const allocation = await this.allocationRepository.findOne({
|
||||
where: { id: allocationId },
|
||||
relations: ['payment', 'invoice'],
|
||||
});
|
||||
|
||||
if (!allocation || allocation.payment.tenantId !== ctx.tenantId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (allocation.payment.status === 'reconciled') {
|
||||
throw new Error('No se pueden eliminar asignaciones de pagos conciliados');
|
||||
}
|
||||
|
||||
// Revert invoice amounts
|
||||
const invoice = allocation.invoice;
|
||||
const newAmountPaid = Number(invoice.amountPaid) - Number(allocation.amount);
|
||||
invoice.amountPaid = Math.max(0, newAmountPaid);
|
||||
invoice.paidAmount = invoice.amountPaid;
|
||||
|
||||
if (invoice.status === 'paid' || invoice.status === 'partial') {
|
||||
if (invoice.amountPaid <= 0) {
|
||||
invoice.status = 'sent';
|
||||
invoice.paidAt = null;
|
||||
invoice.paymentDate = null;
|
||||
} else {
|
||||
invoice.status = 'partial';
|
||||
}
|
||||
}
|
||||
|
||||
invoice.updatedBy = ctx.userId;
|
||||
await this.invoiceRepository.save(invoice);
|
||||
|
||||
await this.allocationRepository.remove(allocation);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get allocations for a payment
|
||||
*/
|
||||
async getAllocations(
|
||||
ctx: ServiceContext,
|
||||
paymentId: string
|
||||
): Promise<PaymentAllocation[]> {
|
||||
const payment = await this.findById(ctx, paymentId);
|
||||
if (!payment) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.allocationRepository.find({
|
||||
where: { paymentId },
|
||||
relations: ['invoice'],
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== STATUS WORKFLOW ====================
|
||||
|
||||
/**
|
||||
* Confirm payment (draft -> confirmed)
|
||||
*/
|
||||
async confirm(ctx: ServiceContext, id: string): Promise<Payment | null> {
|
||||
const payment = await this.findById(ctx, id);
|
||||
if (!payment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (payment.status !== 'draft') {
|
||||
throw new Error('Solo se pueden confirmar pagos en borrador');
|
||||
}
|
||||
|
||||
payment.status = 'confirmed';
|
||||
return this.repository.save(payment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile payment (confirmed -> reconciled)
|
||||
*/
|
||||
async reconcile(ctx: ServiceContext, id: string): Promise<Payment | null> {
|
||||
const payment = await this.findById(ctx, id);
|
||||
if (!payment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (payment.status !== 'confirmed') {
|
||||
throw new Error('Solo se pueden conciliar pagos confirmados');
|
||||
}
|
||||
|
||||
payment.status = 'reconciled';
|
||||
return this.repository.save(payment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel payment
|
||||
*/
|
||||
async cancel(ctx: ServiceContext, id: string, reason?: string): Promise<Payment | null> {
|
||||
const payment = await this.findById(ctx, id);
|
||||
if (!payment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (payment.status === 'reconciled') {
|
||||
throw new Error('No se pueden cancelar pagos conciliados');
|
||||
}
|
||||
|
||||
// Remove all allocations and revert invoice amounts
|
||||
const allocations = await this.getAllocations(ctx, id);
|
||||
for (const allocation of allocations) {
|
||||
await this.removeAllocation(ctx, allocation.id);
|
||||
}
|
||||
|
||||
payment.status = 'cancelled';
|
||||
payment.notes = `${payment.notes || ''}\n[CANCELADO]: ${reason || 'Sin motivo especificado'}`;
|
||||
|
||||
return this.repository.save(payment);
|
||||
}
|
||||
|
||||
// ==================== REPORTS ====================
|
||||
|
||||
/**
|
||||
* Get payment summary
|
||||
*/
|
||||
async getSummary(
|
||||
ctx: ServiceContext,
|
||||
filters: PaymentFilters = {}
|
||||
): Promise<PaymentSummary> {
|
||||
const queryBuilder = this.repository
|
||||
.createQueryBuilder('payment')
|
||||
.where('payment.tenantId = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('payment.deletedAt IS NULL')
|
||||
.andWhere('payment.status != :cancelled', { cancelled: 'cancelled' });
|
||||
|
||||
if (filters.paymentType) {
|
||||
queryBuilder.andWhere('payment.paymentType = :paymentType', {
|
||||
paymentType: filters.paymentType,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.startDate) {
|
||||
queryBuilder.andWhere('payment.paymentDate >= :startDate', {
|
||||
startDate: filters.startDate,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.endDate) {
|
||||
queryBuilder.andWhere('payment.paymentDate <= :endDate', {
|
||||
endDate: filters.endDate,
|
||||
});
|
||||
}
|
||||
|
||||
const payments = await queryBuilder.getMany();
|
||||
|
||||
const summary: PaymentSummary = {
|
||||
totalPayments: payments.length,
|
||||
totalAmount: 0,
|
||||
byMethod: {},
|
||||
byStatus: {},
|
||||
};
|
||||
|
||||
for (const payment of payments) {
|
||||
const amount = Number(payment.amount) || 0;
|
||||
|
||||
summary.totalAmount += amount;
|
||||
|
||||
// By method
|
||||
if (!summary.byMethod[payment.paymentMethod]) {
|
||||
summary.byMethod[payment.paymentMethod] = { count: 0, amount: 0 };
|
||||
}
|
||||
summary.byMethod[payment.paymentMethod].count++;
|
||||
summary.byMethod[payment.paymentMethod].amount += amount;
|
||||
|
||||
// By status
|
||||
if (!summary.byStatus[payment.status]) {
|
||||
summary.byStatus[payment.status] = { count: 0, amount: 0 };
|
||||
}
|
||||
summary.byStatus[payment.status]!.count++;
|
||||
summary.byStatus[payment.status]!.amount += amount;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get payments by partner
|
||||
*/
|
||||
async findByPartner(
|
||||
ctx: ServiceContext,
|
||||
partnerId: string,
|
||||
page = 1,
|
||||
limit = 20
|
||||
): Promise<PaginatedResult<Payment>> {
|
||||
return this.findAll(ctx, { partnerId }, page, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unallocated amount for a payment
|
||||
*/
|
||||
async getUnallocatedAmount(ctx: ServiceContext, paymentId: string): Promise<number> {
|
||||
const payment = await this.findById(ctx, paymentId);
|
||||
if (!payment) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const allocations = await this.allocationRepository.find({
|
||||
where: { paymentId },
|
||||
});
|
||||
|
||||
const allocatedAmount = allocations.reduce(
|
||||
(sum, a) => sum + Number(a.amount),
|
||||
0
|
||||
);
|
||||
|
||||
return Number(payment.amount) - allocatedAmount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get invoices available for allocation
|
||||
*/
|
||||
async getInvoicesForAllocation(
|
||||
ctx: ServiceContext,
|
||||
partnerId: string
|
||||
): Promise<Invoice[]> {
|
||||
return this.invoiceRepository.find({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
partnerId,
|
||||
deletedAt: IsNull(),
|
||||
},
|
||||
}).then((invoices) =>
|
||||
invoices.filter((inv) => ['sent', 'partial', 'overdue'].includes(inv.status))
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== PRIVATE HELPERS ====================
|
||||
|
||||
private async generatePaymentNumber(
|
||||
ctx: ServiceContext,
|
||||
paymentType: PaymentType
|
||||
): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const prefix = paymentType === 'received' ? 'REC' : 'PAG';
|
||||
|
||||
const lastPayment = await this.repository
|
||||
.createQueryBuilder('payment')
|
||||
.where('payment.tenantId = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('payment.paymentNumber LIKE :pattern', {
|
||||
pattern: `${prefix}-${year}%`,
|
||||
})
|
||||
.orderBy('payment.paymentNumber', 'DESC')
|
||||
.getOne();
|
||||
|
||||
let sequence = 1;
|
||||
if (lastPayment) {
|
||||
const match = lastPayment.paymentNumber.match(/(\d+)$/);
|
||||
if (match) {
|
||||
sequence = parseInt(match[1], 10) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return `${prefix}-${year}-${String(sequence).padStart(6, '0')}`;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,627 @@
|
||||
/**
|
||||
* In-App Notification Controller
|
||||
* REST endpoints for in-app notification management.
|
||||
*
|
||||
* @module Notifications
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import {
|
||||
InAppNotificationService,
|
||||
CreateInAppNotificationDto,
|
||||
InAppNotificationFilters,
|
||||
} from '../services/in-app-notification.service';
|
||||
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
|
||||
import { AuthService } from '../../auth/services/auth.service';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
import { Tenant } from '../../core/entities/tenant.entity';
|
||||
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
|
||||
import { InAppNotification, InAppCategory, InAppPriority } from '../entities/in-app-notification.entity';
|
||||
|
||||
/**
|
||||
* Create in-app notification controller router
|
||||
*/
|
||||
export function createInAppNotificationController(dataSource: DataSource): Router {
|
||||
const router = Router();
|
||||
|
||||
// Repositories
|
||||
const userRepository = dataSource.getRepository(User);
|
||||
const tenantRepository = dataSource.getRepository(Tenant);
|
||||
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
|
||||
const inAppNotificationRepository = dataSource.getRepository(InAppNotification);
|
||||
|
||||
// Services
|
||||
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
|
||||
const authMiddleware = new AuthMiddleware(authService, dataSource);
|
||||
const inAppNotificationService = new InAppNotificationService(inAppNotificationRepository);
|
||||
|
||||
// Helper to get service context
|
||||
const getContext = (req: Request) => ({
|
||||
tenantId: req.tenantId!,
|
||||
userId: req.user?.sub,
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /notifications/in-app
|
||||
* Get all in-app notifications for current user
|
||||
*/
|
||||
router.get(
|
||||
'/',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId || !ctx.userId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID and User ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const includeRead = req.query.includeRead === 'true';
|
||||
const includeArchived = req.query.includeArchived === 'true';
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
||||
|
||||
const notifications = await inAppNotificationService.findByUser(ctx, ctx.userId, {
|
||||
includeRead,
|
||||
includeArchived,
|
||||
limit,
|
||||
});
|
||||
|
||||
res.status(200).json({ success: true, data: notifications });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /notifications/in-app/counts
|
||||
* Get notification counts for current user
|
||||
*/
|
||||
router.get(
|
||||
'/counts',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId || !ctx.userId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID and User ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const counts = await inAppNotificationService.getCounts(ctx, ctx.userId);
|
||||
|
||||
res.status(200).json({ success: true, data: counts });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /notifications/in-app/recent
|
||||
* Get recent notifications since a given date
|
||||
*/
|
||||
router.get(
|
||||
'/recent',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId || !ctx.userId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID and User ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const since = req.query.since
|
||||
? new Date(req.query.since as string)
|
||||
: new Date(Date.now() - 24 * 60 * 60 * 1000); // Default: last 24 hours
|
||||
|
||||
const notifications = await inAppNotificationService.getRecent(ctx, ctx.userId, since);
|
||||
|
||||
res.status(200).json({ success: true, data: notifications });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /notifications/in-app/:id
|
||||
* Get a specific in-app notification
|
||||
*/
|
||||
router.get(
|
||||
'/:id',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const notification = await inAppNotificationService.findById(ctx, req.params.id);
|
||||
if (!notification) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Notification not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure user can only access their own notifications
|
||||
if (notification.userId !== ctx.userId) {
|
||||
res.status(403).json({ error: 'Forbidden', message: 'Access denied' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: notification });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /notifications/in-app/:id/read
|
||||
* Mark notification as read
|
||||
*/
|
||||
router.post(
|
||||
'/:id/read',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const notification = await inAppNotificationService.markAsRead(ctx, req.params.id);
|
||||
if (!notification) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Notification not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: notification });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /notifications/in-app/:id/unread
|
||||
* Mark notification as unread
|
||||
*/
|
||||
router.post(
|
||||
'/:id/unread',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const notification = await inAppNotificationService.markAsUnread(ctx, req.params.id);
|
||||
if (!notification) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Notification not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: notification });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /notifications/in-app/read-all
|
||||
* Mark all notifications as read for current user
|
||||
*/
|
||||
router.post(
|
||||
'/read-all',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId || !ctx.userId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID and User ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const count = await inAppNotificationService.markAllAsRead(ctx, ctx.userId);
|
||||
|
||||
res.status(200).json({ success: true, data: { updated: count } });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /notifications/in-app/read-bulk
|
||||
* Mark multiple notifications as read
|
||||
*/
|
||||
router.post(
|
||||
'/read-bulk',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'ids array is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const count = await inAppNotificationService.markMultipleAsRead(ctx, ids);
|
||||
|
||||
res.status(200).json({ success: true, data: { updated: count } });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /notifications/in-app/:id/archive
|
||||
* Archive a notification
|
||||
*/
|
||||
router.post(
|
||||
'/:id/archive',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const notification = await inAppNotificationService.archive(ctx, req.params.id);
|
||||
if (!notification) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Notification not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: notification });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /notifications/in-app/:id/unarchive
|
||||
* Unarchive a notification
|
||||
*/
|
||||
router.post(
|
||||
'/:id/unarchive',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const notification = await inAppNotificationService.unarchive(ctx, req.params.id);
|
||||
if (!notification) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Notification not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: notification });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /notifications/in-app/archive-all
|
||||
* Archive all notifications for current user
|
||||
*/
|
||||
router.post(
|
||||
'/archive-all',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId || !ctx.userId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID and User ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const count = await inAppNotificationService.archiveAll(ctx, ctx.userId);
|
||||
|
||||
res.status(200).json({ success: true, data: { archived: count } });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /notifications/in-app/archive-bulk
|
||||
* Archive multiple notifications
|
||||
*/
|
||||
router.post(
|
||||
'/archive-bulk',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'ids array is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const count = await inAppNotificationService.archiveMultiple(ctx, ids);
|
||||
|
||||
res.status(200).json({ success: true, data: { archived: count } });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /notifications/in-app/:id
|
||||
* Delete a notification
|
||||
*/
|
||||
router.delete(
|
||||
'/:id',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = await inAppNotificationService.delete(ctx, req.params.id);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Notification not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, message: 'Notification deleted' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /notifications/in-app/archived
|
||||
* Delete all archived notifications for current user
|
||||
*/
|
||||
router.delete(
|
||||
'/archived',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId || !ctx.userId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID and User ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const count = await inAppNotificationService.deleteArchived(ctx, ctx.userId);
|
||||
|
||||
res.status(200).json({ success: true, data: { deleted: count } });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ============= Admin Routes =============
|
||||
|
||||
/**
|
||||
* GET /notifications/in-app/admin/all
|
||||
* Get all in-app notifications (admin)
|
||||
*/
|
||||
router.get(
|
||||
'/admin/all',
|
||||
authMiddleware.authenticate,
|
||||
authMiddleware.authorize('admin', 'super_admin'),
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const filters: InAppNotificationFilters = {
|
||||
userId: req.query.userId as string,
|
||||
category: req.query.category as InAppCategory,
|
||||
priority: req.query.priority as InAppPriority,
|
||||
isRead: req.query.isRead === 'true' ? true : req.query.isRead === 'false' ? false : undefined,
|
||||
isArchived: req.query.isArchived === 'true' ? true : req.query.isArchived === 'false' ? false : undefined,
|
||||
contextType: req.query.contextType as string,
|
||||
contextId: req.query.contextId as string,
|
||||
fromDate: req.query.fromDate ? new Date(req.query.fromDate as string) : undefined,
|
||||
toDate: req.query.toDate ? new Date(req.query.toDate as string) : undefined,
|
||||
page: parseInt(req.query.page as string) || 1,
|
||||
limit: Math.min(parseInt(req.query.limit as string) || 20, 100),
|
||||
};
|
||||
|
||||
const result = await inAppNotificationService.findAll(ctx, filters);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: {
|
||||
page: result.page,
|
||||
limit: result.limit,
|
||||
total: result.total,
|
||||
totalPages: Math.ceil(result.total / result.limit),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /notifications/in-app/admin/create
|
||||
* Create an in-app notification (admin)
|
||||
*/
|
||||
router.post(
|
||||
'/admin/create',
|
||||
authMiddleware.authenticate,
|
||||
authMiddleware.authorize('admin', 'super_admin'),
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dto: CreateInAppNotificationDto = req.body;
|
||||
|
||||
if (!dto.userId || !dto.title || !dto.message) {
|
||||
res.status(400).json({
|
||||
error: 'Bad Request',
|
||||
message: 'userId, title, and message are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const notification = await inAppNotificationService.create(ctx, dto);
|
||||
|
||||
res.status(201).json({ success: true, data: notification });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /notifications/in-app/admin/broadcast
|
||||
* Broadcast notification to multiple users (admin)
|
||||
*/
|
||||
router.post(
|
||||
'/admin/broadcast',
|
||||
authMiddleware.authenticate,
|
||||
authMiddleware.authorize('admin', 'super_admin'),
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { userIds, title, message, category, priority, actionUrl } = req.body;
|
||||
|
||||
if (!Array.isArray(userIds) || userIds.length === 0) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'userIds array is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!title || !message) {
|
||||
res.status(400).json({
|
||||
error: 'Bad Request',
|
||||
message: 'title and message are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const count = await inAppNotificationService.broadcastToTenant(ctx, userIds, {
|
||||
title,
|
||||
message,
|
||||
category,
|
||||
priority,
|
||||
actionUrl,
|
||||
});
|
||||
|
||||
res.status(201).json({ success: true, data: { created: count } });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /notifications/in-app/admin/by-context/:contextType/:contextId
|
||||
* Get notifications by context (admin)
|
||||
*/
|
||||
router.get(
|
||||
'/admin/by-context/:contextType/:contextId',
|
||||
authMiddleware.authenticate,
|
||||
authMiddleware.authorize('admin', 'super_admin'),
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const notifications = await inAppNotificationService.findByContext(
|
||||
ctx,
|
||||
req.params.contextType,
|
||||
req.params.contextId,
|
||||
);
|
||||
|
||||
res.status(200).json({ success: true, data: notifications });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /notifications/in-app/admin/by-context/:contextType/:contextId
|
||||
* Delete notifications by context (admin)
|
||||
*/
|
||||
router.delete(
|
||||
'/admin/by-context/:contextType/:contextId',
|
||||
authMiddleware.authenticate,
|
||||
authMiddleware.authorize('admin', 'super_admin'),
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const count = await inAppNotificationService.deleteByContext(
|
||||
ctx,
|
||||
req.params.contextType,
|
||||
req.params.contextId,
|
||||
);
|
||||
|
||||
res.status(200).json({ success: true, data: { deleted: count } });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
export default createInAppNotificationController;
|
||||
8
src/modules/notifications/controllers/index.ts
Normal file
8
src/modules/notifications/controllers/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Notifications Controllers Index
|
||||
* @module Notifications
|
||||
*/
|
||||
|
||||
export * from './notification.controller';
|
||||
export * from './preference.controller';
|
||||
export * from './in-app-notification.controller';
|
||||
752
src/modules/notifications/controllers/notification.controller.ts
Normal file
752
src/modules/notifications/controllers/notification.controller.ts
Normal file
@ -0,0 +1,752 @@
|
||||
/**
|
||||
* Notification Controller
|
||||
* REST endpoints for notification management.
|
||||
*
|
||||
* @module Notifications
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import {
|
||||
NotificationService,
|
||||
SendNotificationDto,
|
||||
NotificationFilters,
|
||||
} from '../services/notification.service';
|
||||
import { TemplateService } from '../services/template.service';
|
||||
import { ChannelService } from '../services/channel.service';
|
||||
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
|
||||
import { AuthService } from '../../auth/services/auth.service';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
import { Tenant } from '../../core/entities/tenant.entity';
|
||||
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
|
||||
import {
|
||||
Notification,
|
||||
NotificationStatus,
|
||||
NotificationPriority,
|
||||
} from '../entities/notification.entity';
|
||||
import { NotificationTemplate, TemplateTranslation } from '../entities/template.entity';
|
||||
import { NotificationPreference } from '../entities/preference.entity';
|
||||
import { Channel, ChannelType } from '../entities/channel.entity';
|
||||
import { InAppNotification } from '../entities/in-app-notification.entity';
|
||||
|
||||
/**
|
||||
* Create notification controller router
|
||||
*/
|
||||
export function createNotificationController(dataSource: DataSource): Router {
|
||||
const router = Router();
|
||||
|
||||
// Repositories
|
||||
const userRepository = dataSource.getRepository(User);
|
||||
const tenantRepository = dataSource.getRepository(Tenant);
|
||||
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
|
||||
const notificationRepository = dataSource.getRepository(Notification);
|
||||
const templateRepository = dataSource.getRepository(NotificationTemplate);
|
||||
const translationRepository = dataSource.getRepository(TemplateTranslation);
|
||||
const preferenceRepository = dataSource.getRepository(NotificationPreference);
|
||||
const channelRepository = dataSource.getRepository(Channel);
|
||||
const inAppNotificationRepository = dataSource.getRepository(InAppNotification);
|
||||
|
||||
// Services
|
||||
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
|
||||
const authMiddleware = new AuthMiddleware(authService, dataSource);
|
||||
const notificationService = new NotificationService(
|
||||
notificationRepository,
|
||||
templateRepository,
|
||||
preferenceRepository,
|
||||
channelRepository,
|
||||
inAppNotificationRepository,
|
||||
);
|
||||
const templateService = new TemplateService(templateRepository, translationRepository);
|
||||
const channelService = new ChannelService(channelRepository);
|
||||
|
||||
// Helper to get service context
|
||||
const getContext = (req: Request) => ({
|
||||
tenantId: req.tenantId!,
|
||||
userId: req.user?.sub,
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /notifications
|
||||
* List notifications with filters
|
||||
*/
|
||||
router.get(
|
||||
'/',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const filters: NotificationFilters = {
|
||||
userId: req.query.userId as string,
|
||||
channelType: req.query.channelType as ChannelType,
|
||||
status: req.query.status as NotificationStatus,
|
||||
priority: req.query.priority as NotificationPriority,
|
||||
contextType: req.query.contextType as string,
|
||||
contextId: req.query.contextId as string,
|
||||
fromDate: req.query.fromDate ? new Date(req.query.fromDate as string) : undefined,
|
||||
toDate: req.query.toDate ? new Date(req.query.toDate as string) : undefined,
|
||||
page: parseInt(req.query.page as string) || 1,
|
||||
limit: Math.min(parseInt(req.query.limit as string) || 20, 100),
|
||||
};
|
||||
|
||||
const result = await notificationService.findAll(ctx, filters);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: {
|
||||
page: result.page,
|
||||
limit: result.limit,
|
||||
total: result.total,
|
||||
totalPages: Math.ceil(result.total / result.limit),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /notifications/stats
|
||||
* Get notification statistics
|
||||
*/
|
||||
router.get(
|
||||
'/stats',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const options = {
|
||||
fromDate: req.query.fromDate ? new Date(req.query.fromDate as string) : undefined,
|
||||
toDate: req.query.toDate ? new Date(req.query.toDate as string) : undefined,
|
||||
userId: req.query.userId as string,
|
||||
};
|
||||
|
||||
const stats = await notificationService.getStatistics(ctx, options);
|
||||
|
||||
res.status(200).json({ success: true, data: stats });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /notifications/:id
|
||||
* Get notification by ID
|
||||
*/
|
||||
router.get(
|
||||
'/:id',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const notification = await notificationService.findById(ctx, req.params.id);
|
||||
if (!notification) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Notification not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: notification });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /notifications/send
|
||||
* Send a notification using a template
|
||||
*/
|
||||
router.post(
|
||||
'/send',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dto: SendNotificationDto = req.body;
|
||||
|
||||
if (!dto.templateCode || !dto.channelType) {
|
||||
res.status(400).json({
|
||||
error: 'Bad Request',
|
||||
message: 'templateCode and channelType are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const notification = await notificationService.send(ctx, dto);
|
||||
|
||||
res.status(201).json({ success: true, data: notification });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('Template not found')) {
|
||||
res.status(404).json({ error: 'Not Found', message: error.message });
|
||||
return;
|
||||
}
|
||||
if (error.message.includes('disabled')) {
|
||||
res.status(422).json({ error: 'Unprocessable Entity', message: error.message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /notifications/:id/mark-read
|
||||
* Mark notification as read
|
||||
*/
|
||||
router.post(
|
||||
'/:id/mark-read',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const notification = await notificationService.markAsRead(ctx, req.params.id);
|
||||
if (!notification) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Notification not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: notification });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /notifications/:id/mark-unread
|
||||
* Mark notification as unread
|
||||
*/
|
||||
router.post(
|
||||
'/:id/mark-unread',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const notification = await notificationService.markAsUnread(ctx, req.params.id);
|
||||
if (!notification) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Notification not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: notification });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /notifications/mark-read-bulk
|
||||
* Mark multiple notifications as read
|
||||
*/
|
||||
router.post(
|
||||
'/mark-read-bulk',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'ids array is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const count = await notificationService.markMultipleAsRead(ctx, ids);
|
||||
|
||||
res.status(200).json({ success: true, data: { updated: count } });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /notifications/:id/cancel
|
||||
* Cancel a pending notification
|
||||
*/
|
||||
router.post(
|
||||
'/:id/cancel',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const notification = await notificationService.cancel(ctx, req.params.id);
|
||||
if (!notification) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Notification not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: notification });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('Only pending')) {
|
||||
res.status(422).json({ error: 'Unprocessable Entity', message: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /notifications/:id/retry
|
||||
* Retry a failed notification
|
||||
*/
|
||||
router.post(
|
||||
'/:id/retry',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const notification = await notificationService.retry(ctx, req.params.id);
|
||||
if (!notification) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Notification not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: notification });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('Only failed')) {
|
||||
res.status(422).json({ error: 'Unprocessable Entity', message: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ============= Template Routes =============
|
||||
|
||||
/**
|
||||
* GET /notifications/templates
|
||||
* List notification templates
|
||||
*/
|
||||
router.get(
|
||||
'/templates',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const filters = {
|
||||
category: req.query.category as any,
|
||||
channelType: req.query.channelType as ChannelType,
|
||||
isActive: req.query.isActive === 'true' ? true : req.query.isActive === 'false' ? false : undefined,
|
||||
isSystem: req.query.isSystem === 'true' ? true : req.query.isSystem === 'false' ? false : undefined,
|
||||
search: req.query.search as string,
|
||||
page: parseInt(req.query.page as string) || 1,
|
||||
limit: Math.min(parseInt(req.query.limit as string) || 20, 100),
|
||||
};
|
||||
|
||||
const result = await templateService.findAll(ctx, filters);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: {
|
||||
page: result.page,
|
||||
limit: result.limit,
|
||||
total: result.total,
|
||||
totalPages: Math.ceil(result.total / result.limit),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /notifications/templates/:id
|
||||
* Get template by ID
|
||||
*/
|
||||
router.get(
|
||||
'/templates/:id',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const template = await templateService.findById(ctx, req.params.id);
|
||||
if (!template) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Template not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: template });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /notifications/templates
|
||||
* Create a new template
|
||||
*/
|
||||
router.post(
|
||||
'/templates',
|
||||
authMiddleware.authenticate,
|
||||
authMiddleware.authorize('admin', 'super_admin'),
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { code, name, channelType } = req.body;
|
||||
if (!code || !name || !channelType) {
|
||||
res.status(400).json({
|
||||
error: 'Bad Request',
|
||||
message: 'code, name, and channelType are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const template = await templateService.create(ctx, req.body);
|
||||
|
||||
res.status(201).json({ success: true, data: template });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('already exists')) {
|
||||
res.status(409).json({ error: 'Conflict', message: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* PATCH /notifications/templates/:id
|
||||
* Update a template
|
||||
*/
|
||||
router.patch(
|
||||
'/templates/:id',
|
||||
authMiddleware.authenticate,
|
||||
authMiddleware.authorize('admin', 'super_admin'),
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const template = await templateService.update(ctx, req.params.id, req.body);
|
||||
if (!template) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Template not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: template });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('Cannot modify system')) {
|
||||
res.status(403).json({ error: 'Forbidden', message: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /notifications/templates/:id
|
||||
* Delete a template
|
||||
*/
|
||||
router.delete(
|
||||
'/templates/:id',
|
||||
authMiddleware.authenticate,
|
||||
authMiddleware.authorize('admin', 'super_admin'),
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = await templateService.delete(ctx, req.params.id);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Template not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, message: 'Template deleted' });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('Cannot delete system')) {
|
||||
res.status(403).json({ error: 'Forbidden', message: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /notifications/templates/:id/preview
|
||||
* Preview a template with sample variables
|
||||
*/
|
||||
router.post(
|
||||
'/templates/:id/preview',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { variables, locale } = req.body;
|
||||
const rendered = await templateService.preview(ctx, req.params.id, variables, locale);
|
||||
|
||||
res.status(200).json({ success: true, data: rendered });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
res.status(404).json({ error: 'Not Found', message: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ============= Channel Routes =============
|
||||
|
||||
/**
|
||||
* GET /notifications/channels
|
||||
* List notification channels
|
||||
*/
|
||||
router.get(
|
||||
'/channels',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const filters = {
|
||||
channelType: req.query.channelType as ChannelType,
|
||||
isActive: req.query.isActive === 'true' ? true : req.query.isActive === 'false' ? false : undefined,
|
||||
provider: req.query.provider as string,
|
||||
page: parseInt(req.query.page as string) || 1,
|
||||
limit: Math.min(parseInt(req.query.limit as string) || 20, 100),
|
||||
};
|
||||
|
||||
const result = await channelService.findAll(filters);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: {
|
||||
page: result.page,
|
||||
limit: result.limit,
|
||||
total: result.total,
|
||||
totalPages: Math.ceil(result.total / result.limit),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /notifications/channels/types
|
||||
* Get available channel types
|
||||
*/
|
||||
router.get(
|
||||
'/channels/types',
|
||||
authMiddleware.authenticate,
|
||||
async (_req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const types = channelService.getChannelTypes();
|
||||
res.status(200).json({ success: true, data: types });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /notifications/channels/:id
|
||||
* Get channel by ID
|
||||
*/
|
||||
router.get(
|
||||
'/channels/:id',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const channel = await channelService.findById(req.params.id);
|
||||
if (!channel) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Channel not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: channel });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /notifications/channels
|
||||
* Create a new channel (admin only)
|
||||
*/
|
||||
router.post(
|
||||
'/channels',
|
||||
authMiddleware.authenticate,
|
||||
authMiddleware.authorize('super_admin'),
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const { code, name, channelType } = req.body;
|
||||
if (!code || !name || !channelType) {
|
||||
res.status(400).json({
|
||||
error: 'Bad Request',
|
||||
message: 'code, name, and channelType are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = await channelService.create(req.body);
|
||||
|
||||
res.status(201).json({ success: true, data: channel });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('already exists')) {
|
||||
res.status(409).json({ error: 'Conflict', message: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* PATCH /notifications/channels/:id
|
||||
* Update a channel (admin only)
|
||||
*/
|
||||
router.patch(
|
||||
'/channels/:id',
|
||||
authMiddleware.authenticate,
|
||||
authMiddleware.authorize('super_admin'),
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const channel = await channelService.update(req.params.id, req.body);
|
||||
if (!channel) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Channel not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: channel });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /notifications/channels/:id
|
||||
* Delete a channel (admin only)
|
||||
*/
|
||||
router.delete(
|
||||
'/channels/:id',
|
||||
authMiddleware.authenticate,
|
||||
authMiddleware.authorize('super_admin'),
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const deleted = await channelService.delete(req.params.id);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Channel not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, message: 'Channel deleted' });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('Cannot delete')) {
|
||||
res.status(422).json({ error: 'Unprocessable Entity', message: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /notifications/channels/:id/test
|
||||
* Test channel connectivity
|
||||
*/
|
||||
router.post(
|
||||
'/channels/:id/test',
|
||||
authMiddleware.authenticate,
|
||||
authMiddleware.authorize('admin', 'super_admin'),
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const result = await channelService.testChannel(req.params.id);
|
||||
|
||||
res.status(200).json({ success: result.success, data: result });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
export default createNotificationController;
|
||||
469
src/modules/notifications/controllers/preference.controller.ts
Normal file
469
src/modules/notifications/controllers/preference.controller.ts
Normal file
@ -0,0 +1,469 @@
|
||||
/**
|
||||
* Preference Controller
|
||||
* REST endpoints for user notification preferences.
|
||||
*
|
||||
* @module Notifications
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import {
|
||||
PreferenceService,
|
||||
UpdatePreferenceDto,
|
||||
ChannelPreferences,
|
||||
} from '../services/preference.service';
|
||||
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
|
||||
import { AuthService } from '../../auth/services/auth.service';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
import { Tenant } from '../../core/entities/tenant.entity';
|
||||
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
|
||||
import { NotificationPreference, DigestFrequency } from '../entities/preference.entity';
|
||||
|
||||
/**
|
||||
* Create preference controller router
|
||||
*/
|
||||
export function createPreferenceController(dataSource: DataSource): Router {
|
||||
const router = Router();
|
||||
|
||||
// Repositories
|
||||
const userRepository = dataSource.getRepository(User);
|
||||
const tenantRepository = dataSource.getRepository(Tenant);
|
||||
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
|
||||
const preferenceRepository = dataSource.getRepository(NotificationPreference);
|
||||
|
||||
// Services
|
||||
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
|
||||
const authMiddleware = new AuthMiddleware(authService, dataSource);
|
||||
const preferenceService = new PreferenceService(preferenceRepository);
|
||||
|
||||
// Helper to get service context
|
||||
const getContext = (req: Request) => ({
|
||||
tenantId: req.tenantId!,
|
||||
userId: req.user?.sub,
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /notifications/preferences/me
|
||||
* Get current user's notification preferences
|
||||
*/
|
||||
router.get(
|
||||
'/me',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId || !ctx.userId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID and User ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const preferences = await preferenceService.getOrCreate(ctx, ctx.userId);
|
||||
|
||||
res.status(200).json({ success: true, data: preferences });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* PATCH /notifications/preferences/me
|
||||
* Update current user's notification preferences
|
||||
*/
|
||||
router.patch(
|
||||
'/me',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId || !ctx.userId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID and User ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dto: UpdatePreferenceDto = req.body;
|
||||
const preferences = await preferenceService.upsert(ctx, ctx.userId, dto);
|
||||
|
||||
res.status(200).json({ success: true, data: preferences });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /notifications/preferences/me/channels
|
||||
* Get current user's channel preferences
|
||||
*/
|
||||
router.get(
|
||||
'/me/channels',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId || !ctx.userId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID and User ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const channels = await preferenceService.getChannelPreferences(ctx, ctx.userId);
|
||||
|
||||
res.status(200).json({ success: true, data: channels });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* PATCH /notifications/preferences/me/channels
|
||||
* Update current user's channel preferences
|
||||
*/
|
||||
router.patch(
|
||||
'/me/channels',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId || !ctx.userId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID and User ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const channels: Partial<ChannelPreferences> = req.body;
|
||||
const preferences = await preferenceService.updateChannelPreferences(ctx, ctx.userId, channels);
|
||||
|
||||
res.status(200).json({ success: true, data: preferences });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /notifications/preferences/me/enable-all
|
||||
* Enable all notifications for current user
|
||||
*/
|
||||
router.post(
|
||||
'/me/enable-all',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId || !ctx.userId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID and User ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const preferences = await preferenceService.enableAll(ctx, ctx.userId);
|
||||
|
||||
res.status(200).json({ success: true, data: preferences });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /notifications/preferences/me/disable-all
|
||||
* Disable all notifications for current user
|
||||
*/
|
||||
router.post(
|
||||
'/me/disable-all',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId || !ctx.userId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID and User ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const preferences = await preferenceService.disableAll(ctx, ctx.userId);
|
||||
|
||||
res.status(200).json({ success: true, data: preferences });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* PATCH /notifications/preferences/me/quiet-hours
|
||||
* Set quiet hours for current user
|
||||
*/
|
||||
router.patch(
|
||||
'/me/quiet-hours',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId || !ctx.userId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID and User ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { start, end } = req.body;
|
||||
const preferences = await preferenceService.setQuietHours(ctx, ctx.userId, start, end);
|
||||
|
||||
res.status(200).json({ success: true, data: preferences });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /notifications/preferences/me/quiet-hours
|
||||
* Clear quiet hours for current user
|
||||
*/
|
||||
router.delete(
|
||||
'/me/quiet-hours',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId || !ctx.userId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID and User ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const preferences = await preferenceService.clearQuietHours(ctx, ctx.userId);
|
||||
|
||||
res.status(200).json({ success: true, data: preferences });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* PATCH /notifications/preferences/me/digest
|
||||
* Set digest frequency for current user
|
||||
*/
|
||||
router.patch(
|
||||
'/me/digest',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId || !ctx.userId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID and User ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { frequency, day, hour } = req.body;
|
||||
if (!frequency) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'frequency is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const validFrequencies: DigestFrequency[] = ['instant', 'hourly', 'daily', 'weekly'];
|
||||
if (!validFrequencies.includes(frequency)) {
|
||||
res.status(400).json({
|
||||
error: 'Bad Request',
|
||||
message: `Invalid frequency. Must be one of: ${validFrequencies.join(', ')}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const preferences = await preferenceService.setDigestFrequency(
|
||||
ctx,
|
||||
ctx.userId,
|
||||
frequency,
|
||||
{ day, hour },
|
||||
);
|
||||
|
||||
res.status(200).json({ success: true, data: preferences });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* PATCH /notifications/preferences/me/timezone
|
||||
* Set timezone for current user
|
||||
*/
|
||||
router.patch(
|
||||
'/me/timezone',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId || !ctx.userId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID and User ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { timezone } = req.body;
|
||||
if (!timezone) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'timezone is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const preferences = await preferenceService.setTimezone(ctx, ctx.userId, timezone);
|
||||
|
||||
res.status(200).json({ success: true, data: preferences });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* PATCH /notifications/preferences/me/categories
|
||||
* Update category preferences for current user
|
||||
*/
|
||||
router.patch(
|
||||
'/me/categories',
|
||||
authMiddleware.authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId || !ctx.userId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID and User ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const categoryPreferences = req.body;
|
||||
const preferences = await preferenceService.updateCategoryPreferences(
|
||||
ctx,
|
||||
ctx.userId,
|
||||
categoryPreferences,
|
||||
);
|
||||
|
||||
res.status(200).json({ success: true, data: preferences });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ============= Admin Routes =============
|
||||
|
||||
/**
|
||||
* GET /notifications/preferences/users/:userId
|
||||
* Get preferences for a specific user (admin only)
|
||||
*/
|
||||
router.get(
|
||||
'/users/:userId',
|
||||
authMiddleware.authenticate,
|
||||
authMiddleware.authorize('admin', 'super_admin'),
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const preferences = await preferenceService.findByUserId(ctx, req.params.userId);
|
||||
if (!preferences) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Preferences not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: preferences });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* PATCH /notifications/preferences/users/:userId
|
||||
* Update preferences for a specific user (admin only)
|
||||
*/
|
||||
router.patch(
|
||||
'/users/:userId',
|
||||
authMiddleware.authenticate,
|
||||
authMiddleware.authorize('admin', 'super_admin'),
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dto: UpdatePreferenceDto = req.body;
|
||||
const preferences = await preferenceService.upsert(ctx, req.params.userId, dto);
|
||||
|
||||
res.status(200).json({ success: true, data: preferences });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /notifications/preferences/users/:userId
|
||||
* Delete preferences for a specific user (admin only)
|
||||
*/
|
||||
router.delete(
|
||||
'/users/:userId',
|
||||
authMiddleware.authenticate,
|
||||
authMiddleware.authorize('admin', 'super_admin'),
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = await preferenceService.delete(ctx, req.params.userId);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Preferences not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, message: 'Preferences deleted' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /notifications/preferences
|
||||
* List all preferences for the tenant (admin only)
|
||||
*/
|
||||
router.get(
|
||||
'/',
|
||||
authMiddleware.authenticate,
|
||||
authMiddleware.authorize('admin', 'super_admin'),
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const ctx = getContext(req);
|
||||
if (!ctx.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
||||
|
||||
const result = await preferenceService.findAllByTenant(ctx, { page, limit });
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: result.total,
|
||||
totalPages: Math.ceil(result.total / limit),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
export default createPreferenceController;
|
||||
10
src/modules/notifications/index.ts
Normal file
10
src/modules/notifications/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Notifications Module
|
||||
* System notifications, alerts, and messaging
|
||||
*
|
||||
* ERP Construccion
|
||||
*/
|
||||
|
||||
export * from './entities';
|
||||
export * from './services';
|
||||
export * from './controllers';
|
||||
433
src/modules/notifications/services/channel.service.ts
Normal file
433
src/modules/notifications/services/channel.service.ts
Normal file
@ -0,0 +1,433 @@
|
||||
/**
|
||||
* Channel Service
|
||||
* Notification channel management.
|
||||
*
|
||||
* @module Notifications
|
||||
*/
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import { Channel, ChannelType } from '../entities/channel.entity';
|
||||
|
||||
export interface CreateChannelDto {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
channelType: ChannelType;
|
||||
provider?: string;
|
||||
providerConfig?: Record<string, any>;
|
||||
rateLimitPerMinute?: number;
|
||||
rateLimitPerHour?: number;
|
||||
rateLimitPerDay?: number;
|
||||
isActive?: boolean;
|
||||
isDefault?: boolean;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface UpdateChannelDto {
|
||||
name?: string;
|
||||
description?: string;
|
||||
provider?: string;
|
||||
providerConfig?: Record<string, any>;
|
||||
rateLimitPerMinute?: number | null;
|
||||
rateLimitPerHour?: number | null;
|
||||
rateLimitPerDay?: number | null;
|
||||
isActive?: boolean;
|
||||
isDefault?: boolean;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ChannelFilters {
|
||||
channelType?: ChannelType;
|
||||
isActive?: boolean;
|
||||
provider?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface ChannelStats {
|
||||
totalMessages: number;
|
||||
successRate: number;
|
||||
averageDeliveryTime: number;
|
||||
}
|
||||
|
||||
export class ChannelService {
|
||||
constructor(
|
||||
private readonly channelRepository: Repository<Channel>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a new channel
|
||||
*/
|
||||
async create(dto: CreateChannelDto): Promise<Channel> {
|
||||
// Check for duplicate code
|
||||
const existing = await this.findByCode(dto.code);
|
||||
if (existing) {
|
||||
throw new Error(`Channel with code '${dto.code}' already exists`);
|
||||
}
|
||||
|
||||
// If setting as default, clear other defaults for this type
|
||||
if (dto.isDefault) {
|
||||
await this.clearDefaultForType(dto.channelType);
|
||||
}
|
||||
|
||||
const channel = this.channelRepository.create(dto);
|
||||
return this.channelRepository.save(channel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find channel by ID
|
||||
*/
|
||||
async findById(id: string): Promise<Channel | null> {
|
||||
return this.channelRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find channel by code
|
||||
*/
|
||||
async findByCode(code: string): Promise<Channel | null> {
|
||||
return this.channelRepository.findOne({
|
||||
where: { code },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all channels with filters
|
||||
*/
|
||||
async findAll(
|
||||
filters: ChannelFilters,
|
||||
): Promise<{ data: Channel[]; total: number; page: number; limit: number }> {
|
||||
const page = filters.page || 1;
|
||||
const limit = filters.limit || 20;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const queryBuilder = this.channelRepository.createQueryBuilder('c');
|
||||
|
||||
if (filters.channelType) {
|
||||
queryBuilder.andWhere('c.channel_type = :channelType', { channelType: filters.channelType });
|
||||
}
|
||||
|
||||
if (filters.isActive !== undefined) {
|
||||
queryBuilder.andWhere('c.is_active = :isActive', { isActive: filters.isActive });
|
||||
}
|
||||
|
||||
if (filters.provider) {
|
||||
queryBuilder.andWhere('c.provider = :provider', { provider: filters.provider });
|
||||
}
|
||||
|
||||
const [data, total] = await queryBuilder
|
||||
.orderBy('c.channel_type', 'ASC')
|
||||
.addOrderBy('c.is_default', 'DESC')
|
||||
.addOrderBy('c.name', 'ASC')
|
||||
.skip(skip)
|
||||
.take(limit)
|
||||
.getManyAndCount();
|
||||
|
||||
return { data, total, page, limit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a channel
|
||||
*/
|
||||
async update(id: string, dto: UpdateChannelDto): Promise<Channel | null> {
|
||||
const channel = await this.findById(id);
|
||||
if (!channel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If setting as default, clear other defaults for this type
|
||||
if (dto.isDefault && !channel.isDefault) {
|
||||
await this.clearDefaultForType(channel.channelType);
|
||||
}
|
||||
|
||||
Object.assign(channel, dto);
|
||||
return this.channelRepository.save(channel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a channel
|
||||
*/
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const channel = await this.findById(id);
|
||||
if (!channel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cannot delete default channel
|
||||
if (channel.isDefault) {
|
||||
throw new Error('Cannot delete the default channel. Set another channel as default first.');
|
||||
}
|
||||
|
||||
const result = await this.channelRepository.delete(id);
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate/deactivate a channel
|
||||
*/
|
||||
async setActive(id: string, isActive: boolean): Promise<Channel | null> {
|
||||
const channel = await this.findById(id);
|
||||
if (!channel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cannot deactivate default channel
|
||||
if (!isActive && channel.isDefault) {
|
||||
throw new Error('Cannot deactivate the default channel. Set another channel as default first.');
|
||||
}
|
||||
|
||||
channel.isActive = isActive;
|
||||
return this.channelRepository.save(channel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a channel as default for its type
|
||||
*/
|
||||
async setDefault(id: string): Promise<Channel | null> {
|
||||
const channel = await this.findById(id);
|
||||
if (!channel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!channel.isActive) {
|
||||
throw new Error('Cannot set an inactive channel as default');
|
||||
}
|
||||
|
||||
// Clear other defaults for this type
|
||||
await this.clearDefaultForType(channel.channelType);
|
||||
|
||||
channel.isDefault = true;
|
||||
return this.channelRepository.save(channel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear default flag for all channels of a type
|
||||
*/
|
||||
private async clearDefaultForType(channelType: ChannelType): Promise<void> {
|
||||
await this.channelRepository.update(
|
||||
{ channelType, isDefault: true },
|
||||
{ isDefault: false },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default channel for a type
|
||||
*/
|
||||
async getDefault(channelType: ChannelType): Promise<Channel | null> {
|
||||
return this.channelRepository.findOne({
|
||||
where: { channelType, isDefault: true, isActive: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active channels for a type
|
||||
*/
|
||||
async getActiveByType(channelType: ChannelType): Promise<Channel[]> {
|
||||
return this.channelRepository.find({
|
||||
where: { channelType, isActive: true },
|
||||
order: { isDefault: 'DESC', name: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update provider configuration
|
||||
*/
|
||||
async updateProviderConfig(
|
||||
id: string,
|
||||
providerConfig: Record<string, any>,
|
||||
): Promise<Channel | null> {
|
||||
const channel = await this.findById(id);
|
||||
if (!channel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
channel.providerConfig = {
|
||||
...channel.providerConfig,
|
||||
...providerConfig,
|
||||
};
|
||||
|
||||
return this.channelRepository.save(channel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update rate limits
|
||||
*/
|
||||
async updateRateLimits(
|
||||
id: string,
|
||||
limits: {
|
||||
perMinute?: number | null;
|
||||
perHour?: number | null;
|
||||
perDay?: number | null;
|
||||
},
|
||||
): Promise<Channel | null> {
|
||||
const channel = await this.findById(id);
|
||||
if (!channel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (limits.perMinute !== undefined) {
|
||||
channel.rateLimitPerMinute = limits.perMinute as number;
|
||||
}
|
||||
if (limits.perHour !== undefined) {
|
||||
channel.rateLimitPerHour = limits.perHour as number;
|
||||
}
|
||||
if (limits.perDay !== undefined) {
|
||||
channel.rateLimitPerDay = limits.perDay as number;
|
||||
}
|
||||
|
||||
return this.channelRepository.save(channel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if rate limit is exceeded for a channel
|
||||
*/
|
||||
async checkRateLimit(
|
||||
id: string,
|
||||
sentCounts: { perMinute: number; perHour: number; perDay: number },
|
||||
): Promise<{ allowed: boolean; reason?: string }> {
|
||||
const channel = await this.findById(id);
|
||||
if (!channel) {
|
||||
return { allowed: false, reason: 'Channel not found' };
|
||||
}
|
||||
|
||||
if (!channel.isActive) {
|
||||
return { allowed: false, reason: 'Channel is inactive' };
|
||||
}
|
||||
|
||||
if (channel.rateLimitPerMinute && sentCounts.perMinute >= channel.rateLimitPerMinute) {
|
||||
return { allowed: false, reason: 'Per-minute rate limit exceeded' };
|
||||
}
|
||||
|
||||
if (channel.rateLimitPerHour && sentCounts.perHour >= channel.rateLimitPerHour) {
|
||||
return { allowed: false, reason: 'Per-hour rate limit exceeded' };
|
||||
}
|
||||
|
||||
if (channel.rateLimitPerDay && sentCounts.perDay >= channel.rateLimitPerDay) {
|
||||
return { allowed: false, reason: 'Per-day rate limit exceeded' };
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all channel types
|
||||
*/
|
||||
getChannelTypes(): ChannelType[] {
|
||||
return ['email', 'sms', 'push', 'whatsapp', 'in_app', 'webhook'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available providers for a channel type
|
||||
*/
|
||||
getProvidersForType(channelType: ChannelType): string[] {
|
||||
switch (channelType) {
|
||||
case 'email':
|
||||
return ['sendgrid', 'ses', 'mailgun', 'smtp'];
|
||||
case 'sms':
|
||||
return ['twilio', 'nexmo', 'aws_sns'];
|
||||
case 'push':
|
||||
return ['fcm', 'apns', 'onesignal'];
|
||||
case 'whatsapp':
|
||||
return ['twilio', 'meta_whatsapp_business'];
|
||||
case 'in_app':
|
||||
return ['internal'];
|
||||
case 'webhook':
|
||||
return ['http'];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate provider configuration
|
||||
*/
|
||||
validateProviderConfig(
|
||||
channelType: ChannelType,
|
||||
provider: string,
|
||||
config: Record<string, any>,
|
||||
): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
const requiredFields = this.getRequiredConfigFields(channelType, provider);
|
||||
for (const field of requiredFields) {
|
||||
if (!config[field]) {
|
||||
errors.push(`Missing required field: ${field}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get required configuration fields for a provider
|
||||
*/
|
||||
private getRequiredConfigFields(channelType: ChannelType, provider: string): string[] {
|
||||
const configMap: Record<string, Record<string, string[]>> = {
|
||||
email: {
|
||||
sendgrid: ['apiKey'],
|
||||
ses: ['accessKeyId', 'secretAccessKey', 'region'],
|
||||
mailgun: ['apiKey', 'domain'],
|
||||
smtp: ['host', 'port', 'username', 'password'],
|
||||
},
|
||||
sms: {
|
||||
twilio: ['accountSid', 'authToken', 'fromNumber'],
|
||||
nexmo: ['apiKey', 'apiSecret', 'fromNumber'],
|
||||
aws_sns: ['accessKeyId', 'secretAccessKey', 'region'],
|
||||
},
|
||||
push: {
|
||||
fcm: ['serviceAccountKey'],
|
||||
apns: ['keyId', 'teamId', 'bundleId', 'privateKey'],
|
||||
onesignal: ['appId', 'apiKey'],
|
||||
},
|
||||
whatsapp: {
|
||||
twilio: ['accountSid', 'authToken', 'fromNumber'],
|
||||
meta_whatsapp_business: ['accessToken', 'phoneNumberId'],
|
||||
},
|
||||
in_app: {
|
||||
internal: [],
|
||||
},
|
||||
webhook: {
|
||||
http: ['url'],
|
||||
},
|
||||
};
|
||||
|
||||
return configMap[channelType]?.[provider] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Test channel connectivity
|
||||
*/
|
||||
async testChannel(id: string): Promise<{ success: boolean; message: string }> {
|
||||
const channel = await this.findById(id);
|
||||
if (!channel) {
|
||||
return { success: false, message: 'Channel not found' };
|
||||
}
|
||||
|
||||
if (!channel.isActive) {
|
||||
return { success: false, message: 'Channel is inactive' };
|
||||
}
|
||||
|
||||
// Validate configuration
|
||||
if (channel.provider) {
|
||||
const validation = this.validateProviderConfig(
|
||||
channel.channelType,
|
||||
channel.provider,
|
||||
channel.providerConfig,
|
||||
);
|
||||
|
||||
if (!validation.valid) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Invalid configuration: ${validation.errors.join(', ')}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// In production, this would actually test the provider connectivity
|
||||
return { success: true, message: 'Channel configuration is valid' };
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,471 @@
|
||||
/**
|
||||
* In-App Notification Service
|
||||
* In-app notification management with read/archive tracking.
|
||||
*
|
||||
* @module Notifications
|
||||
*/
|
||||
|
||||
import { Repository, In, LessThan, MoreThan } from 'typeorm';
|
||||
import {
|
||||
InAppNotification,
|
||||
InAppCategory,
|
||||
InAppPriority,
|
||||
InAppActionType,
|
||||
} from '../entities/in-app-notification.entity';
|
||||
import { ServiceContext } from './notification.service';
|
||||
|
||||
export interface CreateInAppNotificationDto {
|
||||
userId: string;
|
||||
title: string;
|
||||
message: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
actionType?: InAppActionType;
|
||||
actionUrl?: string;
|
||||
actionData?: Record<string, any>;
|
||||
category?: InAppCategory;
|
||||
contextType?: string;
|
||||
contextId?: string;
|
||||
priority?: InAppPriority;
|
||||
expiresAt?: Date;
|
||||
}
|
||||
|
||||
export interface InAppNotificationFilters {
|
||||
userId?: string;
|
||||
category?: InAppCategory;
|
||||
priority?: InAppPriority;
|
||||
isRead?: boolean;
|
||||
isArchived?: boolean;
|
||||
contextType?: string;
|
||||
contextId?: string;
|
||||
fromDate?: Date;
|
||||
toDate?: Date;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface InAppNotificationCounts {
|
||||
total: number;
|
||||
unread: number;
|
||||
byCategory: Record<InAppCategory, number>;
|
||||
}
|
||||
|
||||
export class InAppNotificationService {
|
||||
constructor(
|
||||
private readonly inAppNotificationRepository: Repository<InAppNotification>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a new in-app notification
|
||||
*/
|
||||
async create(
|
||||
ctx: ServiceContext,
|
||||
dto: CreateInAppNotificationDto,
|
||||
): Promise<InAppNotification> {
|
||||
const notification = this.inAppNotificationRepository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
...dto,
|
||||
});
|
||||
|
||||
return this.inAppNotificationRepository.save(notification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create multiple in-app notifications (broadcast)
|
||||
*/
|
||||
async createMany(
|
||||
ctx: ServiceContext,
|
||||
userIds: string[],
|
||||
data: Omit<CreateInAppNotificationDto, 'userId'>,
|
||||
): Promise<InAppNotification[]> {
|
||||
const notifications = userIds.map((userId) =>
|
||||
this.inAppNotificationRepository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
userId,
|
||||
...data,
|
||||
}),
|
||||
);
|
||||
|
||||
return this.inAppNotificationRepository.save(notifications);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find notification by ID
|
||||
*/
|
||||
async findById(ctx: ServiceContext, id: string): Promise<InAppNotification | null> {
|
||||
return this.inAppNotificationRepository.findOne({
|
||||
where: { id, tenantId: ctx.tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all notifications for a user
|
||||
*/
|
||||
async findByUser(
|
||||
ctx: ServiceContext,
|
||||
userId: string,
|
||||
options?: {
|
||||
includeRead?: boolean;
|
||||
includeArchived?: boolean;
|
||||
limit?: number;
|
||||
},
|
||||
): Promise<InAppNotification[]> {
|
||||
const queryBuilder = this.inAppNotificationRepository
|
||||
.createQueryBuilder('n')
|
||||
.where('n.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('n.user_id = :userId', { userId });
|
||||
|
||||
if (!options?.includeRead) {
|
||||
queryBuilder.andWhere('n.is_read = false');
|
||||
}
|
||||
|
||||
if (!options?.includeArchived) {
|
||||
queryBuilder.andWhere('n.is_archived = false');
|
||||
}
|
||||
|
||||
// Exclude expired notifications
|
||||
queryBuilder.andWhere('(n.expires_at IS NULL OR n.expires_at > :now)', { now: new Date() });
|
||||
|
||||
return queryBuilder
|
||||
.orderBy('n.priority', 'DESC')
|
||||
.addOrderBy('n.created_at', 'DESC')
|
||||
.take(options?.limit || 50)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all notifications with filters
|
||||
*/
|
||||
async findAll(
|
||||
ctx: ServiceContext,
|
||||
filters: InAppNotificationFilters,
|
||||
): Promise<{ data: InAppNotification[]; total: number; page: number; limit: number }> {
|
||||
const page = filters.page || 1;
|
||||
const limit = filters.limit || 20;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const queryBuilder = this.inAppNotificationRepository
|
||||
.createQueryBuilder('n')
|
||||
.where('n.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (filters.userId) {
|
||||
queryBuilder.andWhere('n.user_id = :userId', { userId: filters.userId });
|
||||
}
|
||||
|
||||
if (filters.category) {
|
||||
queryBuilder.andWhere('n.category = :category', { category: filters.category });
|
||||
}
|
||||
|
||||
if (filters.priority) {
|
||||
queryBuilder.andWhere('n.priority = :priority', { priority: filters.priority });
|
||||
}
|
||||
|
||||
if (filters.isRead !== undefined) {
|
||||
queryBuilder.andWhere('n.is_read = :isRead', { isRead: filters.isRead });
|
||||
}
|
||||
|
||||
if (filters.isArchived !== undefined) {
|
||||
queryBuilder.andWhere('n.is_archived = :isArchived', { isArchived: filters.isArchived });
|
||||
}
|
||||
|
||||
if (filters.contextType) {
|
||||
queryBuilder.andWhere('n.context_type = :contextType', { contextType: filters.contextType });
|
||||
}
|
||||
|
||||
if (filters.contextId) {
|
||||
queryBuilder.andWhere('n.context_id = :contextId', { contextId: filters.contextId });
|
||||
}
|
||||
|
||||
if (filters.fromDate) {
|
||||
queryBuilder.andWhere('n.created_at >= :fromDate', { fromDate: filters.fromDate });
|
||||
}
|
||||
|
||||
if (filters.toDate) {
|
||||
queryBuilder.andWhere('n.created_at <= :toDate', { toDate: filters.toDate });
|
||||
}
|
||||
|
||||
const [data, total] = await queryBuilder
|
||||
.orderBy('n.created_at', 'DESC')
|
||||
.skip(skip)
|
||||
.take(limit)
|
||||
.getManyAndCount();
|
||||
|
||||
return { data, total, page, limit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark notification as read
|
||||
*/
|
||||
async markAsRead(ctx: ServiceContext, id: string): Promise<InAppNotification | null> {
|
||||
const notification = await this.findById(ctx, id);
|
||||
if (!notification) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!notification.isRead) {
|
||||
notification.isRead = true;
|
||||
notification.readAt = new Date();
|
||||
return this.inAppNotificationRepository.save(notification);
|
||||
}
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark notification as unread
|
||||
*/
|
||||
async markAsUnread(ctx: ServiceContext, id: string): Promise<InAppNotification | null> {
|
||||
const notification = await this.findById(ctx, id);
|
||||
if (!notification) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (notification.isRead) {
|
||||
notification.isRead = false;
|
||||
notification.readAt = null as unknown as Date;
|
||||
return this.inAppNotificationRepository.save(notification);
|
||||
}
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all notifications as read for a user
|
||||
*/
|
||||
async markAllAsRead(ctx: ServiceContext, userId: string): Promise<number> {
|
||||
const result = await this.inAppNotificationRepository.update(
|
||||
{ tenantId: ctx.tenantId, userId, isRead: false },
|
||||
{ isRead: true, readAt: new Date() },
|
||||
);
|
||||
return result.affected || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark multiple notifications as read
|
||||
*/
|
||||
async markMultipleAsRead(ctx: ServiceContext, ids: string[]): Promise<number> {
|
||||
const result = await this.inAppNotificationRepository.update(
|
||||
{ id: In(ids), tenantId: ctx.tenantId, isRead: false },
|
||||
{ isRead: true, readAt: new Date() },
|
||||
);
|
||||
return result.affected || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive a notification
|
||||
*/
|
||||
async archive(ctx: ServiceContext, id: string): Promise<InAppNotification | null> {
|
||||
const notification = await this.findById(ctx, id);
|
||||
if (!notification) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!notification.isArchived) {
|
||||
notification.isArchived = true;
|
||||
notification.archivedAt = new Date();
|
||||
return this.inAppNotificationRepository.save(notification);
|
||||
}
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unarchive a notification
|
||||
*/
|
||||
async unarchive(ctx: ServiceContext, id: string): Promise<InAppNotification | null> {
|
||||
const notification = await this.findById(ctx, id);
|
||||
if (!notification) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (notification.isArchived) {
|
||||
notification.isArchived = false;
|
||||
notification.archivedAt = null as unknown as Date;
|
||||
return this.inAppNotificationRepository.save(notification);
|
||||
}
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive all notifications for a user
|
||||
*/
|
||||
async archiveAll(ctx: ServiceContext, userId: string): Promise<number> {
|
||||
const result = await this.inAppNotificationRepository.update(
|
||||
{ tenantId: ctx.tenantId, userId, isArchived: false },
|
||||
{ isArchived: true, archivedAt: new Date() },
|
||||
);
|
||||
return result.affected || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive multiple notifications
|
||||
*/
|
||||
async archiveMultiple(ctx: ServiceContext, ids: string[]): Promise<number> {
|
||||
const result = await this.inAppNotificationRepository.update(
|
||||
{ id: In(ids), tenantId: ctx.tenantId, isArchived: false },
|
||||
{ isArchived: true, archivedAt: new Date() },
|
||||
);
|
||||
return result.affected || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a notification
|
||||
*/
|
||||
async delete(ctx: ServiceContext, id: string): Promise<boolean> {
|
||||
const result = await this.inAppNotificationRepository.delete({
|
||||
id,
|
||||
tenantId: ctx.tenantId,
|
||||
});
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all archived notifications for a user
|
||||
*/
|
||||
async deleteArchived(ctx: ServiceContext, userId: string): Promise<number> {
|
||||
const result = await this.inAppNotificationRepository.delete({
|
||||
tenantId: ctx.tenantId,
|
||||
userId,
|
||||
isArchived: true,
|
||||
});
|
||||
return result.affected || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notification counts for a user
|
||||
*/
|
||||
async getCounts(ctx: ServiceContext, userId: string): Promise<InAppNotificationCounts> {
|
||||
const [total, unread, byCategoryRaw] = await Promise.all([
|
||||
this.inAppNotificationRepository.count({
|
||||
where: { tenantId: ctx.tenantId, userId, isArchived: false },
|
||||
}),
|
||||
this.inAppNotificationRepository.count({
|
||||
where: { tenantId: ctx.tenantId, userId, isRead: false, isArchived: false },
|
||||
}),
|
||||
this.inAppNotificationRepository
|
||||
.createQueryBuilder('n')
|
||||
.select('n.category', 'category')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('n.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('n.user_id = :userId', { userId })
|
||||
.andWhere('n.is_read = false')
|
||||
.andWhere('n.is_archived = false')
|
||||
.groupBy('n.category')
|
||||
.getRawMany(),
|
||||
]);
|
||||
|
||||
const byCategory: Record<string, number> = {};
|
||||
byCategoryRaw.forEach((row: { category: string; count: string }) => {
|
||||
byCategory[row.category] = parseInt(row.count, 10);
|
||||
});
|
||||
|
||||
return {
|
||||
total,
|
||||
unread,
|
||||
byCategory: byCategory as Record<InAppCategory, number>,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent notifications for a user (for real-time updates)
|
||||
*/
|
||||
async getRecent(
|
||||
ctx: ServiceContext,
|
||||
userId: string,
|
||||
sinceDate: Date,
|
||||
): Promise<InAppNotification[]> {
|
||||
return this.inAppNotificationRepository.find({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
userId,
|
||||
createdAt: MoreThan(sinceDate),
|
||||
},
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired notifications
|
||||
*/
|
||||
async cleanupExpired(): Promise<number> {
|
||||
const result = await this.inAppNotificationRepository.delete({
|
||||
expiresAt: LessThan(new Date()),
|
||||
});
|
||||
return result.affected || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old archived notifications
|
||||
*/
|
||||
async cleanupOldArchived(daysOld: number = 30): Promise<number> {
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - daysOld);
|
||||
|
||||
const result = await this.inAppNotificationRepository.delete({
|
||||
isArchived: true,
|
||||
archivedAt: LessThan(cutoffDate),
|
||||
});
|
||||
|
||||
return result.affected || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notifications by context (e.g., for a specific project or task)
|
||||
*/
|
||||
async findByContext(
|
||||
ctx: ServiceContext,
|
||||
contextType: string,
|
||||
contextId: string,
|
||||
): Promise<InAppNotification[]> {
|
||||
return this.inAppNotificationRepository.find({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
contextType,
|
||||
contextId,
|
||||
},
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete notifications by context (e.g., when entity is deleted)
|
||||
*/
|
||||
async deleteByContext(
|
||||
ctx: ServiceContext,
|
||||
contextType: string,
|
||||
contextId: string,
|
||||
): Promise<number> {
|
||||
const result = await this.inAppNotificationRepository.delete({
|
||||
tenantId: ctx.tenantId,
|
||||
contextType,
|
||||
contextId,
|
||||
});
|
||||
return result.affected || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a system notification to all users in a tenant
|
||||
*/
|
||||
async broadcastToTenant(
|
||||
ctx: ServiceContext,
|
||||
userIds: string[],
|
||||
data: {
|
||||
title: string;
|
||||
message: string;
|
||||
category?: InAppCategory;
|
||||
priority?: InAppPriority;
|
||||
actionUrl?: string;
|
||||
},
|
||||
): Promise<number> {
|
||||
const notifications = await this.createMany(ctx, userIds, {
|
||||
title: data.title,
|
||||
message: data.message,
|
||||
category: data.category || 'info',
|
||||
priority: data.priority || 'normal',
|
||||
actionUrl: data.actionUrl,
|
||||
});
|
||||
|
||||
return notifications.length;
|
||||
}
|
||||
}
|
||||
10
src/modules/notifications/services/index.ts
Normal file
10
src/modules/notifications/services/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Notifications Services Index
|
||||
* @module Notifications
|
||||
*/
|
||||
|
||||
export * from './notification.service';
|
||||
export * from './preference.service';
|
||||
export * from './template.service';
|
||||
export * from './channel.service';
|
||||
export * from './in-app-notification.service';
|
||||
609
src/modules/notifications/services/notification.service.ts
Normal file
609
src/modules/notifications/services/notification.service.ts
Normal file
@ -0,0 +1,609 @@
|
||||
/**
|
||||
* Notification Service
|
||||
* Core notification logic: CRUD, sending, and delivery tracking.
|
||||
*
|
||||
* @module Notifications
|
||||
*/
|
||||
|
||||
import { Repository, In, LessThan } from 'typeorm';
|
||||
import {
|
||||
Notification,
|
||||
NotificationStatus,
|
||||
NotificationPriority,
|
||||
} from '../entities/notification.entity';
|
||||
import { NotificationTemplate } from '../entities/template.entity';
|
||||
import { NotificationPreference } from '../entities/preference.entity';
|
||||
import { Channel, ChannelType } from '../entities/channel.entity';
|
||||
import { InAppNotification, InAppCategory } from '../entities/in-app-notification.entity';
|
||||
|
||||
export interface ServiceContext {
|
||||
tenantId: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface CreateNotificationDto {
|
||||
userId?: string;
|
||||
recipientEmail?: string;
|
||||
recipientPhone?: string;
|
||||
recipientDeviceId?: string;
|
||||
templateId?: string;
|
||||
templateCode?: string;
|
||||
channelType: ChannelType;
|
||||
channelId?: string;
|
||||
subject?: string;
|
||||
body?: string;
|
||||
bodyHtml?: string;
|
||||
variables?: Record<string, any>;
|
||||
contextType?: string;
|
||||
contextId?: string;
|
||||
priority?: NotificationPriority;
|
||||
expiresAt?: Date;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface SendNotificationDto {
|
||||
userId?: string;
|
||||
recipientEmail?: string;
|
||||
recipientPhone?: string;
|
||||
recipientDeviceId?: string;
|
||||
templateCode: string;
|
||||
channelType: ChannelType;
|
||||
variables?: Record<string, any>;
|
||||
contextType?: string;
|
||||
contextId?: string;
|
||||
priority?: NotificationPriority;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface NotificationFilters {
|
||||
userId?: string;
|
||||
channelType?: ChannelType;
|
||||
status?: NotificationStatus;
|
||||
priority?: NotificationPriority;
|
||||
contextType?: string;
|
||||
contextId?: string;
|
||||
fromDate?: Date;
|
||||
toDate?: Date;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface NotificationStats {
|
||||
total: number;
|
||||
byStatus: Record<NotificationStatus, number>;
|
||||
byChannel: Record<ChannelType, number>;
|
||||
deliveryRate: number;
|
||||
readRate: number;
|
||||
}
|
||||
|
||||
export class NotificationService {
|
||||
constructor(
|
||||
private readonly notificationRepository: Repository<Notification>,
|
||||
private readonly templateRepository: Repository<NotificationTemplate>,
|
||||
private readonly preferenceRepository: Repository<NotificationPreference>,
|
||||
private readonly channelRepository: Repository<Channel>,
|
||||
private readonly inAppNotificationRepository: Repository<InAppNotification>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a new notification (queued for delivery)
|
||||
*/
|
||||
async create(
|
||||
ctx: ServiceContext,
|
||||
dto: CreateNotificationDto,
|
||||
): Promise<Notification> {
|
||||
const notification = this.notificationRepository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
...dto,
|
||||
status: 'pending',
|
||||
queuedAt: new Date(),
|
||||
});
|
||||
|
||||
return this.notificationRepository.save(notification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification using a template
|
||||
*/
|
||||
async send(
|
||||
ctx: ServiceContext,
|
||||
dto: SendNotificationDto,
|
||||
): Promise<Notification> {
|
||||
// Get the template
|
||||
const template = await this.templateRepository.findOne({
|
||||
where: [
|
||||
{ code: dto.templateCode, tenantId: ctx.tenantId, channelType: dto.channelType, isActive: true },
|
||||
{ code: dto.templateCode, tenantId: null as any, channelType: dto.channelType, isActive: true },
|
||||
],
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
throw new Error(`Template not found: ${dto.templateCode}`);
|
||||
}
|
||||
|
||||
// Check user preferences if userId is provided
|
||||
if (dto.userId) {
|
||||
const preferences = await this.preferenceRepository.findOne({
|
||||
where: { userId: dto.userId, tenantId: ctx.tenantId },
|
||||
});
|
||||
|
||||
if (preferences) {
|
||||
// Check if notifications are globally disabled
|
||||
if (!preferences.globalEnabled) {
|
||||
throw new Error('User has disabled all notifications');
|
||||
}
|
||||
|
||||
// Check channel-specific preferences
|
||||
const channelEnabled = this.isChannelEnabled(preferences, dto.channelType);
|
||||
if (!channelEnabled) {
|
||||
throw new Error(`User has disabled ${dto.channelType} notifications`);
|
||||
}
|
||||
|
||||
// Check quiet hours
|
||||
if (this.isInQuietHours(preferences)) {
|
||||
// Queue for later instead of immediate send
|
||||
const notification = await this.create(ctx, {
|
||||
...dto,
|
||||
templateId: template.id,
|
||||
templateCode: template.code,
|
||||
subject: this.renderTemplate(template.subject || '', dto.variables || {}),
|
||||
body: this.renderTemplate(template.bodyTemplate || '', dto.variables || {}),
|
||||
bodyHtml: template.bodyHtml ? this.renderTemplate(template.bodyHtml, dto.variables || {}) : undefined,
|
||||
});
|
||||
return notification;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get the default channel for this type
|
||||
const channel = await this.channelRepository.findOne({
|
||||
where: { channelType: dto.channelType, isActive: true, isDefault: true },
|
||||
});
|
||||
|
||||
// Render the template with variables
|
||||
const subject = this.renderTemplate(template.subject || '', dto.variables || {});
|
||||
const body = this.renderTemplate(template.bodyTemplate || '', dto.variables || {});
|
||||
const bodyHtml = template.bodyHtml ? this.renderTemplate(template.bodyHtml, dto.variables || {}) : undefined;
|
||||
|
||||
// Create the notification
|
||||
const notification = await this.create(ctx, {
|
||||
userId: dto.userId,
|
||||
recipientEmail: dto.recipientEmail,
|
||||
recipientPhone: dto.recipientPhone,
|
||||
recipientDeviceId: dto.recipientDeviceId,
|
||||
templateId: template.id,
|
||||
templateCode: template.code,
|
||||
channelType: dto.channelType,
|
||||
channelId: channel?.id,
|
||||
subject,
|
||||
body,
|
||||
bodyHtml,
|
||||
variables: dto.variables,
|
||||
contextType: dto.contextType,
|
||||
contextId: dto.contextId,
|
||||
priority: dto.priority || 'normal',
|
||||
metadata: dto.metadata,
|
||||
});
|
||||
|
||||
// For in-app notifications, also create an InAppNotification record
|
||||
if (dto.channelType === 'in_app' && dto.userId) {
|
||||
await this.createInAppNotification(ctx, {
|
||||
userId: dto.userId,
|
||||
title: subject,
|
||||
message: body,
|
||||
category: 'info',
|
||||
contextType: dto.contextType,
|
||||
contextId: dto.contextId,
|
||||
priority: dto.priority || 'normal',
|
||||
});
|
||||
}
|
||||
|
||||
// Simulate sending (in production, this would be queued to a message broker)
|
||||
await this.processNotification(notification);
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a notification (send via the appropriate channel)
|
||||
*/
|
||||
private async processNotification(notification: Notification): Promise<void> {
|
||||
try {
|
||||
notification.status = 'sending';
|
||||
await this.notificationRepository.save(notification);
|
||||
|
||||
// Simulate channel-specific sending
|
||||
switch (notification.channelType) {
|
||||
case 'email':
|
||||
// In production: send via email provider (SendGrid, SES, etc.)
|
||||
break;
|
||||
case 'sms':
|
||||
// In production: send via SMS provider (Twilio, etc.)
|
||||
break;
|
||||
case 'push':
|
||||
// In production: send via push notification service (FCM, APNS, etc.)
|
||||
break;
|
||||
case 'whatsapp':
|
||||
// In production: send via WhatsApp Business API
|
||||
break;
|
||||
case 'in_app':
|
||||
// Already handled via InAppNotification
|
||||
break;
|
||||
case 'webhook':
|
||||
// In production: call webhook URL
|
||||
break;
|
||||
}
|
||||
|
||||
notification.status = 'sent';
|
||||
notification.sentAt = new Date();
|
||||
await this.notificationRepository.save(notification);
|
||||
} catch (error) {
|
||||
notification.status = 'failed';
|
||||
notification.failedAt = new Date();
|
||||
notification.errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
notification.retryCount += 1;
|
||||
|
||||
if (notification.retryCount < notification.maxRetries) {
|
||||
notification.status = 'pending';
|
||||
notification.nextRetryAt = new Date(Date.now() + this.getRetryDelay(notification.retryCount));
|
||||
}
|
||||
|
||||
await this.notificationRepository.save(notification);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get retry delay based on retry count (exponential backoff)
|
||||
*/
|
||||
private getRetryDelay(retryCount: number): number {
|
||||
return Math.min(1000 * Math.pow(2, retryCount), 3600000); // Max 1 hour
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a template with variables
|
||||
*/
|
||||
private renderTemplate(template: string, variables: Record<string, any>): string {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
||||
return variables[key] !== undefined ? String(variables[key]) : match;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a channel is enabled for a user
|
||||
*/
|
||||
private isChannelEnabled(preferences: NotificationPreference, channelType: ChannelType): boolean {
|
||||
switch (channelType) {
|
||||
case 'email':
|
||||
return preferences.emailEnabled;
|
||||
case 'sms':
|
||||
return preferences.smsEnabled;
|
||||
case 'push':
|
||||
return preferences.pushEnabled;
|
||||
case 'whatsapp':
|
||||
return preferences.whatsappEnabled;
|
||||
case 'in_app':
|
||||
return preferences.inAppEnabled;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current time is within quiet hours
|
||||
*/
|
||||
private isInQuietHours(preferences: NotificationPreference): boolean {
|
||||
if (!preferences.quietHoursStart || !preferences.quietHoursEnd) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const currentTime = now.toTimeString().slice(0, 5);
|
||||
const start = preferences.quietHoursStart;
|
||||
const end = preferences.quietHoursEnd;
|
||||
|
||||
if (start <= end) {
|
||||
return currentTime >= start && currentTime <= end;
|
||||
} else {
|
||||
// Quiet hours span midnight
|
||||
return currentTime >= start || currentTime <= end;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an in-app notification
|
||||
*/
|
||||
private async createInAppNotification(
|
||||
ctx: ServiceContext,
|
||||
data: {
|
||||
userId: string;
|
||||
title: string;
|
||||
message: string;
|
||||
category?: InAppCategory;
|
||||
contextType?: string;
|
||||
contextId?: string;
|
||||
priority?: NotificationPriority;
|
||||
actionUrl?: string;
|
||||
actionType?: string;
|
||||
},
|
||||
): Promise<InAppNotification> {
|
||||
const inAppNotification = this.inAppNotificationRepository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: data.userId,
|
||||
title: data.title,
|
||||
message: data.message,
|
||||
category: data.category || 'info',
|
||||
contextType: data.contextType,
|
||||
contextId: data.contextId,
|
||||
priority: data.priority || 'normal',
|
||||
actionUrl: data.actionUrl,
|
||||
actionType: data.actionType as any,
|
||||
});
|
||||
|
||||
return this.inAppNotificationRepository.save(inAppNotification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find notification by ID
|
||||
*/
|
||||
async findById(ctx: ServiceContext, id: string): Promise<Notification | null> {
|
||||
return this.notificationRepository.findOne({
|
||||
where: { id, tenantId: ctx.tenantId },
|
||||
relations: ['template', 'channel'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all notifications with filters
|
||||
*/
|
||||
async findAll(
|
||||
ctx: ServiceContext,
|
||||
filters: NotificationFilters,
|
||||
): Promise<{ data: Notification[]; total: number; page: number; limit: number }> {
|
||||
const page = filters.page || 1;
|
||||
const limit = filters.limit || 20;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const queryBuilder = this.notificationRepository
|
||||
.createQueryBuilder('n')
|
||||
.leftJoinAndSelect('n.template', 'template')
|
||||
.leftJoinAndSelect('n.channel', 'channel')
|
||||
.where('n.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (filters.userId) {
|
||||
queryBuilder.andWhere('n.user_id = :userId', { userId: filters.userId });
|
||||
}
|
||||
|
||||
if (filters.channelType) {
|
||||
queryBuilder.andWhere('n.channel_type = :channelType', { channelType: filters.channelType });
|
||||
}
|
||||
|
||||
if (filters.status) {
|
||||
queryBuilder.andWhere('n.status = :status', { status: filters.status });
|
||||
}
|
||||
|
||||
if (filters.priority) {
|
||||
queryBuilder.andWhere('n.priority = :priority', { priority: filters.priority });
|
||||
}
|
||||
|
||||
if (filters.contextType) {
|
||||
queryBuilder.andWhere('n.context_type = :contextType', { contextType: filters.contextType });
|
||||
}
|
||||
|
||||
if (filters.contextId) {
|
||||
queryBuilder.andWhere('n.context_id = :contextId', { contextId: filters.contextId });
|
||||
}
|
||||
|
||||
if (filters.fromDate) {
|
||||
queryBuilder.andWhere('n.created_at >= :fromDate', { fromDate: filters.fromDate });
|
||||
}
|
||||
|
||||
if (filters.toDate) {
|
||||
queryBuilder.andWhere('n.created_at <= :toDate', { toDate: filters.toDate });
|
||||
}
|
||||
|
||||
const [data, total] = await queryBuilder
|
||||
.orderBy('n.created_at', 'DESC')
|
||||
.skip(skip)
|
||||
.take(limit)
|
||||
.getManyAndCount();
|
||||
|
||||
return { data, total, page, limit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark notification as delivered
|
||||
*/
|
||||
async markAsDelivered(ctx: ServiceContext, id: string): Promise<Notification | null> {
|
||||
const notification = await this.findById(ctx, id);
|
||||
if (!notification) {
|
||||
return null;
|
||||
}
|
||||
|
||||
notification.status = 'delivered';
|
||||
notification.deliveredAt = new Date();
|
||||
return this.notificationRepository.save(notification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark notification as read
|
||||
*/
|
||||
async markAsRead(ctx: ServiceContext, id: string): Promise<Notification | null> {
|
||||
const notification = await this.findById(ctx, id);
|
||||
if (!notification) {
|
||||
return null;
|
||||
}
|
||||
|
||||
notification.status = 'read';
|
||||
notification.readAt = new Date();
|
||||
return this.notificationRepository.save(notification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark notification as unread
|
||||
*/
|
||||
async markAsUnread(ctx: ServiceContext, id: string): Promise<Notification | null> {
|
||||
const notification = await this.findById(ctx, id);
|
||||
if (!notification) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Can only mark as unread if previously read
|
||||
if (notification.status === 'read') {
|
||||
notification.status = 'delivered';
|
||||
notification.readAt = null as unknown as Date;
|
||||
return this.notificationRepository.save(notification);
|
||||
}
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark multiple notifications as read
|
||||
*/
|
||||
async markMultipleAsRead(ctx: ServiceContext, ids: string[]): Promise<number> {
|
||||
const result = await this.notificationRepository.update(
|
||||
{ id: In(ids), tenantId: ctx.tenantId },
|
||||
{ status: 'read', readAt: new Date() },
|
||||
);
|
||||
return result.affected || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a pending notification
|
||||
*/
|
||||
async cancel(ctx: ServiceContext, id: string): Promise<Notification | null> {
|
||||
const notification = await this.findById(ctx, id);
|
||||
if (!notification) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (notification.status !== 'pending' && notification.status !== 'queued') {
|
||||
throw new Error('Only pending or queued notifications can be cancelled');
|
||||
}
|
||||
|
||||
notification.status = 'cancelled';
|
||||
return this.notificationRepository.save(notification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a failed notification
|
||||
*/
|
||||
async retry(ctx: ServiceContext, id: string): Promise<Notification | null> {
|
||||
const notification = await this.findById(ctx, id);
|
||||
if (!notification) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (notification.status !== 'failed') {
|
||||
throw new Error('Only failed notifications can be retried');
|
||||
}
|
||||
|
||||
notification.status = 'pending';
|
||||
notification.retryCount = 0;
|
||||
notification.errorMessage = null as unknown as string;
|
||||
notification.failedAt = null as unknown as Date;
|
||||
notification.nextRetryAt = null as unknown as Date;
|
||||
|
||||
await this.notificationRepository.save(notification);
|
||||
await this.processNotification(notification);
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notification statistics
|
||||
*/
|
||||
async getStatistics(
|
||||
ctx: ServiceContext,
|
||||
options?: { fromDate?: Date; toDate?: Date; userId?: string },
|
||||
): Promise<NotificationStats> {
|
||||
const queryBuilder = this.notificationRepository
|
||||
.createQueryBuilder('n')
|
||||
.where('n.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (options?.fromDate) {
|
||||
queryBuilder.andWhere('n.created_at >= :fromDate', { fromDate: options.fromDate });
|
||||
}
|
||||
|
||||
if (options?.toDate) {
|
||||
queryBuilder.andWhere('n.created_at <= :toDate', { toDate: options.toDate });
|
||||
}
|
||||
|
||||
if (options?.userId) {
|
||||
queryBuilder.andWhere('n.user_id = :userId', { userId: options.userId });
|
||||
}
|
||||
|
||||
const [total, byStatusRaw, byChannelRaw, deliveredCount, readCount] = await Promise.all([
|
||||
queryBuilder.getCount(),
|
||||
this.notificationRepository
|
||||
.createQueryBuilder('n')
|
||||
.select('n.status', 'status')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('n.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.groupBy('n.status')
|
||||
.getRawMany(),
|
||||
this.notificationRepository
|
||||
.createQueryBuilder('n')
|
||||
.select('n.channel_type', 'channelType')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('n.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.groupBy('n.channel_type')
|
||||
.getRawMany(),
|
||||
this.notificationRepository.count({
|
||||
where: { tenantId: ctx.tenantId, status: In(['delivered', 'read']) },
|
||||
}),
|
||||
this.notificationRepository.count({
|
||||
where: { tenantId: ctx.tenantId, status: 'read' },
|
||||
}),
|
||||
]);
|
||||
|
||||
const byStatus: Record<string, number> = {};
|
||||
byStatusRaw.forEach((row: { status: string; count: string }) => {
|
||||
byStatus[row.status] = parseInt(row.count, 10);
|
||||
});
|
||||
|
||||
const byChannel: Record<string, number> = {};
|
||||
byChannelRaw.forEach((row: { channelType: string; count: string }) => {
|
||||
byChannel[row.channelType] = parseInt(row.count, 10);
|
||||
});
|
||||
|
||||
const sentCount = total - (byStatus['pending'] || 0) - (byStatus['queued'] || 0) - (byStatus['cancelled'] || 0);
|
||||
|
||||
return {
|
||||
total,
|
||||
byStatus: byStatus as Record<NotificationStatus, number>,
|
||||
byChannel: byChannel as Record<ChannelType, number>,
|
||||
deliveryRate: sentCount > 0 ? deliveredCount / sentCount : 0,
|
||||
readRate: deliveredCount > 0 ? readCount / deliveredCount : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending notifications for processing
|
||||
*/
|
||||
async getPendingNotifications(limit: number = 100): Promise<Notification[]> {
|
||||
return this.notificationRepository.find({
|
||||
where: [
|
||||
{ status: 'pending' },
|
||||
{ status: 'queued', nextRetryAt: LessThan(new Date()) },
|
||||
],
|
||||
order: { priority: 'DESC', createdAt: 'ASC' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old notifications
|
||||
*/
|
||||
async cleanupOldNotifications(daysOld: number = 90): Promise<number> {
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - daysOld);
|
||||
|
||||
const result = await this.notificationRepository.delete({
|
||||
createdAt: LessThan(cutoffDate),
|
||||
status: In(['sent', 'delivered', 'read', 'cancelled', 'failed']),
|
||||
});
|
||||
|
||||
return result.affected || 0;
|
||||
}
|
||||
}
|
||||
396
src/modules/notifications/services/preference.service.ts
Normal file
396
src/modules/notifications/services/preference.service.ts
Normal file
@ -0,0 +1,396 @@
|
||||
/**
|
||||
* Preference Service
|
||||
* User notification preferences management.
|
||||
*
|
||||
* @module Notifications
|
||||
*/
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import { NotificationPreference, DigestFrequency } from '../entities/preference.entity';
|
||||
import { ServiceContext } from './notification.service';
|
||||
|
||||
export interface CreatePreferenceDto {
|
||||
userId: string;
|
||||
globalEnabled?: boolean;
|
||||
quietHoursStart?: string;
|
||||
quietHoursEnd?: string;
|
||||
timezone?: string;
|
||||
emailEnabled?: boolean;
|
||||
smsEnabled?: boolean;
|
||||
pushEnabled?: boolean;
|
||||
whatsappEnabled?: boolean;
|
||||
inAppEnabled?: boolean;
|
||||
categoryPreferences?: Record<string, any>;
|
||||
digestFrequency?: DigestFrequency;
|
||||
digestDay?: number;
|
||||
digestHour?: number;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface UpdatePreferenceDto {
|
||||
globalEnabled?: boolean;
|
||||
quietHoursStart?: string;
|
||||
quietHoursEnd?: string;
|
||||
timezone?: string;
|
||||
emailEnabled?: boolean;
|
||||
smsEnabled?: boolean;
|
||||
pushEnabled?: boolean;
|
||||
whatsappEnabled?: boolean;
|
||||
inAppEnabled?: boolean;
|
||||
categoryPreferences?: Record<string, any>;
|
||||
digestFrequency?: DigestFrequency;
|
||||
digestDay?: number;
|
||||
digestHour?: number;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ChannelPreferences {
|
||||
email: boolean;
|
||||
sms: boolean;
|
||||
push: boolean;
|
||||
whatsapp: boolean;
|
||||
inApp: boolean;
|
||||
}
|
||||
|
||||
export class PreferenceService {
|
||||
constructor(
|
||||
private readonly preferenceRepository: Repository<NotificationPreference>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create user preferences
|
||||
*/
|
||||
async create(
|
||||
ctx: ServiceContext,
|
||||
dto: CreatePreferenceDto,
|
||||
): Promise<NotificationPreference> {
|
||||
// Check if preferences already exist
|
||||
const existing = await this.findByUserId(ctx, dto.userId);
|
||||
if (existing) {
|
||||
throw new Error('Preferences already exist for this user');
|
||||
}
|
||||
|
||||
const preference = this.preferenceRepository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
...dto,
|
||||
});
|
||||
|
||||
return this.preferenceRepository.save(preference);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get preferences by user ID
|
||||
*/
|
||||
async findByUserId(
|
||||
ctx: ServiceContext,
|
||||
userId: string,
|
||||
): Promise<NotificationPreference | null> {
|
||||
return this.preferenceRepository.findOne({
|
||||
where: { userId, tenantId: ctx.tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create preferences for a user
|
||||
*/
|
||||
async getOrCreate(
|
||||
ctx: ServiceContext,
|
||||
userId: string,
|
||||
): Promise<NotificationPreference> {
|
||||
let preference = await this.findByUserId(ctx, userId);
|
||||
|
||||
if (!preference) {
|
||||
preference = await this.create(ctx, { userId });
|
||||
}
|
||||
|
||||
return preference;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user preferences
|
||||
*/
|
||||
async update(
|
||||
ctx: ServiceContext,
|
||||
userId: string,
|
||||
dto: UpdatePreferenceDto,
|
||||
): Promise<NotificationPreference | null> {
|
||||
const preference = await this.findByUserId(ctx, userId);
|
||||
if (!preference) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Object.assign(preference, dto);
|
||||
return this.preferenceRepository.save(preference);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update or create preferences
|
||||
*/
|
||||
async upsert(
|
||||
ctx: ServiceContext,
|
||||
userId: string,
|
||||
dto: UpdatePreferenceDto,
|
||||
): Promise<NotificationPreference> {
|
||||
let preference = await this.findByUserId(ctx, userId);
|
||||
|
||||
if (preference) {
|
||||
Object.assign(preference, dto);
|
||||
} else {
|
||||
preference = this.preferenceRepository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
userId,
|
||||
...dto,
|
||||
});
|
||||
}
|
||||
|
||||
return this.preferenceRepository.save(preference);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete user preferences
|
||||
*/
|
||||
async delete(ctx: ServiceContext, userId: string): Promise<boolean> {
|
||||
const result = await this.preferenceRepository.delete({
|
||||
userId,
|
||||
tenantId: ctx.tenantId,
|
||||
});
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable all notifications for a user
|
||||
*/
|
||||
async enableAll(ctx: ServiceContext, userId: string): Promise<NotificationPreference> {
|
||||
return this.upsert(ctx, userId, {
|
||||
globalEnabled: true,
|
||||
emailEnabled: true,
|
||||
smsEnabled: true,
|
||||
pushEnabled: true,
|
||||
whatsappEnabled: true,
|
||||
inAppEnabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable all notifications for a user
|
||||
*/
|
||||
async disableAll(ctx: ServiceContext, userId: string): Promise<NotificationPreference> {
|
||||
return this.upsert(ctx, userId, {
|
||||
globalEnabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update channel preferences
|
||||
*/
|
||||
async updateChannelPreferences(
|
||||
ctx: ServiceContext,
|
||||
userId: string,
|
||||
channels: Partial<ChannelPreferences>,
|
||||
): Promise<NotificationPreference> {
|
||||
const dto: UpdatePreferenceDto = {};
|
||||
|
||||
if (channels.email !== undefined) dto.emailEnabled = channels.email;
|
||||
if (channels.sms !== undefined) dto.smsEnabled = channels.sms;
|
||||
if (channels.push !== undefined) dto.pushEnabled = channels.push;
|
||||
if (channels.whatsapp !== undefined) dto.whatsappEnabled = channels.whatsapp;
|
||||
if (channels.inApp !== undefined) dto.inAppEnabled = channels.inApp;
|
||||
|
||||
return this.upsert(ctx, userId, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get channel preferences
|
||||
*/
|
||||
async getChannelPreferences(
|
||||
ctx: ServiceContext,
|
||||
userId: string,
|
||||
): Promise<ChannelPreferences> {
|
||||
const preference = await this.getOrCreate(ctx, userId);
|
||||
|
||||
return {
|
||||
email: preference.emailEnabled,
|
||||
sms: preference.smsEnabled,
|
||||
push: preference.pushEnabled,
|
||||
whatsapp: preference.whatsappEnabled,
|
||||
inApp: preference.inAppEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set quiet hours
|
||||
*/
|
||||
async setQuietHours(
|
||||
ctx: ServiceContext,
|
||||
userId: string,
|
||||
start: string | null,
|
||||
end: string | null,
|
||||
): Promise<NotificationPreference> {
|
||||
return this.upsert(ctx, userId, {
|
||||
quietHoursStart: start || undefined,
|
||||
quietHoursEnd: end || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear quiet hours
|
||||
*/
|
||||
async clearQuietHours(ctx: ServiceContext, userId: string): Promise<NotificationPreference> {
|
||||
return this.setQuietHours(ctx, userId, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set digest frequency
|
||||
*/
|
||||
async setDigestFrequency(
|
||||
ctx: ServiceContext,
|
||||
userId: string,
|
||||
frequency: DigestFrequency,
|
||||
options?: { day?: number; hour?: number },
|
||||
): Promise<NotificationPreference> {
|
||||
return this.upsert(ctx, userId, {
|
||||
digestFrequency: frequency,
|
||||
digestDay: options?.day,
|
||||
digestHour: options?.hour,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update category preferences
|
||||
*/
|
||||
async updateCategoryPreferences(
|
||||
ctx: ServiceContext,
|
||||
userId: string,
|
||||
categoryPreferences: Record<string, any>,
|
||||
): Promise<NotificationPreference> {
|
||||
const preference = await this.getOrCreate(ctx, userId);
|
||||
|
||||
preference.categoryPreferences = {
|
||||
...preference.categoryPreferences,
|
||||
...categoryPreferences,
|
||||
};
|
||||
|
||||
return this.preferenceRepository.save(preference);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get category preference
|
||||
*/
|
||||
async getCategoryPreference(
|
||||
ctx: ServiceContext,
|
||||
userId: string,
|
||||
category: string,
|
||||
): Promise<Record<string, any> | null> {
|
||||
const preference = await this.findByUserId(ctx, userId);
|
||||
if (!preference) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return preference.categoryPreferences[category] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific notification type is enabled for a user
|
||||
*/
|
||||
async isNotificationEnabled(
|
||||
ctx: ServiceContext,
|
||||
userId: string,
|
||||
channelType: string,
|
||||
category?: string,
|
||||
): Promise<boolean> {
|
||||
const preference = await this.findByUserId(ctx, userId);
|
||||
|
||||
// Default to enabled if no preferences exist
|
||||
if (!preference) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check global enabled flag
|
||||
if (!preference.globalEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check channel-specific flag
|
||||
switch (channelType) {
|
||||
case 'email':
|
||||
if (!preference.emailEnabled) return false;
|
||||
break;
|
||||
case 'sms':
|
||||
if (!preference.smsEnabled) return false;
|
||||
break;
|
||||
case 'push':
|
||||
if (!preference.pushEnabled) return false;
|
||||
break;
|
||||
case 'whatsapp':
|
||||
if (!preference.whatsappEnabled) return false;
|
||||
break;
|
||||
case 'in_app':
|
||||
if (!preference.inAppEnabled) return false;
|
||||
break;
|
||||
}
|
||||
|
||||
// Check category-specific preference if provided
|
||||
if (category && preference.categoryPreferences[category] !== undefined) {
|
||||
return preference.categoryPreferences[category].enabled !== false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get users who prefer a specific digest frequency
|
||||
*/
|
||||
async getUsersByDigestFrequency(
|
||||
ctx: ServiceContext,
|
||||
frequency: DigestFrequency,
|
||||
options?: { day?: number; hour?: number },
|
||||
): Promise<NotificationPreference[]> {
|
||||
const queryBuilder = this.preferenceRepository
|
||||
.createQueryBuilder('p')
|
||||
.where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('p.digest_frequency = :frequency', { frequency })
|
||||
.andWhere('p.global_enabled = true');
|
||||
|
||||
if (options?.day !== undefined) {
|
||||
queryBuilder.andWhere('p.digest_day = :day', { day: options.day });
|
||||
}
|
||||
|
||||
if (options?.hour !== undefined) {
|
||||
queryBuilder.andWhere('p.digest_hour = :hour', { hour: options.hour });
|
||||
}
|
||||
|
||||
return queryBuilder.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set timezone for a user
|
||||
*/
|
||||
async setTimezone(
|
||||
ctx: ServiceContext,
|
||||
userId: string,
|
||||
timezone: string,
|
||||
): Promise<NotificationPreference> {
|
||||
return this.upsert(ctx, userId, { timezone });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all preferences for a tenant (admin use)
|
||||
*/
|
||||
async findAllByTenant(
|
||||
ctx: ServiceContext,
|
||||
options?: { page?: number; limit?: number },
|
||||
): Promise<{ data: NotificationPreference[]; total: number }> {
|
||||
const page = options?.page || 1;
|
||||
const limit = options?.limit || 50;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [data, total] = await this.preferenceRepository.findAndCount({
|
||||
where: { tenantId: ctx.tenantId },
|
||||
skip,
|
||||
take: limit,
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
return { data, total };
|
||||
}
|
||||
}
|
||||
509
src/modules/notifications/services/template.service.ts
Normal file
509
src/modules/notifications/services/template.service.ts
Normal file
@ -0,0 +1,509 @@
|
||||
/**
|
||||
* Template Service
|
||||
* Notification template management with i18n support.
|
||||
*
|
||||
* @module Notifications
|
||||
*/
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import {
|
||||
NotificationTemplate,
|
||||
TemplateTranslation,
|
||||
TemplateCategory,
|
||||
} from '../entities/template.entity';
|
||||
import { ChannelType } from '../entities/channel.entity';
|
||||
import { ServiceContext } from './notification.service';
|
||||
|
||||
export interface CreateTemplateDto {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
category?: TemplateCategory;
|
||||
channelType: ChannelType;
|
||||
subject?: string;
|
||||
bodyTemplate?: string;
|
||||
bodyHtml?: string;
|
||||
availableVariables?: string[];
|
||||
defaultLocale?: string;
|
||||
isActive?: boolean;
|
||||
isSystem?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateTemplateDto {
|
||||
name?: string;
|
||||
description?: string;
|
||||
category?: TemplateCategory;
|
||||
subject?: string;
|
||||
bodyTemplate?: string;
|
||||
bodyHtml?: string;
|
||||
availableVariables?: string[];
|
||||
defaultLocale?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateTranslationDto {
|
||||
locale: string;
|
||||
subject?: string;
|
||||
bodyTemplate?: string;
|
||||
bodyHtml?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface TemplateFilters {
|
||||
category?: TemplateCategory;
|
||||
channelType?: ChannelType;
|
||||
isActive?: boolean;
|
||||
isSystem?: boolean;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface RenderedTemplate {
|
||||
subject: string;
|
||||
body: string;
|
||||
bodyHtml?: string;
|
||||
}
|
||||
|
||||
export class TemplateService {
|
||||
constructor(
|
||||
private readonly templateRepository: Repository<NotificationTemplate>,
|
||||
private readonly translationRepository: Repository<TemplateTranslation>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a new template
|
||||
*/
|
||||
async create(
|
||||
ctx: ServiceContext,
|
||||
dto: CreateTemplateDto,
|
||||
): Promise<NotificationTemplate> {
|
||||
// Check for duplicate code
|
||||
const existing = await this.findByCode(ctx, dto.code, dto.channelType);
|
||||
if (existing) {
|
||||
throw new Error(`Template with code '${dto.code}' already exists for channel '${dto.channelType}'`);
|
||||
}
|
||||
|
||||
const template = this.templateRepository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
...dto,
|
||||
createdBy: ctx.userId,
|
||||
});
|
||||
|
||||
return this.templateRepository.save(template);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find template by ID
|
||||
*/
|
||||
async findById(ctx: ServiceContext, id: string): Promise<NotificationTemplate | null> {
|
||||
return this.templateRepository.findOne({
|
||||
where: [
|
||||
{ id, tenantId: ctx.tenantId },
|
||||
{ id, tenantId: null as any }, // System templates
|
||||
],
|
||||
relations: ['translations'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find template by code and channel type
|
||||
*/
|
||||
async findByCode(
|
||||
ctx: ServiceContext,
|
||||
code: string,
|
||||
channelType: ChannelType,
|
||||
): Promise<NotificationTemplate | null> {
|
||||
return this.templateRepository.findOne({
|
||||
where: [
|
||||
{ code, channelType, tenantId: ctx.tenantId },
|
||||
{ code, channelType, tenantId: null as any }, // System templates
|
||||
],
|
||||
relations: ['translations'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all templates with filters
|
||||
*/
|
||||
async findAll(
|
||||
ctx: ServiceContext,
|
||||
filters: TemplateFilters,
|
||||
): Promise<{ data: NotificationTemplate[]; total: number; page: number; limit: number }> {
|
||||
const page = filters.page || 1;
|
||||
const limit = filters.limit || 20;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const queryBuilder = this.templateRepository
|
||||
.createQueryBuilder('t')
|
||||
.leftJoinAndSelect('t.translations', 'translations')
|
||||
.where('(t.tenant_id = :tenantId OR t.tenant_id IS NULL)', { tenantId: ctx.tenantId });
|
||||
|
||||
if (filters.category) {
|
||||
queryBuilder.andWhere('t.category = :category', { category: filters.category });
|
||||
}
|
||||
|
||||
if (filters.channelType) {
|
||||
queryBuilder.andWhere('t.channel_type = :channelType', { channelType: filters.channelType });
|
||||
}
|
||||
|
||||
if (filters.isActive !== undefined) {
|
||||
queryBuilder.andWhere('t.is_active = :isActive', { isActive: filters.isActive });
|
||||
}
|
||||
|
||||
if (filters.isSystem !== undefined) {
|
||||
queryBuilder.andWhere('t.is_system = :isSystem', { isSystem: filters.isSystem });
|
||||
}
|
||||
|
||||
if (filters.search) {
|
||||
queryBuilder.andWhere(
|
||||
'(t.code ILIKE :search OR t.name ILIKE :search OR t.description ILIKE :search)',
|
||||
{ search: `%${filters.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
const [data, total] = await queryBuilder
|
||||
.orderBy('t.is_system', 'DESC')
|
||||
.addOrderBy('t.code', 'ASC')
|
||||
.skip(skip)
|
||||
.take(limit)
|
||||
.getManyAndCount();
|
||||
|
||||
return { data, total, page, limit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a template
|
||||
*/
|
||||
async update(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
dto: UpdateTemplateDto,
|
||||
): Promise<NotificationTemplate | null> {
|
||||
const template = await this.findById(ctx, id);
|
||||
if (!template) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cannot modify system templates from tenant context
|
||||
if (template.isSystem && template.tenantId === null) {
|
||||
throw new Error('Cannot modify system templates');
|
||||
}
|
||||
|
||||
Object.assign(template, dto, { updatedBy: ctx.userId });
|
||||
template.version += 1;
|
||||
|
||||
return this.templateRepository.save(template);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a template
|
||||
*/
|
||||
async delete(ctx: ServiceContext, id: string): Promise<boolean> {
|
||||
const template = await this.findById(ctx, id);
|
||||
if (!template) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cannot delete system templates
|
||||
if (template.isSystem && template.tenantId === null) {
|
||||
throw new Error('Cannot delete system templates');
|
||||
}
|
||||
|
||||
const result = await this.templateRepository.delete({
|
||||
id,
|
||||
tenantId: ctx.tenantId,
|
||||
});
|
||||
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate/deactivate a template
|
||||
*/
|
||||
async setActive(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
isActive: boolean,
|
||||
): Promise<NotificationTemplate | null> {
|
||||
return this.update(ctx, id, { isActive });
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a translation to a template
|
||||
*/
|
||||
async addTranslation(
|
||||
ctx: ServiceContext,
|
||||
templateId: string,
|
||||
dto: CreateTranslationDto,
|
||||
): Promise<TemplateTranslation> {
|
||||
const template = await this.findById(ctx, templateId);
|
||||
if (!template) {
|
||||
throw new Error('Template not found');
|
||||
}
|
||||
|
||||
// Check for existing translation
|
||||
const existing = await this.translationRepository.findOne({
|
||||
where: { templateId, locale: dto.locale },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new Error(`Translation for locale '${dto.locale}' already exists`);
|
||||
}
|
||||
|
||||
const translation = this.translationRepository.create({
|
||||
templateId,
|
||||
...dto,
|
||||
});
|
||||
|
||||
return this.translationRepository.save(translation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a translation
|
||||
*/
|
||||
async updateTranslation(
|
||||
ctx: ServiceContext,
|
||||
templateId: string,
|
||||
locale: string,
|
||||
dto: Partial<CreateTranslationDto>,
|
||||
): Promise<TemplateTranslation | null> {
|
||||
const template = await this.findById(ctx, templateId);
|
||||
if (!template) {
|
||||
throw new Error('Template not found');
|
||||
}
|
||||
|
||||
const translation = await this.translationRepository.findOne({
|
||||
where: { templateId, locale },
|
||||
});
|
||||
|
||||
if (!translation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Object.assign(translation, dto);
|
||||
return this.translationRepository.save(translation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a translation
|
||||
*/
|
||||
async deleteTranslation(
|
||||
ctx: ServiceContext,
|
||||
templateId: string,
|
||||
locale: string,
|
||||
): Promise<boolean> {
|
||||
const template = await this.findById(ctx, templateId);
|
||||
if (!template) {
|
||||
throw new Error('Template not found');
|
||||
}
|
||||
|
||||
const result = await this.translationRepository.delete({
|
||||
templateId,
|
||||
locale,
|
||||
});
|
||||
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get translation for a specific locale
|
||||
*/
|
||||
async getTranslation(
|
||||
templateId: string,
|
||||
locale: string,
|
||||
): Promise<TemplateTranslation | null> {
|
||||
return this.translationRepository.findOne({
|
||||
where: { templateId, locale, isActive: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a template with variables
|
||||
*/
|
||||
async render(
|
||||
ctx: ServiceContext,
|
||||
code: string,
|
||||
channelType: ChannelType,
|
||||
variables: Record<string, any>,
|
||||
locale?: string,
|
||||
): Promise<RenderedTemplate> {
|
||||
const template = await this.findByCode(ctx, code, channelType);
|
||||
if (!template) {
|
||||
throw new Error(`Template not found: ${code} (${channelType})`);
|
||||
}
|
||||
|
||||
if (!template.isActive) {
|
||||
throw new Error(`Template is inactive: ${code}`);
|
||||
}
|
||||
|
||||
let subject = template.subject || '';
|
||||
let body = template.bodyTemplate || '';
|
||||
let bodyHtml = template.bodyHtml;
|
||||
|
||||
// Try to get translation if locale is specified
|
||||
if (locale && locale !== template.defaultLocale) {
|
||||
const translation = await this.getTranslation(template.id, locale);
|
||||
if (translation) {
|
||||
subject = translation.subject || subject;
|
||||
body = translation.bodyTemplate || body;
|
||||
bodyHtml = translation.bodyHtml || bodyHtml;
|
||||
}
|
||||
}
|
||||
|
||||
// Render variables
|
||||
subject = this.renderVariables(subject, variables);
|
||||
body = this.renderVariables(body, variables);
|
||||
if (bodyHtml) {
|
||||
bodyHtml = this.renderVariables(bodyHtml, variables);
|
||||
}
|
||||
|
||||
return { subject, body, bodyHtml };
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace variables in a template string
|
||||
*/
|
||||
private renderVariables(template: string, variables: Record<string, any>): string {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
||||
return variables[key] !== undefined ? String(variables[key]) : match;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate template variables
|
||||
*/
|
||||
async validateVariables(
|
||||
ctx: ServiceContext,
|
||||
code: string,
|
||||
channelType: ChannelType,
|
||||
variables: Record<string, any>,
|
||||
): Promise<{ valid: boolean; missing: string[]; extra: string[] }> {
|
||||
const template = await this.findByCode(ctx, code, channelType);
|
||||
if (!template) {
|
||||
throw new Error(`Template not found: ${code} (${channelType})`);
|
||||
}
|
||||
|
||||
const requiredVariables = template.availableVariables || [];
|
||||
const providedVariables = Object.keys(variables);
|
||||
|
||||
const missing = requiredVariables.filter((v) => !providedVariables.includes(v));
|
||||
const extra = providedVariables.filter((v) => !requiredVariables.includes(v));
|
||||
|
||||
return {
|
||||
valid: missing.length === 0,
|
||||
missing,
|
||||
extra,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone a template
|
||||
*/
|
||||
async clone(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
newCode: string,
|
||||
): Promise<NotificationTemplate> {
|
||||
const template = await this.findById(ctx, id);
|
||||
if (!template) {
|
||||
throw new Error('Template not found');
|
||||
}
|
||||
|
||||
// Create new template
|
||||
const cloned = await this.create(ctx, {
|
||||
code: newCode,
|
||||
name: `${template.name} (Copy)`,
|
||||
description: template.description,
|
||||
category: template.category as TemplateCategory,
|
||||
channelType: template.channelType,
|
||||
subject: template.subject,
|
||||
bodyTemplate: template.bodyTemplate,
|
||||
bodyHtml: template.bodyHtml,
|
||||
availableVariables: [...(template.availableVariables || [])],
|
||||
defaultLocale: template.defaultLocale,
|
||||
isActive: false, // Start as inactive
|
||||
isSystem: false,
|
||||
});
|
||||
|
||||
// Clone translations
|
||||
if (template.translations && template.translations.length > 0) {
|
||||
for (const translation of template.translations) {
|
||||
await this.addTranslation(ctx, cloned.id, {
|
||||
locale: translation.locale,
|
||||
subject: translation.subject,
|
||||
bodyTemplate: translation.bodyTemplate,
|
||||
bodyHtml: translation.bodyHtml,
|
||||
isActive: translation.isActive,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return this.findById(ctx, cloned.id) as Promise<NotificationTemplate>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get templates by channel type
|
||||
*/
|
||||
async findByChannelType(
|
||||
ctx: ServiceContext,
|
||||
channelType: ChannelType,
|
||||
): Promise<NotificationTemplate[]> {
|
||||
return this.templateRepository.find({
|
||||
where: [
|
||||
{ channelType, tenantId: ctx.tenantId, isActive: true },
|
||||
{ channelType, tenantId: null as any, isActive: true },
|
||||
],
|
||||
relations: ['translations'],
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get templates by category
|
||||
*/
|
||||
async findByCategory(
|
||||
ctx: ServiceContext,
|
||||
category: TemplateCategory,
|
||||
): Promise<NotificationTemplate[]> {
|
||||
return this.templateRepository.find({
|
||||
where: [
|
||||
{ category, tenantId: ctx.tenantId, isActive: true },
|
||||
{ category, tenantId: null as any, isActive: true },
|
||||
],
|
||||
relations: ['translations'],
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview a template with sample variables
|
||||
*/
|
||||
async preview(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
sampleVariables?: Record<string, any>,
|
||||
locale?: string,
|
||||
): Promise<RenderedTemplate> {
|
||||
const template = await this.findById(ctx, id);
|
||||
if (!template) {
|
||||
throw new Error('Template not found');
|
||||
}
|
||||
|
||||
// Generate sample variables if not provided
|
||||
const variables = sampleVariables || this.generateSampleVariables(template.availableVariables || []);
|
||||
|
||||
return this.render(ctx, template.code, template.channelType, variables, locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate sample variables for preview
|
||||
*/
|
||||
private generateSampleVariables(variableNames: string[]): Record<string, any> {
|
||||
const samples: Record<string, string> = {};
|
||||
for (const name of variableNames) {
|
||||
samples[name] = `[${name}]`;
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
}
|
||||
11
src/modules/products/controllers/index.ts
Normal file
11
src/modules/products/controllers/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Products Controllers Index
|
||||
* @module Products
|
||||
*/
|
||||
|
||||
export { default as productController } from './product.controller';
|
||||
export { default as productCategoryController } from './product-category.controller';
|
||||
export { default as productPriceController } from './product-price.controller';
|
||||
export { default as productSupplierController } from './product-supplier.controller';
|
||||
export { default as productAttributeController } from './product-attribute.controller';
|
||||
export { default as productVariantController } from './product-variant.controller';
|
||||
306
src/modules/products/controllers/product-attribute.controller.ts
Normal file
306
src/modules/products/controllers/product-attribute.controller.ts
Normal file
@ -0,0 +1,306 @@
|
||||
/**
|
||||
* Product Attribute Controller
|
||||
* API endpoints para gestion de atributos de productos
|
||||
*
|
||||
* @module Products
|
||||
* @prefix /api/v1/products/attributes
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import {
|
||||
ProductAttributeService,
|
||||
CreateProductAttributeDto,
|
||||
UpdateProductAttributeDto,
|
||||
UpdateAttributeValueDto
|
||||
} from '../services/product-attribute.service';
|
||||
|
||||
const router = Router();
|
||||
const attributeService = new ProductAttributeService();
|
||||
|
||||
/**
|
||||
* GET /api/v1/products/attributes
|
||||
* Lista todos los atributos del tenant
|
||||
*/
|
||||
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { isActive, search } = req.query;
|
||||
|
||||
const attributes = await attributeService.findAll({
|
||||
tenantId,
|
||||
isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined,
|
||||
search: search as string,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: attributes,
|
||||
count: attributes.length,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/products/attributes/:id
|
||||
* Obtiene un atributo por ID
|
||||
*/
|
||||
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const attribute = await attributeService.findById(req.params.id, tenantId);
|
||||
if (!attribute) {
|
||||
return res.status(404).json({ error: 'Atributo no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: attribute });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/products/attributes/:id/values
|
||||
* Obtiene los valores de un atributo
|
||||
*/
|
||||
router.get('/:id/values', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const attribute = await attributeService.findById(req.params.id, tenantId);
|
||||
if (!attribute) {
|
||||
return res.status(404).json({ error: 'Atributo no encontrado' });
|
||||
}
|
||||
|
||||
const values = await attributeService.findValuesByAttribute(req.params.id);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: values,
|
||||
count: values.length,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/products/attributes
|
||||
* Crea un nuevo atributo
|
||||
*/
|
||||
router.post('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { values, ...attributeData } = req.body;
|
||||
|
||||
const data: CreateProductAttributeDto = {
|
||||
...attributeData,
|
||||
tenantId,
|
||||
createdBy: (req as any).user?.id,
|
||||
};
|
||||
|
||||
if (!data.code || !data.name) {
|
||||
return res.status(400).json({
|
||||
error: 'code y name son requeridos'
|
||||
});
|
||||
}
|
||||
|
||||
const existing = await attributeService.findByCode(data.code, tenantId);
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: 'Ya existe un atributo con ese codigo' });
|
||||
}
|
||||
|
||||
let attribute;
|
||||
if (values && Array.isArray(values) && values.length > 0) {
|
||||
attribute = await attributeService.createWithValues(data, values);
|
||||
} else {
|
||||
attribute = await attributeService.create(data);
|
||||
}
|
||||
|
||||
return res.status(201).json({ success: true, data: attribute });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/products/attributes/:id/values
|
||||
* Agrega valores a un atributo
|
||||
*/
|
||||
router.post('/:id/values', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const attribute = await attributeService.findById(req.params.id, tenantId);
|
||||
if (!attribute) {
|
||||
return res.status(404).json({ error: 'Atributo no encontrado' });
|
||||
}
|
||||
|
||||
const { values } = req.body;
|
||||
if (!values || !Array.isArray(values) || values.length === 0) {
|
||||
return res.status(400).json({ error: 'values array is required' });
|
||||
}
|
||||
|
||||
const createdValues = await attributeService.addValues(req.params.id, values);
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: createdValues,
|
||||
count: createdValues.length,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/products/attributes/:id/values/reorder
|
||||
* Reordena los valores de un atributo
|
||||
*/
|
||||
router.post('/:id/values/reorder', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const attribute = await attributeService.findById(req.params.id, tenantId);
|
||||
if (!attribute) {
|
||||
return res.status(404).json({ error: 'Atributo no encontrado' });
|
||||
}
|
||||
|
||||
const { valueIds } = req.body;
|
||||
if (!valueIds || !Array.isArray(valueIds)) {
|
||||
return res.status(400).json({ error: 'valueIds array is required' });
|
||||
}
|
||||
|
||||
await attributeService.reorderValues(req.params.id, valueIds);
|
||||
|
||||
return res.json({ success: true, message: 'Valores reordenados' });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/products/attributes/:id
|
||||
* Actualiza un atributo
|
||||
*/
|
||||
router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const data: UpdateProductAttributeDto = {
|
||||
...req.body,
|
||||
updatedBy: (req as any).user?.id,
|
||||
};
|
||||
|
||||
if (data.code) {
|
||||
const existing = await attributeService.findByCode(data.code, tenantId);
|
||||
if (existing && existing.id !== req.params.id) {
|
||||
return res.status(409).json({ error: 'Ya existe un atributo con ese codigo' });
|
||||
}
|
||||
}
|
||||
|
||||
const attribute = await attributeService.update(req.params.id, tenantId, data);
|
||||
if (!attribute) {
|
||||
return res.status(404).json({ error: 'Atributo no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: attribute });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/products/attributes/values/:valueId
|
||||
* Actualiza un valor de atributo
|
||||
*/
|
||||
router.patch('/values/:valueId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const data: UpdateAttributeValueDto = req.body;
|
||||
const value = await attributeService.updateValue(req.params.valueId, data);
|
||||
|
||||
if (!value) {
|
||||
return res.status(404).json({ error: 'Valor no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: value });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/products/attributes/:id
|
||||
* Elimina un atributo y todos sus valores
|
||||
*/
|
||||
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const deleted = await attributeService.delete(req.params.id, tenantId);
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Atributo no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, message: 'Atributo eliminado' });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/products/attributes/values/:valueId
|
||||
* Elimina un valor de atributo
|
||||
*/
|
||||
router.delete('/values/:valueId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const deleted = await attributeService.deleteValue(req.params.valueId);
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Valor no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, message: 'Valor eliminado' });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
255
src/modules/products/controllers/product-category.controller.ts
Normal file
255
src/modules/products/controllers/product-category.controller.ts
Normal file
@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Product Category Controller
|
||||
* API endpoints para gestion de categorias de productos
|
||||
*
|
||||
* @module Products
|
||||
* @prefix /api/v1/products/categories
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import {
|
||||
ProductCategoryService,
|
||||
CreateProductCategoryDto,
|
||||
UpdateProductCategoryDto
|
||||
} from '../services/product-category.service';
|
||||
|
||||
const router = Router();
|
||||
const categoryService = new ProductCategoryService();
|
||||
|
||||
/**
|
||||
* GET /api/v1/products/categories
|
||||
* Lista todas las categorias del tenant
|
||||
*/
|
||||
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { parentId, isActive, search } = req.query;
|
||||
|
||||
const categories = await categoryService.findAll({
|
||||
tenantId,
|
||||
parentId: parentId === 'null' ? null : (parentId as string),
|
||||
isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined,
|
||||
search: search as string,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: categories,
|
||||
count: categories.length,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/products/categories/tree
|
||||
* Obtiene el arbol jerarquico de categorias
|
||||
*/
|
||||
router.get('/tree', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const tree = await categoryService.getHierarchyTree(tenantId);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: tree,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/products/categories/roots
|
||||
* Obtiene las categorias raiz (sin padre)
|
||||
*/
|
||||
router.get('/roots', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const categories = await categoryService.findRootCategories(tenantId);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: categories,
|
||||
count: categories.length,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/products/categories/:id
|
||||
* Obtiene una categoria por ID
|
||||
*/
|
||||
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const category = await categoryService.findById(req.params.id, tenantId);
|
||||
if (!category) {
|
||||
return res.status(404).json({ error: 'Categoria no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: category });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/products/categories/:id/children
|
||||
* Obtiene las subcategorias de una categoria
|
||||
*/
|
||||
router.get('/:id/children', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const children = await categoryService.findChildren(req.params.id, tenantId);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: children,
|
||||
count: children.length,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/products/categories/:id/products/count
|
||||
* Cuenta los productos en una categoria
|
||||
*/
|
||||
router.get('/:id/products/count', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const count = await categoryService.countProducts(req.params.id, tenantId);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: { count },
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/products/categories
|
||||
* Crea una nueva categoria
|
||||
*/
|
||||
router.post('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const data: CreateProductCategoryDto = {
|
||||
...req.body,
|
||||
tenantId,
|
||||
};
|
||||
|
||||
if (!data.code || !data.name) {
|
||||
return res.status(400).json({
|
||||
error: 'code y name son requeridos'
|
||||
});
|
||||
}
|
||||
|
||||
const existing = await categoryService.findByCode(data.code, tenantId);
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: 'Ya existe una categoria con ese codigo' });
|
||||
}
|
||||
|
||||
const category = await categoryService.create(data);
|
||||
return res.status(201).json({ success: true, data: category });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/products/categories/:id
|
||||
* Actualiza una categoria
|
||||
*/
|
||||
router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const data: UpdateProductCategoryDto = req.body;
|
||||
|
||||
if (data.code) {
|
||||
const existing = await categoryService.findByCode(data.code, tenantId);
|
||||
if (existing && existing.id !== req.params.id) {
|
||||
return res.status(409).json({ error: 'Ya existe una categoria con ese codigo' });
|
||||
}
|
||||
}
|
||||
|
||||
const category = await categoryService.update(req.params.id, tenantId, data);
|
||||
if (!category) {
|
||||
return res.status(404).json({ error: 'Categoria no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: category });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/products/categories/:id
|
||||
* Elimina una categoria (soft delete)
|
||||
*/
|
||||
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const productCount = await categoryService.countProducts(req.params.id, tenantId);
|
||||
if (productCount > 0) {
|
||||
return res.status(409).json({
|
||||
error: `No se puede eliminar la categoria porque tiene ${productCount} productos asociados`
|
||||
});
|
||||
}
|
||||
|
||||
const deleted = await categoryService.delete(req.params.id, tenantId);
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Categoria no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, message: 'Categoria eliminada' });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
331
src/modules/products/controllers/product-price.controller.ts
Normal file
331
src/modules/products/controllers/product-price.controller.ts
Normal file
@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Product Price Controller
|
||||
* API endpoints para gestion de precios de productos
|
||||
*
|
||||
* @module Products
|
||||
* @prefix /api/v1/products/:productId/prices
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import {
|
||||
ProductPriceService,
|
||||
CreateProductPriceDto,
|
||||
UpdateProductPriceDto
|
||||
} from '../services/product-price.service';
|
||||
import { ProductService } from '../services/product.service';
|
||||
|
||||
const router = Router({ mergeParams: true });
|
||||
const priceService = new ProductPriceService();
|
||||
const productService = new ProductService();
|
||||
|
||||
/**
|
||||
* GET /api/v1/products/:productId/prices
|
||||
* Lista todos los precios de un producto
|
||||
*/
|
||||
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { productId } = req.params;
|
||||
const { priceType, isActive, activeOnly } = req.query;
|
||||
|
||||
const product = await productService.findById(productId, tenantId);
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Producto no encontrado' });
|
||||
}
|
||||
|
||||
let prices;
|
||||
if (activeOnly === 'true') {
|
||||
prices = await priceService.findActivePricesByProduct(productId);
|
||||
} else {
|
||||
prices = await priceService.findAll({
|
||||
productId,
|
||||
priceType: priceType as 'standard' | 'wholesale' | 'retail' | 'promo',
|
||||
isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: prices,
|
||||
count: prices.length,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/products/:productId/prices/best
|
||||
* Obtiene el mejor precio para una cantidad dada
|
||||
*/
|
||||
router.get('/best', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { productId } = req.params;
|
||||
const { quantity } = req.query;
|
||||
|
||||
const product = await productService.findById(productId, tenantId);
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Producto no encontrado' });
|
||||
}
|
||||
|
||||
const price = await priceService.findBestPrice(
|
||||
productId,
|
||||
quantity ? parseInt(quantity as string, 10) : 1
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: price,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/products/:productId/prices/active
|
||||
* Obtiene el precio activo por tipo
|
||||
*/
|
||||
router.get('/active', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { productId } = req.params;
|
||||
const { priceType, quantity } = req.query;
|
||||
|
||||
const product = await productService.findById(productId, tenantId);
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Producto no encontrado' });
|
||||
}
|
||||
|
||||
const price = await priceService.findActivePrice(
|
||||
productId,
|
||||
(priceType as 'standard' | 'wholesale' | 'retail' | 'promo') || 'standard',
|
||||
quantity ? parseInt(quantity as string, 10) : 1
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: price,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/products/:productId/prices/:id
|
||||
* Obtiene un precio por ID
|
||||
*/
|
||||
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const price = await priceService.findById(req.params.id);
|
||||
if (!price || price.productId !== req.params.productId) {
|
||||
return res.status(404).json({ error: 'Precio no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: price });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/products/:productId/prices
|
||||
* Crea un nuevo precio para un producto
|
||||
*/
|
||||
router.post('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { productId } = req.params;
|
||||
|
||||
const product = await productService.findById(productId, tenantId);
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Producto no encontrado' });
|
||||
}
|
||||
|
||||
const data: CreateProductPriceDto = {
|
||||
...req.body,
|
||||
productId,
|
||||
};
|
||||
|
||||
if (data.price === undefined || data.price === null) {
|
||||
return res.status(400).json({
|
||||
error: 'price es requerido'
|
||||
});
|
||||
}
|
||||
|
||||
const price = await priceService.create(data);
|
||||
return res.status(201).json({ success: true, data: price });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/products/:productId/prices/standard
|
||||
* Establece el precio estandar de un producto
|
||||
*/
|
||||
router.post('/standard', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { productId } = req.params;
|
||||
const { price, currency } = req.body;
|
||||
|
||||
const product = await productService.findById(productId, tenantId);
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Producto no encontrado' });
|
||||
}
|
||||
|
||||
if (price === undefined || price === null) {
|
||||
return res.status(400).json({
|
||||
error: 'price es requerido'
|
||||
});
|
||||
}
|
||||
|
||||
const productPrice = await priceService.setStandardPrice(productId, price, currency);
|
||||
return res.json({ success: true, data: productPrice });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/products/:productId/prices/promo
|
||||
* Crea un precio promocional para un producto
|
||||
*/
|
||||
router.post('/promo', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { productId } = req.params;
|
||||
const { price, validFrom, validTo, priceListName } = req.body;
|
||||
|
||||
const product = await productService.findById(productId, tenantId);
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Producto no encontrado' });
|
||||
}
|
||||
|
||||
if (price === undefined || !validFrom || !validTo) {
|
||||
return res.status(400).json({
|
||||
error: 'price, validFrom y validTo son requeridos'
|
||||
});
|
||||
}
|
||||
|
||||
const productPrice = await priceService.createPromoPrice(
|
||||
productId,
|
||||
price,
|
||||
new Date(validFrom),
|
||||
new Date(validTo),
|
||||
priceListName
|
||||
);
|
||||
|
||||
return res.status(201).json({ success: true, data: productPrice });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/products/:productId/prices/:id
|
||||
* Actualiza un precio
|
||||
*/
|
||||
router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const data: UpdateProductPriceDto = req.body;
|
||||
const price = await priceService.update(req.params.id, data);
|
||||
|
||||
if (!price || price.productId !== req.params.productId) {
|
||||
return res.status(404).json({ error: 'Precio no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: price });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/products/:productId/prices/:id/deactivate
|
||||
* Desactiva un precio
|
||||
*/
|
||||
router.patch('/:id/deactivate', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const price = await priceService.deactivate(req.params.id);
|
||||
|
||||
if (!price || price.productId !== req.params.productId) {
|
||||
return res.status(404).json({ error: 'Precio no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: price });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/products/:productId/prices/:id
|
||||
* Elimina un precio
|
||||
*/
|
||||
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const price = await priceService.findById(req.params.id);
|
||||
if (!price || price.productId !== req.params.productId) {
|
||||
return res.status(404).json({ error: 'Precio no encontrado' });
|
||||
}
|
||||
|
||||
const deleted = await priceService.delete(req.params.id);
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Precio no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, message: 'Precio eliminado' });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
331
src/modules/products/controllers/product-supplier.controller.ts
Normal file
331
src/modules/products/controllers/product-supplier.controller.ts
Normal file
@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Product Supplier Controller
|
||||
* API endpoints para gestion de proveedores de productos
|
||||
*
|
||||
* @module Products
|
||||
* @prefix /api/v1/products/:productId/suppliers
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import {
|
||||
ProductSupplierService,
|
||||
CreateProductSupplierDto,
|
||||
UpdateProductSupplierDto
|
||||
} from '../services/product-supplier.service';
|
||||
import { ProductService } from '../services/product.service';
|
||||
|
||||
const router = Router({ mergeParams: true });
|
||||
const supplierService = new ProductSupplierService();
|
||||
const productService = new ProductService();
|
||||
|
||||
/**
|
||||
* GET /api/v1/products/:productId/suppliers
|
||||
* Lista todos los proveedores de un producto
|
||||
*/
|
||||
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { productId } = req.params;
|
||||
const { isPreferred, isActive } = req.query;
|
||||
|
||||
const product = await productService.findById(productId, tenantId);
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Producto no encontrado' });
|
||||
}
|
||||
|
||||
const suppliers = await supplierService.findAll({
|
||||
productId,
|
||||
isPreferred: isPreferred === 'true' ? true : isPreferred === 'false' ? false : undefined,
|
||||
isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: suppliers,
|
||||
count: suppliers.length,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/products/:productId/suppliers/preferred
|
||||
* Obtiene el proveedor preferido de un producto
|
||||
*/
|
||||
router.get('/preferred', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { productId } = req.params;
|
||||
|
||||
const product = await productService.findById(productId, tenantId);
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Producto no encontrado' });
|
||||
}
|
||||
|
||||
const supplier = await supplierService.findPreferred(productId);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: supplier,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/products/:productId/suppliers/cheapest
|
||||
* Obtiene el proveedor mas barato de un producto
|
||||
*/
|
||||
router.get('/cheapest', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { productId } = req.params;
|
||||
|
||||
const product = await productService.findById(productId, tenantId);
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Producto no encontrado' });
|
||||
}
|
||||
|
||||
const supplier = await supplierService.findCheapest(productId);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: supplier,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/products/:productId/suppliers/stats
|
||||
* Obtiene estadisticas de precios de proveedores
|
||||
*/
|
||||
router.get('/stats', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { productId } = req.params;
|
||||
|
||||
const product = await productService.findById(productId, tenantId);
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Producto no encontrado' });
|
||||
}
|
||||
|
||||
const [count, avgPrice, minPrice] = await Promise.all([
|
||||
supplierService.countByProduct(productId),
|
||||
supplierService.getAveragePurchasePrice(productId),
|
||||
supplierService.getLowestPurchasePrice(productId),
|
||||
]);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
suppliersCount: count,
|
||||
averagePurchasePrice: avgPrice,
|
||||
lowestPurchasePrice: minPrice,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/products/:productId/suppliers/:id
|
||||
* Obtiene un proveedor por ID
|
||||
*/
|
||||
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const supplier = await supplierService.findById(req.params.id);
|
||||
if (!supplier || supplier.productId !== req.params.productId) {
|
||||
return res.status(404).json({ error: 'Proveedor no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: supplier });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/products/:productId/suppliers
|
||||
* Asocia un proveedor a un producto
|
||||
*/
|
||||
router.post('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { productId } = req.params;
|
||||
|
||||
const product = await productService.findById(productId, tenantId);
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Producto no encontrado' });
|
||||
}
|
||||
|
||||
const data: CreateProductSupplierDto = {
|
||||
...req.body,
|
||||
productId,
|
||||
};
|
||||
|
||||
if (!data.supplierId) {
|
||||
return res.status(400).json({
|
||||
error: 'supplierId es requerido'
|
||||
});
|
||||
}
|
||||
|
||||
const existing = await supplierService.findByProductAndSupplier(productId, data.supplierId);
|
||||
if (existing) {
|
||||
return res.status(409).json({
|
||||
error: 'Este proveedor ya esta asociado a este producto'
|
||||
});
|
||||
}
|
||||
|
||||
const supplier = await supplierService.create(data);
|
||||
return res.status(201).json({ success: true, data: supplier });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/products/:productId/suppliers/:id
|
||||
* Actualiza la relacion proveedor-producto
|
||||
*/
|
||||
router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const data: UpdateProductSupplierDto = req.body;
|
||||
const supplier = await supplierService.update(req.params.id, data);
|
||||
|
||||
if (!supplier || supplier.productId !== req.params.productId) {
|
||||
return res.status(404).json({ error: 'Proveedor no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: supplier });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/products/:productId/suppliers/:id/set-preferred
|
||||
* Establece un proveedor como preferido
|
||||
*/
|
||||
router.patch('/:id/set-preferred', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const existing = await supplierService.findById(req.params.id);
|
||||
if (!existing || existing.productId !== req.params.productId) {
|
||||
return res.status(404).json({ error: 'Proveedor no encontrado' });
|
||||
}
|
||||
|
||||
const supplier = await supplierService.setPreferred(req.params.id);
|
||||
if (!supplier) {
|
||||
return res.status(404).json({ error: 'Proveedor no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: supplier });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/products/:productId/suppliers/:id/update-price
|
||||
* Actualiza el precio de compra
|
||||
*/
|
||||
router.patch('/:id/update-price', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { purchasePrice, currency } = req.body;
|
||||
if (purchasePrice === undefined) {
|
||||
return res.status(400).json({ error: 'purchasePrice es requerido' });
|
||||
}
|
||||
|
||||
const existing = await supplierService.findById(req.params.id);
|
||||
if (!existing || existing.productId !== req.params.productId) {
|
||||
return res.status(404).json({ error: 'Proveedor no encontrado' });
|
||||
}
|
||||
|
||||
const supplier = await supplierService.updatePurchasePrice(
|
||||
req.params.id,
|
||||
purchasePrice,
|
||||
currency
|
||||
);
|
||||
|
||||
if (!supplier) {
|
||||
return res.status(404).json({ error: 'Proveedor no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: supplier });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/products/:productId/suppliers/:id
|
||||
* Elimina la relacion proveedor-producto
|
||||
*/
|
||||
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const existing = await supplierService.findById(req.params.id);
|
||||
if (!existing || existing.productId !== req.params.productId) {
|
||||
return res.status(404).json({ error: 'Proveedor no encontrado' });
|
||||
}
|
||||
|
||||
const deleted = await supplierService.delete(req.params.id);
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Proveedor no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, message: 'Proveedor desvinculado del producto' });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
367
src/modules/products/controllers/product-variant.controller.ts
Normal file
367
src/modules/products/controllers/product-variant.controller.ts
Normal file
@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Product Variant Controller
|
||||
* API endpoints para gestion de variantes de productos
|
||||
*
|
||||
* @module Products
|
||||
* @prefix /api/v1/products/:productId/variants
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import {
|
||||
ProductVariantService,
|
||||
CreateProductVariantDto,
|
||||
UpdateProductVariantDto
|
||||
} from '../services/product-variant.service';
|
||||
import { ProductService } from '../services/product.service';
|
||||
|
||||
const router = Router({ mergeParams: true });
|
||||
const variantService = new ProductVariantService();
|
||||
const productService = new ProductService();
|
||||
|
||||
/**
|
||||
* GET /api/v1/products/:productId/variants
|
||||
* Lista todas las variantes de un producto
|
||||
*/
|
||||
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { productId } = req.params;
|
||||
const { isActive, search } = req.query;
|
||||
|
||||
const product = await productService.findById(productId, tenantId);
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Producto no encontrado' });
|
||||
}
|
||||
|
||||
const variants = await variantService.findAll({
|
||||
tenantId,
|
||||
productId,
|
||||
isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined,
|
||||
search: search as string,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: variants,
|
||||
count: variants.length,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/products/:productId/variants/total-stock
|
||||
* Obtiene el stock total de todas las variantes
|
||||
*/
|
||||
router.get('/total-stock', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { productId } = req.params;
|
||||
|
||||
const product = await productService.findById(productId, tenantId);
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Producto no encontrado' });
|
||||
}
|
||||
|
||||
const totalStock = await variantService.getTotalStock(productId, tenantId);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: { totalStock },
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/products/:productId/variants/:id
|
||||
* Obtiene una variante por ID
|
||||
*/
|
||||
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const variant = await variantService.findById(req.params.id, tenantId);
|
||||
if (!variant || variant.productId !== req.params.productId) {
|
||||
return res.status(404).json({ error: 'Variante no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: variant });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/products/:productId/variants
|
||||
* Crea una nueva variante
|
||||
*/
|
||||
router.post('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { productId } = req.params;
|
||||
|
||||
const product = await productService.findById(productId, tenantId);
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Producto no encontrado' });
|
||||
}
|
||||
|
||||
const data: CreateProductVariantDto = {
|
||||
...req.body,
|
||||
productId,
|
||||
tenantId,
|
||||
createdBy: (req as any).user?.id,
|
||||
};
|
||||
|
||||
if (!data.sku || !data.name) {
|
||||
return res.status(400).json({
|
||||
error: 'sku y name son requeridos'
|
||||
});
|
||||
}
|
||||
|
||||
const existingBySku = await variantService.findBySku(data.sku, tenantId);
|
||||
if (existingBySku) {
|
||||
return res.status(409).json({ error: 'Ya existe una variante con ese SKU' });
|
||||
}
|
||||
|
||||
if (data.barcode) {
|
||||
const existingByBarcode = await variantService.findByBarcode(data.barcode, tenantId);
|
||||
if (existingByBarcode) {
|
||||
return res.status(409).json({ error: 'Ya existe una variante con ese codigo de barras' });
|
||||
}
|
||||
}
|
||||
|
||||
const variant = await variantService.create(data);
|
||||
return res.status(201).json({ success: true, data: variant });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/products/:productId/variants/bulk
|
||||
* Crea multiples variantes en lote
|
||||
*/
|
||||
router.post('/bulk', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { productId } = req.params;
|
||||
const { variants } = req.body;
|
||||
|
||||
const product = await productService.findById(productId, tenantId);
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Producto no encontrado' });
|
||||
}
|
||||
|
||||
if (!variants || !Array.isArray(variants) || variants.length === 0) {
|
||||
return res.status(400).json({ error: 'variants array is required' });
|
||||
}
|
||||
|
||||
const variantsData: CreateProductVariantDto[] = variants.map((v: any) => ({
|
||||
...v,
|
||||
productId,
|
||||
tenantId,
|
||||
createdBy: (req as any).user?.id,
|
||||
}));
|
||||
|
||||
const createdVariants = await variantService.bulkCreate(variantsData);
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: createdVariants,
|
||||
count: createdVariants.length,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/products/:productId/variants/:id
|
||||
* Actualiza una variante
|
||||
*/
|
||||
router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const data: UpdateProductVariantDto = {
|
||||
...req.body,
|
||||
updatedBy: (req as any).user?.id,
|
||||
};
|
||||
|
||||
if (data.sku) {
|
||||
const existing = await variantService.findBySku(data.sku, tenantId);
|
||||
if (existing && existing.id !== req.params.id) {
|
||||
return res.status(409).json({ error: 'Ya existe una variante con ese SKU' });
|
||||
}
|
||||
}
|
||||
|
||||
if (data.barcode) {
|
||||
const existing = await variantService.findByBarcode(data.barcode, tenantId);
|
||||
if (existing && existing.id !== req.params.id) {
|
||||
return res.status(409).json({ error: 'Ya existe una variante con ese codigo de barras' });
|
||||
}
|
||||
}
|
||||
|
||||
const variant = await variantService.update(req.params.id, tenantId, data);
|
||||
if (!variant || variant.productId !== req.params.productId) {
|
||||
return res.status(404).json({ error: 'Variante no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: variant });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/products/:productId/variants/:id/stock
|
||||
* Actualiza el stock de una variante
|
||||
*/
|
||||
router.patch('/:id/stock', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { quantity, operation } = req.body;
|
||||
|
||||
if (quantity === undefined) {
|
||||
return res.status(400).json({ error: 'quantity es requerido' });
|
||||
}
|
||||
|
||||
const existing = await variantService.findById(req.params.id, tenantId);
|
||||
if (!existing || existing.productId !== req.params.productId) {
|
||||
return res.status(404).json({ error: 'Variante no encontrada' });
|
||||
}
|
||||
|
||||
const variant = await variantService.updateStock(
|
||||
req.params.id,
|
||||
tenantId,
|
||||
quantity,
|
||||
operation || 'set'
|
||||
);
|
||||
|
||||
if (!variant) {
|
||||
return res.status(404).json({ error: 'Variante no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: variant });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/products/:productId/variants/:id/price-extra
|
||||
* Actualiza el precio extra de una variante
|
||||
*/
|
||||
router.patch('/:id/price-extra', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { priceExtra } = req.body;
|
||||
|
||||
if (priceExtra === undefined) {
|
||||
return res.status(400).json({ error: 'priceExtra es requerido' });
|
||||
}
|
||||
|
||||
const existing = await variantService.findById(req.params.id, tenantId);
|
||||
if (!existing || existing.productId !== req.params.productId) {
|
||||
return res.status(404).json({ error: 'Variante no encontrada' });
|
||||
}
|
||||
|
||||
const variant = await variantService.updatePriceExtra(req.params.id, tenantId, priceExtra);
|
||||
if (!variant) {
|
||||
return res.status(404).json({ error: 'Variante no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: variant });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/products/:productId/variants/:id/toggle-active
|
||||
* Activa/desactiva una variante
|
||||
*/
|
||||
router.patch('/:id/toggle-active', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const existing = await variantService.findById(req.params.id, tenantId);
|
||||
if (!existing || existing.productId !== req.params.productId) {
|
||||
return res.status(404).json({ error: 'Variante no encontrada' });
|
||||
}
|
||||
|
||||
const variant = await variantService.toggleActive(req.params.id, tenantId);
|
||||
if (!variant) {
|
||||
return res.status(404).json({ error: 'Variante no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: variant });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/products/:productId/variants/:id
|
||||
* Elimina una variante
|
||||
*/
|
||||
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const existing = await variantService.findById(req.params.id, tenantId);
|
||||
if (!existing || existing.productId !== req.params.productId) {
|
||||
return res.status(404).json({ error: 'Variante no encontrada' });
|
||||
}
|
||||
|
||||
const deleted = await variantService.delete(req.params.id, tenantId);
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Variante no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, message: 'Variante eliminada' });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
391
src/modules/products/controllers/product.controller.ts
Normal file
391
src/modules/products/controllers/product.controller.ts
Normal file
@ -0,0 +1,391 @@
|
||||
/**
|
||||
* Product Controller
|
||||
* API endpoints para gestion de productos comerciales
|
||||
*
|
||||
* @module Products
|
||||
* @prefix /api/v1/products
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import {
|
||||
ProductService,
|
||||
CreateProductDto,
|
||||
UpdateProductDto
|
||||
} from '../services/product.service';
|
||||
|
||||
const router = Router();
|
||||
const productService = new ProductService();
|
||||
|
||||
/**
|
||||
* GET /api/v1/products
|
||||
* Lista todos los productos del tenant (paginado)
|
||||
*/
|
||||
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { categoryId, productType, isActive, isSellable, isPurchasable, search, tags, limit, offset } = req.query;
|
||||
|
||||
const result = await productService.findAll({
|
||||
tenantId,
|
||||
categoryId: categoryId as string,
|
||||
productType: productType as 'product' | 'service' | 'consumable' | 'kit',
|
||||
isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined,
|
||||
isSellable: isSellable === 'true' ? true : isSellable === 'false' ? false : undefined,
|
||||
isPurchasable: isPurchasable === 'true' ? true : isPurchasable === 'false' ? false : undefined,
|
||||
search: search as string,
|
||||
tags: tags ? (tags as string).split(',') : undefined,
|
||||
limit: limit ? parseInt(limit as string, 10) : undefined,
|
||||
offset: offset ? parseInt(offset as string, 10) : undefined,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
total: result.total,
|
||||
limit: result.limit,
|
||||
offset: result.offset,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/products/search
|
||||
* Busqueda rapida de productos
|
||||
*/
|
||||
router.get('/search', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { q, limit } = req.query;
|
||||
if (!q) {
|
||||
return res.status(400).json({ error: 'Query parameter q is required' });
|
||||
}
|
||||
|
||||
const products = await productService.searchProducts(
|
||||
tenantId,
|
||||
q as string,
|
||||
limit ? parseInt(limit as string, 10) : 20
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: products,
|
||||
count: products.length,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/products/low-stock
|
||||
* Obtiene productos con stock bajo
|
||||
*/
|
||||
router.get('/low-stock', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const products = await productService.getLowStockProducts(tenantId);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: products,
|
||||
count: products.length,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/products/:id
|
||||
* Obtiene un producto por ID
|
||||
*/
|
||||
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const product = await productService.findById(req.params.id, tenantId);
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Producto no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: product });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/products/sku/:sku
|
||||
* Obtiene un producto por SKU
|
||||
*/
|
||||
router.get('/sku/:sku', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const product = await productService.findBySku(req.params.sku, tenantId);
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Producto no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: product });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/products/barcode/:barcode
|
||||
* Obtiene un producto por codigo de barras
|
||||
*/
|
||||
router.get('/barcode/:barcode', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const product = await productService.findByBarcode(req.params.barcode, tenantId);
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Producto no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: product });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/products
|
||||
* Crea un nuevo producto
|
||||
*/
|
||||
router.post('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const data: CreateProductDto = {
|
||||
...req.body,
|
||||
tenantId,
|
||||
createdBy: (req as any).user?.id,
|
||||
};
|
||||
|
||||
if (!data.sku || !data.name) {
|
||||
return res.status(400).json({
|
||||
error: 'sku y name son requeridos'
|
||||
});
|
||||
}
|
||||
|
||||
const existingBySku = await productService.findBySku(data.sku, tenantId);
|
||||
if (existingBySku) {
|
||||
return res.status(409).json({ error: 'Ya existe un producto con ese SKU' });
|
||||
}
|
||||
|
||||
if (data.barcode) {
|
||||
const existingByBarcode = await productService.findByBarcode(data.barcode, tenantId);
|
||||
if (existingByBarcode) {
|
||||
return res.status(409).json({ error: 'Ya existe un producto con ese codigo de barras' });
|
||||
}
|
||||
}
|
||||
|
||||
const product = await productService.create(data);
|
||||
return res.status(201).json({ success: true, data: product });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/products/:id
|
||||
* Actualiza un producto
|
||||
*/
|
||||
router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const data: UpdateProductDto = {
|
||||
...req.body,
|
||||
updatedBy: (req as any).user?.id,
|
||||
};
|
||||
|
||||
if (data.sku) {
|
||||
const existing = await productService.findBySku(data.sku, tenantId);
|
||||
if (existing && existing.id !== req.params.id) {
|
||||
return res.status(409).json({ error: 'Ya existe un producto con ese SKU' });
|
||||
}
|
||||
}
|
||||
|
||||
if (data.barcode) {
|
||||
const existing = await productService.findByBarcode(data.barcode, tenantId);
|
||||
if (existing && existing.id !== req.params.id) {
|
||||
return res.status(409).json({ error: 'Ya existe un producto con ese codigo de barras' });
|
||||
}
|
||||
}
|
||||
|
||||
const product = await productService.update(req.params.id, tenantId, data);
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Producto no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: product });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/products/:id/prices
|
||||
* Actualiza los precios de un producto
|
||||
*/
|
||||
router.patch('/:id/prices', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { salePrice, costPrice, minSalePrice } = req.body;
|
||||
|
||||
const product = await productService.updatePrices(req.params.id, tenantId, {
|
||||
salePrice,
|
||||
costPrice,
|
||||
minSalePrice,
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Producto no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: product });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/products/:id/stock-settings
|
||||
* Actualiza la configuracion de stock de un producto
|
||||
*/
|
||||
router.patch('/:id/stock-settings', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { minStock, maxStock, reorderPoint, reorderQuantity } = req.body;
|
||||
|
||||
const product = await productService.updateStock(req.params.id, tenantId, {
|
||||
minStock,
|
||||
maxStock,
|
||||
reorderPoint,
|
||||
reorderQuantity,
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Producto no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: product });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/products/:id/toggle-active
|
||||
* Activa/desactiva un producto
|
||||
*/
|
||||
router.patch('/:id/toggle-active', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const product = await productService.toggleActive(req.params.id, tenantId);
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Producto no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: product });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/products/bulk-update-prices
|
||||
* Actualiza precios en lote
|
||||
*/
|
||||
router.post('/bulk-update-prices', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { updates } = req.body;
|
||||
if (!updates || !Array.isArray(updates)) {
|
||||
return res.status(400).json({ error: 'updates array is required' });
|
||||
}
|
||||
|
||||
const updated = await productService.bulkUpdatePrices(tenantId, updates);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: { updated },
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/products/:id
|
||||
* Elimina un producto (soft delete)
|
||||
*/
|
||||
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const deleted = await productService.delete(req.params.id, tenantId);
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Producto no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, message: 'Producto eliminado' });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
10
src/modules/products/index.ts
Normal file
10
src/modules/products/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Products Module
|
||||
* Modulo de productos comerciales para ERP Construccion
|
||||
*
|
||||
* @module Products
|
||||
*/
|
||||
|
||||
export * from './entities';
|
||||
export * from './services';
|
||||
export * from './controllers';
|
||||
53
src/modules/products/services/index.ts
Normal file
53
src/modules/products/services/index.ts
Normal file
@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Products Services Index
|
||||
* @module Products
|
||||
*/
|
||||
|
||||
// Export ServiceContext only once from the first service
|
||||
export { ServiceContext } from './product-category.service';
|
||||
|
||||
// Export services and DTOs (excluding duplicate ServiceContext)
|
||||
export {
|
||||
ProductCategoryService,
|
||||
CreateProductCategoryDto,
|
||||
UpdateProductCategoryDto,
|
||||
ProductCategoryFilters,
|
||||
} from './product-category.service';
|
||||
|
||||
export {
|
||||
ProductService,
|
||||
CreateProductDto,
|
||||
UpdateProductDto,
|
||||
ProductFilters,
|
||||
PaginatedResult,
|
||||
} from './product.service';
|
||||
|
||||
export {
|
||||
ProductPriceService,
|
||||
CreateProductPriceDto,
|
||||
UpdateProductPriceDto,
|
||||
ProductPriceFilters,
|
||||
} from './product-price.service';
|
||||
|
||||
export {
|
||||
ProductSupplierService,
|
||||
CreateProductSupplierDto,
|
||||
UpdateProductSupplierDto,
|
||||
ProductSupplierFilters,
|
||||
} from './product-supplier.service';
|
||||
|
||||
export {
|
||||
ProductAttributeService,
|
||||
CreateProductAttributeDto,
|
||||
UpdateProductAttributeDto,
|
||||
CreateAttributeValueDto,
|
||||
UpdateAttributeValueDto,
|
||||
ProductAttributeFilters,
|
||||
} from './product-attribute.service';
|
||||
|
||||
export {
|
||||
ProductVariantService,
|
||||
CreateProductVariantDto,
|
||||
UpdateProductVariantDto,
|
||||
ProductVariantFilters,
|
||||
} from './product-variant.service';
|
||||
235
src/modules/products/services/product-attribute.service.ts
Normal file
235
src/modules/products/services/product-attribute.service.ts
Normal file
@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Product Attribute Service
|
||||
* Servicio para gestion de atributos de productos (color, talla, etc.)
|
||||
*
|
||||
* @module Products
|
||||
*/
|
||||
|
||||
import { Repository, FindOptionsWhere } from 'typeorm';
|
||||
import { AppDataSource } from '../../../shared/database/typeorm.config';
|
||||
import { ProductAttribute } from '../entities/product-attribute.entity';
|
||||
import { ProductAttributeValue } from '../entities/product-attribute-value.entity';
|
||||
|
||||
export interface CreateProductAttributeDto {
|
||||
tenantId: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
displayType?: 'radio' | 'select' | 'color' | 'pills';
|
||||
sortOrder?: number;
|
||||
createdBy?: string;
|
||||
}
|
||||
|
||||
export interface UpdateProductAttributeDto {
|
||||
code?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
displayType?: 'radio' | 'select' | 'color' | 'pills';
|
||||
sortOrder?: number;
|
||||
isActive?: boolean;
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface CreateAttributeValueDto {
|
||||
attributeId: string;
|
||||
code?: string;
|
||||
name: string;
|
||||
htmlColor?: string;
|
||||
imageUrl?: string;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface UpdateAttributeValueDto {
|
||||
code?: string;
|
||||
name?: string;
|
||||
htmlColor?: string;
|
||||
imageUrl?: string;
|
||||
sortOrder?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface ProductAttributeFilters {
|
||||
tenantId: string;
|
||||
isActive?: boolean;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface ServiceContext {
|
||||
tenantId: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export class ProductAttributeService {
|
||||
private attributeRepository: Repository<ProductAttribute>;
|
||||
private valueRepository: Repository<ProductAttributeValue>;
|
||||
|
||||
constructor() {
|
||||
this.attributeRepository = AppDataSource.getRepository(ProductAttribute);
|
||||
this.valueRepository = AppDataSource.getRepository(ProductAttributeValue);
|
||||
}
|
||||
|
||||
async findAll(filters: ProductAttributeFilters): Promise<ProductAttribute[]> {
|
||||
const where: FindOptionsWhere<ProductAttribute> = {
|
||||
tenantId: filters.tenantId,
|
||||
};
|
||||
|
||||
if (filters.isActive !== undefined) {
|
||||
where.isActive = filters.isActive;
|
||||
}
|
||||
|
||||
const queryBuilder = this.attributeRepository.createQueryBuilder('attr')
|
||||
.where(where)
|
||||
.leftJoinAndSelect('attr.values', 'values', 'values.isActive = true')
|
||||
.orderBy('attr.sortOrder', 'ASC')
|
||||
.addOrderBy('attr.name', 'ASC')
|
||||
.addOrderBy('values.sortOrder', 'ASC');
|
||||
|
||||
if (filters.search) {
|
||||
queryBuilder.andWhere(
|
||||
'(attr.name ILIKE :search OR attr.code ILIKE :search)',
|
||||
{ search: `%${filters.search}%` }
|
||||
);
|
||||
}
|
||||
|
||||
return queryBuilder.getMany();
|
||||
}
|
||||
|
||||
async findById(id: string, tenantId: string): Promise<ProductAttribute | null> {
|
||||
return this.attributeRepository.findOne({
|
||||
where: { id, tenantId },
|
||||
relations: ['values'],
|
||||
});
|
||||
}
|
||||
|
||||
async findByCode(code: string, tenantId: string): Promise<ProductAttribute | null> {
|
||||
return this.attributeRepository.findOne({
|
||||
where: { code, tenantId },
|
||||
relations: ['values'],
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: CreateProductAttributeDto): Promise<ProductAttribute> {
|
||||
const attribute = this.attributeRepository.create(data);
|
||||
return this.attributeRepository.save(attribute);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
tenantId: string,
|
||||
data: UpdateProductAttributeDto
|
||||
): Promise<ProductAttribute | null> {
|
||||
const attribute = await this.findById(id, tenantId);
|
||||
if (!attribute) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Object.assign(attribute, data);
|
||||
return this.attributeRepository.save(attribute);
|
||||
}
|
||||
|
||||
async delete(id: string, tenantId: string): Promise<boolean> {
|
||||
const attribute = await this.findById(id, tenantId);
|
||||
if (!attribute) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.valueRepository.delete({ attributeId: id });
|
||||
|
||||
const result = await this.attributeRepository.delete({ id, tenantId });
|
||||
return result.affected ? result.affected > 0 : false;
|
||||
}
|
||||
|
||||
async findValueById(id: string): Promise<ProductAttributeValue | null> {
|
||||
return this.valueRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['attribute'],
|
||||
});
|
||||
}
|
||||
|
||||
async findValuesByAttribute(attributeId: string): Promise<ProductAttributeValue[]> {
|
||||
return this.valueRepository.find({
|
||||
where: { attributeId, isActive: true },
|
||||
order: { sortOrder: 'ASC', name: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async createValue(data: CreateAttributeValueDto): Promise<ProductAttributeValue> {
|
||||
const value = this.valueRepository.create(data);
|
||||
return this.valueRepository.save(value);
|
||||
}
|
||||
|
||||
async updateValue(
|
||||
id: string,
|
||||
data: UpdateAttributeValueDto
|
||||
): Promise<ProductAttributeValue | null> {
|
||||
const value = await this.findValueById(id);
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Object.assign(value, data);
|
||||
return this.valueRepository.save(value);
|
||||
}
|
||||
|
||||
async deleteValue(id: string): Promise<boolean> {
|
||||
const result = await this.valueRepository.delete({ id });
|
||||
return result.affected ? result.affected > 0 : false;
|
||||
}
|
||||
|
||||
async addValues(
|
||||
attributeId: string,
|
||||
values: Array<{ code?: string; name: string; htmlColor?: string; sortOrder?: number }>
|
||||
): Promise<ProductAttributeValue[]> {
|
||||
const created: ProductAttributeValue[] = [];
|
||||
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const val = values[i];
|
||||
const value = this.valueRepository.create({
|
||||
attributeId,
|
||||
code: val.code,
|
||||
name: val.name,
|
||||
htmlColor: val.htmlColor,
|
||||
sortOrder: val.sortOrder ?? i,
|
||||
});
|
||||
const saved = await this.valueRepository.save(value);
|
||||
created.push(saved);
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async reorderValues(
|
||||
attributeId: string,
|
||||
valueIds: string[]
|
||||
): Promise<void> {
|
||||
for (let i = 0; i < valueIds.length; i++) {
|
||||
await this.valueRepository.update(
|
||||
{ id: valueIds[i], attributeId },
|
||||
{ sortOrder: i }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async countValues(attributeId: string): Promise<number> {
|
||||
return this.valueRepository.count({
|
||||
where: { attributeId, isActive: true },
|
||||
});
|
||||
}
|
||||
|
||||
async createWithValues(
|
||||
data: CreateProductAttributeDto,
|
||||
values: Array<{ code?: string; name: string; htmlColor?: string }>
|
||||
): Promise<ProductAttribute> {
|
||||
const attribute = await this.create(data);
|
||||
|
||||
if (values.length > 0) {
|
||||
await this.addValues(attribute.id, values);
|
||||
const refreshed = await this.findById(attribute.id, data.tenantId);
|
||||
if (refreshed) {
|
||||
return refreshed;
|
||||
}
|
||||
}
|
||||
|
||||
return attribute;
|
||||
}
|
||||
}
|
||||
196
src/modules/products/services/product-category.service.ts
Normal file
196
src/modules/products/services/product-category.service.ts
Normal file
@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Product Category Service
|
||||
* Servicio para gestion de categorias de productos
|
||||
*
|
||||
* @module Products
|
||||
*/
|
||||
|
||||
import { Repository, FindOptionsWhere, IsNull } from 'typeorm';
|
||||
import { AppDataSource } from '../../../shared/database/typeorm.config';
|
||||
import { ProductCategory } from '../entities/product-category.entity';
|
||||
|
||||
export interface CreateProductCategoryDto {
|
||||
tenantId: string;
|
||||
parentId?: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
imageUrl?: string;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface UpdateProductCategoryDto {
|
||||
parentId?: string | null;
|
||||
code?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
imageUrl?: string;
|
||||
sortOrder?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface ProductCategoryFilters {
|
||||
tenantId: string;
|
||||
parentId?: string | null;
|
||||
isActive?: boolean;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface ServiceContext {
|
||||
tenantId: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export class ProductCategoryService {
|
||||
private repository: Repository<ProductCategory>;
|
||||
|
||||
constructor() {
|
||||
this.repository = AppDataSource.getRepository(ProductCategory);
|
||||
}
|
||||
|
||||
async findAll(filters: ProductCategoryFilters): Promise<ProductCategory[]> {
|
||||
const where: FindOptionsWhere<ProductCategory> = {
|
||||
tenantId: filters.tenantId,
|
||||
};
|
||||
|
||||
if (filters.parentId !== undefined) {
|
||||
where.parentId = filters.parentId === null ? IsNull() : filters.parentId;
|
||||
}
|
||||
|
||||
if (filters.isActive !== undefined) {
|
||||
where.isActive = filters.isActive;
|
||||
}
|
||||
|
||||
const queryBuilder = this.repository.createQueryBuilder('category')
|
||||
.where(where)
|
||||
.leftJoinAndSelect('category.parent', 'parent')
|
||||
.orderBy('category.sortOrder', 'ASC')
|
||||
.addOrderBy('category.name', 'ASC');
|
||||
|
||||
if (filters.search) {
|
||||
queryBuilder.andWhere(
|
||||
'(category.name ILIKE :search OR category.code ILIKE :search)',
|
||||
{ search: `%${filters.search}%` }
|
||||
);
|
||||
}
|
||||
|
||||
return queryBuilder.getMany();
|
||||
}
|
||||
|
||||
async findById(id: string, tenantId: string): Promise<ProductCategory | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id, tenantId },
|
||||
relations: ['parent'],
|
||||
});
|
||||
}
|
||||
|
||||
async findByCode(code: string, tenantId: string): Promise<ProductCategory | null> {
|
||||
return this.repository.findOne({
|
||||
where: { code, tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
async findRootCategories(tenantId: string): Promise<ProductCategory[]> {
|
||||
return this.repository.find({
|
||||
where: { tenantId, parentId: IsNull() },
|
||||
order: { sortOrder: 'ASC', name: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findChildren(parentId: string, tenantId: string): Promise<ProductCategory[]> {
|
||||
return this.repository.find({
|
||||
where: { parentId, tenantId },
|
||||
order: { sortOrder: 'ASC', name: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: CreateProductCategoryDto): Promise<ProductCategory> {
|
||||
let hierarchyPath = '';
|
||||
let hierarchyLevel = 0;
|
||||
|
||||
if (data.parentId) {
|
||||
const parent = await this.findById(data.parentId, data.tenantId);
|
||||
if (parent) {
|
||||
hierarchyPath = parent.hierarchyPath
|
||||
? `${parent.hierarchyPath}/${parent.id}`
|
||||
: parent.id;
|
||||
hierarchyLevel = parent.hierarchyLevel + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const category = this.repository.create({
|
||||
...data,
|
||||
hierarchyPath,
|
||||
hierarchyLevel,
|
||||
});
|
||||
|
||||
return this.repository.save(category);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
tenantId: string,
|
||||
data: UpdateProductCategoryDto
|
||||
): Promise<ProductCategory | null> {
|
||||
const category = await this.findById(id, tenantId);
|
||||
if (!category) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (data.parentId !== undefined && data.parentId !== category.parentId) {
|
||||
if (data.parentId === null) {
|
||||
data = { ...data, parentId: undefined } as UpdateProductCategoryDto;
|
||||
(category as any).hierarchyPath = '';
|
||||
(category as any).hierarchyLevel = 0;
|
||||
} else {
|
||||
const parent = await this.findById(data.parentId, tenantId);
|
||||
if (parent) {
|
||||
(category as any).hierarchyPath = parent.hierarchyPath
|
||||
? `${parent.hierarchyPath}/${parent.id}`
|
||||
: parent.id;
|
||||
(category as any).hierarchyLevel = parent.hierarchyLevel + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(category, data);
|
||||
return this.repository.save(category);
|
||||
}
|
||||
|
||||
async delete(id: string, tenantId: string): Promise<boolean> {
|
||||
const result = await this.repository.softDelete({ id, tenantId });
|
||||
return result.affected ? result.affected > 0 : false;
|
||||
}
|
||||
|
||||
async hardDelete(id: string, tenantId: string): Promise<boolean> {
|
||||
const result = await this.repository.delete({ id, tenantId });
|
||||
return result.affected ? result.affected > 0 : false;
|
||||
}
|
||||
|
||||
async countProducts(categoryId: string, tenantId: string): Promise<number> {
|
||||
const result = await this.repository.manager.query(
|
||||
`SELECT COUNT(*) as count FROM products.products
|
||||
WHERE category_id = $1 AND tenant_id = $2 AND deleted_at IS NULL`,
|
||||
[categoryId, tenantId]
|
||||
);
|
||||
return parseInt(result[0].count, 10);
|
||||
}
|
||||
|
||||
async getHierarchyTree(tenantId: string): Promise<ProductCategory[]> {
|
||||
const categories = await this.repository.find({
|
||||
where: { tenantId, isActive: true },
|
||||
order: { hierarchyLevel: 'ASC', sortOrder: 'ASC', name: 'ASC' },
|
||||
});
|
||||
|
||||
return this.buildTree(categories);
|
||||
}
|
||||
|
||||
private buildTree(categories: ProductCategory[], parentId: string | null = null): ProductCategory[] {
|
||||
return categories
|
||||
.filter(cat => cat.parentId === parentId)
|
||||
.map(cat => ({
|
||||
...cat,
|
||||
children: this.buildTree(categories, cat.id),
|
||||
})) as ProductCategory[];
|
||||
}
|
||||
}
|
||||
234
src/modules/products/services/product-price.service.ts
Normal file
234
src/modules/products/services/product-price.service.ts
Normal file
@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Product Price Service
|
||||
* Servicio para gestion de precios de productos (listas de precios, promociones)
|
||||
*
|
||||
* @module Products
|
||||
*/
|
||||
|
||||
import { Repository, FindOptionsWhere } from 'typeorm';
|
||||
import { AppDataSource } from '../../../shared/database/typeorm.config';
|
||||
import { ProductPrice } from '../entities/product-price.entity';
|
||||
|
||||
export interface CreateProductPriceDto {
|
||||
productId: string;
|
||||
priceType?: 'standard' | 'wholesale' | 'retail' | 'promo';
|
||||
priceListName?: string;
|
||||
price: number;
|
||||
currency?: string;
|
||||
minQuantity?: number;
|
||||
validFrom?: Date;
|
||||
validTo?: Date;
|
||||
}
|
||||
|
||||
export interface UpdateProductPriceDto {
|
||||
priceType?: 'standard' | 'wholesale' | 'retail' | 'promo';
|
||||
priceListName?: string;
|
||||
price?: number;
|
||||
currency?: string;
|
||||
minQuantity?: number;
|
||||
validFrom?: Date;
|
||||
validTo?: Date;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface ProductPriceFilters {
|
||||
productId: string;
|
||||
priceType?: 'standard' | 'wholesale' | 'retail' | 'promo';
|
||||
isActive?: boolean;
|
||||
validAt?: Date;
|
||||
}
|
||||
|
||||
export interface ServiceContext {
|
||||
tenantId: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export class ProductPriceService {
|
||||
private repository: Repository<ProductPrice>;
|
||||
|
||||
constructor() {
|
||||
this.repository = AppDataSource.getRepository(ProductPrice);
|
||||
}
|
||||
|
||||
async findAll(filters: ProductPriceFilters): Promise<ProductPrice[]> {
|
||||
const where: FindOptionsWhere<ProductPrice> = {
|
||||
productId: filters.productId,
|
||||
};
|
||||
|
||||
if (filters.priceType) {
|
||||
where.priceType = filters.priceType;
|
||||
}
|
||||
|
||||
if (filters.isActive !== undefined) {
|
||||
where.isActive = filters.isActive;
|
||||
}
|
||||
|
||||
return this.repository.find({
|
||||
where,
|
||||
relations: ['product'],
|
||||
order: { priceType: 'ASC', minQuantity: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<ProductPrice | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id },
|
||||
relations: ['product'],
|
||||
});
|
||||
}
|
||||
|
||||
async findActivePrice(
|
||||
productId: string,
|
||||
priceType: 'standard' | 'wholesale' | 'retail' | 'promo' = 'standard',
|
||||
quantity: number = 1,
|
||||
date: Date = new Date()
|
||||
): Promise<ProductPrice | null> {
|
||||
return this.repository.createQueryBuilder('price')
|
||||
.where('price.productId = :productId', { productId })
|
||||
.andWhere('price.priceType = :priceType', { priceType })
|
||||
.andWhere('price.isActive = true')
|
||||
.andWhere('price.minQuantity <= :quantity', { quantity })
|
||||
.andWhere('price.validFrom <= :date', { date })
|
||||
.andWhere('(price.validTo IS NULL OR price.validTo >= :date)', { date })
|
||||
.orderBy('price.minQuantity', 'DESC')
|
||||
.getOne();
|
||||
}
|
||||
|
||||
async findBestPrice(
|
||||
productId: string,
|
||||
quantity: number = 1,
|
||||
date: Date = new Date()
|
||||
): Promise<ProductPrice | null> {
|
||||
return this.repository.createQueryBuilder('price')
|
||||
.where('price.productId = :productId', { productId })
|
||||
.andWhere('price.isActive = true')
|
||||
.andWhere('price.minQuantity <= :quantity', { quantity })
|
||||
.andWhere('price.validFrom <= :date', { date })
|
||||
.andWhere('(price.validTo IS NULL OR price.validTo >= :date)', { date })
|
||||
.orderBy('price.price', 'ASC')
|
||||
.getOne();
|
||||
}
|
||||
|
||||
async findByProduct(productId: string): Promise<ProductPrice[]> {
|
||||
return this.repository.find({
|
||||
where: { productId },
|
||||
order: { priceType: 'ASC', minQuantity: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findActivePricesByProduct(productId: string, date: Date = new Date()): Promise<ProductPrice[]> {
|
||||
return this.repository.createQueryBuilder('price')
|
||||
.where('price.productId = :productId', { productId })
|
||||
.andWhere('price.isActive = true')
|
||||
.andWhere('price.validFrom <= :date', { date })
|
||||
.andWhere('(price.validTo IS NULL OR price.validTo >= :date)', { date })
|
||||
.orderBy('price.priceType', 'ASC')
|
||||
.addOrderBy('price.minQuantity', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async create(data: CreateProductPriceDto): Promise<ProductPrice> {
|
||||
const price = this.repository.create({
|
||||
...data,
|
||||
validFrom: data.validFrom || new Date(),
|
||||
});
|
||||
return this.repository.save(price);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
data: UpdateProductPriceDto
|
||||
): Promise<ProductPrice | null> {
|
||||
const price = await this.findById(id);
|
||||
if (!price) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Object.assign(price, data);
|
||||
return this.repository.save(price);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const result = await this.repository.delete({ id });
|
||||
return result.affected ? result.affected > 0 : false;
|
||||
}
|
||||
|
||||
async deactivate(id: string): Promise<ProductPrice | null> {
|
||||
const price = await this.findById(id);
|
||||
if (!price) {
|
||||
return null;
|
||||
}
|
||||
|
||||
price.isActive = false;
|
||||
return this.repository.save(price);
|
||||
}
|
||||
|
||||
async deactivateByProduct(productId: string, priceType?: 'standard' | 'wholesale' | 'retail' | 'promo'): Promise<number> {
|
||||
const where: FindOptionsWhere<ProductPrice> = { productId };
|
||||
if (priceType) {
|
||||
where.priceType = priceType;
|
||||
}
|
||||
|
||||
const result = await this.repository.update(where, { isActive: false });
|
||||
return result.affected || 0;
|
||||
}
|
||||
|
||||
async setStandardPrice(productId: string, price: number, currency: string = 'MXN'): Promise<ProductPrice> {
|
||||
const existing = await this.repository.findOne({
|
||||
where: { productId, priceType: 'standard', minQuantity: 1 },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
existing.price = price;
|
||||
existing.currency = currency;
|
||||
existing.isActive = true;
|
||||
return this.repository.save(existing);
|
||||
}
|
||||
|
||||
return this.create({
|
||||
productId,
|
||||
priceType: 'standard',
|
||||
price,
|
||||
currency,
|
||||
minQuantity: 1,
|
||||
});
|
||||
}
|
||||
|
||||
async createPromoPrice(
|
||||
productId: string,
|
||||
price: number,
|
||||
validFrom: Date,
|
||||
validTo: Date,
|
||||
priceListName?: string
|
||||
): Promise<ProductPrice> {
|
||||
return this.create({
|
||||
productId,
|
||||
priceType: 'promo',
|
||||
price,
|
||||
priceListName,
|
||||
validFrom,
|
||||
validTo,
|
||||
});
|
||||
}
|
||||
|
||||
async getExpiredPromotions(date: Date = new Date()): Promise<ProductPrice[]> {
|
||||
return this.repository.createQueryBuilder('price')
|
||||
.where('price.priceType = :priceType', { priceType: 'promo' })
|
||||
.andWhere('price.isActive = true')
|
||||
.andWhere('price.validTo < :date', { date })
|
||||
.leftJoinAndSelect('price.product', 'product')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async deactivateExpiredPromotions(date: Date = new Date()): Promise<number> {
|
||||
const result = await this.repository.createQueryBuilder()
|
||||
.update(ProductPrice)
|
||||
.set({ isActive: false })
|
||||
.where('priceType = :priceType', { priceType: 'promo' })
|
||||
.andWhere('isActive = true')
|
||||
.andWhere('validTo < :date', { date })
|
||||
.execute();
|
||||
|
||||
return result.affected || 0;
|
||||
}
|
||||
}
|
||||
224
src/modules/products/services/product-supplier.service.ts
Normal file
224
src/modules/products/services/product-supplier.service.ts
Normal file
@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Product Supplier Service
|
||||
* Servicio para gestion de proveedores de productos
|
||||
*
|
||||
* @module Products
|
||||
*/
|
||||
|
||||
import { Repository, FindOptionsWhere } from 'typeorm';
|
||||
import { AppDataSource } from '../../../shared/database/typeorm.config';
|
||||
import { ProductSupplier } from '../entities/product-supplier.entity';
|
||||
|
||||
export interface CreateProductSupplierDto {
|
||||
productId: string;
|
||||
supplierId: string;
|
||||
supplierSku?: string;
|
||||
supplierName?: string;
|
||||
purchasePrice?: number;
|
||||
currency?: string;
|
||||
minOrderQty?: number;
|
||||
leadTimeDays?: number;
|
||||
isPreferred?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateProductSupplierDto {
|
||||
supplierSku?: string;
|
||||
supplierName?: string;
|
||||
purchasePrice?: number;
|
||||
currency?: string;
|
||||
minOrderQty?: number;
|
||||
leadTimeDays?: number;
|
||||
isPreferred?: boolean;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface ProductSupplierFilters {
|
||||
productId?: string;
|
||||
supplierId?: string;
|
||||
isPreferred?: boolean;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface ServiceContext {
|
||||
tenantId: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export class ProductSupplierService {
|
||||
private repository: Repository<ProductSupplier>;
|
||||
|
||||
constructor() {
|
||||
this.repository = AppDataSource.getRepository(ProductSupplier);
|
||||
}
|
||||
|
||||
async findAll(filters: ProductSupplierFilters): Promise<ProductSupplier[]> {
|
||||
const where: FindOptionsWhere<ProductSupplier> = {};
|
||||
|
||||
if (filters.productId) {
|
||||
where.productId = filters.productId;
|
||||
}
|
||||
|
||||
if (filters.supplierId) {
|
||||
where.supplierId = filters.supplierId;
|
||||
}
|
||||
|
||||
if (filters.isPreferred !== undefined) {
|
||||
where.isPreferred = filters.isPreferred;
|
||||
}
|
||||
|
||||
if (filters.isActive !== undefined) {
|
||||
where.isActive = filters.isActive;
|
||||
}
|
||||
|
||||
return this.repository.find({
|
||||
where,
|
||||
relations: ['product'],
|
||||
order: { isPreferred: 'DESC', purchasePrice: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<ProductSupplier | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id },
|
||||
relations: ['product'],
|
||||
});
|
||||
}
|
||||
|
||||
async findByProduct(productId: string): Promise<ProductSupplier[]> {
|
||||
return this.repository.find({
|
||||
where: { productId, isActive: true },
|
||||
order: { isPreferred: 'DESC', purchasePrice: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findBySupplier(supplierId: string): Promise<ProductSupplier[]> {
|
||||
return this.repository.find({
|
||||
where: { supplierId, isActive: true },
|
||||
relations: ['product'],
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findPreferred(productId: string): Promise<ProductSupplier | null> {
|
||||
return this.repository.findOne({
|
||||
where: { productId, isPreferred: true, isActive: true },
|
||||
});
|
||||
}
|
||||
|
||||
async findCheapest(productId: string): Promise<ProductSupplier | null> {
|
||||
return this.repository.createQueryBuilder('ps')
|
||||
.where('ps.productId = :productId', { productId })
|
||||
.andWhere('ps.isActive = true')
|
||||
.andWhere('ps.purchasePrice IS NOT NULL')
|
||||
.orderBy('ps.purchasePrice', 'ASC')
|
||||
.getOne();
|
||||
}
|
||||
|
||||
async findByProductAndSupplier(productId: string, supplierId: string): Promise<ProductSupplier | null> {
|
||||
return this.repository.findOne({
|
||||
where: { productId, supplierId },
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: CreateProductSupplierDto): Promise<ProductSupplier> {
|
||||
if (data.isPreferred) {
|
||||
await this.clearPreferred(data.productId);
|
||||
}
|
||||
|
||||
const supplier = this.repository.create(data);
|
||||
return this.repository.save(supplier);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
data: UpdateProductSupplierDto
|
||||
): Promise<ProductSupplier | null> {
|
||||
const supplier = await this.findById(id);
|
||||
if (!supplier) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (data.isPreferred && !supplier.isPreferred) {
|
||||
await this.clearPreferred(supplier.productId);
|
||||
}
|
||||
|
||||
Object.assign(supplier, data);
|
||||
return this.repository.save(supplier);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const result = await this.repository.delete({ id });
|
||||
return result.affected ? result.affected > 0 : false;
|
||||
}
|
||||
|
||||
async setPreferred(id: string): Promise<ProductSupplier | null> {
|
||||
const supplier = await this.findById(id);
|
||||
if (!supplier) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.clearPreferred(supplier.productId);
|
||||
|
||||
supplier.isPreferred = true;
|
||||
return this.repository.save(supplier);
|
||||
}
|
||||
|
||||
async clearPreferred(productId: string): Promise<void> {
|
||||
await this.repository.update(
|
||||
{ productId, isPreferred: true },
|
||||
{ isPreferred: false }
|
||||
);
|
||||
}
|
||||
|
||||
async updatePurchasePrice(
|
||||
id: string,
|
||||
purchasePrice: number,
|
||||
currency?: string
|
||||
): Promise<ProductSupplier | null> {
|
||||
const supplier = await this.findById(id);
|
||||
if (!supplier) {
|
||||
return null;
|
||||
}
|
||||
|
||||
supplier.purchasePrice = purchasePrice;
|
||||
if (currency) {
|
||||
supplier.currency = currency;
|
||||
}
|
||||
|
||||
return this.repository.save(supplier);
|
||||
}
|
||||
|
||||
async countByProduct(productId: string): Promise<number> {
|
||||
return this.repository.count({
|
||||
where: { productId, isActive: true },
|
||||
});
|
||||
}
|
||||
|
||||
async countBySupplier(supplierId: string): Promise<number> {
|
||||
return this.repository.count({
|
||||
where: { supplierId, isActive: true },
|
||||
});
|
||||
}
|
||||
|
||||
async getAveragePurchasePrice(productId: string): Promise<number | null> {
|
||||
const result = await this.repository.createQueryBuilder('ps')
|
||||
.select('AVG(ps.purchasePrice)', 'avgPrice')
|
||||
.where('ps.productId = :productId', { productId })
|
||||
.andWhere('ps.isActive = true')
|
||||
.andWhere('ps.purchasePrice IS NOT NULL')
|
||||
.getRawOne();
|
||||
|
||||
return result?.avgPrice ? parseFloat(result.avgPrice) : null;
|
||||
}
|
||||
|
||||
async getLowestPurchasePrice(productId: string): Promise<number | null> {
|
||||
const result = await this.repository.createQueryBuilder('ps')
|
||||
.select('MIN(ps.purchasePrice)', 'minPrice')
|
||||
.where('ps.productId = :productId', { productId })
|
||||
.andWhere('ps.isActive = true')
|
||||
.andWhere('ps.purchasePrice IS NOT NULL')
|
||||
.getRawOne();
|
||||
|
||||
return result?.minPrice ? parseFloat(result.minPrice) : null;
|
||||
}
|
||||
}
|
||||
238
src/modules/products/services/product-variant.service.ts
Normal file
238
src/modules/products/services/product-variant.service.ts
Normal file
@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Product Variant Service
|
||||
* Servicio para gestion de variantes de productos
|
||||
*
|
||||
* @module Products
|
||||
*/
|
||||
|
||||
import { Repository, FindOptionsWhere, In } from 'typeorm';
|
||||
import { AppDataSource } from '../../../shared/database/typeorm.config';
|
||||
import { ProductVariant } from '../entities/product-variant.entity';
|
||||
|
||||
export interface CreateProductVariantDto {
|
||||
productId: string;
|
||||
tenantId: string;
|
||||
sku: string;
|
||||
barcode?: string;
|
||||
name: string;
|
||||
priceExtra?: number;
|
||||
costExtra?: number;
|
||||
stockQty?: number;
|
||||
imageUrl?: string;
|
||||
createdBy?: string;
|
||||
}
|
||||
|
||||
export interface UpdateProductVariantDto {
|
||||
sku?: string;
|
||||
barcode?: string;
|
||||
name?: string;
|
||||
priceExtra?: number;
|
||||
costExtra?: number;
|
||||
stockQty?: number;
|
||||
imageUrl?: string;
|
||||
isActive?: boolean;
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface ProductVariantFilters {
|
||||
tenantId: string;
|
||||
productId?: string;
|
||||
isActive?: boolean;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface ServiceContext {
|
||||
tenantId: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export class ProductVariantService {
|
||||
private repository: Repository<ProductVariant>;
|
||||
|
||||
constructor() {
|
||||
this.repository = AppDataSource.getRepository(ProductVariant);
|
||||
}
|
||||
|
||||
async findAll(filters: ProductVariantFilters): Promise<ProductVariant[]> {
|
||||
const where: FindOptionsWhere<ProductVariant> = {
|
||||
tenantId: filters.tenantId,
|
||||
};
|
||||
|
||||
if (filters.productId) {
|
||||
where.productId = filters.productId;
|
||||
}
|
||||
|
||||
if (filters.isActive !== undefined) {
|
||||
where.isActive = filters.isActive;
|
||||
}
|
||||
|
||||
const queryBuilder = this.repository.createQueryBuilder('variant')
|
||||
.where(where)
|
||||
.leftJoinAndSelect('variant.product', 'product')
|
||||
.orderBy('variant.name', 'ASC');
|
||||
|
||||
if (filters.search) {
|
||||
queryBuilder.andWhere(
|
||||
'(variant.name ILIKE :search OR variant.sku ILIKE :search OR variant.barcode ILIKE :search)',
|
||||
{ search: `%${filters.search}%` }
|
||||
);
|
||||
}
|
||||
|
||||
return queryBuilder.getMany();
|
||||
}
|
||||
|
||||
async findById(id: string, tenantId: string): Promise<ProductVariant | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id, tenantId },
|
||||
relations: ['product'],
|
||||
});
|
||||
}
|
||||
|
||||
async findBySku(sku: string, tenantId: string): Promise<ProductVariant | null> {
|
||||
return this.repository.findOne({
|
||||
where: { sku, tenantId },
|
||||
relations: ['product'],
|
||||
});
|
||||
}
|
||||
|
||||
async findByBarcode(barcode: string, tenantId: string): Promise<ProductVariant | null> {
|
||||
return this.repository.findOne({
|
||||
where: { barcode, tenantId },
|
||||
relations: ['product'],
|
||||
});
|
||||
}
|
||||
|
||||
async findByProduct(productId: string, tenantId: string): Promise<ProductVariant[]> {
|
||||
return this.repository.find({
|
||||
where: { productId, tenantId, isActive: true },
|
||||
order: { name: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findByIds(ids: string[], tenantId: string): Promise<ProductVariant[]> {
|
||||
return this.repository.find({
|
||||
where: { id: In(ids), tenantId },
|
||||
relations: ['product'],
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: CreateProductVariantDto): Promise<ProductVariant> {
|
||||
const variant = this.repository.create(data);
|
||||
return this.repository.save(variant);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
tenantId: string,
|
||||
data: UpdateProductVariantDto
|
||||
): Promise<ProductVariant | null> {
|
||||
const variant = await this.findById(id, tenantId);
|
||||
if (!variant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Object.assign(variant, data);
|
||||
return this.repository.save(variant);
|
||||
}
|
||||
|
||||
async delete(id: string, tenantId: string): Promise<boolean> {
|
||||
const result = await this.repository.delete({ id, tenantId });
|
||||
return result.affected ? result.affected > 0 : false;
|
||||
}
|
||||
|
||||
async updateStock(
|
||||
id: string,
|
||||
tenantId: string,
|
||||
quantity: number,
|
||||
operation: 'set' | 'add' | 'subtract' = 'set'
|
||||
): Promise<ProductVariant | null> {
|
||||
const variant = await this.findById(id, tenantId);
|
||||
if (!variant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (operation) {
|
||||
case 'add':
|
||||
variant.stockQty = Number(variant.stockQty) + quantity;
|
||||
break;
|
||||
case 'subtract':
|
||||
variant.stockQty = Math.max(0, Number(variant.stockQty) - quantity);
|
||||
break;
|
||||
case 'set':
|
||||
default:
|
||||
variant.stockQty = quantity;
|
||||
break;
|
||||
}
|
||||
|
||||
return this.repository.save(variant);
|
||||
}
|
||||
|
||||
async updatePriceExtra(
|
||||
id: string,
|
||||
tenantId: string,
|
||||
priceExtra: number
|
||||
): Promise<ProductVariant | null> {
|
||||
const variant = await this.findById(id, tenantId);
|
||||
if (!variant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
variant.priceExtra = priceExtra;
|
||||
return this.repository.save(variant);
|
||||
}
|
||||
|
||||
async toggleActive(id: string, tenantId: string): Promise<ProductVariant | null> {
|
||||
const variant = await this.findById(id, tenantId);
|
||||
if (!variant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
variant.isActive = !variant.isActive;
|
||||
return this.repository.save(variant);
|
||||
}
|
||||
|
||||
async countByProduct(productId: string, tenantId: string): Promise<number> {
|
||||
return this.repository.count({
|
||||
where: { productId, tenantId, isActive: true },
|
||||
});
|
||||
}
|
||||
|
||||
async getTotalStock(productId: string, tenantId: string): Promise<number> {
|
||||
const result = await this.repository.createQueryBuilder('variant')
|
||||
.select('SUM(variant.stockQty)', 'totalStock')
|
||||
.where('variant.productId = :productId', { productId })
|
||||
.andWhere('variant.tenantId = :tenantId', { tenantId })
|
||||
.andWhere('variant.isActive = true')
|
||||
.getRawOne();
|
||||
|
||||
return result?.totalStock ? parseFloat(result.totalStock) : 0;
|
||||
}
|
||||
|
||||
async searchVariants(
|
||||
tenantId: string,
|
||||
query: string,
|
||||
limit: number = 20
|
||||
): Promise<ProductVariant[]> {
|
||||
return this.repository.createQueryBuilder('variant')
|
||||
.where('variant.tenantId = :tenantId', { tenantId })
|
||||
.andWhere('variant.isActive = true')
|
||||
.andWhere(
|
||||
'(variant.name ILIKE :query OR variant.sku ILIKE :query OR variant.barcode ILIKE :query)',
|
||||
{ query: `%${query}%` }
|
||||
)
|
||||
.leftJoinAndSelect('variant.product', 'product')
|
||||
.orderBy('variant.name', 'ASC')
|
||||
.limit(limit)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async bulkCreate(variants: CreateProductVariantDto[]): Promise<ProductVariant[]> {
|
||||
const entities = this.repository.create(variants);
|
||||
return this.repository.save(entities);
|
||||
}
|
||||
|
||||
async deleteByProduct(productId: string, tenantId: string): Promise<number> {
|
||||
const result = await this.repository.delete({ productId, tenantId });
|
||||
return result.affected || 0;
|
||||
}
|
||||
}
|
||||
348
src/modules/products/services/product.service.ts
Normal file
348
src/modules/products/services/product.service.ts
Normal file
@ -0,0 +1,348 @@
|
||||
/**
|
||||
* Product Service
|
||||
* Servicio para gestion de productos comerciales
|
||||
*
|
||||
* @module Products
|
||||
*/
|
||||
|
||||
import { Repository, FindOptionsWhere, In } from 'typeorm';
|
||||
import { AppDataSource } from '../../../shared/database/typeorm.config';
|
||||
import { Product } from '../entities/product.entity';
|
||||
|
||||
export interface CreateProductDto {
|
||||
tenantId: string;
|
||||
categoryId?: string;
|
||||
inventoryProductId?: string;
|
||||
sku: string;
|
||||
barcode?: string;
|
||||
name: string;
|
||||
shortName?: string;
|
||||
description?: string;
|
||||
productType?: 'product' | 'service' | 'consumable' | 'kit';
|
||||
salePrice?: number;
|
||||
costPrice?: number;
|
||||
minSalePrice?: number;
|
||||
currency?: string;
|
||||
taxRate?: number;
|
||||
taxIncluded?: boolean;
|
||||
satProductCode?: string;
|
||||
satUnitCode?: string;
|
||||
uom?: string;
|
||||
uomPurchase?: string;
|
||||
conversionFactor?: number;
|
||||
trackInventory?: boolean;
|
||||
minStock?: number;
|
||||
maxStock?: number;
|
||||
reorderPoint?: number;
|
||||
reorderQuantity?: number;
|
||||
trackLots?: boolean;
|
||||
trackSerials?: boolean;
|
||||
trackExpiry?: boolean;
|
||||
weight?: number;
|
||||
weightUnit?: string;
|
||||
length?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
dimensionUnit?: string;
|
||||
imageUrl?: string;
|
||||
images?: string[];
|
||||
tags?: string[];
|
||||
notes?: string;
|
||||
createdBy?: string;
|
||||
}
|
||||
|
||||
export interface UpdateProductDto {
|
||||
categoryId?: string | null;
|
||||
inventoryProductId?: string | null;
|
||||
sku?: string;
|
||||
barcode?: string;
|
||||
name?: string;
|
||||
shortName?: string;
|
||||
description?: string;
|
||||
productType?: 'product' | 'service' | 'consumable' | 'kit';
|
||||
salePrice?: number;
|
||||
costPrice?: number;
|
||||
minSalePrice?: number;
|
||||
currency?: string;
|
||||
taxRate?: number;
|
||||
taxIncluded?: boolean;
|
||||
satProductCode?: string;
|
||||
satUnitCode?: string;
|
||||
uom?: string;
|
||||
uomPurchase?: string;
|
||||
conversionFactor?: number;
|
||||
trackInventory?: boolean;
|
||||
minStock?: number;
|
||||
maxStock?: number;
|
||||
reorderPoint?: number;
|
||||
reorderQuantity?: number;
|
||||
trackLots?: boolean;
|
||||
trackSerials?: boolean;
|
||||
trackExpiry?: boolean;
|
||||
weight?: number;
|
||||
weightUnit?: string;
|
||||
length?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
dimensionUnit?: string;
|
||||
imageUrl?: string;
|
||||
images?: string[];
|
||||
tags?: string[];
|
||||
notes?: string;
|
||||
isActive?: boolean;
|
||||
isSellable?: boolean;
|
||||
isPurchasable?: boolean;
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface ProductFilters {
|
||||
tenantId: string;
|
||||
categoryId?: string;
|
||||
productType?: 'product' | 'service' | 'consumable' | 'kit';
|
||||
isActive?: boolean;
|
||||
isSellable?: boolean;
|
||||
isPurchasable?: boolean;
|
||||
search?: string;
|
||||
tags?: string[];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface ServiceContext {
|
||||
tenantId: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export class ProductService {
|
||||
private repository: Repository<Product>;
|
||||
|
||||
constructor() {
|
||||
this.repository = AppDataSource.getRepository(Product);
|
||||
}
|
||||
|
||||
async findAll(filters: ProductFilters): Promise<PaginatedResult<Product>> {
|
||||
const where: FindOptionsWhere<Product> = {
|
||||
tenantId: filters.tenantId,
|
||||
};
|
||||
|
||||
if (filters.categoryId) {
|
||||
where.categoryId = filters.categoryId;
|
||||
}
|
||||
|
||||
if (filters.productType) {
|
||||
where.productType = filters.productType;
|
||||
}
|
||||
|
||||
if (filters.isActive !== undefined) {
|
||||
where.isActive = filters.isActive;
|
||||
}
|
||||
|
||||
if (filters.isSellable !== undefined) {
|
||||
where.isSellable = filters.isSellable;
|
||||
}
|
||||
|
||||
if (filters.isPurchasable !== undefined) {
|
||||
where.isPurchasable = filters.isPurchasable;
|
||||
}
|
||||
|
||||
const queryBuilder = this.repository.createQueryBuilder('product')
|
||||
.where(where)
|
||||
.leftJoinAndSelect('product.category', 'category');
|
||||
|
||||
if (filters.search) {
|
||||
queryBuilder.andWhere(
|
||||
'(product.name ILIKE :search OR product.sku ILIKE :search OR product.barcode ILIKE :search)',
|
||||
{ search: `%${filters.search}%` }
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.tags && filters.tags.length > 0) {
|
||||
queryBuilder.andWhere('product.tags && :tags', { tags: filters.tags });
|
||||
}
|
||||
|
||||
const total = await queryBuilder.getCount();
|
||||
|
||||
queryBuilder
|
||||
.orderBy('product.name', 'ASC')
|
||||
.limit(filters.limit || 50)
|
||||
.offset(filters.offset || 0);
|
||||
|
||||
const data = await queryBuilder.getMany();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
limit: filters.limit || 50,
|
||||
offset: filters.offset || 0,
|
||||
};
|
||||
}
|
||||
|
||||
async findById(id: string, tenantId: string): Promise<Product | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id, tenantId },
|
||||
relations: ['category'],
|
||||
});
|
||||
}
|
||||
|
||||
async findBySku(sku: string, tenantId: string): Promise<Product | null> {
|
||||
return this.repository.findOne({
|
||||
where: { sku, tenantId },
|
||||
relations: ['category'],
|
||||
});
|
||||
}
|
||||
|
||||
async findByBarcode(barcode: string, tenantId: string): Promise<Product | null> {
|
||||
return this.repository.findOne({
|
||||
where: { barcode, tenantId },
|
||||
relations: ['category'],
|
||||
});
|
||||
}
|
||||
|
||||
async findByCategory(categoryId: string, tenantId: string): Promise<Product[]> {
|
||||
return this.repository.find({
|
||||
where: { categoryId, tenantId, isActive: true },
|
||||
order: { name: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findByIds(ids: string[], tenantId: string): Promise<Product[]> {
|
||||
return this.repository.find({
|
||||
where: { id: In(ids), tenantId },
|
||||
relations: ['category'],
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: CreateProductDto): Promise<Product> {
|
||||
const product = this.repository.create(data);
|
||||
return this.repository.save(product);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
tenantId: string,
|
||||
data: UpdateProductDto
|
||||
): Promise<Product | null> {
|
||||
const product = await this.findById(id, tenantId);
|
||||
if (!product) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Object.assign(product, data);
|
||||
return this.repository.save(product);
|
||||
}
|
||||
|
||||
async delete(id: string, tenantId: string): Promise<boolean> {
|
||||
const result = await this.repository.softDelete({ id, tenantId });
|
||||
return result.affected ? result.affected > 0 : false;
|
||||
}
|
||||
|
||||
async hardDelete(id: string, tenantId: string): Promise<boolean> {
|
||||
const result = await this.repository.delete({ id, tenantId });
|
||||
return result.affected ? result.affected > 0 : false;
|
||||
}
|
||||
|
||||
async updatePrices(
|
||||
id: string,
|
||||
tenantId: string,
|
||||
prices: { salePrice?: number; costPrice?: number; minSalePrice?: number }
|
||||
): Promise<Product | null> {
|
||||
const product = await this.findById(id, tenantId);
|
||||
if (!product) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (prices.salePrice !== undefined) product.salePrice = prices.salePrice;
|
||||
if (prices.costPrice !== undefined) product.costPrice = prices.costPrice;
|
||||
if (prices.minSalePrice !== undefined) product.minSalePrice = prices.minSalePrice;
|
||||
|
||||
return this.repository.save(product);
|
||||
}
|
||||
|
||||
async updateStock(
|
||||
id: string,
|
||||
tenantId: string,
|
||||
stockData: { minStock?: number; maxStock?: number; reorderPoint?: number; reorderQuantity?: number }
|
||||
): Promise<Product | null> {
|
||||
const product = await this.findById(id, tenantId);
|
||||
if (!product) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Object.assign(product, stockData);
|
||||
return this.repository.save(product);
|
||||
}
|
||||
|
||||
async toggleActive(id: string, tenantId: string): Promise<Product | null> {
|
||||
const product = await this.findById(id, tenantId);
|
||||
if (!product) {
|
||||
return null;
|
||||
}
|
||||
|
||||
product.isActive = !product.isActive;
|
||||
return this.repository.save(product);
|
||||
}
|
||||
|
||||
async countByCategory(categoryId: string, tenantId: string): Promise<number> {
|
||||
return this.repository.count({
|
||||
where: { categoryId, tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
async searchProducts(
|
||||
tenantId: string,
|
||||
query: string,
|
||||
limit: number = 20
|
||||
): Promise<Product[]> {
|
||||
return this.repository.createQueryBuilder('product')
|
||||
.where('product.tenantId = :tenantId', { tenantId })
|
||||
.andWhere('product.isActive = true')
|
||||
.andWhere(
|
||||
'(product.name ILIKE :query OR product.sku ILIKE :query OR product.barcode ILIKE :query OR product.shortName ILIKE :query)',
|
||||
{ query: `%${query}%` }
|
||||
)
|
||||
.leftJoinAndSelect('product.category', 'category')
|
||||
.orderBy('product.name', 'ASC')
|
||||
.limit(limit)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async getLowStockProducts(tenantId: string): Promise<Product[]> {
|
||||
return this.repository.createQueryBuilder('product')
|
||||
.where('product.tenantId = :tenantId', { tenantId })
|
||||
.andWhere('product.isActive = true')
|
||||
.andWhere('product.trackInventory = true')
|
||||
.andWhere('product.reorderPoint IS NOT NULL')
|
||||
.leftJoinAndSelect('product.category', 'category')
|
||||
.orderBy('product.name', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async bulkUpdatePrices(
|
||||
tenantId: string,
|
||||
updates: Array<{ id: string; salePrice?: number; costPrice?: number }>
|
||||
): Promise<number> {
|
||||
let updated = 0;
|
||||
|
||||
for (const update of updates) {
|
||||
const result = await this.repository.update(
|
||||
{ id: update.id, tenantId },
|
||||
{
|
||||
...(update.salePrice !== undefined && { salePrice: update.salePrice }),
|
||||
...(update.costPrice !== undefined && { costPrice: update.costPrice }),
|
||||
}
|
||||
);
|
||||
if (result.affected) {
|
||||
updated += result.affected;
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
1
src/modules/projects/controllers/index.ts
Normal file
1
src/modules/projects/controllers/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './projects.controller';
|
||||
1130
src/modules/projects/controllers/projects.controller.ts
Normal file
1130
src/modules/projects/controllers/projects.controller.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -1 +1,6 @@
|
||||
export * from './project.entity';
|
||||
export * from './task.entity';
|
||||
export * from './milestone.entity';
|
||||
export * from './project-member.entity';
|
||||
export * from './project-stage.entity';
|
||||
export * from './timesheet.entity';
|
||||
|
||||
68
src/modules/projects/entities/milestone.entity.ts
Normal file
68
src/modules/projects/entities/milestone.entity.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { ProjectEntity } from './project.entity';
|
||||
|
||||
export enum MilestoneStatus {
|
||||
PENDING = 'pending',
|
||||
COMPLETED = 'completed',
|
||||
CANCELLED = 'cancelled',
|
||||
}
|
||||
|
||||
@Entity({ schema: 'projects', name: 'milestones' })
|
||||
@Index('idx_milestones_tenant', ['tenantId'])
|
||||
@Index('idx_milestones_project', ['projectId'])
|
||||
@Index('idx_milestones_status', ['status'])
|
||||
@Index('idx_milestones_deadline', ['dateDeadline'])
|
||||
export class MilestoneEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ type: 'uuid', nullable: false, name: 'project_id' })
|
||||
projectId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: false })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column({ type: 'date', nullable: true, name: 'date_deadline' })
|
||||
dateDeadline: Date | null;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: MilestoneStatus,
|
||||
default: MilestoneStatus.PENDING,
|
||||
nullable: false,
|
||||
})
|
||||
status: MilestoneStatus;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => ProjectEntity, (project) => project.milestones, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'project_id' })
|
||||
project: ProjectEntity;
|
||||
|
||||
// Audit fields
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||
createdBy: string | null;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true })
|
||||
updatedAt: Date | null;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||
updatedBy: string | null;
|
||||
}
|
||||
48
src/modules/projects/entities/project-member.entity.ts
Normal file
48
src/modules/projects/entities/project-member.entity.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Unique,
|
||||
} from 'typeorm';
|
||||
import { ProjectEntity } from './project.entity';
|
||||
|
||||
export enum ProjectMemberRole {
|
||||
MEMBER = 'member',
|
||||
CONTRIBUTOR = 'contributor',
|
||||
VIEWER = 'viewer',
|
||||
}
|
||||
|
||||
@Entity({ schema: 'projects', name: 'project_members' })
|
||||
@Index('idx_project_members_tenant', ['tenantId'])
|
||||
@Index('idx_project_members_project', ['projectId'])
|
||||
@Index('idx_project_members_user', ['userId'])
|
||||
@Unique(['projectId', 'userId'])
|
||||
export class ProjectMemberEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ type: 'uuid', nullable: false, name: 'project_id' })
|
||||
projectId: string;
|
||||
|
||||
@Column({ type: 'uuid', nullable: false, name: 'user_id' })
|
||||
userId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, default: 'member', nullable: false })
|
||||
role: string;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => ProjectEntity, (project) => project.members, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'project_id' })
|
||||
project: ProjectEntity;
|
||||
|
||||
// Audit fields
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
}
|
||||
50
src/modules/projects/entities/project-stage.entity.ts
Normal file
50
src/modules/projects/entities/project-stage.entity.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { ProjectEntity } from './project.entity';
|
||||
|
||||
@Entity({ schema: 'projects', name: 'project_stages' })
|
||||
@Index('idx_project_stages_tenant', ['tenantId'])
|
||||
@Index('idx_project_stages_project', ['projectId'])
|
||||
@Index('idx_project_stages_sequence', ['sequence'])
|
||||
export class ProjectStageEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true, name: 'project_id' })
|
||||
projectId: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 100, nullable: false })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'int', default: 0, nullable: false })
|
||||
sequence: number;
|
||||
|
||||
@Column({ type: 'boolean', default: false, nullable: false })
|
||||
fold: boolean;
|
||||
|
||||
@Column({ type: 'boolean', default: false, nullable: false, name: 'is_closed' })
|
||||
isClosed: boolean;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => ProjectEntity, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'project_id' })
|
||||
project: ProjectEntity | null;
|
||||
|
||||
// Audit fields
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true })
|
||||
updatedAt: Date | null;
|
||||
}
|
||||
123
src/modules/projects/entities/project.entity.ts
Normal file
123
src/modules/projects/entities/project.entity.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { TaskEntity } from './task.entity';
|
||||
import { MilestoneEntity } from './milestone.entity';
|
||||
import { ProjectMemberEntity } from './project-member.entity';
|
||||
import { TimesheetEntity } from './timesheet.entity';
|
||||
|
||||
export enum ProjectStatus {
|
||||
DRAFT = 'draft',
|
||||
ACTIVE = 'active',
|
||||
COMPLETED = 'completed',
|
||||
CANCELLED = 'cancelled',
|
||||
ON_HOLD = 'on_hold',
|
||||
}
|
||||
|
||||
export enum ProjectPrivacy {
|
||||
PUBLIC = 'public',
|
||||
PRIVATE = 'private',
|
||||
FOLLOWERS = 'followers',
|
||||
}
|
||||
|
||||
@Entity({ schema: 'projects', name: 'projects' })
|
||||
@Index('idx_projects_tenant', ['tenantId'])
|
||||
@Index('idx_projects_company', ['companyId'])
|
||||
@Index('idx_projects_manager', ['managerId'])
|
||||
@Index('idx_projects_partner', ['partnerId'])
|
||||
@Index('idx_projects_status', ['status'])
|
||||
@Index('idx_projects_code', ['code'])
|
||||
export class ProjectEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ type: 'uuid', nullable: false, name: 'company_id' })
|
||||
companyId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: false })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||
code: string | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true, name: 'manager_id' })
|
||||
managerId: string | null;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true, name: 'partner_id' })
|
||||
partnerId: string | null;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true, name: 'analytic_account_id' })
|
||||
analyticAccountId: string | null;
|
||||
|
||||
@Column({ type: 'date', nullable: true, name: 'date_start' })
|
||||
dateStart: Date | null;
|
||||
|
||||
@Column({ type: 'date', nullable: true, name: 'date_end' })
|
||||
dateEnd: Date | null;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ProjectStatus,
|
||||
default: ProjectStatus.DRAFT,
|
||||
nullable: false,
|
||||
})
|
||||
status: ProjectStatus;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ProjectPrivacy,
|
||||
default: ProjectPrivacy.PUBLIC,
|
||||
nullable: false,
|
||||
})
|
||||
privacy: ProjectPrivacy;
|
||||
|
||||
@Column({ type: 'boolean', default: true, nullable: false, name: 'allow_timesheets' })
|
||||
allowTimesheets: boolean;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||
color: string | null;
|
||||
|
||||
// Relations
|
||||
@OneToMany(() => TaskEntity, (task) => task.project)
|
||||
tasks: TaskEntity[];
|
||||
|
||||
@OneToMany(() => MilestoneEntity, (milestone) => milestone.project)
|
||||
milestones: MilestoneEntity[];
|
||||
|
||||
@OneToMany(() => ProjectMemberEntity, (member) => member.project)
|
||||
members: ProjectMemberEntity[];
|
||||
|
||||
@OneToMany(() => TimesheetEntity, (timesheet) => timesheet.project)
|
||||
timesheets: TimesheetEntity[];
|
||||
|
||||
// Audit fields
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||
createdBy: string | null;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true })
|
||||
updatedAt: Date | null;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||
updatedBy: string | null;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' })
|
||||
deletedAt: Date | null;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
||||
deletedBy: string | null;
|
||||
}
|
||||
125
src/modules/projects/entities/task.entity.ts
Normal file
125
src/modules/projects/entities/task.entity.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { ProjectEntity } from './project.entity';
|
||||
import { TimesheetEntity } from './timesheet.entity';
|
||||
|
||||
export enum TaskStatus {
|
||||
TODO = 'todo',
|
||||
IN_PROGRESS = 'in_progress',
|
||||
REVIEW = 'review',
|
||||
DONE = 'done',
|
||||
CANCELLED = 'cancelled',
|
||||
}
|
||||
|
||||
export enum TaskPriority {
|
||||
LOW = 'low',
|
||||
NORMAL = 'normal',
|
||||
HIGH = 'high',
|
||||
URGENT = 'urgent',
|
||||
}
|
||||
|
||||
@Entity({ schema: 'projects', name: 'tasks' })
|
||||
@Index('idx_tasks_tenant', ['tenantId'])
|
||||
@Index('idx_tasks_project', ['projectId'])
|
||||
@Index('idx_tasks_stage', ['stageId'])
|
||||
@Index('idx_tasks_parent', ['parentId'])
|
||||
@Index('idx_tasks_assigned', ['assignedTo'])
|
||||
@Index('idx_tasks_status', ['status'])
|
||||
@Index('idx_tasks_priority', ['priority'])
|
||||
@Index('idx_tasks_deadline', ['dateDeadline'])
|
||||
export class TaskEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ type: 'uuid', nullable: false, name: 'project_id' })
|
||||
projectId: string;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true, name: 'stage_id' })
|
||||
stageId: string | null;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true, name: 'parent_id' })
|
||||
parentId: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: false })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true, name: 'assigned_to' })
|
||||
assignedTo: string | null;
|
||||
|
||||
@Column({ type: 'date', nullable: true, name: 'date_deadline' })
|
||||
dateDeadline: Date | null;
|
||||
|
||||
@Column({ type: 'decimal', precision: 10, scale: 2, default: 0, name: 'estimated_hours' })
|
||||
estimatedHours: number;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: TaskPriority,
|
||||
default: TaskPriority.NORMAL,
|
||||
nullable: false,
|
||||
})
|
||||
priority: TaskPriority;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: TaskStatus,
|
||||
default: TaskStatus.TODO,
|
||||
nullable: false,
|
||||
})
|
||||
status: TaskStatus;
|
||||
|
||||
@Column({ type: 'int', default: 0, nullable: false })
|
||||
sequence: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||
color: string | null;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => ProjectEntity, (project) => project.tasks, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'project_id' })
|
||||
project: ProjectEntity;
|
||||
|
||||
@ManyToOne(() => TaskEntity, (task) => task.children, { onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'parent_id' })
|
||||
parent: TaskEntity | null;
|
||||
|
||||
@OneToMany(() => TaskEntity, (task) => task.parent)
|
||||
children: TaskEntity[];
|
||||
|
||||
@OneToMany(() => TimesheetEntity, (timesheet) => timesheet.task)
|
||||
timesheets: TimesheetEntity[];
|
||||
|
||||
// Audit fields
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||
createdBy: string | null;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true })
|
||||
updatedAt: Date | null;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||
updatedBy: string | null;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' })
|
||||
deletedAt: Date | null;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
||||
deletedBy: string | null;
|
||||
}
|
||||
@ -5,7 +5,11 @@ import {
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { ProjectEntity } from './project.entity';
|
||||
import { TaskEntity } from './task.entity';
|
||||
|
||||
export enum TimesheetStatus {
|
||||
DRAFT = 'draft',
|
||||
@ -90,4 +94,13 @@ export class TimesheetEntity {
|
||||
|
||||
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||
updatedBy: string | null;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => ProjectEntity, (project) => project.timesheets, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'project_id' })
|
||||
project: ProjectEntity;
|
||||
|
||||
@ManyToOne(() => TaskEntity, (task) => task.timesheets, { onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'task_id' })
|
||||
task: TaskEntity | null;
|
||||
}
|
||||
|
||||
17
src/modules/projects/index.ts
Normal file
17
src/modules/projects/index.ts
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Projects Module
|
||||
*
|
||||
* Generic project management module for ERP Construccion.
|
||||
* Provides project, task, timesheet, milestone, and team member management.
|
||||
*
|
||||
* @module Projects
|
||||
*/
|
||||
|
||||
// Entities
|
||||
export * from './entities';
|
||||
|
||||
// Services
|
||||
export * from './services';
|
||||
|
||||
// Controllers
|
||||
export * from './controllers';
|
||||
6
src/modules/projects/services/index.ts
Normal file
6
src/modules/projects/services/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export * from './project.service';
|
||||
export * from './task.service';
|
||||
export * from './timesheet.service';
|
||||
export * from './milestone.service';
|
||||
export * from './project-member.service';
|
||||
export * from './project-stage.service';
|
||||
266
src/modules/projects/services/milestone.service.ts
Normal file
266
src/modules/projects/services/milestone.service.ts
Normal file
@ -0,0 +1,266 @@
|
||||
/**
|
||||
* MilestoneService - Project Milestone Management Service
|
||||
*
|
||||
* Provides CRUD operations for project milestones.
|
||||
* Supports multi-tenant architecture with ServiceContext pattern.
|
||||
*
|
||||
* @module Projects
|
||||
*/
|
||||
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { MilestoneEntity, MilestoneStatus } from '../entities/milestone.entity';
|
||||
import { ServiceContext, PaginatedResult } from './project.service';
|
||||
|
||||
export interface CreateMilestoneDto {
|
||||
projectId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
dateDeadline?: string;
|
||||
}
|
||||
|
||||
export interface UpdateMilestoneDto {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
dateDeadline?: string | null;
|
||||
status?: MilestoneStatus;
|
||||
}
|
||||
|
||||
export interface MilestoneFilters {
|
||||
projectId?: string;
|
||||
status?: MilestoneStatus;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export class MilestoneService {
|
||||
private repository: Repository<MilestoneEntity>;
|
||||
|
||||
constructor(dataSource: DataSource) {
|
||||
this.repository = dataSource.getRepository(MilestoneEntity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find milestone by ID
|
||||
*/
|
||||
async findById(ctx: ServiceContext, id: string): Promise<MilestoneEntity | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
id,
|
||||
tenantId: ctx.tenantId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all milestones with pagination and filters
|
||||
*/
|
||||
async findAll(
|
||||
ctx: ServiceContext,
|
||||
filters: MilestoneFilters = {},
|
||||
page = 1,
|
||||
limit = 20
|
||||
): Promise<PaginatedResult<MilestoneEntity>> {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('m')
|
||||
.where('m.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (filters.projectId) {
|
||||
qb.andWhere('m.project_id = :projectId', { projectId: filters.projectId });
|
||||
}
|
||||
if (filters.status) {
|
||||
qb.andWhere('m.status = :status', { status: filters.status });
|
||||
}
|
||||
if (filters.search) {
|
||||
qb.andWhere('m.name ILIKE :search', { search: `%${filters.search}%` });
|
||||
}
|
||||
|
||||
qb.orderBy('m.date_deadline', 'ASC').addOrderBy('m.created_at', 'DESC').skip(skip).take(limit);
|
||||
|
||||
const [data, total] = await qb.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new milestone
|
||||
*/
|
||||
async create(ctx: ServiceContext, dto: CreateMilestoneDto): Promise<MilestoneEntity> {
|
||||
const entity = this.repository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
projectId: dto.projectId,
|
||||
name: dto.name,
|
||||
description: dto.description || null,
|
||||
dateDeadline: dto.dateDeadline ? new Date(dto.dateDeadline) : null,
|
||||
status: MilestoneStatus.PENDING,
|
||||
createdBy: ctx.userId || null,
|
||||
});
|
||||
|
||||
return this.repository.save(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a milestone
|
||||
*/
|
||||
async update(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
dto: UpdateMilestoneDto
|
||||
): Promise<MilestoneEntity | null> {
|
||||
const entity = await this.findById(ctx, id);
|
||||
if (!entity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (dto.name !== undefined) entity.name = dto.name;
|
||||
if (dto.description !== undefined) entity.description = dto.description;
|
||||
if (dto.dateDeadline !== undefined) {
|
||||
entity.dateDeadline = dto.dateDeadline ? new Date(dto.dateDeadline) : null;
|
||||
}
|
||||
if (dto.status !== undefined) entity.status = dto.status;
|
||||
|
||||
entity.updatedBy = ctx.userId || null;
|
||||
|
||||
return this.repository.save(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a milestone
|
||||
*/
|
||||
async delete(ctx: ServiceContext, id: string): Promise<boolean> {
|
||||
const entity = await this.findById(ctx, id);
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.repository.delete(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a milestone
|
||||
*/
|
||||
async complete(ctx: ServiceContext, id: string): Promise<MilestoneEntity | null> {
|
||||
return this.update(ctx, id, { status: MilestoneStatus.COMPLETED });
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a milestone
|
||||
*/
|
||||
async cancel(ctx: ServiceContext, id: string): Promise<MilestoneEntity | null> {
|
||||
return this.update(ctx, id, { status: MilestoneStatus.CANCELLED });
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset milestone to pending
|
||||
*/
|
||||
async reset(ctx: ServiceContext, id: string): Promise<MilestoneEntity | null> {
|
||||
return this.update(ctx, id, { status: MilestoneStatus.PENDING });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find milestones by project
|
||||
*/
|
||||
async findByProject(
|
||||
ctx: ServiceContext,
|
||||
projectId: string,
|
||||
page = 1,
|
||||
limit = 20
|
||||
): Promise<PaginatedResult<MilestoneEntity>> {
|
||||
return this.findAll(ctx, { projectId }, page, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find pending milestones
|
||||
*/
|
||||
async findPending(
|
||||
ctx: ServiceContext,
|
||||
projectId?: string,
|
||||
page = 1,
|
||||
limit = 20
|
||||
): Promise<PaginatedResult<MilestoneEntity>> {
|
||||
const filters: MilestoneFilters = { status: MilestoneStatus.PENDING };
|
||||
if (projectId) {
|
||||
filters.projectId = projectId;
|
||||
}
|
||||
return this.findAll(ctx, filters, page, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find overdue milestones
|
||||
*/
|
||||
async findOverdue(
|
||||
ctx: ServiceContext,
|
||||
projectId?: string,
|
||||
page = 1,
|
||||
limit = 20
|
||||
): Promise<PaginatedResult<MilestoneEntity>> {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('m')
|
||||
.where('m.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('m.date_deadline < :today', { today: new Date() })
|
||||
.andWhere('m.status = :status', { status: MilestoneStatus.PENDING });
|
||||
|
||||
if (projectId) {
|
||||
qb.andWhere('m.project_id = :projectId', { projectId });
|
||||
}
|
||||
|
||||
qb.orderBy('m.date_deadline', 'ASC').skip(skip).take(limit);
|
||||
|
||||
const [data, total] = await qb.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find upcoming milestones (within next N days)
|
||||
*/
|
||||
async findUpcoming(
|
||||
ctx: ServiceContext,
|
||||
days = 7,
|
||||
projectId?: string,
|
||||
page = 1,
|
||||
limit = 20
|
||||
): Promise<PaginatedResult<MilestoneEntity>> {
|
||||
const skip = (page - 1) * limit;
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + days);
|
||||
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('m')
|
||||
.where('m.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('m.date_deadline >= :today', { today: new Date() })
|
||||
.andWhere('m.date_deadline <= :futureDate', { futureDate })
|
||||
.andWhere('m.status = :status', { status: MilestoneStatus.PENDING });
|
||||
|
||||
if (projectId) {
|
||||
qb.andWhere('m.project_id = :projectId', { projectId });
|
||||
}
|
||||
|
||||
qb.orderBy('m.date_deadline', 'ASC').skip(skip).take(limit);
|
||||
|
||||
const [data, total] = await qb.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
}
|
||||
263
src/modules/projects/services/project-member.service.ts
Normal file
263
src/modules/projects/services/project-member.service.ts
Normal file
@ -0,0 +1,263 @@
|
||||
/**
|
||||
* ProjectMemberService - Project Team Member Management Service
|
||||
*
|
||||
* Provides team member management for projects.
|
||||
* Supports multi-tenant architecture with ServiceContext pattern.
|
||||
*
|
||||
* @module Projects
|
||||
*/
|
||||
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { ProjectMemberEntity, ProjectMemberRole } from '../entities/project-member.entity';
|
||||
import { ServiceContext, PaginatedResult } from './project.service';
|
||||
|
||||
export interface AddMemberDto {
|
||||
projectId: string;
|
||||
userId: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface UpdateMemberRoleDto {
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface ProjectMemberFilters {
|
||||
projectId?: string;
|
||||
userId?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export class ProjectMemberService {
|
||||
private repository: Repository<ProjectMemberEntity>;
|
||||
|
||||
constructor(dataSource: DataSource) {
|
||||
this.repository = dataSource.getRepository(ProjectMemberEntity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find member by ID
|
||||
*/
|
||||
async findById(ctx: ServiceContext, id: string): Promise<ProjectMemberEntity | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
id,
|
||||
tenantId: ctx.tenantId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find member by project and user
|
||||
*/
|
||||
async findByProjectAndUser(
|
||||
ctx: ServiceContext,
|
||||
projectId: string,
|
||||
userId: string
|
||||
): Promise<ProjectMemberEntity | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
projectId,
|
||||
userId,
|
||||
tenantId: ctx.tenantId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all members with pagination and filters
|
||||
*/
|
||||
async findAll(
|
||||
ctx: ServiceContext,
|
||||
filters: ProjectMemberFilters = {},
|
||||
page = 1,
|
||||
limit = 20
|
||||
): Promise<PaginatedResult<ProjectMemberEntity>> {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('pm')
|
||||
.where('pm.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (filters.projectId) {
|
||||
qb.andWhere('pm.project_id = :projectId', { projectId: filters.projectId });
|
||||
}
|
||||
if (filters.userId) {
|
||||
qb.andWhere('pm.user_id = :userId', { userId: filters.userId });
|
||||
}
|
||||
if (filters.role) {
|
||||
qb.andWhere('pm.role = :role', { role: filters.role });
|
||||
}
|
||||
|
||||
qb.orderBy('pm.created_at', 'DESC').skip(skip).take(limit);
|
||||
|
||||
const [data, total] = await qb.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a member to a project
|
||||
*/
|
||||
async addMember(ctx: ServiceContext, dto: AddMemberDto): Promise<ProjectMemberEntity> {
|
||||
// Check if already a member
|
||||
const existing = await this.findByProjectAndUser(ctx, dto.projectId, dto.userId);
|
||||
if (existing) {
|
||||
throw new Error('User is already a member of this project');
|
||||
}
|
||||
|
||||
const entity = this.repository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
projectId: dto.projectId,
|
||||
userId: dto.userId,
|
||||
role: dto.role || ProjectMemberRole.MEMBER,
|
||||
});
|
||||
|
||||
return this.repository.save(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update member role
|
||||
*/
|
||||
async updateRole(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
dto: UpdateMemberRoleDto
|
||||
): Promise<ProjectMemberEntity | null> {
|
||||
const entity = await this.findById(ctx, id);
|
||||
if (!entity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
entity.role = dto.role;
|
||||
|
||||
return this.repository.save(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a member from a project
|
||||
*/
|
||||
async removeMember(ctx: ServiceContext, id: string): Promise<boolean> {
|
||||
const entity = await this.findById(ctx, id);
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.repository.delete(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a member by project and user
|
||||
*/
|
||||
async removeMemberByProjectAndUser(
|
||||
ctx: ServiceContext,
|
||||
projectId: string,
|
||||
userId: string
|
||||
): Promise<boolean> {
|
||||
const entity = await this.findByProjectAndUser(ctx, projectId, userId);
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.repository.delete(entity.id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find members by project
|
||||
*/
|
||||
async findByProject(
|
||||
ctx: ServiceContext,
|
||||
projectId: string,
|
||||
page = 1,
|
||||
limit = 20
|
||||
): Promise<PaginatedResult<ProjectMemberEntity>> {
|
||||
return this.findAll(ctx, { projectId }, page, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find projects where user is a member
|
||||
*/
|
||||
async findProjectsByUser(
|
||||
ctx: ServiceContext,
|
||||
userId: string,
|
||||
page = 1,
|
||||
limit = 20
|
||||
): Promise<PaginatedResult<ProjectMemberEntity>> {
|
||||
return this.findAll(ctx, { userId }, page, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is a member of a project
|
||||
*/
|
||||
async isMember(ctx: ServiceContext, projectId: string, userId: string): Promise<boolean> {
|
||||
const member = await this.findByProjectAndUser(ctx, projectId, userId);
|
||||
return member !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user role in a project
|
||||
*/
|
||||
async getUserRole(
|
||||
ctx: ServiceContext,
|
||||
projectId: string,
|
||||
userId: string
|
||||
): Promise<string | null> {
|
||||
const member = await this.findByProjectAndUser(ctx, projectId, userId);
|
||||
return member?.role || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count members in a project
|
||||
*/
|
||||
async countMembers(ctx: ServiceContext, projectId: string): Promise<number> {
|
||||
return this.repository.count({
|
||||
where: { projectId, tenantId: ctx.tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple members to a project
|
||||
*/
|
||||
async addMembers(
|
||||
ctx: ServiceContext,
|
||||
projectId: string,
|
||||
userIds: string[],
|
||||
role?: string
|
||||
): Promise<ProjectMemberEntity[]> {
|
||||
const added: ProjectMemberEntity[] = [];
|
||||
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
const member = await this.addMember(ctx, {
|
||||
projectId,
|
||||
userId,
|
||||
role: role || ProjectMemberRole.MEMBER,
|
||||
});
|
||||
added.push(member);
|
||||
} catch {
|
||||
// Skip if already a member
|
||||
}
|
||||
}
|
||||
|
||||
return added;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all members from a project
|
||||
*/
|
||||
async removeAllMembers(ctx: ServiceContext, projectId: string): Promise<number> {
|
||||
const result = await this.repository.delete({
|
||||
projectId,
|
||||
tenantId: ctx.tenantId,
|
||||
});
|
||||
|
||||
return result.affected || 0;
|
||||
}
|
||||
}
|
||||
257
src/modules/projects/services/project-stage.service.ts
Normal file
257
src/modules/projects/services/project-stage.service.ts
Normal file
@ -0,0 +1,257 @@
|
||||
/**
|
||||
* ProjectStageService - Kanban Stage Management Service
|
||||
*
|
||||
* Provides stage/column management for project task boards.
|
||||
* Supports multi-tenant architecture with ServiceContext pattern.
|
||||
*
|
||||
* @module Projects
|
||||
*/
|
||||
|
||||
import { DataSource, Repository, IsNull } from 'typeorm';
|
||||
import { ProjectStageEntity } from '../entities/project-stage.entity';
|
||||
import { ServiceContext, PaginatedResult } from './project.service';
|
||||
|
||||
export interface CreateStageDto {
|
||||
projectId?: string;
|
||||
name: string;
|
||||
sequence?: number;
|
||||
fold?: boolean;
|
||||
isClosed?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateStageDto {
|
||||
name?: string;
|
||||
sequence?: number;
|
||||
fold?: boolean;
|
||||
isClosed?: boolean;
|
||||
}
|
||||
|
||||
export interface StageFilters {
|
||||
projectId?: string | null;
|
||||
isClosed?: boolean;
|
||||
}
|
||||
|
||||
export class ProjectStageService {
|
||||
private repository: Repository<ProjectStageEntity>;
|
||||
|
||||
constructor(dataSource: DataSource) {
|
||||
this.repository = dataSource.getRepository(ProjectStageEntity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find stage by ID
|
||||
*/
|
||||
async findById(ctx: ServiceContext, id: string): Promise<ProjectStageEntity | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
id,
|
||||
tenantId: ctx.tenantId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all stages with pagination and filters
|
||||
*/
|
||||
async findAll(
|
||||
ctx: ServiceContext,
|
||||
filters: StageFilters = {},
|
||||
page = 1,
|
||||
limit = 50
|
||||
): Promise<PaginatedResult<ProjectStageEntity>> {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('s')
|
||||
.where('s.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (filters.projectId !== undefined) {
|
||||
if (filters.projectId === null) {
|
||||
qb.andWhere('s.project_id IS NULL');
|
||||
} else {
|
||||
qb.andWhere('(s.project_id = :projectId OR s.project_id IS NULL)', {
|
||||
projectId: filters.projectId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.isClosed !== undefined) {
|
||||
qb.andWhere('s.is_closed = :isClosed', { isClosed: filters.isClosed });
|
||||
}
|
||||
|
||||
qb.orderBy('s.sequence', 'ASC').addOrderBy('s.created_at', 'ASC').skip(skip).take(limit);
|
||||
|
||||
const [data, total] = await qb.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new stage
|
||||
*/
|
||||
async create(ctx: ServiceContext, dto: CreateStageDto): Promise<ProjectStageEntity> {
|
||||
// Get next sequence
|
||||
let sequence = dto.sequence;
|
||||
if (sequence === undefined) {
|
||||
const maxSeq = await this.repository
|
||||
.createQueryBuilder('s')
|
||||
.select('COALESCE(MAX(s.sequence), 0) + 1', 'nextSeq')
|
||||
.where('s.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere(
|
||||
dto.projectId ? 's.project_id = :projectId' : 's.project_id IS NULL',
|
||||
dto.projectId ? { projectId: dto.projectId } : {}
|
||||
)
|
||||
.getRawOne();
|
||||
sequence = parseInt(maxSeq?.nextSeq || '1');
|
||||
}
|
||||
|
||||
const entity = this.repository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
projectId: dto.projectId || null,
|
||||
name: dto.name,
|
||||
sequence,
|
||||
fold: dto.fold ?? false,
|
||||
isClosed: dto.isClosed ?? false,
|
||||
});
|
||||
|
||||
return this.repository.save(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a stage
|
||||
*/
|
||||
async update(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
dto: UpdateStageDto
|
||||
): Promise<ProjectStageEntity | null> {
|
||||
const entity = await this.findById(ctx, id);
|
||||
if (!entity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (dto.name !== undefined) entity.name = dto.name;
|
||||
if (dto.sequence !== undefined) entity.sequence = dto.sequence;
|
||||
if (dto.fold !== undefined) entity.fold = dto.fold;
|
||||
if (dto.isClosed !== undefined) entity.isClosed = dto.isClosed;
|
||||
|
||||
return this.repository.save(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a stage
|
||||
*/
|
||||
async delete(ctx: ServiceContext, id: string): Promise<boolean> {
|
||||
const entity = await this.findById(ctx, id);
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.repository.delete(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find global stages (not project-specific)
|
||||
*/
|
||||
async findGlobalStages(
|
||||
ctx: ServiceContext,
|
||||
page = 1,
|
||||
limit = 50
|
||||
): Promise<PaginatedResult<ProjectStageEntity>> {
|
||||
return this.findAll(ctx, { projectId: null }, page, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find stages for a project (includes global + project-specific)
|
||||
*/
|
||||
async findByProject(
|
||||
ctx: ServiceContext,
|
||||
projectId: string,
|
||||
page = 1,
|
||||
limit = 50
|
||||
): Promise<PaginatedResult<ProjectStageEntity>> {
|
||||
return this.findAll(ctx, { projectId }, page, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder stages
|
||||
*/
|
||||
async reorder(
|
||||
ctx: ServiceContext,
|
||||
stageIds: string[]
|
||||
): Promise<ProjectStageEntity[]> {
|
||||
const updated: ProjectStageEntity[] = [];
|
||||
|
||||
for (let i = 0; i < stageIds.length; i++) {
|
||||
const stage = await this.update(ctx, stageIds[i], { sequence: i + 1 });
|
||||
if (stage) {
|
||||
updated.push(stage);
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default stages for a project
|
||||
*/
|
||||
async createDefaultStages(
|
||||
ctx: ServiceContext,
|
||||
projectId?: string
|
||||
): Promise<ProjectStageEntity[]> {
|
||||
const defaultStages = [
|
||||
{ name: 'Backlog', sequence: 1, fold: true, isClosed: false },
|
||||
{ name: 'To Do', sequence: 2, fold: false, isClosed: false },
|
||||
{ name: 'In Progress', sequence: 3, fold: false, isClosed: false },
|
||||
{ name: 'Review', sequence: 4, fold: false, isClosed: false },
|
||||
{ name: 'Done', sequence: 5, fold: false, isClosed: true },
|
||||
];
|
||||
|
||||
const stages: ProjectStageEntity[] = [];
|
||||
for (const stage of defaultStages) {
|
||||
const created = await this.create(ctx, {
|
||||
projectId,
|
||||
...stage,
|
||||
});
|
||||
stages.push(created);
|
||||
}
|
||||
|
||||
return stages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle fold state
|
||||
*/
|
||||
async toggleFold(ctx: ServiceContext, id: string): Promise<ProjectStageEntity | null> {
|
||||
const entity = await this.findById(ctx, id);
|
||||
if (!entity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.update(ctx, id, { fold: !entity.fold });
|
||||
}
|
||||
|
||||
/**
|
||||
* Count stages for a project
|
||||
*/
|
||||
async countStages(ctx: ServiceContext, projectId?: string): Promise<number> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('s')
|
||||
.where('s.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (projectId) {
|
||||
qb.andWhere('(s.project_id = :projectId OR s.project_id IS NULL)', { projectId });
|
||||
} else {
|
||||
qb.andWhere('s.project_id IS NULL');
|
||||
}
|
||||
|
||||
return qb.getCount();
|
||||
}
|
||||
}
|
||||
381
src/modules/projects/services/project.service.ts
Normal file
381
src/modules/projects/services/project.service.ts
Normal file
@ -0,0 +1,381 @@
|
||||
/**
|
||||
* ProjectService - Generic Project Management Service
|
||||
*
|
||||
* Provides CRUD operations and lifecycle management for projects.
|
||||
* Supports multi-tenant architecture with ServiceContext pattern.
|
||||
*
|
||||
* @module Projects
|
||||
*/
|
||||
|
||||
import { DataSource, Repository, IsNull } from 'typeorm';
|
||||
import { ProjectEntity, ProjectStatus, ProjectPrivacy } from '../entities/project.entity';
|
||||
import { TaskEntity } from '../entities/task.entity';
|
||||
import { TimesheetEntity } from '../entities/timesheet.entity';
|
||||
import { MilestoneEntity } from '../entities/milestone.entity';
|
||||
|
||||
export interface ServiceContext {
|
||||
tenantId: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface CreateProjectDto {
|
||||
companyId: string;
|
||||
name: string;
|
||||
code?: string;
|
||||
description?: string;
|
||||
managerId?: string;
|
||||
partnerId?: string;
|
||||
analyticAccountId?: string;
|
||||
dateStart?: string;
|
||||
dateEnd?: string;
|
||||
privacy?: ProjectPrivacy;
|
||||
allowTimesheets?: boolean;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface UpdateProjectDto {
|
||||
name?: string;
|
||||
code?: string | null;
|
||||
description?: string | null;
|
||||
managerId?: string | null;
|
||||
partnerId?: string | null;
|
||||
analyticAccountId?: string | null;
|
||||
dateStart?: string | null;
|
||||
dateEnd?: string | null;
|
||||
status?: ProjectStatus;
|
||||
privacy?: ProjectPrivacy;
|
||||
allowTimesheets?: boolean;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectFilters {
|
||||
companyId?: string;
|
||||
managerId?: string;
|
||||
partnerId?: string;
|
||||
status?: ProjectStatus;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface ProjectStats {
|
||||
totalTasks: number;
|
||||
completedTasks: number;
|
||||
inProgressTasks: number;
|
||||
completionPercentage: number;
|
||||
totalHours: number;
|
||||
totalMilestones: number;
|
||||
completedMilestones: number;
|
||||
}
|
||||
|
||||
export class ProjectService {
|
||||
private repository: Repository<ProjectEntity>;
|
||||
private taskRepository: Repository<TaskEntity>;
|
||||
private timesheetRepository: Repository<TimesheetEntity>;
|
||||
private milestoneRepository: Repository<MilestoneEntity>;
|
||||
|
||||
constructor(dataSource: DataSource) {
|
||||
this.repository = dataSource.getRepository(ProjectEntity);
|
||||
this.taskRepository = dataSource.getRepository(TaskEntity);
|
||||
this.timesheetRepository = dataSource.getRepository(TimesheetEntity);
|
||||
this.milestoneRepository = dataSource.getRepository(MilestoneEntity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find project by ID
|
||||
*/
|
||||
async findById(ctx: ServiceContext, id: string): Promise<ProjectEntity | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
id,
|
||||
tenantId: ctx.tenantId,
|
||||
deletedAt: IsNull(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all projects with pagination and filters
|
||||
*/
|
||||
async findAll(
|
||||
ctx: ServiceContext,
|
||||
filters: ProjectFilters = {},
|
||||
page = 1,
|
||||
limit = 20
|
||||
): Promise<PaginatedResult<ProjectEntity>> {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('p')
|
||||
.where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('p.deleted_at IS NULL');
|
||||
|
||||
if (filters.companyId) {
|
||||
qb.andWhere('p.company_id = :companyId', { companyId: filters.companyId });
|
||||
}
|
||||
if (filters.managerId) {
|
||||
qb.andWhere('p.manager_id = :managerId', { managerId: filters.managerId });
|
||||
}
|
||||
if (filters.partnerId) {
|
||||
qb.andWhere('p.partner_id = :partnerId', { partnerId: filters.partnerId });
|
||||
}
|
||||
if (filters.status) {
|
||||
qb.andWhere('p.status = :status', { status: filters.status });
|
||||
}
|
||||
if (filters.search) {
|
||||
qb.andWhere('(p.name ILIKE :search OR p.code ILIKE :search)', {
|
||||
search: `%${filters.search}%`,
|
||||
});
|
||||
}
|
||||
|
||||
qb.orderBy('p.created_at', 'DESC').skip(skip).take(limit);
|
||||
|
||||
const [data, total] = await qb.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new project
|
||||
*/
|
||||
async create(ctx: ServiceContext, dto: CreateProjectDto): Promise<ProjectEntity> {
|
||||
const entity = this.repository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
companyId: dto.companyId,
|
||||
name: dto.name,
|
||||
code: dto.code || null,
|
||||
description: dto.description || null,
|
||||
managerId: dto.managerId || null,
|
||||
partnerId: dto.partnerId || null,
|
||||
analyticAccountId: dto.analyticAccountId || null,
|
||||
dateStart: dto.dateStart ? new Date(dto.dateStart) : null,
|
||||
dateEnd: dto.dateEnd ? new Date(dto.dateEnd) : null,
|
||||
privacy: dto.privacy || ProjectPrivacy.PUBLIC,
|
||||
allowTimesheets: dto.allowTimesheets ?? true,
|
||||
color: dto.color || null,
|
||||
status: ProjectStatus.DRAFT,
|
||||
createdBy: ctx.userId || null,
|
||||
});
|
||||
|
||||
return this.repository.save(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a project
|
||||
*/
|
||||
async update(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
dto: UpdateProjectDto
|
||||
): Promise<ProjectEntity | null> {
|
||||
const entity = await this.findById(ctx, id);
|
||||
if (!entity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (dto.name !== undefined) entity.name = dto.name;
|
||||
if (dto.code !== undefined) entity.code = dto.code;
|
||||
if (dto.description !== undefined) entity.description = dto.description;
|
||||
if (dto.managerId !== undefined) entity.managerId = dto.managerId;
|
||||
if (dto.partnerId !== undefined) entity.partnerId = dto.partnerId;
|
||||
if (dto.analyticAccountId !== undefined) entity.analyticAccountId = dto.analyticAccountId;
|
||||
if (dto.dateStart !== undefined) {
|
||||
entity.dateStart = dto.dateStart ? new Date(dto.dateStart) : null;
|
||||
}
|
||||
if (dto.dateEnd !== undefined) {
|
||||
entity.dateEnd = dto.dateEnd ? new Date(dto.dateEnd) : null;
|
||||
}
|
||||
if (dto.status !== undefined) entity.status = dto.status;
|
||||
if (dto.privacy !== undefined) entity.privacy = dto.privacy;
|
||||
if (dto.allowTimesheets !== undefined) entity.allowTimesheets = dto.allowTimesheets;
|
||||
if (dto.color !== undefined) entity.color = dto.color;
|
||||
|
||||
entity.updatedBy = ctx.userId || null;
|
||||
|
||||
return this.repository.save(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete a project
|
||||
*/
|
||||
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
|
||||
const entity = await this.findById(ctx, id);
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
entity.deletedAt = new Date();
|
||||
entity.deletedBy = ctx.userId || null;
|
||||
await this.repository.save(entity);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project with relations (tasks, milestones, members)
|
||||
*/
|
||||
async findWithRelations(ctx: ServiceContext, id: string): Promise<ProjectEntity | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
id,
|
||||
tenantId: ctx.tenantId,
|
||||
deletedAt: IsNull(),
|
||||
},
|
||||
relations: ['tasks', 'milestones', 'members'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project statistics
|
||||
*/
|
||||
async getStats(ctx: ServiceContext, id: string): Promise<ProjectStats | null> {
|
||||
const project = await this.findById(ctx, id);
|
||||
if (!project) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get task counts
|
||||
const totalTasks = await this.taskRepository.count({
|
||||
where: { projectId: id, tenantId: ctx.tenantId, deletedAt: IsNull() },
|
||||
});
|
||||
|
||||
const completedTasks = await this.taskRepository.count({
|
||||
where: { projectId: id, tenantId: ctx.tenantId, deletedAt: IsNull(), status: 'done' as any },
|
||||
});
|
||||
|
||||
const inProgressTasks = await this.taskRepository.count({
|
||||
where: { projectId: id, tenantId: ctx.tenantId, deletedAt: IsNull(), status: 'in_progress' as any },
|
||||
});
|
||||
|
||||
// Get total hours from timesheets
|
||||
const hoursResult = await this.timesheetRepository
|
||||
.createQueryBuilder('t')
|
||||
.select('SUM(t.hours)', 'total')
|
||||
.where('t.project_id = :projectId', { projectId: id })
|
||||
.andWhere('t.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.getRawOne();
|
||||
|
||||
const totalHours = parseFloat(hoursResult?.total || '0');
|
||||
|
||||
// Get milestone counts
|
||||
const totalMilestones = await this.milestoneRepository.count({
|
||||
where: { projectId: id, tenantId: ctx.tenantId },
|
||||
});
|
||||
|
||||
const completedMilestones = await this.milestoneRepository.count({
|
||||
where: { projectId: id, tenantId: ctx.tenantId, status: 'completed' as any },
|
||||
});
|
||||
|
||||
return {
|
||||
totalTasks,
|
||||
completedTasks,
|
||||
inProgressTasks,
|
||||
completionPercentage: totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0,
|
||||
totalHours,
|
||||
totalMilestones,
|
||||
completedMilestones,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a project (change status to active)
|
||||
*/
|
||||
async activate(ctx: ServiceContext, id: string): Promise<ProjectEntity | null> {
|
||||
return this.update(ctx, id, { status: ProjectStatus.ACTIVE });
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a project
|
||||
*/
|
||||
async complete(ctx: ServiceContext, id: string): Promise<ProjectEntity | null> {
|
||||
return this.update(ctx, id, { status: ProjectStatus.COMPLETED });
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a project
|
||||
*/
|
||||
async cancel(ctx: ServiceContext, id: string): Promise<ProjectEntity | null> {
|
||||
return this.update(ctx, id, { status: ProjectStatus.CANCELLED });
|
||||
}
|
||||
|
||||
/**
|
||||
* Put project on hold
|
||||
*/
|
||||
async hold(ctx: ServiceContext, id: string): Promise<ProjectEntity | null> {
|
||||
return this.update(ctx, id, { status: ProjectStatus.ON_HOLD });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find projects by company
|
||||
*/
|
||||
async findByCompany(
|
||||
ctx: ServiceContext,
|
||||
companyId: string,
|
||||
page = 1,
|
||||
limit = 20
|
||||
): Promise<PaginatedResult<ProjectEntity>> {
|
||||
return this.findAll(ctx, { companyId }, page, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find projects by manager
|
||||
*/
|
||||
async findByManager(
|
||||
ctx: ServiceContext,
|
||||
managerId: string,
|
||||
page = 1,
|
||||
limit = 20
|
||||
): Promise<PaginatedResult<ProjectEntity>> {
|
||||
return this.findAll(ctx, { managerId }, page, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find active projects
|
||||
*/
|
||||
async findActive(
|
||||
ctx: ServiceContext,
|
||||
page = 1,
|
||||
limit = 20
|
||||
): Promise<PaginatedResult<ProjectEntity>> {
|
||||
return this.findAll(ctx, { status: ProjectStatus.ACTIVE }, page, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a project (without tasks/milestones)
|
||||
*/
|
||||
async duplicate(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
newName: string,
|
||||
newCode?: string
|
||||
): Promise<ProjectEntity | null> {
|
||||
const original = await this.findById(ctx, id);
|
||||
if (!original) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.create(ctx, {
|
||||
companyId: original.companyId,
|
||||
name: newName,
|
||||
code: newCode,
|
||||
description: original.description || undefined,
|
||||
managerId: original.managerId || undefined,
|
||||
partnerId: original.partnerId || undefined,
|
||||
analyticAccountId: original.analyticAccountId || undefined,
|
||||
privacy: original.privacy,
|
||||
allowTimesheets: original.allowTimesheets,
|
||||
color: original.color || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
402
src/modules/projects/services/task.service.ts
Normal file
402
src/modules/projects/services/task.service.ts
Normal file
@ -0,0 +1,402 @@
|
||||
/**
|
||||
* TaskService - Task Management Service
|
||||
*
|
||||
* Provides CRUD operations and task management for projects.
|
||||
* Supports multi-tenant architecture with ServiceContext pattern.
|
||||
*
|
||||
* @module Projects
|
||||
*/
|
||||
|
||||
import { DataSource, Repository, IsNull } from 'typeorm';
|
||||
import { TaskEntity, TaskStatus, TaskPriority } from '../entities/task.entity';
|
||||
import { TimesheetEntity } from '../entities/timesheet.entity';
|
||||
import { ServiceContext, PaginatedResult } from './project.service';
|
||||
|
||||
export interface CreateTaskDto {
|
||||
projectId: string;
|
||||
stageId?: string;
|
||||
parentId?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
assignedTo?: string;
|
||||
dateDeadline?: string;
|
||||
estimatedHours?: number;
|
||||
priority?: TaskPriority;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface UpdateTaskDto {
|
||||
stageId?: string | null;
|
||||
parentId?: string | null;
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
assignedTo?: string | null;
|
||||
dateDeadline?: string | null;
|
||||
estimatedHours?: number | null;
|
||||
priority?: TaskPriority;
|
||||
status?: TaskStatus;
|
||||
sequence?: number;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
export interface TaskFilters {
|
||||
projectId?: string;
|
||||
stageId?: string;
|
||||
parentId?: string;
|
||||
assignedTo?: string;
|
||||
status?: TaskStatus;
|
||||
priority?: TaskPriority;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface TaskWithHours extends TaskEntity {
|
||||
spentHours?: number;
|
||||
}
|
||||
|
||||
export class TaskService {
|
||||
private repository: Repository<TaskEntity>;
|
||||
private timesheetRepository: Repository<TimesheetEntity>;
|
||||
|
||||
constructor(dataSource: DataSource) {
|
||||
this.repository = dataSource.getRepository(TaskEntity);
|
||||
this.timesheetRepository = dataSource.getRepository(TimesheetEntity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find task by ID
|
||||
*/
|
||||
async findById(ctx: ServiceContext, id: string): Promise<TaskEntity | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
id,
|
||||
tenantId: ctx.tenantId,
|
||||
deletedAt: IsNull(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find task by ID with spent hours
|
||||
*/
|
||||
async findByIdWithHours(ctx: ServiceContext, id: string): Promise<TaskWithHours | null> {
|
||||
const task = await this.findById(ctx, id);
|
||||
if (!task) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hoursResult = await this.timesheetRepository
|
||||
.createQueryBuilder('t')
|
||||
.select('SUM(t.hours)', 'total')
|
||||
.where('t.task_id = :taskId', { taskId: id })
|
||||
.andWhere('t.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.getRawOne();
|
||||
|
||||
return {
|
||||
...task,
|
||||
spentHours: parseFloat(hoursResult?.total || '0'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all tasks with pagination and filters
|
||||
*/
|
||||
async findAll(
|
||||
ctx: ServiceContext,
|
||||
filters: TaskFilters = {},
|
||||
page = 1,
|
||||
limit = 20
|
||||
): Promise<PaginatedResult<TaskEntity>> {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('t')
|
||||
.where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('t.deleted_at IS NULL');
|
||||
|
||||
if (filters.projectId) {
|
||||
qb.andWhere('t.project_id = :projectId', { projectId: filters.projectId });
|
||||
}
|
||||
if (filters.stageId) {
|
||||
qb.andWhere('t.stage_id = :stageId', { stageId: filters.stageId });
|
||||
}
|
||||
if (filters.parentId) {
|
||||
qb.andWhere('t.parent_id = :parentId', { parentId: filters.parentId });
|
||||
}
|
||||
if (filters.assignedTo) {
|
||||
qb.andWhere('t.assigned_to = :assignedTo', { assignedTo: filters.assignedTo });
|
||||
}
|
||||
if (filters.status) {
|
||||
qb.andWhere('t.status = :status', { status: filters.status });
|
||||
}
|
||||
if (filters.priority) {
|
||||
qb.andWhere('t.priority = :priority', { priority: filters.priority });
|
||||
}
|
||||
if (filters.search) {
|
||||
qb.andWhere('t.name ILIKE :search', { search: `%${filters.search}%` });
|
||||
}
|
||||
|
||||
qb.orderBy('t.sequence', 'ASC').addOrderBy('t.created_at', 'DESC').skip(skip).take(limit);
|
||||
|
||||
const [data, total] = await qb.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new task
|
||||
*/
|
||||
async create(ctx: ServiceContext, dto: CreateTaskDto): Promise<TaskEntity> {
|
||||
// Get next sequence for project
|
||||
const maxSeq = await this.repository
|
||||
.createQueryBuilder('t')
|
||||
.select('COALESCE(MAX(t.sequence), 0) + 1', 'nextSeq')
|
||||
.where('t.project_id = :projectId', { projectId: dto.projectId })
|
||||
.andWhere('t.deleted_at IS NULL')
|
||||
.getRawOne();
|
||||
|
||||
const entity = this.repository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
projectId: dto.projectId,
|
||||
stageId: dto.stageId || null,
|
||||
parentId: dto.parentId || null,
|
||||
name: dto.name,
|
||||
description: dto.description || null,
|
||||
assignedTo: dto.assignedTo || null,
|
||||
dateDeadline: dto.dateDeadline ? new Date(dto.dateDeadline) : null,
|
||||
estimatedHours: dto.estimatedHours || 0,
|
||||
priority: dto.priority || TaskPriority.NORMAL,
|
||||
status: TaskStatus.TODO,
|
||||
sequence: parseInt(maxSeq?.nextSeq || '1'),
|
||||
color: dto.color || null,
|
||||
createdBy: ctx.userId || null,
|
||||
});
|
||||
|
||||
return this.repository.save(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a task
|
||||
*/
|
||||
async update(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
dto: UpdateTaskDto
|
||||
): Promise<TaskEntity | null> {
|
||||
const entity = await this.findById(ctx, id);
|
||||
if (!entity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prevent circular parent reference
|
||||
if (dto.parentId !== undefined && dto.parentId === id) {
|
||||
throw new Error('A task cannot be its own parent');
|
||||
}
|
||||
|
||||
if (dto.stageId !== undefined) entity.stageId = dto.stageId;
|
||||
if (dto.parentId !== undefined) entity.parentId = dto.parentId;
|
||||
if (dto.name !== undefined) entity.name = dto.name;
|
||||
if (dto.description !== undefined) entity.description = dto.description;
|
||||
if (dto.assignedTo !== undefined) entity.assignedTo = dto.assignedTo;
|
||||
if (dto.dateDeadline !== undefined) {
|
||||
entity.dateDeadline = dto.dateDeadline ? new Date(dto.dateDeadline) : null;
|
||||
}
|
||||
if (dto.estimatedHours !== undefined) {
|
||||
entity.estimatedHours = dto.estimatedHours ?? 0;
|
||||
}
|
||||
if (dto.priority !== undefined) entity.priority = dto.priority;
|
||||
if (dto.status !== undefined) entity.status = dto.status;
|
||||
if (dto.sequence !== undefined) entity.sequence = dto.sequence;
|
||||
if (dto.color !== undefined) entity.color = dto.color;
|
||||
|
||||
entity.updatedBy = ctx.userId || null;
|
||||
|
||||
return this.repository.save(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete a task
|
||||
*/
|
||||
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
|
||||
const entity = await this.findById(ctx, id);
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
entity.deletedAt = new Date();
|
||||
entity.deletedBy = ctx.userId || null;
|
||||
await this.repository.save(entity);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move task to a different stage and sequence
|
||||
*/
|
||||
async move(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
stageId: string | null,
|
||||
sequence: number
|
||||
): Promise<TaskEntity | null> {
|
||||
return this.update(ctx, id, { stageId, sequence });
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign task to a user
|
||||
*/
|
||||
async assign(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
userId: string | null
|
||||
): Promise<TaskEntity | null> {
|
||||
return this.update(ctx, id, { assignedTo: userId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Change task status
|
||||
*/
|
||||
async changeStatus(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
status: TaskStatus
|
||||
): Promise<TaskEntity | null> {
|
||||
return this.update(ctx, id, { status });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark task as done
|
||||
*/
|
||||
async complete(ctx: ServiceContext, id: string): Promise<TaskEntity | null> {
|
||||
return this.changeStatus(ctx, id, TaskStatus.DONE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark task as in progress
|
||||
*/
|
||||
async start(ctx: ServiceContext, id: string): Promise<TaskEntity | null> {
|
||||
return this.changeStatus(ctx, id, TaskStatus.IN_PROGRESS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a task
|
||||
*/
|
||||
async cancel(ctx: ServiceContext, id: string): Promise<TaskEntity | null> {
|
||||
return this.changeStatus(ctx, id, TaskStatus.CANCELLED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find tasks by project
|
||||
*/
|
||||
async findByProject(
|
||||
ctx: ServiceContext,
|
||||
projectId: string,
|
||||
page = 1,
|
||||
limit = 20
|
||||
): Promise<PaginatedResult<TaskEntity>> {
|
||||
return this.findAll(ctx, { projectId }, page, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find tasks assigned to a user
|
||||
*/
|
||||
async findByAssignee(
|
||||
ctx: ServiceContext,
|
||||
assignedTo: string,
|
||||
page = 1,
|
||||
limit = 20
|
||||
): Promise<PaginatedResult<TaskEntity>> {
|
||||
return this.findAll(ctx, { assignedTo }, page, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find subtasks of a parent task
|
||||
*/
|
||||
async findSubtasks(
|
||||
ctx: ServiceContext,
|
||||
parentId: string,
|
||||
page = 1,
|
||||
limit = 20
|
||||
): Promise<PaginatedResult<TaskEntity>> {
|
||||
return this.findAll(ctx, { parentId }, page, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find overdue tasks
|
||||
*/
|
||||
async findOverdue(
|
||||
ctx: ServiceContext,
|
||||
projectId?: string,
|
||||
page = 1,
|
||||
limit = 20
|
||||
): Promise<PaginatedResult<TaskEntity>> {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('t')
|
||||
.where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('t.deleted_at IS NULL')
|
||||
.andWhere('t.date_deadline < :today', { today: new Date() })
|
||||
.andWhere('t.status NOT IN (:...completedStatuses)', {
|
||||
completedStatuses: [TaskStatus.DONE, TaskStatus.CANCELLED],
|
||||
});
|
||||
|
||||
if (projectId) {
|
||||
qb.andWhere('t.project_id = :projectId', { projectId });
|
||||
}
|
||||
|
||||
qb.orderBy('t.date_deadline', 'ASC').skip(skip).take(limit);
|
||||
|
||||
const [data, total] = await qb.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task with timesheets
|
||||
*/
|
||||
async findWithTimesheets(ctx: ServiceContext, id: string): Promise<TaskEntity | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
id,
|
||||
tenantId: ctx.tenantId,
|
||||
deletedAt: IsNull(),
|
||||
},
|
||||
relations: ['timesheets'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a task
|
||||
*/
|
||||
async duplicate(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
newName?: string
|
||||
): Promise<TaskEntity | null> {
|
||||
const original = await this.findById(ctx, id);
|
||||
if (!original) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.create(ctx, {
|
||||
projectId: original.projectId,
|
||||
stageId: original.stageId || undefined,
|
||||
name: newName || `${original.name} (copy)`,
|
||||
description: original.description || undefined,
|
||||
priority: original.priority,
|
||||
estimatedHours: original.estimatedHours,
|
||||
color: original.color || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
435
src/modules/projects/services/timesheet.service.ts
Normal file
435
src/modules/projects/services/timesheet.service.ts
Normal file
@ -0,0 +1,435 @@
|
||||
/**
|
||||
* TimesheetService - Time Tracking Service
|
||||
*
|
||||
* Provides time entry management with approval workflow.
|
||||
* Supports multi-tenant architecture with ServiceContext pattern.
|
||||
*
|
||||
* @module Projects
|
||||
*/
|
||||
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { TimesheetEntity, TimesheetStatus } from '../entities/timesheet.entity';
|
||||
import { ProjectEntity } from '../entities/project.entity';
|
||||
import { ServiceContext, PaginatedResult } from './project.service';
|
||||
|
||||
export interface CreateTimesheetDto {
|
||||
companyId: string;
|
||||
projectId: string;
|
||||
taskId?: string;
|
||||
date: string;
|
||||
hours: number;
|
||||
description?: string;
|
||||
billable?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateTimesheetDto {
|
||||
taskId?: string | null;
|
||||
date?: string;
|
||||
hours?: number;
|
||||
description?: string | null;
|
||||
billable?: boolean;
|
||||
}
|
||||
|
||||
export interface TimesheetFilters {
|
||||
companyId?: string;
|
||||
projectId?: string;
|
||||
taskId?: string;
|
||||
userId?: string;
|
||||
status?: TimesheetStatus;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
billable?: boolean;
|
||||
invoiced?: boolean;
|
||||
}
|
||||
|
||||
export interface TimesheetSummary {
|
||||
totalHours: number;
|
||||
billableHours: number;
|
||||
nonBillableHours: number;
|
||||
approvedHours: number;
|
||||
pendingHours: number;
|
||||
rejectedHours: number;
|
||||
}
|
||||
|
||||
export class TimesheetService {
|
||||
private repository: Repository<TimesheetEntity>;
|
||||
private projectRepository: Repository<ProjectEntity>;
|
||||
|
||||
constructor(dataSource: DataSource) {
|
||||
this.repository = dataSource.getRepository(TimesheetEntity);
|
||||
this.projectRepository = dataSource.getRepository(ProjectEntity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find timesheet by ID
|
||||
*/
|
||||
async findById(ctx: ServiceContext, id: string): Promise<TimesheetEntity | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
id,
|
||||
tenantId: ctx.tenantId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all timesheets with pagination and filters
|
||||
*/
|
||||
async findAll(
|
||||
ctx: ServiceContext,
|
||||
filters: TimesheetFilters = {},
|
||||
page = 1,
|
||||
limit = 20
|
||||
): Promise<PaginatedResult<TimesheetEntity>> {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('t')
|
||||
.where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (filters.companyId) {
|
||||
qb.andWhere('t.company_id = :companyId', { companyId: filters.companyId });
|
||||
}
|
||||
if (filters.projectId) {
|
||||
qb.andWhere('t.project_id = :projectId', { projectId: filters.projectId });
|
||||
}
|
||||
if (filters.taskId) {
|
||||
qb.andWhere('t.task_id = :taskId', { taskId: filters.taskId });
|
||||
}
|
||||
if (filters.userId) {
|
||||
qb.andWhere('t.user_id = :userId', { userId: filters.userId });
|
||||
}
|
||||
if (filters.status) {
|
||||
qb.andWhere('t.status = :status', { status: filters.status });
|
||||
}
|
||||
if (filters.dateFrom) {
|
||||
qb.andWhere('t.date >= :dateFrom', { dateFrom: filters.dateFrom });
|
||||
}
|
||||
if (filters.dateTo) {
|
||||
qb.andWhere('t.date <= :dateTo', { dateTo: filters.dateTo });
|
||||
}
|
||||
if (filters.billable !== undefined) {
|
||||
qb.andWhere('t.billable = :billable', { billable: filters.billable });
|
||||
}
|
||||
if (filters.invoiced !== undefined) {
|
||||
qb.andWhere('t.invoiced = :invoiced', { invoiced: filters.invoiced });
|
||||
}
|
||||
|
||||
qb.orderBy('t.date', 'DESC').addOrderBy('t.created_at', 'DESC').skip(skip).take(limit);
|
||||
|
||||
const [data, total] = await qb.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new timesheet entry
|
||||
*/
|
||||
async create(ctx: ServiceContext, dto: CreateTimesheetDto): Promise<TimesheetEntity> {
|
||||
// Validate hours
|
||||
if (dto.hours <= 0 || dto.hours > 24) {
|
||||
throw new Error('Hours must be between 0 and 24');
|
||||
}
|
||||
|
||||
// Validate project exists and allows timesheets
|
||||
const project = await this.projectRepository.findOne({
|
||||
where: { id: dto.projectId, tenantId: ctx.tenantId },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new Error('Project not found');
|
||||
}
|
||||
|
||||
if (!project.allowTimesheets) {
|
||||
throw new Error('This project does not allow timesheets');
|
||||
}
|
||||
|
||||
const entity = this.repository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
companyId: dto.companyId,
|
||||
projectId: dto.projectId,
|
||||
taskId: dto.taskId || null,
|
||||
userId: ctx.userId!,
|
||||
date: new Date(dto.date),
|
||||
hours: dto.hours,
|
||||
description: dto.description || null,
|
||||
billable: dto.billable ?? true,
|
||||
invoiced: false,
|
||||
status: TimesheetStatus.DRAFT,
|
||||
createdBy: ctx.userId || null,
|
||||
});
|
||||
|
||||
return this.repository.save(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a timesheet entry
|
||||
*/
|
||||
async update(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
dto: UpdateTimesheetDto
|
||||
): Promise<TimesheetEntity | null> {
|
||||
const entity = await this.findById(ctx, id);
|
||||
if (!entity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only draft timesheets can be edited
|
||||
if (entity.status !== TimesheetStatus.DRAFT) {
|
||||
throw new Error('Only draft timesheets can be edited');
|
||||
}
|
||||
|
||||
// Only the owner can edit
|
||||
if (entity.userId !== ctx.userId) {
|
||||
throw new Error('You can only edit your own timesheets');
|
||||
}
|
||||
|
||||
if (dto.taskId !== undefined) entity.taskId = dto.taskId;
|
||||
if (dto.date !== undefined) entity.date = new Date(dto.date);
|
||||
if (dto.hours !== undefined) {
|
||||
if (dto.hours <= 0 || dto.hours > 24) {
|
||||
throw new Error('Hours must be between 0 and 24');
|
||||
}
|
||||
entity.hours = dto.hours;
|
||||
}
|
||||
if (dto.description !== undefined) entity.description = dto.description;
|
||||
if (dto.billable !== undefined) entity.billable = dto.billable;
|
||||
|
||||
entity.updatedBy = ctx.userId || null;
|
||||
|
||||
return this.repository.save(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a timesheet entry
|
||||
*/
|
||||
async delete(ctx: ServiceContext, id: string): Promise<boolean> {
|
||||
const entity = await this.findById(ctx, id);
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only draft timesheets can be deleted
|
||||
if (entity.status !== TimesheetStatus.DRAFT) {
|
||||
throw new Error('Only draft timesheets can be deleted');
|
||||
}
|
||||
|
||||
// Only the owner can delete
|
||||
if (entity.userId !== ctx.userId) {
|
||||
throw new Error('You can only delete your own timesheets');
|
||||
}
|
||||
|
||||
await this.repository.delete(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit timesheet for approval
|
||||
*/
|
||||
async submit(ctx: ServiceContext, id: string): Promise<TimesheetEntity | null> {
|
||||
const entity = await this.findById(ctx, id);
|
||||
if (!entity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (entity.status !== TimesheetStatus.DRAFT) {
|
||||
throw new Error('Only draft timesheets can be submitted');
|
||||
}
|
||||
|
||||
if (entity.userId !== ctx.userId) {
|
||||
throw new Error('You can only submit your own timesheets');
|
||||
}
|
||||
|
||||
entity.status = TimesheetStatus.SUBMITTED;
|
||||
entity.updatedBy = ctx.userId || null;
|
||||
|
||||
return this.repository.save(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a timesheet
|
||||
*/
|
||||
async approve(ctx: ServiceContext, id: string): Promise<TimesheetEntity | null> {
|
||||
const entity = await this.findById(ctx, id);
|
||||
if (!entity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (entity.status !== TimesheetStatus.SUBMITTED) {
|
||||
throw new Error('Only submitted timesheets can be approved');
|
||||
}
|
||||
|
||||
entity.status = TimesheetStatus.APPROVED;
|
||||
entity.approvedBy = ctx.userId || null;
|
||||
entity.approvedAt = new Date();
|
||||
entity.updatedBy = ctx.userId || null;
|
||||
|
||||
return this.repository.save(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a timesheet
|
||||
*/
|
||||
async reject(ctx: ServiceContext, id: string): Promise<TimesheetEntity | null> {
|
||||
const entity = await this.findById(ctx, id);
|
||||
if (!entity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (entity.status !== TimesheetStatus.SUBMITTED) {
|
||||
throw new Error('Only submitted timesheets can be rejected');
|
||||
}
|
||||
|
||||
entity.status = TimesheetStatus.REJECTED;
|
||||
entity.updatedBy = ctx.userId || null;
|
||||
|
||||
return this.repository.save(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get my timesheets (for current user)
|
||||
*/
|
||||
async findMyTimesheets(
|
||||
ctx: ServiceContext,
|
||||
filters: Omit<TimesheetFilters, 'userId'> = {},
|
||||
page = 1,
|
||||
limit = 20
|
||||
): Promise<PaginatedResult<TimesheetEntity>> {
|
||||
return this.findAll(ctx, { ...filters, userId: ctx.userId }, page, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending approvals
|
||||
*/
|
||||
async findPendingApprovals(
|
||||
ctx: ServiceContext,
|
||||
filters: Omit<TimesheetFilters, 'status'> = {},
|
||||
page = 1,
|
||||
limit = 20
|
||||
): Promise<PaginatedResult<TimesheetEntity>> {
|
||||
return this.findAll(ctx, { ...filters, status: TimesheetStatus.SUBMITTED }, page, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get timesheet summary for a project
|
||||
*/
|
||||
async getProjectSummary(ctx: ServiceContext, projectId: string): Promise<TimesheetSummary> {
|
||||
const results = await this.repository
|
||||
.createQueryBuilder('t')
|
||||
.select([
|
||||
'SUM(t.hours) as total_hours',
|
||||
'SUM(CASE WHEN t.billable = true THEN t.hours ELSE 0 END) as billable_hours',
|
||||
'SUM(CASE WHEN t.billable = false THEN t.hours ELSE 0 END) as non_billable_hours',
|
||||
'SUM(CASE WHEN t.status = :approved THEN t.hours ELSE 0 END) as approved_hours',
|
||||
'SUM(CASE WHEN t.status IN (:...pending) THEN t.hours ELSE 0 END) as pending_hours',
|
||||
'SUM(CASE WHEN t.status = :rejected THEN t.hours ELSE 0 END) as rejected_hours',
|
||||
])
|
||||
.where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('t.project_id = :projectId', { projectId })
|
||||
.setParameters({
|
||||
approved: TimesheetStatus.APPROVED,
|
||||
pending: [TimesheetStatus.DRAFT, TimesheetStatus.SUBMITTED],
|
||||
rejected: TimesheetStatus.REJECTED,
|
||||
})
|
||||
.getRawOne();
|
||||
|
||||
return {
|
||||
totalHours: parseFloat(results?.total_hours || '0'),
|
||||
billableHours: parseFloat(results?.billable_hours || '0'),
|
||||
nonBillableHours: parseFloat(results?.non_billable_hours || '0'),
|
||||
approvedHours: parseFloat(results?.approved_hours || '0'),
|
||||
pendingHours: parseFloat(results?.pending_hours || '0'),
|
||||
rejectedHours: parseFloat(results?.rejected_hours || '0'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get timesheet summary for a user in a date range
|
||||
*/
|
||||
async getUserSummary(
|
||||
ctx: ServiceContext,
|
||||
userId: string,
|
||||
dateFrom: string,
|
||||
dateTo: string
|
||||
): Promise<TimesheetSummary> {
|
||||
const results = await this.repository
|
||||
.createQueryBuilder('t')
|
||||
.select([
|
||||
'SUM(t.hours) as total_hours',
|
||||
'SUM(CASE WHEN t.billable = true THEN t.hours ELSE 0 END) as billable_hours',
|
||||
'SUM(CASE WHEN t.billable = false THEN t.hours ELSE 0 END) as non_billable_hours',
|
||||
'SUM(CASE WHEN t.status = :approved THEN t.hours ELSE 0 END) as approved_hours',
|
||||
'SUM(CASE WHEN t.status IN (:...pending) THEN t.hours ELSE 0 END) as pending_hours',
|
||||
'SUM(CASE WHEN t.status = :rejected THEN t.hours ELSE 0 END) as rejected_hours',
|
||||
])
|
||||
.where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('t.user_id = :userId', { userId })
|
||||
.andWhere('t.date >= :dateFrom', { dateFrom })
|
||||
.andWhere('t.date <= :dateTo', { dateTo })
|
||||
.setParameters({
|
||||
approved: TimesheetStatus.APPROVED,
|
||||
pending: [TimesheetStatus.DRAFT, TimesheetStatus.SUBMITTED],
|
||||
rejected: TimesheetStatus.REJECTED,
|
||||
})
|
||||
.getRawOne();
|
||||
|
||||
return {
|
||||
totalHours: parseFloat(results?.total_hours || '0'),
|
||||
billableHours: parseFloat(results?.billable_hours || '0'),
|
||||
nonBillableHours: parseFloat(results?.non_billable_hours || '0'),
|
||||
approvedHours: parseFloat(results?.approved_hours || '0'),
|
||||
pendingHours: parseFloat(results?.pending_hours || '0'),
|
||||
rejectedHours: parseFloat(results?.rejected_hours || '0'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark timesheets as invoiced
|
||||
*/
|
||||
async markAsInvoiced(
|
||||
ctx: ServiceContext,
|
||||
ids: string[],
|
||||
invoiceId: string
|
||||
): Promise<number> {
|
||||
const result = await this.repository
|
||||
.createQueryBuilder()
|
||||
.update(TimesheetEntity)
|
||||
.set({ invoiced: true, invoiceId, updatedBy: ctx.userId || null })
|
||||
.where('id IN (:...ids)', { ids })
|
||||
.andWhere('tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('status = :status', { status: TimesheetStatus.APPROVED })
|
||||
.andWhere('invoiced = false')
|
||||
.execute();
|
||||
|
||||
return result.affected || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find billable but not invoiced timesheets
|
||||
*/
|
||||
async findBillableNotInvoiced(
|
||||
ctx: ServiceContext,
|
||||
projectId?: string,
|
||||
page = 1,
|
||||
limit = 20
|
||||
): Promise<PaginatedResult<TimesheetEntity>> {
|
||||
const filters: TimesheetFilters = {
|
||||
billable: true,
|
||||
invoiced: false,
|
||||
status: TimesheetStatus.APPROVED,
|
||||
};
|
||||
|
||||
if (projectId) {
|
||||
filters.projectId = projectId;
|
||||
}
|
||||
|
||||
return this.findAll(ctx, filters, page, limit);
|
||||
}
|
||||
}
|
||||
7
src/modules/sales/controllers/index.ts
Normal file
7
src/modules/sales/controllers/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Sales Controllers Index
|
||||
* @module Sales (MAI-010)
|
||||
*/
|
||||
|
||||
export { default as quotationController } from './quotation.controller';
|
||||
export { default as salesOrderController } from './sales-order.controller';
|
||||
530
src/modules/sales/controllers/quotation.controller.ts
Normal file
530
src/modules/sales/controllers/quotation.controller.ts
Normal file
@ -0,0 +1,530 @@
|
||||
/**
|
||||
* Quotation Controller
|
||||
* API endpoints para gestión de cotizaciones de venta
|
||||
*
|
||||
* @module Sales (MAI-010)
|
||||
* @prefix /api/v1/quotations
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import {
|
||||
QuotationService,
|
||||
CreateQuotationDto,
|
||||
UpdateQuotationDto,
|
||||
CreateQuotationItemDto,
|
||||
UpdateQuotationItemDto,
|
||||
} from '../services/quotation.service';
|
||||
|
||||
const router = Router();
|
||||
const quotationService = new QuotationService(null as any);
|
||||
|
||||
/**
|
||||
* GET /api/v1/quotations
|
||||
* Lista todas las cotizaciones del tenant
|
||||
*/
|
||||
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const {
|
||||
partnerId,
|
||||
salesRepId,
|
||||
status,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
minTotal,
|
||||
maxTotal,
|
||||
search,
|
||||
page,
|
||||
limit,
|
||||
} = req.query;
|
||||
|
||||
const result = await quotationService.findAll(
|
||||
{ tenantId },
|
||||
{
|
||||
partnerId: partnerId as string,
|
||||
salesRepId: salesRepId as string,
|
||||
status: status as string,
|
||||
dateFrom: dateFrom ? new Date(dateFrom as string) : undefined,
|
||||
dateTo: dateTo ? new Date(dateTo as string) : undefined,
|
||||
minTotal: minTotal ? Number(minTotal) : undefined,
|
||||
maxTotal: maxTotal ? Number(maxTotal) : undefined,
|
||||
search: search as string,
|
||||
page: page ? Number(page) : undefined,
|
||||
limit: limit ? Number(limit) : undefined,
|
||||
},
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
limit: result.limit,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/quotations/statistics
|
||||
* Estadísticas de cotizaciones
|
||||
*/
|
||||
router.get('/statistics', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { dateFrom, dateTo } = req.query;
|
||||
const stats = await quotationService.getStatistics(
|
||||
{ tenantId },
|
||||
dateFrom ? new Date(dateFrom as string) : undefined,
|
||||
dateTo ? new Date(dateTo as string) : undefined,
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: stats });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/quotations/pending
|
||||
* Cotizaciones pendientes (borrador o enviadas)
|
||||
*/
|
||||
router.get('/pending', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const data = await quotationService.findPending({ tenantId });
|
||||
return res.json({ success: true, data, count: data.length });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/quotations/expired
|
||||
* Cotizaciones vencidas
|
||||
*/
|
||||
router.get('/expired', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const data = await quotationService.findExpired({ tenantId });
|
||||
return res.json({ success: true, data, count: data.length });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/quotations/by-partner/:partnerId
|
||||
* Cotizaciones por cliente
|
||||
*/
|
||||
router.get('/by-partner/:partnerId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const data = await quotationService.findByPartner({ tenantId }, req.params.partnerId);
|
||||
return res.json({ success: true, data, count: data.length });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/quotations/by-number/:number
|
||||
* Buscar cotización por número
|
||||
*/
|
||||
router.get('/by-number/:number', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const quotation = await quotationService.findByNumber({ tenantId }, req.params.number);
|
||||
if (!quotation) {
|
||||
return res.status(404).json({ error: 'Cotización no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: quotation });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/quotations/:id
|
||||
* Obtiene una cotización por ID
|
||||
*/
|
||||
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const quotation = await quotationService.findById({ tenantId }, req.params.id);
|
||||
if (!quotation) {
|
||||
return res.status(404).json({ error: 'Cotización no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: quotation });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/quotations/:id/with-items
|
||||
* Obtiene una cotización con sus líneas
|
||||
*/
|
||||
router.get('/:id/with-items', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const quotation = await quotationService.findByIdWithItems({ tenantId }, req.params.id);
|
||||
if (!quotation) {
|
||||
return res.status(404).json({ error: 'Cotización no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: quotation });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/quotations
|
||||
* Crea una nueva cotización
|
||||
*/
|
||||
router.post('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const dto: CreateQuotationDto = req.body;
|
||||
|
||||
if (!dto.partnerId) {
|
||||
return res.status(400).json({ error: 'partnerId es requerido' });
|
||||
}
|
||||
|
||||
const quotation = await quotationService.create({ tenantId, userId }, dto);
|
||||
return res.status(201).json({ success: true, data: quotation });
|
||||
} catch (error: any) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/quotations/:id
|
||||
* Actualiza una cotización
|
||||
*/
|
||||
router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const dto: UpdateQuotationDto = req.body;
|
||||
|
||||
const quotation = await quotationService.update({ tenantId, userId }, req.params.id, dto);
|
||||
if (!quotation) {
|
||||
return res.status(404).json({ error: 'Cotización no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: quotation });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('Cannot update')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/quotations/:id/items
|
||||
* Agrega una línea a la cotización
|
||||
*/
|
||||
router.post('/:id/items', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const dto: CreateQuotationItemDto = req.body;
|
||||
|
||||
if (!dto.productName || dto.quantity === undefined || dto.unitPrice === undefined) {
|
||||
return res.status(400).json({ error: 'productName, quantity y unitPrice son requeridos' });
|
||||
}
|
||||
|
||||
const item = await quotationService.addItem({ tenantId, userId }, req.params.id, dto);
|
||||
if (!item) {
|
||||
return res.status(404).json({ error: 'Cotización no encontrada' });
|
||||
}
|
||||
|
||||
return res.status(201).json({ success: true, data: item });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('Cannot add')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/quotations/:id/items/:itemId
|
||||
* Actualiza una línea de la cotización
|
||||
*/
|
||||
router.patch('/:id/items/:itemId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const dto: UpdateQuotationItemDto = req.body;
|
||||
|
||||
const item = await quotationService.updateItem(
|
||||
{ tenantId, userId },
|
||||
req.params.id,
|
||||
req.params.itemId,
|
||||
dto,
|
||||
);
|
||||
|
||||
if (!item) {
|
||||
return res.status(404).json({ error: 'Línea no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: item });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('Cannot update')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/quotations/:id/items/:itemId
|
||||
* Elimina una línea de la cotización
|
||||
*/
|
||||
router.delete('/:id/items/:itemId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const deleted = await quotationService.removeItem(
|
||||
{ tenantId, userId },
|
||||
req.params.id,
|
||||
req.params.itemId,
|
||||
);
|
||||
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Línea no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, message: 'Línea eliminada' });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('Cannot remove')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/quotations/:id/send
|
||||
* Envía la cotización al cliente
|
||||
*/
|
||||
router.post('/:id/send', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const quotation = await quotationService.send({ tenantId, userId }, req.params.id);
|
||||
|
||||
if (!quotation) {
|
||||
return res.status(404).json({ error: 'Cotización no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: quotation, message: 'Cotización enviada' });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('Only draft')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/quotations/:id/accept
|
||||
* Acepta la cotización
|
||||
*/
|
||||
router.post('/:id/accept', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const quotation = await quotationService.accept({ tenantId, userId }, req.params.id);
|
||||
|
||||
if (!quotation) {
|
||||
return res.status(404).json({ error: 'Cotización no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: quotation, message: 'Cotización aceptada' });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('Only')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/quotations/:id/reject
|
||||
* Rechaza la cotización
|
||||
*/
|
||||
router.post('/:id/reject', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const quotation = await quotationService.reject({ tenantId, userId }, req.params.id);
|
||||
|
||||
if (!quotation) {
|
||||
return res.status(404).json({ error: 'Cotización no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: quotation, message: 'Cotización rechazada' });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('Only')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/quotations/:id/expire
|
||||
* Marca la cotización como vencida
|
||||
*/
|
||||
router.post('/:id/expire', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const quotation = await quotationService.markExpired({ tenantId, userId }, req.params.id);
|
||||
|
||||
if (!quotation) {
|
||||
return res.status(404).json({ error: 'Cotización no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: quotation, message: 'Cotización marcada como vencida' });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('Cannot expire')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/quotations/:id/duplicate
|
||||
* Duplica una cotización
|
||||
*/
|
||||
router.post('/:id/duplicate', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const quotation = await quotationService.duplicate({ tenantId, userId }, req.params.id);
|
||||
|
||||
if (!quotation) {
|
||||
return res.status(404).json({ error: 'Cotización no encontrada' });
|
||||
}
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: quotation,
|
||||
message: 'Cotización duplicada',
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/quotations/:id
|
||||
* Elimina una cotización (soft delete)
|
||||
*/
|
||||
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const deleted = await quotationService.softDelete({ tenantId, userId }, req.params.id);
|
||||
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Cotización no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, message: 'Cotización eliminada' });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('Cannot delete')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
639
src/modules/sales/controllers/sales-order.controller.ts
Normal file
639
src/modules/sales/controllers/sales-order.controller.ts
Normal file
@ -0,0 +1,639 @@
|
||||
/**
|
||||
* Sales Order Controller
|
||||
* API endpoints para gestión de órdenes de venta
|
||||
*
|
||||
* @module Sales (MAI-010)
|
||||
* @prefix /api/v1/sales-orders
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import {
|
||||
SalesOrderService,
|
||||
CreateSalesOrderDto,
|
||||
UpdateSalesOrderDto,
|
||||
CreateSalesOrderItemDto,
|
||||
UpdateSalesOrderItemDto,
|
||||
} from '../services/sales-order.service';
|
||||
|
||||
const router = Router();
|
||||
const salesOrderService = new SalesOrderService(null as any);
|
||||
|
||||
/**
|
||||
* GET /api/v1/sales-orders
|
||||
* Lista todas las órdenes de venta del tenant
|
||||
*/
|
||||
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const {
|
||||
partnerId,
|
||||
userId,
|
||||
salesTeamId,
|
||||
status,
|
||||
invoiceStatus,
|
||||
deliveryStatus,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
minTotal,
|
||||
maxTotal,
|
||||
search,
|
||||
page,
|
||||
limit,
|
||||
} = req.query;
|
||||
|
||||
const result = await salesOrderService.findAll(
|
||||
{ tenantId },
|
||||
{
|
||||
partnerId: partnerId as string,
|
||||
userId: userId as string,
|
||||
salesTeamId: salesTeamId as string,
|
||||
status: status as string,
|
||||
invoiceStatus: invoiceStatus as string,
|
||||
deliveryStatus: deliveryStatus as string,
|
||||
dateFrom: dateFrom ? new Date(dateFrom as string) : undefined,
|
||||
dateTo: dateTo ? new Date(dateTo as string) : undefined,
|
||||
minTotal: minTotal ? Number(minTotal) : undefined,
|
||||
maxTotal: maxTotal ? Number(maxTotal) : undefined,
|
||||
search: search as string,
|
||||
page: page ? Number(page) : undefined,
|
||||
limit: limit ? Number(limit) : undefined,
|
||||
},
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
limit: result.limit,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/sales-orders/statistics
|
||||
* Estadísticas de órdenes de venta
|
||||
*/
|
||||
router.get('/statistics', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { dateFrom, dateTo } = req.query;
|
||||
const stats = await salesOrderService.getStatistics(
|
||||
{ tenantId },
|
||||
dateFrom ? new Date(dateFrom as string) : undefined,
|
||||
dateTo ? new Date(dateTo as string) : undefined,
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: stats });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/sales-orders/pending-delivery
|
||||
* Órdenes pendientes de entrega
|
||||
*/
|
||||
router.get('/pending-delivery', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const data = await salesOrderService.findPendingDelivery({ tenantId });
|
||||
return res.json({ success: true, data, count: data.length });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/sales-orders/pending-invoice
|
||||
* Órdenes pendientes de facturar
|
||||
*/
|
||||
router.get('/pending-invoice', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const data = await salesOrderService.findPendingInvoice({ tenantId });
|
||||
return res.json({ success: true, data, count: data.length });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/sales-orders/by-partner/:partnerId
|
||||
* Órdenes por cliente
|
||||
*/
|
||||
router.get('/by-partner/:partnerId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const data = await salesOrderService.findByPartner({ tenantId }, req.params.partnerId);
|
||||
return res.json({ success: true, data, count: data.length });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/sales-orders/by-quotation/:quotationId
|
||||
* Orden generada desde una cotización
|
||||
*/
|
||||
router.get('/by-quotation/:quotationId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const order = await salesOrderService.findByQuotation({ tenantId }, req.params.quotationId);
|
||||
if (!order) {
|
||||
return res.status(404).json({ error: 'Orden no encontrada para esta cotización' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: order });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/sales-orders/by-name/:name
|
||||
* Buscar orden por número
|
||||
*/
|
||||
router.get('/by-name/:name', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const order = await salesOrderService.findByName({ tenantId }, req.params.name);
|
||||
if (!order) {
|
||||
return res.status(404).json({ error: 'Orden no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: order });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/sales-orders/:id
|
||||
* Obtiene una orden por ID
|
||||
*/
|
||||
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const order = await salesOrderService.findById({ tenantId }, req.params.id);
|
||||
if (!order) {
|
||||
return res.status(404).json({ error: 'Orden no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: order });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/sales-orders/:id/with-items
|
||||
* Obtiene una orden con sus líneas
|
||||
*/
|
||||
router.get('/:id/with-items', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const order = await salesOrderService.findByIdWithItems({ tenantId }, req.params.id);
|
||||
if (!order) {
|
||||
return res.status(404).json({ error: 'Orden no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: order });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/sales-orders
|
||||
* Crea una nueva orden de venta
|
||||
*/
|
||||
router.post('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const companyId = req.headers['x-company-id'] as string;
|
||||
const dto: CreateSalesOrderDto = req.body;
|
||||
|
||||
if (!dto.partnerId || !dto.currencyId) {
|
||||
return res.status(400).json({ error: 'partnerId y currencyId son requeridos' });
|
||||
}
|
||||
|
||||
const order = await salesOrderService.create({ tenantId, userId, companyId }, dto);
|
||||
return res.status(201).json({ success: true, data: order });
|
||||
} catch (error: any) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/sales-orders/from-quotation/:quotationId
|
||||
* Crea una orden desde una cotización aceptada
|
||||
*/
|
||||
router.post('/from-quotation/:quotationId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const companyId = req.headers['x-company-id'] as string;
|
||||
|
||||
const order = await salesOrderService.createFromQuotation(
|
||||
{ tenantId, userId, companyId },
|
||||
req.params.quotationId,
|
||||
);
|
||||
|
||||
if (!order) {
|
||||
return res.status(404).json({ error: 'Cotización no encontrada' });
|
||||
}
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: order,
|
||||
message: 'Orden creada desde cotización',
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('Only accepted')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/sales-orders/:id
|
||||
* Actualiza una orden de venta
|
||||
*/
|
||||
router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const dto: UpdateSalesOrderDto = req.body;
|
||||
|
||||
const order = await salesOrderService.update({ tenantId, userId }, req.params.id, dto);
|
||||
if (!order) {
|
||||
return res.status(404).json({ error: 'Orden no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: order });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('Can only update')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/sales-orders/:id/items
|
||||
* Agrega una línea a la orden
|
||||
*/
|
||||
router.post('/:id/items', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const dto: CreateSalesOrderItemDto = req.body;
|
||||
|
||||
if (!dto.productName || dto.quantity === undefined || dto.unitPrice === undefined) {
|
||||
return res.status(400).json({ error: 'productName, quantity y unitPrice son requeridos' });
|
||||
}
|
||||
|
||||
const item = await salesOrderService.addItem({ tenantId, userId }, req.params.id, dto);
|
||||
if (!item) {
|
||||
return res.status(404).json({ error: 'Orden no encontrada' });
|
||||
}
|
||||
|
||||
return res.status(201).json({ success: true, data: item });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('Can only add')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/sales-orders/:id/items/:itemId
|
||||
* Actualiza una línea de la orden
|
||||
*/
|
||||
router.patch('/:id/items/:itemId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const dto: UpdateSalesOrderItemDto = req.body;
|
||||
|
||||
const item = await salesOrderService.updateItem(
|
||||
{ tenantId, userId },
|
||||
req.params.id,
|
||||
req.params.itemId,
|
||||
dto,
|
||||
);
|
||||
|
||||
if (!item) {
|
||||
return res.status(404).json({ error: 'Línea no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: item });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('Can only update')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/sales-orders/:id/items/:itemId
|
||||
* Elimina una línea de la orden
|
||||
*/
|
||||
router.delete('/:id/items/:itemId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const deleted = await salesOrderService.removeItem(
|
||||
{ tenantId, userId },
|
||||
req.params.id,
|
||||
req.params.itemId,
|
||||
);
|
||||
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Línea no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, message: 'Línea eliminada' });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('Can only remove')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/sales-orders/:id/send
|
||||
* Envía la orden al cliente
|
||||
*/
|
||||
router.post('/:id/send', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const order = await salesOrderService.send({ tenantId, userId }, req.params.id);
|
||||
|
||||
if (!order) {
|
||||
return res.status(404).json({ error: 'Orden no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: order, message: 'Orden enviada' });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('Only draft')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/sales-orders/:id/confirm
|
||||
* Confirma la orden de venta
|
||||
*/
|
||||
router.post('/:id/confirm', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const order = await salesOrderService.confirm({ tenantId, userId }, req.params.id);
|
||||
|
||||
if (!order) {
|
||||
return res.status(404).json({ error: 'Orden no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: order, message: 'Orden confirmada' });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('Can only confirm')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/sales-orders/:id/cancel
|
||||
* Cancela la orden de venta
|
||||
*/
|
||||
router.post('/:id/cancel', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const order = await salesOrderService.cancel({ tenantId, userId }, req.params.id);
|
||||
|
||||
if (!order) {
|
||||
return res.status(404).json({ error: 'Orden no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: order, message: 'Orden cancelada' });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('Cannot cancel')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/sales-orders/:id/done
|
||||
* Marca la orden como completada
|
||||
*/
|
||||
router.post('/:id/done', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const order = await salesOrderService.markDone({ tenantId, userId }, req.params.id);
|
||||
|
||||
if (!order) {
|
||||
return res.status(404).json({ error: 'Orden no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: order, message: 'Orden completada' });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('Only confirmed')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/sales-orders/:id/items/:itemId/delivery
|
||||
* Actualiza la cantidad entregada de una línea
|
||||
*/
|
||||
router.patch('/:id/items/:itemId/delivery', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const { quantityDelivered } = req.body;
|
||||
|
||||
if (quantityDelivered === undefined) {
|
||||
return res.status(400).json({ error: 'quantityDelivered es requerido' });
|
||||
}
|
||||
|
||||
const item = await salesOrderService.updateItemDelivery(
|
||||
{ tenantId, userId },
|
||||
req.params.id,
|
||||
req.params.itemId,
|
||||
Number(quantityDelivered),
|
||||
);
|
||||
|
||||
if (!item) {
|
||||
return res.status(404).json({ error: 'Línea no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: item, message: 'Entrega actualizada' });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('cannot exceed')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/sales-orders/:id/invoice-status
|
||||
* Actualiza el estado de facturación
|
||||
*/
|
||||
router.patch('/:id/invoice-status', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const { status } = req.body;
|
||||
|
||||
if (!status || !['pending', 'partial', 'invoiced'].includes(status)) {
|
||||
return res.status(400).json({ error: 'status debe ser pending, partial o invoiced' });
|
||||
}
|
||||
|
||||
const order = await salesOrderService.updateInvoiceStatus(
|
||||
{ tenantId, userId },
|
||||
req.params.id,
|
||||
status,
|
||||
);
|
||||
|
||||
if (!order) {
|
||||
return res.status(404).json({ error: 'Orden no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: order, message: 'Estado de facturación actualizado' });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/sales-orders/:id
|
||||
* Elimina una orden (soft delete)
|
||||
*/
|
||||
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const deleted = await salesOrderService.softDelete({ tenantId, userId }, req.params.id);
|
||||
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Orden no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, message: 'Orden eliminada' });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('Cannot delete')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
17
src/modules/sales/index.ts
Normal file
17
src/modules/sales/index.ts
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Sales Module Index
|
||||
*
|
||||
* Módulo de ventas para gestión de cotizaciones y órdenes de venta
|
||||
* de unidades inmobiliarias (departamentos, lotes, viviendas).
|
||||
*
|
||||
* @module Sales (MAI-010)
|
||||
*/
|
||||
|
||||
// Entities
|
||||
export * from './entities';
|
||||
|
||||
// Services
|
||||
export * from './services';
|
||||
|
||||
// Controllers
|
||||
export * from './controllers';
|
||||
7
src/modules/sales/services/index.ts
Normal file
7
src/modules/sales/services/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Sales Services Index
|
||||
* @module Sales (MAI-010)
|
||||
*/
|
||||
|
||||
export * from './quotation.service';
|
||||
export * from './sales-order.service';
|
||||
704
src/modules/sales/services/quotation.service.ts
Normal file
704
src/modules/sales/services/quotation.service.ts
Normal file
@ -0,0 +1,704 @@
|
||||
/**
|
||||
* QuotationService - Servicio de cotizaciones de venta
|
||||
*
|
||||
* Gestión de cotizaciones para venta de unidades inmobiliarias
|
||||
* (departamentos, lotes, viviendas).
|
||||
*
|
||||
* @module Sales (MAI-010)
|
||||
*/
|
||||
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { Quotation } from '../entities/quotation.entity';
|
||||
import { QuotationItem } from '../entities/quotation-item.entity';
|
||||
|
||||
interface ServiceContext {
|
||||
tenantId: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface CreateQuotationDto {
|
||||
partnerId: string;
|
||||
partnerName?: string;
|
||||
partnerEmail?: string;
|
||||
billingAddress?: object;
|
||||
shippingAddress?: object;
|
||||
quotationDate?: Date;
|
||||
validUntil?: Date;
|
||||
expectedCloseDate?: Date;
|
||||
salesRepId?: string;
|
||||
currency?: string;
|
||||
paymentTermDays?: number;
|
||||
paymentMethod?: string;
|
||||
notes?: string;
|
||||
internalNotes?: string;
|
||||
termsAndConditions?: string;
|
||||
items?: CreateQuotationItemDto[];
|
||||
}
|
||||
|
||||
export interface UpdateQuotationDto {
|
||||
partnerName?: string;
|
||||
partnerEmail?: string;
|
||||
billingAddress?: object;
|
||||
shippingAddress?: object;
|
||||
validUntil?: Date;
|
||||
expectedCloseDate?: Date;
|
||||
salesRepId?: string;
|
||||
currency?: string;
|
||||
paymentTermDays?: number;
|
||||
paymentMethod?: string;
|
||||
notes?: string;
|
||||
internalNotes?: string;
|
||||
termsAndConditions?: string;
|
||||
}
|
||||
|
||||
export interface CreateQuotationItemDto {
|
||||
productId?: string;
|
||||
productSku?: string;
|
||||
productName: string;
|
||||
description?: string;
|
||||
quantity: number;
|
||||
uom?: string;
|
||||
unitPrice: number;
|
||||
discountPercent?: number;
|
||||
taxRate?: number;
|
||||
}
|
||||
|
||||
export interface UpdateQuotationItemDto {
|
||||
productName?: string;
|
||||
description?: string;
|
||||
quantity?: number;
|
||||
uom?: string;
|
||||
unitPrice?: number;
|
||||
discountPercent?: number;
|
||||
taxRate?: number;
|
||||
}
|
||||
|
||||
export interface QuotationFilters {
|
||||
partnerId?: string;
|
||||
salesRepId?: string;
|
||||
status?: string;
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
minTotal?: number;
|
||||
maxTotal?: number;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class QuotationService {
|
||||
private readonly quotationRepo: Repository<Quotation>;
|
||||
private readonly itemRepo: Repository<QuotationItem>;
|
||||
private readonly dataSource: DataSource;
|
||||
|
||||
constructor(dataSource: DataSource) {
|
||||
this.dataSource = dataSource;
|
||||
this.quotationRepo = dataSource.getRepository(Quotation);
|
||||
this.itemRepo = dataSource.getRepository(QuotationItem);
|
||||
}
|
||||
|
||||
async findAll(
|
||||
ctx: ServiceContext,
|
||||
filters: QuotationFilters = {},
|
||||
): Promise<{ data: Quotation[]; total: number; page: number; limit: number }> {
|
||||
const page = filters.page || 1;
|
||||
const limit = filters.limit || 50;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const queryBuilder = this.quotationRepo
|
||||
.createQueryBuilder('q')
|
||||
.where('q.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('q.deleted_at IS NULL');
|
||||
|
||||
if (filters.partnerId) {
|
||||
queryBuilder.andWhere('q.partner_id = :partnerId', { partnerId: filters.partnerId });
|
||||
}
|
||||
|
||||
if (filters.salesRepId) {
|
||||
queryBuilder.andWhere('q.sales_rep_id = :salesRepId', { salesRepId: filters.salesRepId });
|
||||
}
|
||||
|
||||
if (filters.status) {
|
||||
queryBuilder.andWhere('q.status = :status', { status: filters.status });
|
||||
}
|
||||
|
||||
if (filters.dateFrom) {
|
||||
queryBuilder.andWhere('q.quotation_date >= :dateFrom', { dateFrom: filters.dateFrom });
|
||||
}
|
||||
|
||||
if (filters.dateTo) {
|
||||
queryBuilder.andWhere('q.quotation_date <= :dateTo', { dateTo: filters.dateTo });
|
||||
}
|
||||
|
||||
if (filters.minTotal !== undefined) {
|
||||
queryBuilder.andWhere('q.total >= :minTotal', { minTotal: filters.minTotal });
|
||||
}
|
||||
|
||||
if (filters.maxTotal !== undefined) {
|
||||
queryBuilder.andWhere('q.total <= :maxTotal', { maxTotal: filters.maxTotal });
|
||||
}
|
||||
|
||||
if (filters.search) {
|
||||
queryBuilder.andWhere(
|
||||
'(q.quotation_number ILIKE :search OR q.partner_name ILIKE :search)',
|
||||
{ search: `%${filters.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
const [data, total] = await queryBuilder
|
||||
.orderBy('q.created_at', 'DESC')
|
||||
.skip(skip)
|
||||
.take(limit)
|
||||
.getManyAndCount();
|
||||
|
||||
return { data, total, page, limit };
|
||||
}
|
||||
|
||||
async findById(ctx: ServiceContext, id: string): Promise<Quotation | null> {
|
||||
return this.quotationRepo.findOne({
|
||||
where: {
|
||||
id,
|
||||
tenantId: ctx.tenantId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findByIdWithItems(ctx: ServiceContext, id: string): Promise<Quotation | null> {
|
||||
const quotation = await this.findById(ctx, id);
|
||||
if (!quotation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const items = await this.itemRepo.find({
|
||||
where: { quotationId: id },
|
||||
order: { lineNumber: 'ASC' },
|
||||
});
|
||||
|
||||
return { ...quotation, items } as Quotation & { items: QuotationItem[] };
|
||||
}
|
||||
|
||||
async findByNumber(ctx: ServiceContext, quotationNumber: string): Promise<Quotation | null> {
|
||||
return this.quotationRepo.findOne({
|
||||
where: {
|
||||
quotationNumber,
|
||||
tenantId: ctx.tenantId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findByPartner(ctx: ServiceContext, partnerId: string): Promise<Quotation[]> {
|
||||
return this.quotationRepo
|
||||
.createQueryBuilder('q')
|
||||
.where('q.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('q.deleted_at IS NULL')
|
||||
.andWhere('q.partner_id = :partnerId', { partnerId })
|
||||
.orderBy('q.created_at', 'DESC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async findPending(ctx: ServiceContext): Promise<Quotation[]> {
|
||||
return this.quotationRepo
|
||||
.createQueryBuilder('q')
|
||||
.where('q.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('q.deleted_at IS NULL')
|
||||
.andWhere('q.status IN (:...statuses)', { statuses: ['draft', 'sent'] })
|
||||
.orderBy('q.created_at', 'DESC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async findExpired(ctx: ServiceContext): Promise<Quotation[]> {
|
||||
return this.quotationRepo
|
||||
.createQueryBuilder('q')
|
||||
.where('q.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('q.deleted_at IS NULL')
|
||||
.andWhere('q.status IN (:...statuses)', { statuses: ['draft', 'sent'] })
|
||||
.andWhere('q.valid_until < CURRENT_DATE')
|
||||
.orderBy('q.valid_until', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
private async generateQuotationNumber(ctx: ServiceContext): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const prefix = `COT-${year}-`;
|
||||
|
||||
const lastQuotation = await this.quotationRepo
|
||||
.createQueryBuilder('q')
|
||||
.where('q.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('q.quotation_number LIKE :prefix', { prefix: `${prefix}%` })
|
||||
.orderBy('q.quotation_number', 'DESC')
|
||||
.getOne();
|
||||
|
||||
let sequence = 1;
|
||||
if (lastQuotation) {
|
||||
const lastNumber = lastQuotation.quotationNumber.replace(prefix, '');
|
||||
sequence = parseInt(lastNumber, 10) + 1;
|
||||
}
|
||||
|
||||
return `${prefix}${sequence.toString().padStart(5, '0')}`;
|
||||
}
|
||||
|
||||
private calculateItemTotals(item: CreateQuotationItemDto): {
|
||||
discountAmount: number;
|
||||
taxAmount: number;
|
||||
subtotal: number;
|
||||
total: number;
|
||||
} {
|
||||
const quantity = Number(item.quantity) || 0;
|
||||
const unitPrice = Number(item.unitPrice) || 0;
|
||||
const discountPercent = Number(item.discountPercent) || 0;
|
||||
const taxRate = Number(item.taxRate) ?? 16;
|
||||
|
||||
const grossAmount = quantity * unitPrice;
|
||||
const discountAmount = grossAmount * (discountPercent / 100);
|
||||
const subtotal = grossAmount - discountAmount;
|
||||
const taxAmount = subtotal * (taxRate / 100);
|
||||
const total = subtotal + taxAmount;
|
||||
|
||||
return {
|
||||
discountAmount: Math.round(discountAmount * 100) / 100,
|
||||
taxAmount: Math.round(taxAmount * 100) / 100,
|
||||
subtotal: Math.round(subtotal * 100) / 100,
|
||||
total: Math.round(total * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
private calculateQuotationTotals(items: Array<{ subtotal: number; taxAmount: number; discountAmount: number }>): {
|
||||
subtotal: number;
|
||||
taxAmount: number;
|
||||
discountAmount: number;
|
||||
total: number;
|
||||
} {
|
||||
const subtotal = items.reduce((sum, item) => sum + Number(item.subtotal), 0);
|
||||
const taxAmount = items.reduce((sum, item) => sum + Number(item.taxAmount), 0);
|
||||
const discountAmount = items.reduce((sum, item) => sum + Number(item.discountAmount), 0);
|
||||
const total = subtotal + taxAmount;
|
||||
|
||||
return {
|
||||
subtotal: Math.round(subtotal * 100) / 100,
|
||||
taxAmount: Math.round(taxAmount * 100) / 100,
|
||||
discountAmount: Math.round(discountAmount * 100) / 100,
|
||||
total: Math.round(total * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
async create(ctx: ServiceContext, dto: CreateQuotationDto): Promise<Quotation> {
|
||||
const quotationNumber = await this.generateQuotationNumber(ctx);
|
||||
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const quotationRepo = manager.getRepository(Quotation);
|
||||
const itemRepo = manager.getRepository(QuotationItem);
|
||||
|
||||
const quotation = quotationRepo.create({
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.userId,
|
||||
quotationNumber,
|
||||
partnerId: dto.partnerId,
|
||||
partnerName: dto.partnerName,
|
||||
partnerEmail: dto.partnerEmail,
|
||||
billingAddress: dto.billingAddress,
|
||||
shippingAddress: dto.shippingAddress,
|
||||
quotationDate: dto.quotationDate ?? new Date(),
|
||||
validUntil: dto.validUntil,
|
||||
expectedCloseDate: dto.expectedCloseDate,
|
||||
salesRepId: dto.salesRepId ?? ctx.userId,
|
||||
currency: dto.currency ?? 'MXN',
|
||||
paymentTermDays: dto.paymentTermDays ?? 0,
|
||||
paymentMethod: dto.paymentMethod,
|
||||
notes: dto.notes,
|
||||
internalNotes: dto.internalNotes,
|
||||
termsAndConditions: dto.termsAndConditions,
|
||||
status: 'draft',
|
||||
subtotal: 0,
|
||||
taxAmount: 0,
|
||||
discountAmount: 0,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const savedQuotation = await quotationRepo.save(quotation);
|
||||
|
||||
if (dto.items && dto.items.length > 0) {
|
||||
const itemEntities: QuotationItem[] = [];
|
||||
|
||||
for (let i = 0; i < dto.items.length; i++) {
|
||||
const itemDto = dto.items[i];
|
||||
const calculated = this.calculateItemTotals(itemDto);
|
||||
|
||||
const item = itemRepo.create({
|
||||
quotationId: savedQuotation.id,
|
||||
lineNumber: i + 1,
|
||||
productId: itemDto.productId,
|
||||
productSku: itemDto.productSku,
|
||||
productName: itemDto.productName,
|
||||
description: itemDto.description,
|
||||
quantity: itemDto.quantity,
|
||||
uom: itemDto.uom ?? 'PZA',
|
||||
unitPrice: itemDto.unitPrice,
|
||||
discountPercent: itemDto.discountPercent ?? 0,
|
||||
taxRate: itemDto.taxRate ?? 16,
|
||||
...calculated,
|
||||
});
|
||||
|
||||
itemEntities.push(item);
|
||||
}
|
||||
|
||||
await itemRepo.save(itemEntities);
|
||||
|
||||
const totals = this.calculateQuotationTotals(itemEntities);
|
||||
savedQuotation.subtotal = totals.subtotal;
|
||||
savedQuotation.taxAmount = totals.taxAmount;
|
||||
savedQuotation.discountAmount = totals.discountAmount;
|
||||
savedQuotation.total = totals.total;
|
||||
|
||||
await quotationRepo.save(savedQuotation);
|
||||
}
|
||||
|
||||
return savedQuotation;
|
||||
});
|
||||
}
|
||||
|
||||
async update(ctx: ServiceContext, id: string, dto: UpdateQuotationDto): Promise<Quotation | null> {
|
||||
const quotation = await this.findById(ctx, id);
|
||||
if (!quotation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (quotation.status === 'converted') {
|
||||
throw new Error('Cannot update a converted quotation');
|
||||
}
|
||||
|
||||
Object.assign(quotation, {
|
||||
...dto,
|
||||
updatedBy: ctx.userId,
|
||||
});
|
||||
|
||||
return this.quotationRepo.save(quotation);
|
||||
}
|
||||
|
||||
async addItem(ctx: ServiceContext, quotationId: string, dto: CreateQuotationItemDto): Promise<QuotationItem | null> {
|
||||
const quotation = await this.findById(ctx, quotationId);
|
||||
if (!quotation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (quotation.status === 'converted') {
|
||||
throw new Error('Cannot add items to a converted quotation');
|
||||
}
|
||||
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const itemRepo = manager.getRepository(QuotationItem);
|
||||
|
||||
const lastItem = await itemRepo
|
||||
.createQueryBuilder('i')
|
||||
.where('i.quotation_id = :quotationId', { quotationId })
|
||||
.orderBy('i.line_number', 'DESC')
|
||||
.getOne();
|
||||
|
||||
const lineNumber = (lastItem?.lineNumber ?? 0) + 1;
|
||||
const calculated = this.calculateItemTotals(dto);
|
||||
|
||||
const item = itemRepo.create({
|
||||
quotationId,
|
||||
lineNumber,
|
||||
productId: dto.productId,
|
||||
productSku: dto.productSku,
|
||||
productName: dto.productName,
|
||||
description: dto.description,
|
||||
quantity: dto.quantity,
|
||||
uom: dto.uom ?? 'PZA',
|
||||
unitPrice: dto.unitPrice,
|
||||
discountPercent: dto.discountPercent ?? 0,
|
||||
taxRate: dto.taxRate ?? 16,
|
||||
...calculated,
|
||||
});
|
||||
|
||||
const savedItem = await itemRepo.save(item);
|
||||
|
||||
await this.recalculateQuotationTotals(manager, quotationId);
|
||||
|
||||
return savedItem;
|
||||
});
|
||||
}
|
||||
|
||||
async updateItem(
|
||||
ctx: ServiceContext,
|
||||
quotationId: string,
|
||||
itemId: string,
|
||||
dto: UpdateQuotationItemDto,
|
||||
): Promise<QuotationItem | null> {
|
||||
const quotation = await this.findById(ctx, quotationId);
|
||||
if (!quotation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (quotation.status === 'converted') {
|
||||
throw new Error('Cannot update items on a converted quotation');
|
||||
}
|
||||
|
||||
const item = await this.itemRepo.findOne({
|
||||
where: { id: itemId, quotationId },
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const itemRepo = manager.getRepository(QuotationItem);
|
||||
|
||||
Object.assign(item, dto);
|
||||
|
||||
const calculated = this.calculateItemTotals({
|
||||
quantity: item.quantity,
|
||||
unitPrice: item.unitPrice,
|
||||
discountPercent: item.discountPercent,
|
||||
taxRate: item.taxRate,
|
||||
productName: item.productName,
|
||||
});
|
||||
|
||||
Object.assign(item, calculated);
|
||||
|
||||
const savedItem = await itemRepo.save(item);
|
||||
|
||||
await this.recalculateQuotationTotals(manager, quotationId);
|
||||
|
||||
return savedItem;
|
||||
});
|
||||
}
|
||||
|
||||
async removeItem(ctx: ServiceContext, quotationId: string, itemId: string): Promise<boolean> {
|
||||
const quotation = await this.findById(ctx, quotationId);
|
||||
if (!quotation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (quotation.status === 'converted') {
|
||||
throw new Error('Cannot remove items from a converted quotation');
|
||||
}
|
||||
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const itemRepo = manager.getRepository(QuotationItem);
|
||||
|
||||
const result = await itemRepo.delete({ id: itemId, quotationId });
|
||||
if (!result.affected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.recalculateQuotationTotals(manager, quotationId);
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private async recalculateQuotationTotals(
|
||||
manager: any,
|
||||
quotationId: string,
|
||||
): Promise<void> {
|
||||
const quotationRepo = manager.getRepository(Quotation);
|
||||
const itemRepo = manager.getRepository(QuotationItem);
|
||||
|
||||
const items = await itemRepo.find({
|
||||
where: { quotationId },
|
||||
});
|
||||
|
||||
const totals = this.calculateQuotationTotals(items);
|
||||
|
||||
await quotationRepo.update({ id: quotationId }, totals);
|
||||
}
|
||||
|
||||
async send(ctx: ServiceContext, id: string): Promise<Quotation | null> {
|
||||
const quotation = await this.findById(ctx, id);
|
||||
if (!quotation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (quotation.status !== 'draft') {
|
||||
throw new Error('Only draft quotations can be sent');
|
||||
}
|
||||
|
||||
quotation.status = 'sent';
|
||||
quotation.updatedBy = ctx.userId;
|
||||
|
||||
return this.quotationRepo.save(quotation);
|
||||
}
|
||||
|
||||
async accept(ctx: ServiceContext, id: string): Promise<Quotation | null> {
|
||||
const quotation = await this.findById(ctx, id);
|
||||
if (!quotation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (quotation.status !== 'sent' && quotation.status !== 'draft') {
|
||||
throw new Error('Only draft or sent quotations can be accepted');
|
||||
}
|
||||
|
||||
quotation.status = 'accepted';
|
||||
quotation.updatedBy = ctx.userId;
|
||||
|
||||
return this.quotationRepo.save(quotation);
|
||||
}
|
||||
|
||||
async reject(ctx: ServiceContext, id: string): Promise<Quotation | null> {
|
||||
const quotation = await this.findById(ctx, id);
|
||||
if (!quotation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (quotation.status !== 'sent' && quotation.status !== 'draft') {
|
||||
throw new Error('Only draft or sent quotations can be rejected');
|
||||
}
|
||||
|
||||
quotation.status = 'rejected';
|
||||
quotation.updatedBy = ctx.userId;
|
||||
|
||||
return this.quotationRepo.save(quotation);
|
||||
}
|
||||
|
||||
async markExpired(ctx: ServiceContext, id: string): Promise<Quotation | null> {
|
||||
const quotation = await this.findById(ctx, id);
|
||||
if (!quotation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (quotation.status === 'converted' || quotation.status === 'accepted') {
|
||||
throw new Error('Cannot expire a converted or accepted quotation');
|
||||
}
|
||||
|
||||
quotation.status = 'expired';
|
||||
quotation.updatedBy = ctx.userId;
|
||||
|
||||
return this.quotationRepo.save(quotation);
|
||||
}
|
||||
|
||||
async markConverted(ctx: ServiceContext, id: string, orderId: string): Promise<Quotation | null> {
|
||||
const quotation = await this.findById(ctx, id);
|
||||
if (!quotation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (quotation.status !== 'accepted') {
|
||||
throw new Error('Only accepted quotations can be converted to orders');
|
||||
}
|
||||
|
||||
quotation.status = 'converted';
|
||||
quotation.convertedToOrder = true;
|
||||
quotation.orderId = orderId;
|
||||
quotation.convertedAt = new Date();
|
||||
quotation.updatedBy = ctx.userId;
|
||||
|
||||
return this.quotationRepo.save(quotation);
|
||||
}
|
||||
|
||||
async duplicate(ctx: ServiceContext, id: string): Promise<Quotation | null> {
|
||||
const original = await this.findByIdWithItems(ctx, id);
|
||||
if (!original) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const items = (original as any).items as QuotationItem[] || [];
|
||||
|
||||
const newQuotation = await this.create(ctx, {
|
||||
partnerId: original.partnerId,
|
||||
partnerName: original.partnerName,
|
||||
partnerEmail: original.partnerEmail,
|
||||
billingAddress: original.billingAddress,
|
||||
shippingAddress: original.shippingAddress,
|
||||
salesRepId: original.salesRepId,
|
||||
currency: original.currency,
|
||||
paymentTermDays: original.paymentTermDays,
|
||||
paymentMethod: original.paymentMethod,
|
||||
notes: original.notes,
|
||||
internalNotes: original.internalNotes,
|
||||
termsAndConditions: original.termsAndConditions,
|
||||
items: items.map((item) => ({
|
||||
productId: item.productId,
|
||||
productSku: item.productSku,
|
||||
productName: item.productName,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
uom: item.uom,
|
||||
unitPrice: item.unitPrice,
|
||||
discountPercent: item.discountPercent,
|
||||
taxRate: item.taxRate,
|
||||
})),
|
||||
});
|
||||
|
||||
return newQuotation;
|
||||
}
|
||||
|
||||
async getStatistics(ctx: ServiceContext, dateFrom?: Date, dateTo?: Date): Promise<{
|
||||
totalQuotations: number;
|
||||
draft: number;
|
||||
sent: number;
|
||||
accepted: number;
|
||||
rejected: number;
|
||||
expired: number;
|
||||
converted: number;
|
||||
totalValue: number;
|
||||
acceptedValue: number;
|
||||
conversionRate: number;
|
||||
}> {
|
||||
const queryBuilder = this.quotationRepo
|
||||
.createQueryBuilder('q')
|
||||
.where('q.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('q.deleted_at IS NULL');
|
||||
|
||||
if (dateFrom) {
|
||||
queryBuilder.andWhere('q.quotation_date >= :dateFrom', { dateFrom });
|
||||
}
|
||||
|
||||
if (dateTo) {
|
||||
queryBuilder.andWhere('q.quotation_date <= :dateTo', { dateTo });
|
||||
}
|
||||
|
||||
const quotations = await queryBuilder.getMany();
|
||||
|
||||
const draft = quotations.filter((q) => q.status === 'draft');
|
||||
const sent = quotations.filter((q) => q.status === 'sent');
|
||||
const accepted = quotations.filter((q) => q.status === 'accepted');
|
||||
const rejected = quotations.filter((q) => q.status === 'rejected');
|
||||
const expired = quotations.filter((q) => q.status === 'expired');
|
||||
const converted = quotations.filter((q) => q.status === 'converted');
|
||||
|
||||
const totalValue = quotations.reduce((sum, q) => sum + Number(q.total), 0);
|
||||
const acceptedValue = [...accepted, ...converted].reduce((sum, q) => sum + Number(q.total), 0);
|
||||
|
||||
const sentOrBetter = quotations.filter((q) =>
|
||||
q.status !== 'draft'
|
||||
).length;
|
||||
|
||||
const conversionRate = sentOrBetter > 0
|
||||
? ((accepted.length + converted.length) / sentOrBetter) * 100
|
||||
: 0;
|
||||
|
||||
return {
|
||||
totalQuotations: quotations.length,
|
||||
draft: draft.length,
|
||||
sent: sent.length,
|
||||
accepted: accepted.length,
|
||||
rejected: rejected.length,
|
||||
expired: expired.length,
|
||||
converted: converted.length,
|
||||
totalValue,
|
||||
acceptedValue,
|
||||
conversionRate: Math.round(conversionRate * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
|
||||
const quotation = await this.findById(ctx, id);
|
||||
if (!quotation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (quotation.status === 'converted') {
|
||||
throw new Error('Cannot delete a converted quotation');
|
||||
}
|
||||
|
||||
const result = await this.quotationRepo.update(
|
||||
{ id, tenantId: ctx.tenantId },
|
||||
{ deletedAt: new Date(), updatedBy: ctx.userId },
|
||||
);
|
||||
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
}
|
||||
831
src/modules/sales/services/sales-order.service.ts
Normal file
831
src/modules/sales/services/sales-order.service.ts
Normal file
@ -0,0 +1,831 @@
|
||||
/**
|
||||
* SalesOrderService - Servicio de órdenes de venta
|
||||
*
|
||||
* Gestión de órdenes de venta para unidades inmobiliarias
|
||||
* (departamentos, lotes, viviendas).
|
||||
* Soporta flujo Order-to-Cash completo.
|
||||
*
|
||||
* @module Sales (MAI-010)
|
||||
*/
|
||||
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { SalesOrder } from '../entities/sales-order.entity';
|
||||
import { SalesOrderItem } from '../entities/sales-order-item.entity';
|
||||
import { Quotation } from '../entities/quotation.entity';
|
||||
import { QuotationItem } from '../entities/quotation-item.entity';
|
||||
|
||||
interface ServiceContext {
|
||||
tenantId: string;
|
||||
userId?: string;
|
||||
companyId?: string;
|
||||
}
|
||||
|
||||
export interface CreateSalesOrderDto {
|
||||
quotationId?: string;
|
||||
partnerId: string;
|
||||
clientOrderRef?: string;
|
||||
orderDate?: Date;
|
||||
validityDate?: Date;
|
||||
commitmentDate?: Date;
|
||||
currencyId: string;
|
||||
pricelistId?: string;
|
||||
paymentTermId?: string;
|
||||
userId?: string;
|
||||
salesTeamId?: string;
|
||||
invoicePolicy?: 'order' | 'delivery';
|
||||
notes?: string;
|
||||
termsConditions?: string;
|
||||
items?: CreateSalesOrderItemDto[];
|
||||
}
|
||||
|
||||
export interface UpdateSalesOrderDto {
|
||||
clientOrderRef?: string;
|
||||
validityDate?: Date;
|
||||
commitmentDate?: Date;
|
||||
pricelistId?: string;
|
||||
paymentTermId?: string;
|
||||
userId?: string;
|
||||
salesTeamId?: string;
|
||||
invoicePolicy?: 'order' | 'delivery';
|
||||
notes?: string;
|
||||
termsConditions?: string;
|
||||
}
|
||||
|
||||
export interface CreateSalesOrderItemDto {
|
||||
productId?: string;
|
||||
productSku?: string;
|
||||
productName: string;
|
||||
description?: string;
|
||||
quantity: number;
|
||||
uom?: string;
|
||||
unitPrice: number;
|
||||
unitCost?: number;
|
||||
discountPercent?: number;
|
||||
taxRate?: number;
|
||||
lotNumber?: string;
|
||||
serialNumber?: string;
|
||||
}
|
||||
|
||||
export interface UpdateSalesOrderItemDto {
|
||||
productName?: string;
|
||||
description?: string;
|
||||
quantity?: number;
|
||||
uom?: string;
|
||||
unitPrice?: number;
|
||||
unitCost?: number;
|
||||
discountPercent?: number;
|
||||
taxRate?: number;
|
||||
lotNumber?: string;
|
||||
serialNumber?: string;
|
||||
}
|
||||
|
||||
export interface SalesOrderFilters {
|
||||
partnerId?: string;
|
||||
userId?: string;
|
||||
salesTeamId?: string;
|
||||
status?: string;
|
||||
invoiceStatus?: string;
|
||||
deliveryStatus?: string;
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
minTotal?: number;
|
||||
maxTotal?: number;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class SalesOrderService {
|
||||
private readonly orderRepo: Repository<SalesOrder>;
|
||||
private readonly itemRepo: Repository<SalesOrderItem>;
|
||||
private readonly quotationRepo: Repository<Quotation>;
|
||||
private readonly quotationItemRepo: Repository<QuotationItem>;
|
||||
private readonly dataSource: DataSource;
|
||||
|
||||
constructor(dataSource: DataSource) {
|
||||
this.dataSource = dataSource;
|
||||
this.orderRepo = dataSource.getRepository(SalesOrder);
|
||||
this.itemRepo = dataSource.getRepository(SalesOrderItem);
|
||||
this.quotationRepo = dataSource.getRepository(Quotation);
|
||||
this.quotationItemRepo = dataSource.getRepository(QuotationItem);
|
||||
}
|
||||
|
||||
async findAll(
|
||||
ctx: ServiceContext,
|
||||
filters: SalesOrderFilters = {},
|
||||
): Promise<{ data: SalesOrder[]; total: number; page: number; limit: number }> {
|
||||
const page = filters.page || 1;
|
||||
const limit = filters.limit || 50;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const queryBuilder = this.orderRepo
|
||||
.createQueryBuilder('o')
|
||||
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('o.deleted_at IS NULL');
|
||||
|
||||
if (filters.partnerId) {
|
||||
queryBuilder.andWhere('o.partner_id = :partnerId', { partnerId: filters.partnerId });
|
||||
}
|
||||
|
||||
if (filters.userId) {
|
||||
queryBuilder.andWhere('o.user_id = :userId', { userId: filters.userId });
|
||||
}
|
||||
|
||||
if (filters.salesTeamId) {
|
||||
queryBuilder.andWhere('o.sales_team_id = :salesTeamId', { salesTeamId: filters.salesTeamId });
|
||||
}
|
||||
|
||||
if (filters.status) {
|
||||
queryBuilder.andWhere('o.status = :status', { status: filters.status });
|
||||
}
|
||||
|
||||
if (filters.invoiceStatus) {
|
||||
queryBuilder.andWhere('o.invoice_status = :invoiceStatus', { invoiceStatus: filters.invoiceStatus });
|
||||
}
|
||||
|
||||
if (filters.deliveryStatus) {
|
||||
queryBuilder.andWhere('o.delivery_status = :deliveryStatus', { deliveryStatus: filters.deliveryStatus });
|
||||
}
|
||||
|
||||
if (filters.dateFrom) {
|
||||
queryBuilder.andWhere('o.order_date >= :dateFrom', { dateFrom: filters.dateFrom });
|
||||
}
|
||||
|
||||
if (filters.dateTo) {
|
||||
queryBuilder.andWhere('o.order_date <= :dateTo', { dateTo: filters.dateTo });
|
||||
}
|
||||
|
||||
if (filters.minTotal !== undefined) {
|
||||
queryBuilder.andWhere('o.amount_total >= :minTotal', { minTotal: filters.minTotal });
|
||||
}
|
||||
|
||||
if (filters.maxTotal !== undefined) {
|
||||
queryBuilder.andWhere('o.amount_total <= :maxTotal', { maxTotal: filters.maxTotal });
|
||||
}
|
||||
|
||||
if (filters.search) {
|
||||
queryBuilder.andWhere(
|
||||
'(o.name ILIKE :search OR o.client_order_ref ILIKE :search)',
|
||||
{ search: `%${filters.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
const [data, total] = await queryBuilder
|
||||
.orderBy('o.created_at', 'DESC')
|
||||
.skip(skip)
|
||||
.take(limit)
|
||||
.getManyAndCount();
|
||||
|
||||
return { data, total, page, limit };
|
||||
}
|
||||
|
||||
async findById(ctx: ServiceContext, id: string): Promise<SalesOrder | null> {
|
||||
return this.orderRepo.findOne({
|
||||
where: {
|
||||
id,
|
||||
tenantId: ctx.tenantId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findByIdWithItems(ctx: ServiceContext, id: string): Promise<SalesOrder | null> {
|
||||
const order = await this.findById(ctx, id);
|
||||
if (!order) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const items = await this.itemRepo.find({
|
||||
where: { orderId: id },
|
||||
order: { lineNumber: 'ASC' },
|
||||
});
|
||||
|
||||
return { ...order, items } as SalesOrder & { items: SalesOrderItem[] };
|
||||
}
|
||||
|
||||
async findByName(ctx: ServiceContext, name: string): Promise<SalesOrder | null> {
|
||||
return this.orderRepo.findOne({
|
||||
where: {
|
||||
name,
|
||||
tenantId: ctx.tenantId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findByPartner(ctx: ServiceContext, partnerId: string): Promise<SalesOrder[]> {
|
||||
return this.orderRepo
|
||||
.createQueryBuilder('o')
|
||||
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('o.deleted_at IS NULL')
|
||||
.andWhere('o.partner_id = :partnerId', { partnerId })
|
||||
.orderBy('o.created_at', 'DESC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async findByQuotation(ctx: ServiceContext, quotationId: string): Promise<SalesOrder | null> {
|
||||
return this.orderRepo.findOne({
|
||||
where: {
|
||||
quotationId,
|
||||
tenantId: ctx.tenantId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findPendingDelivery(ctx: ServiceContext): Promise<SalesOrder[]> {
|
||||
return this.orderRepo
|
||||
.createQueryBuilder('o')
|
||||
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('o.deleted_at IS NULL')
|
||||
.andWhere('o.status = :status', { status: 'sale' })
|
||||
.andWhere('o.delivery_status IN (:...statuses)', { statuses: ['pending', 'partial'] })
|
||||
.orderBy('o.commitment_date', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async findPendingInvoice(ctx: ServiceContext): Promise<SalesOrder[]> {
|
||||
return this.orderRepo
|
||||
.createQueryBuilder('o')
|
||||
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('o.deleted_at IS NULL')
|
||||
.andWhere('o.status = :status', { status: 'sale' })
|
||||
.andWhere('o.invoice_status IN (:...statuses)', { statuses: ['pending', 'partial'] })
|
||||
.orderBy('o.order_date', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
private async generateOrderName(ctx: ServiceContext): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const prefix = `SO-${year}-`;
|
||||
|
||||
const lastOrder = await this.orderRepo
|
||||
.createQueryBuilder('o')
|
||||
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('o.name LIKE :prefix', { prefix: `${prefix}%` })
|
||||
.orderBy('o.name', 'DESC')
|
||||
.getOne();
|
||||
|
||||
let sequence = 1;
|
||||
if (lastOrder) {
|
||||
const lastNumber = lastOrder.name.replace(prefix, '');
|
||||
sequence = parseInt(lastNumber, 10) + 1;
|
||||
}
|
||||
|
||||
return `${prefix}${sequence.toString().padStart(5, '0')}`;
|
||||
}
|
||||
|
||||
private calculateItemTotals(item: CreateSalesOrderItemDto): {
|
||||
discountAmount: number;
|
||||
taxAmount: number;
|
||||
subtotal: number;
|
||||
total: number;
|
||||
} {
|
||||
const quantity = Number(item.quantity) || 0;
|
||||
const unitPrice = Number(item.unitPrice) || 0;
|
||||
const discountPercent = Number(item.discountPercent) || 0;
|
||||
const taxRate = Number(item.taxRate) ?? 16;
|
||||
|
||||
const grossAmount = quantity * unitPrice;
|
||||
const discountAmount = grossAmount * (discountPercent / 100);
|
||||
const subtotal = grossAmount - discountAmount;
|
||||
const taxAmount = subtotal * (taxRate / 100);
|
||||
const total = subtotal + taxAmount;
|
||||
|
||||
return {
|
||||
discountAmount: Math.round(discountAmount * 100) / 100,
|
||||
taxAmount: Math.round(taxAmount * 100) / 100,
|
||||
subtotal: Math.round(subtotal * 100) / 100,
|
||||
total: Math.round(total * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
private calculateOrderTotals(items: Array<{ subtotal: number; taxAmount: number }>): {
|
||||
amountUntaxed: number;
|
||||
amountTax: number;
|
||||
amountTotal: number;
|
||||
} {
|
||||
const amountUntaxed = items.reduce((sum, item) => sum + Number(item.subtotal), 0);
|
||||
const amountTax = items.reduce((sum, item) => sum + Number(item.taxAmount), 0);
|
||||
const amountTotal = amountUntaxed + amountTax;
|
||||
|
||||
return {
|
||||
amountUntaxed: Math.round(amountUntaxed * 100) / 100,
|
||||
amountTax: Math.round(amountTax * 100) / 100,
|
||||
amountTotal: Math.round(amountTotal * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
async create(ctx: ServiceContext, dto: CreateSalesOrderDto): Promise<SalesOrder> {
|
||||
const orderName = await this.generateOrderName(ctx);
|
||||
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const orderRepo = manager.getRepository(SalesOrder);
|
||||
const itemRepo = manager.getRepository(SalesOrderItem);
|
||||
|
||||
const order = orderRepo.create({
|
||||
tenantId: ctx.tenantId,
|
||||
companyId: ctx.companyId || ctx.tenantId,
|
||||
createdBy: ctx.userId,
|
||||
name: orderName,
|
||||
quotationId: dto.quotationId,
|
||||
partnerId: dto.partnerId,
|
||||
clientOrderRef: dto.clientOrderRef,
|
||||
orderDate: dto.orderDate ?? new Date(),
|
||||
validityDate: dto.validityDate,
|
||||
commitmentDate: dto.commitmentDate,
|
||||
currencyId: dto.currencyId,
|
||||
pricelistId: dto.pricelistId,
|
||||
paymentTermId: dto.paymentTermId,
|
||||
userId: dto.userId ?? ctx.userId,
|
||||
salesTeamId: dto.salesTeamId,
|
||||
invoicePolicy: dto.invoicePolicy ?? 'order',
|
||||
notes: dto.notes,
|
||||
termsConditions: dto.termsConditions,
|
||||
status: 'draft',
|
||||
invoiceStatus: 'pending',
|
||||
deliveryStatus: 'pending',
|
||||
amountUntaxed: 0,
|
||||
amountTax: 0,
|
||||
amountTotal: 0,
|
||||
});
|
||||
|
||||
const savedOrder = await orderRepo.save(order);
|
||||
|
||||
if (dto.items && dto.items.length > 0) {
|
||||
const itemEntities: SalesOrderItem[] = [];
|
||||
|
||||
for (let i = 0; i < dto.items.length; i++) {
|
||||
const itemDto = dto.items[i];
|
||||
const calculated = this.calculateItemTotals(itemDto);
|
||||
|
||||
const item = itemRepo.create({
|
||||
orderId: savedOrder.id,
|
||||
lineNumber: i + 1,
|
||||
productId: itemDto.productId,
|
||||
productSku: itemDto.productSku,
|
||||
productName: itemDto.productName,
|
||||
description: itemDto.description,
|
||||
quantity: itemDto.quantity,
|
||||
quantityReserved: 0,
|
||||
quantityShipped: 0,
|
||||
quantityDelivered: 0,
|
||||
quantityReturned: 0,
|
||||
uom: itemDto.uom ?? 'PZA',
|
||||
unitPrice: itemDto.unitPrice,
|
||||
unitCost: itemDto.unitCost ?? 0,
|
||||
discountPercent: itemDto.discountPercent ?? 0,
|
||||
taxRate: itemDto.taxRate ?? 16,
|
||||
lotNumber: itemDto.lotNumber,
|
||||
serialNumber: itemDto.serialNumber,
|
||||
status: 'pending',
|
||||
...calculated,
|
||||
});
|
||||
|
||||
itemEntities.push(item);
|
||||
}
|
||||
|
||||
await itemRepo.save(itemEntities);
|
||||
|
||||
const totals = this.calculateOrderTotals(itemEntities);
|
||||
savedOrder.amountUntaxed = totals.amountUntaxed;
|
||||
savedOrder.amountTax = totals.amountTax;
|
||||
savedOrder.amountTotal = totals.amountTotal;
|
||||
|
||||
await orderRepo.save(savedOrder);
|
||||
}
|
||||
|
||||
return savedOrder;
|
||||
});
|
||||
}
|
||||
|
||||
async createFromQuotation(ctx: ServiceContext, quotationId: string): Promise<SalesOrder | null> {
|
||||
const quotation = await this.quotationRepo.findOne({
|
||||
where: { id: quotationId, tenantId: ctx.tenantId },
|
||||
});
|
||||
|
||||
if (!quotation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (quotation.status !== 'accepted') {
|
||||
throw new Error('Only accepted quotations can be converted to orders');
|
||||
}
|
||||
|
||||
const quotationItems = await this.quotationItemRepo.find({
|
||||
where: { quotationId },
|
||||
order: { lineNumber: 'ASC' },
|
||||
});
|
||||
|
||||
const order = await this.create(ctx, {
|
||||
quotationId,
|
||||
partnerId: quotation.partnerId,
|
||||
currencyId: quotation.currency,
|
||||
paymentTermId: undefined,
|
||||
notes: quotation.notes ?? undefined,
|
||||
termsConditions: quotation.termsAndConditions ?? undefined,
|
||||
items: quotationItems.map((item) => ({
|
||||
productId: item.productId,
|
||||
productSku: item.productSku,
|
||||
productName: item.productName,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
uom: item.uom,
|
||||
unitPrice: item.unitPrice,
|
||||
discountPercent: item.discountPercent,
|
||||
taxRate: item.taxRate,
|
||||
})),
|
||||
});
|
||||
|
||||
quotation.status = 'converted';
|
||||
quotation.convertedToOrder = true;
|
||||
quotation.orderId = order.id;
|
||||
quotation.convertedAt = new Date();
|
||||
quotation.updatedBy = ctx.userId;
|
||||
|
||||
await this.quotationRepo.save(quotation);
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
async update(ctx: ServiceContext, id: string, dto: UpdateSalesOrderDto): Promise<SalesOrder | null> {
|
||||
const order = await this.findById(ctx, id);
|
||||
if (!order) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (order.status !== 'draft' && order.status !== 'sent') {
|
||||
throw new Error('Can only update draft or sent orders');
|
||||
}
|
||||
|
||||
Object.assign(order, {
|
||||
...dto,
|
||||
updatedBy: ctx.userId,
|
||||
});
|
||||
|
||||
return this.orderRepo.save(order);
|
||||
}
|
||||
|
||||
async addItem(ctx: ServiceContext, orderId: string, dto: CreateSalesOrderItemDto): Promise<SalesOrderItem | null> {
|
||||
const order = await this.findById(ctx, orderId);
|
||||
if (!order) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (order.status !== 'draft' && order.status !== 'sent') {
|
||||
throw new Error('Can only add items to draft or sent orders');
|
||||
}
|
||||
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const itemRepo = manager.getRepository(SalesOrderItem);
|
||||
|
||||
const lastItem = await itemRepo
|
||||
.createQueryBuilder('i')
|
||||
.where('i.order_id = :orderId', { orderId })
|
||||
.orderBy('i.line_number', 'DESC')
|
||||
.getOne();
|
||||
|
||||
const lineNumber = (lastItem?.lineNumber ?? 0) + 1;
|
||||
const calculated = this.calculateItemTotals(dto);
|
||||
|
||||
const item = itemRepo.create({
|
||||
orderId,
|
||||
lineNumber,
|
||||
productId: dto.productId,
|
||||
productSku: dto.productSku,
|
||||
productName: dto.productName,
|
||||
description: dto.description,
|
||||
quantity: dto.quantity,
|
||||
quantityReserved: 0,
|
||||
quantityShipped: 0,
|
||||
quantityDelivered: 0,
|
||||
quantityReturned: 0,
|
||||
uom: dto.uom ?? 'PZA',
|
||||
unitPrice: dto.unitPrice,
|
||||
unitCost: dto.unitCost ?? 0,
|
||||
discountPercent: dto.discountPercent ?? 0,
|
||||
taxRate: dto.taxRate ?? 16,
|
||||
lotNumber: dto.lotNumber,
|
||||
serialNumber: dto.serialNumber,
|
||||
status: 'pending',
|
||||
...calculated,
|
||||
});
|
||||
|
||||
const savedItem = await itemRepo.save(item);
|
||||
|
||||
await this.recalculateOrderTotals(manager, orderId);
|
||||
|
||||
return savedItem;
|
||||
});
|
||||
}
|
||||
|
||||
async updateItem(
|
||||
ctx: ServiceContext,
|
||||
orderId: string,
|
||||
itemId: string,
|
||||
dto: UpdateSalesOrderItemDto,
|
||||
): Promise<SalesOrderItem | null> {
|
||||
const order = await this.findById(ctx, orderId);
|
||||
if (!order) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (order.status !== 'draft' && order.status !== 'sent') {
|
||||
throw new Error('Can only update items on draft or sent orders');
|
||||
}
|
||||
|
||||
const item = await this.itemRepo.findOne({
|
||||
where: { id: itemId, orderId },
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const itemRepo = manager.getRepository(SalesOrderItem);
|
||||
|
||||
Object.assign(item, dto);
|
||||
|
||||
const calculated = this.calculateItemTotals({
|
||||
quantity: item.quantity,
|
||||
unitPrice: item.unitPrice,
|
||||
discountPercent: item.discountPercent,
|
||||
taxRate: item.taxRate,
|
||||
productName: item.productName,
|
||||
});
|
||||
|
||||
Object.assign(item, calculated);
|
||||
|
||||
const savedItem = await itemRepo.save(item);
|
||||
|
||||
await this.recalculateOrderTotals(manager, orderId);
|
||||
|
||||
return savedItem;
|
||||
});
|
||||
}
|
||||
|
||||
async removeItem(ctx: ServiceContext, orderId: string, itemId: string): Promise<boolean> {
|
||||
const order = await this.findById(ctx, orderId);
|
||||
if (!order) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (order.status !== 'draft' && order.status !== 'sent') {
|
||||
throw new Error('Can only remove items from draft or sent orders');
|
||||
}
|
||||
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const itemRepo = manager.getRepository(SalesOrderItem);
|
||||
|
||||
const result = await itemRepo.delete({ id: itemId, orderId });
|
||||
if (!result.affected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.recalculateOrderTotals(manager, orderId);
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private async recalculateOrderTotals(
|
||||
manager: any,
|
||||
orderId: string,
|
||||
): Promise<void> {
|
||||
const orderRepo = manager.getRepository(SalesOrder);
|
||||
const itemRepo = manager.getRepository(SalesOrderItem);
|
||||
|
||||
const items = await itemRepo.find({
|
||||
where: { orderId },
|
||||
});
|
||||
|
||||
const totals = this.calculateOrderTotals(items);
|
||||
|
||||
await orderRepo.update({ id: orderId }, totals);
|
||||
}
|
||||
|
||||
async confirm(ctx: ServiceContext, id: string): Promise<SalesOrder | null> {
|
||||
const order = await this.findById(ctx, id);
|
||||
if (!order) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (order.status !== 'draft' && order.status !== 'sent') {
|
||||
throw new Error('Can only confirm draft or sent orders');
|
||||
}
|
||||
|
||||
order.status = 'sale';
|
||||
order.confirmedAt = new Date();
|
||||
order.confirmedBy = ctx.userId ?? null;
|
||||
order.updatedBy = ctx.userId ?? null;
|
||||
|
||||
return this.orderRepo.save(order);
|
||||
}
|
||||
|
||||
async send(ctx: ServiceContext, id: string): Promise<SalesOrder | null> {
|
||||
const order = await this.findById(ctx, id);
|
||||
if (!order) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (order.status !== 'draft') {
|
||||
throw new Error('Only draft orders can be sent');
|
||||
}
|
||||
|
||||
order.status = 'sent';
|
||||
order.updatedBy = ctx.userId ?? null;
|
||||
|
||||
return this.orderRepo.save(order);
|
||||
}
|
||||
|
||||
async cancel(ctx: ServiceContext, id: string): Promise<SalesOrder | null> {
|
||||
const order = await this.findById(ctx, id);
|
||||
if (!order) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (order.status === 'done' || order.status === 'cancelled') {
|
||||
throw new Error('Cannot cancel a completed or already cancelled order');
|
||||
}
|
||||
|
||||
if (order.deliveryStatus === 'delivered') {
|
||||
throw new Error('Cannot cancel an order that has been fully delivered');
|
||||
}
|
||||
|
||||
if (order.invoiceStatus === 'invoiced') {
|
||||
throw new Error('Cannot cancel an order that has been fully invoiced');
|
||||
}
|
||||
|
||||
order.status = 'cancelled';
|
||||
order.cancelledAt = new Date();
|
||||
order.cancelledBy = ctx.userId ?? null;
|
||||
order.updatedBy = ctx.userId ?? null;
|
||||
|
||||
await this.itemRepo.update(
|
||||
{ orderId: id },
|
||||
{ status: 'cancelled' },
|
||||
);
|
||||
|
||||
return this.orderRepo.save(order);
|
||||
}
|
||||
|
||||
async markDone(ctx: ServiceContext, id: string): Promise<SalesOrder | null> {
|
||||
const order = await this.findById(ctx, id);
|
||||
if (!order) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (order.status !== 'sale') {
|
||||
throw new Error('Only confirmed orders can be marked as done');
|
||||
}
|
||||
|
||||
order.status = 'done';
|
||||
order.updatedBy = ctx.userId ?? null;
|
||||
|
||||
return this.orderRepo.save(order);
|
||||
}
|
||||
|
||||
async updateDeliveryStatus(ctx: ServiceContext, id: string): Promise<SalesOrder | null> {
|
||||
const order = await this.findByIdWithItems(ctx, id);
|
||||
if (!order) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const items = (order as any).items as SalesOrderItem[];
|
||||
|
||||
const totalQuantity = items.reduce((sum, item) => sum + Number(item.quantity), 0);
|
||||
const deliveredQuantity = items.reduce((sum, item) => sum + Number(item.quantityDelivered), 0);
|
||||
|
||||
let deliveryStatus: 'pending' | 'partial' | 'delivered';
|
||||
if (deliveredQuantity === 0) {
|
||||
deliveryStatus = 'pending';
|
||||
} else if (deliveredQuantity >= totalQuantity) {
|
||||
deliveryStatus = 'delivered';
|
||||
} else {
|
||||
deliveryStatus = 'partial';
|
||||
}
|
||||
|
||||
order.deliveryStatus = deliveryStatus;
|
||||
order.updatedBy = ctx.userId ?? null;
|
||||
|
||||
return this.orderRepo.save(order);
|
||||
}
|
||||
|
||||
async updateInvoiceStatus(ctx: ServiceContext, id: string, status: 'pending' | 'partial' | 'invoiced'): Promise<SalesOrder | null> {
|
||||
const order = await this.findById(ctx, id);
|
||||
if (!order) {
|
||||
return null;
|
||||
}
|
||||
|
||||
order.invoiceStatus = status;
|
||||
order.updatedBy = ctx.userId ?? null;
|
||||
|
||||
return this.orderRepo.save(order);
|
||||
}
|
||||
|
||||
async updateItemDelivery(
|
||||
ctx: ServiceContext,
|
||||
orderId: string,
|
||||
itemId: string,
|
||||
quantityDelivered: number,
|
||||
): Promise<SalesOrderItem | null> {
|
||||
const item = await this.itemRepo.findOne({
|
||||
where: { id: itemId, orderId },
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (quantityDelivered > item.quantity) {
|
||||
throw new Error('Delivered quantity cannot exceed ordered quantity');
|
||||
}
|
||||
|
||||
item.quantityDelivered = quantityDelivered;
|
||||
item.status = quantityDelivered >= item.quantity ? 'delivered' : 'shipped';
|
||||
|
||||
const savedItem = await this.itemRepo.save(item);
|
||||
|
||||
await this.updateDeliveryStatus(ctx, orderId);
|
||||
|
||||
return savedItem;
|
||||
}
|
||||
|
||||
async getStatistics(ctx: ServiceContext, dateFrom?: Date, dateTo?: Date): Promise<{
|
||||
totalOrders: number;
|
||||
draft: number;
|
||||
sent: number;
|
||||
confirmed: number;
|
||||
done: number;
|
||||
cancelled: number;
|
||||
totalRevenue: number;
|
||||
pendingDelivery: number;
|
||||
pendingInvoice: number;
|
||||
averageOrderValue: number;
|
||||
}> {
|
||||
const queryBuilder = this.orderRepo
|
||||
.createQueryBuilder('o')
|
||||
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('o.deleted_at IS NULL');
|
||||
|
||||
if (dateFrom) {
|
||||
queryBuilder.andWhere('o.order_date >= :dateFrom', { dateFrom });
|
||||
}
|
||||
|
||||
if (dateTo) {
|
||||
queryBuilder.andWhere('o.order_date <= :dateTo', { dateTo });
|
||||
}
|
||||
|
||||
const orders = await queryBuilder.getMany();
|
||||
|
||||
const draft = orders.filter((o) => o.status === 'draft');
|
||||
const sent = orders.filter((o) => o.status === 'sent');
|
||||
const confirmed = orders.filter((o) => o.status === 'sale');
|
||||
const done = orders.filter((o) => o.status === 'done');
|
||||
const cancelled = orders.filter((o) => o.status === 'cancelled');
|
||||
|
||||
const activeOrders = orders.filter((o) => o.status !== 'cancelled' && o.status !== 'draft');
|
||||
const totalRevenue = activeOrders.reduce((sum, o) => sum + Number(o.amountTotal), 0);
|
||||
|
||||
const pendingDelivery = orders.filter(
|
||||
(o) => o.status === 'sale' && (o.deliveryStatus === 'pending' || o.deliveryStatus === 'partial'),
|
||||
).length;
|
||||
|
||||
const pendingInvoice = orders.filter(
|
||||
(o) => o.status === 'sale' && (o.invoiceStatus === 'pending' || o.invoiceStatus === 'partial'),
|
||||
).length;
|
||||
|
||||
const averageOrderValue = activeOrders.length > 0
|
||||
? totalRevenue / activeOrders.length
|
||||
: 0;
|
||||
|
||||
return {
|
||||
totalOrders: orders.length,
|
||||
draft: draft.length,
|
||||
sent: sent.length,
|
||||
confirmed: confirmed.length,
|
||||
done: done.length,
|
||||
cancelled: cancelled.length,
|
||||
totalRevenue,
|
||||
pendingDelivery,
|
||||
pendingInvoice,
|
||||
averageOrderValue: Math.round(averageOrderValue * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
|
||||
const order = await this.findById(ctx, id);
|
||||
if (!order) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (order.status === 'sale' || order.status === 'done') {
|
||||
throw new Error('Cannot delete confirmed or completed orders');
|
||||
}
|
||||
|
||||
const result = await this.orderRepo.update(
|
||||
{ id, tenantId: ctx.tenantId },
|
||||
{ deletedAt: new Date(), updatedBy: ctx.userId },
|
||||
);
|
||||
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user