[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:
Adrian Flores Cortes 2026-01-31 00:24:00 -06:00
parent 88e1c4e9b6
commit 8f8843cd10
69 changed files with 19462 additions and 0 deletions

View File

@ -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;

View File

@ -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;

View 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;

View 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;

View 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';

View File

@ -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);
}
}
);

View File

@ -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 };
}
}

View 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);
}
}

View 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;
}
}

View 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;
});
}
}

View 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';

View 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,
});
}
}

View File

@ -0,0 +1,7 @@
/**
* Invoices Controllers Index
* @module Invoices
*/
export { createInvoiceController } from './invoice.controller';
export { createPaymentController } from './payment.controller';

View 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;
}

View 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;
}

View File

@ -0,0 +1,13 @@
/**
* Invoices Module Index
* @module Invoices
*/
// Entities
export * from './entities';
// Services
export * from './services';
// Controllers
export * from './controllers';

View 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';

View 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,
}
);
}
}

View 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')}`;
}
}

View File

@ -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;

View File

@ -0,0 +1,8 @@
/**
* Notifications Controllers Index
* @module Notifications
*/
export * from './notification.controller';
export * from './preference.controller';
export * from './in-app-notification.controller';

View 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;

View 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;

View File

@ -0,0 +1,10 @@
/**
* Notifications Module
* System notifications, alerts, and messaging
*
* ERP Construccion
*/
export * from './entities';
export * from './services';
export * from './controllers';

View 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' };
}
}

View File

@ -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;
}
}

View 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';

View 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;
}
}

View 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 };
}
}

View 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;
}
}

View 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';

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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';

View 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';

View 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;
}
}

View 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[];
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View File

@ -0,0 +1 @@
export * from './projects.controller';

File diff suppressed because it is too large Load Diff

View File

@ -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';

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View File

@ -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;
}

View 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';

View 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';

View 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),
};
}
}

View 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;
}
}

View 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();
}
}

View 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,
});
}
}

View 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,
});
}
}

View 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);
}
}

View 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';

View 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;

View 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;

View 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';

View File

@ -0,0 +1,7 @@
/**
* Sales Services Index
* @module Sales (MAI-010)
*/
export * from './quotation.service';
export * from './sales-order.service';

View 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;
}
}

View 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;
}
}