[MAI-002] feat: Implement tarifas transport service with cost calculation
- Add TarifasService with findTarifaByLane, calcularCostoEnvio methods - Create RecargosService for surcharge catalog and fuel surcharge - Add TarifasController with full REST API for tarifas, lanes, recargos - Implement DTOs with class-validator decorators - Support lane-based tariff lookup with fallback to generic - Calculate shipping cost with fuel surcharge, insurance, and other recargos - Include peso/volumen calculation with volumetric factor (1m3=250kg) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
5d0db6d5fc
commit
48bb0c8d58
@ -1,5 +1,4 @@
|
||||
/**
|
||||
* Tarifas Controllers
|
||||
*/
|
||||
// TODO: Implement controllers
|
||||
// - tarifas.controller.ts
|
||||
export * from './tarifas.controller';
|
||||
|
||||
789
src/modules/tarifas-transporte/controllers/tarifas.controller.ts
Normal file
789
src/modules/tarifas-transporte/controllers/tarifas.controller.ts
Normal file
@ -0,0 +1,789 @@
|
||||
import { Request, Response, NextFunction, Router } from 'express';
|
||||
import {
|
||||
TarifasService,
|
||||
TarifaSearchParams,
|
||||
CreateTarifaDto,
|
||||
UpdateTarifaDto,
|
||||
CotizarParams,
|
||||
FindTarifaByLaneParams,
|
||||
CalcularCostoEnvioParams,
|
||||
ServiceContext,
|
||||
} from '../services/tarifas.service';
|
||||
import { LanesService, LaneSearchParams, CreateLaneDto, UpdateLaneDto } from '../services/lanes.service';
|
||||
import { RecargosService, CreateRecargoDto, UpdateRecargoDto, CreateFuelSurchargeDto, UpdateFuelSurchargeDto } from '../services/recargos.service';
|
||||
import { TipoRecargo } from '../entities';
|
||||
|
||||
export class TarifasController {
|
||||
public router: Router;
|
||||
|
||||
constructor(
|
||||
private readonly tarifasService: TarifasService,
|
||||
private readonly lanesService: LanesService,
|
||||
private readonly recargosService: RecargosService
|
||||
) {
|
||||
this.router = Router();
|
||||
this.initializeRoutes();
|
||||
}
|
||||
|
||||
private initializeRoutes(): void {
|
||||
// Tarifas
|
||||
this.router.get('/tarifas', this.findAllTarifas.bind(this));
|
||||
this.router.get('/tarifas/vigentes', this.findTarifasVigentes.bind(this));
|
||||
this.router.get('/tarifas/por-vencer', this.getTarifasPorVencer.bind(this));
|
||||
this.router.get('/tarifas/:id', this.findOneTarifa.bind(this));
|
||||
this.router.post('/tarifas', this.createTarifa.bind(this));
|
||||
this.router.put('/tarifas/:id', this.updateTarifa.bind(this));
|
||||
this.router.patch('/tarifas/:id/activar', this.activarTarifa.bind(this));
|
||||
this.router.patch('/tarifas/:id/desactivar', this.desactivarTarifa.bind(this));
|
||||
this.router.post('/tarifas/:id/clonar', this.clonarTarifa.bind(this));
|
||||
this.router.delete('/tarifas/:id', this.deleteTarifa.bind(this));
|
||||
|
||||
// Find tarifa by lane
|
||||
this.router.post('/tarifas/find-by-lane', this.findTarifaByLane.bind(this));
|
||||
|
||||
// Cotizacion
|
||||
this.router.post('/tarifas/:id/cotizar', this.cotizarTarifa.bind(this));
|
||||
this.router.post('/cotizar-envio', this.calcularCostoEnvio.bind(this));
|
||||
|
||||
// Lanes
|
||||
this.router.get('/lanes', this.findAllLanes.bind(this));
|
||||
this.router.get('/lanes/ciudades-origen', this.getOrigenCiudades.bind(this));
|
||||
this.router.get('/lanes/ciudades-destino', this.getDestinoCiudades.bind(this));
|
||||
this.router.get('/lanes/estados-origen', this.getOrigenEstados.bind(this));
|
||||
this.router.get('/lanes/estados-destino', this.getDestinoEstados.bind(this));
|
||||
this.router.get('/lanes/:id', this.findOneLane.bind(this));
|
||||
this.router.get('/lanes/:id/tarifas', this.getTarifasLane.bind(this));
|
||||
this.router.get('/lanes/:id/inversa', this.getLaneInversa.bind(this));
|
||||
this.router.post('/lanes', this.createLane.bind(this));
|
||||
this.router.post('/lanes/:id/crear-inversa', this.crearLaneInversa.bind(this));
|
||||
this.router.put('/lanes/:id', this.updateLane.bind(this));
|
||||
this.router.patch('/lanes/:id/activar', this.activarLane.bind(this));
|
||||
this.router.patch('/lanes/:id/desactivar', this.desactivarLane.bind(this));
|
||||
this.router.delete('/lanes/:id', this.deleteLane.bind(this));
|
||||
|
||||
// Recargos
|
||||
this.router.get('/recargos', this.findAllRecargos.bind(this));
|
||||
this.router.get('/recargos/vigentes', this.getRecargosVigentes.bind(this));
|
||||
this.router.get('/recargos/automaticos', this.getRecargosAutomaticos.bind(this));
|
||||
this.router.get('/recargos/:id', this.findOneRecargo.bind(this));
|
||||
this.router.post('/recargos', this.createRecargo.bind(this));
|
||||
this.router.put('/recargos/:id', this.updateRecargo.bind(this));
|
||||
this.router.patch('/recargos/:id/activar', this.activarRecargo.bind(this));
|
||||
this.router.patch('/recargos/:id/desactivar', this.desactivarRecargo.bind(this));
|
||||
this.router.delete('/recargos/:id', this.deleteRecargo.bind(this));
|
||||
this.router.post('/recargos/calcular', this.calcularRecargos.bind(this));
|
||||
|
||||
// Fuel Surcharge
|
||||
this.router.get('/fuel-surcharge', this.findAllFuelSurcharges.bind(this));
|
||||
this.router.get('/fuel-surcharge/vigente', this.getFuelSurchargeVigente.bind(this));
|
||||
this.router.get('/fuel-surcharge/:id', this.findOneFuelSurcharge.bind(this));
|
||||
this.router.post('/fuel-surcharge', this.createFuelSurcharge.bind(this));
|
||||
this.router.put('/fuel-surcharge/:id', this.updateFuelSurcharge.bind(this));
|
||||
this.router.patch('/fuel-surcharge/:id/desactivar', this.desactivarFuelSurcharge.bind(this));
|
||||
this.router.delete('/fuel-surcharge/:id', this.deleteFuelSurcharge.bind(this));
|
||||
}
|
||||
|
||||
private getContext(req: Request): ServiceContext {
|
||||
return {
|
||||
tenantId: req.headers['x-tenant-id'] as string,
|
||||
userId: req.headers['x-user-id'] as string,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// TARIFAS
|
||||
// ============================================
|
||||
|
||||
private async findAllTarifas(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const params: TarifaSearchParams = {
|
||||
tenantId: ctx.tenantId,
|
||||
search: req.query.search as string,
|
||||
clienteId: req.query.clienteId as string,
|
||||
laneId: req.query.laneId as string,
|
||||
tipoTarifa: req.query.tipoTarifa as any,
|
||||
activa: req.query.activa === 'true' ? true : req.query.activa === 'false' ? false : undefined,
|
||||
vigente: req.query.vigente === 'true',
|
||||
moneda: req.query.moneda as string,
|
||||
limit: parseInt(req.query.limit as string) || 50,
|
||||
offset: parseInt(req.query.offset as string) || 0,
|
||||
};
|
||||
|
||||
const result = await this.tarifasService.findAll(params);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async findTarifasVigentes(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const params: TarifaSearchParams = {
|
||||
tenantId: ctx.tenantId,
|
||||
vigente: true,
|
||||
limit: parseInt(req.query.limit as string) || 100,
|
||||
offset: parseInt(req.query.offset as string) || 0,
|
||||
};
|
||||
|
||||
const result = await this.tarifasService.findAll(params);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getTarifasPorVencer(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const diasAntelacion = parseInt(req.query.dias as string) || 30;
|
||||
|
||||
const tarifas = await this.tarifasService.getTarifasPorVencer(ctx.tenantId, diasAntelacion);
|
||||
res.json({ data: tarifas, total: tarifas.length });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async findOneTarifa(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const tarifa = await this.tarifasService.findOne(id, ctx.tenantId);
|
||||
if (!tarifa) {
|
||||
res.status(404).json({ error: 'Tarifa no encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: tarifa });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async createTarifa(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const dto: CreateTarifaDto = req.body;
|
||||
|
||||
const tarifa = await this.tarifasService.createWithValidation(dto, ctx);
|
||||
res.status(201).json({ data: tarifa });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateTarifa(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const { id } = req.params;
|
||||
const dto: UpdateTarifaDto = req.body;
|
||||
|
||||
const tarifa = await this.tarifasService.updateWithHistory(id, dto, ctx);
|
||||
if (!tarifa) {
|
||||
res.status(404).json({ error: 'Tarifa no encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: tarifa });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async activarTarifa(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const tarifa = await this.tarifasService.activar(id, ctx.tenantId);
|
||||
if (!tarifa) {
|
||||
res.status(404).json({ error: 'Tarifa no encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: tarifa });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async desactivarTarifa(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const tarifa = await this.tarifasService.desactivar(id, ctx.tenantId);
|
||||
if (!tarifa) {
|
||||
res.status(404).json({ error: 'Tarifa no encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: tarifa });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async clonarTarifa(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const { id } = req.params;
|
||||
const { nuevoCodigo, nuevaFechaInicio, nuevaFechaFin } = req.body;
|
||||
|
||||
const tarifa = await this.tarifasService.clonarTarifa(
|
||||
id,
|
||||
ctx.tenantId,
|
||||
nuevoCodigo,
|
||||
new Date(nuevaFechaInicio),
|
||||
nuevaFechaFin ? new Date(nuevaFechaFin) : undefined,
|
||||
ctx.userId
|
||||
);
|
||||
|
||||
if (!tarifa) {
|
||||
res.status(404).json({ error: 'Tarifa origen no encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(201).json({ data: tarifa });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteTarifa(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const deleted = await this.tarifasService.delete(id, ctx.tenantId);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Tarifa no encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async findTarifaByLane(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const params: FindTarifaByLaneParams = req.body;
|
||||
|
||||
const tarifa = await this.tarifasService.findTarifaByLane(params, ctx);
|
||||
res.json({ data: tarifa });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async cotizarTarifa(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const { id } = req.params;
|
||||
const params: CotizarParams = req.body;
|
||||
|
||||
const cotizacion = await this.tarifasService.cotizar(id, ctx.tenantId, params);
|
||||
res.json({ data: cotizacion });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async calcularCostoEnvio(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const params: CalcularCostoEnvioParams = req.body;
|
||||
|
||||
const resultado = await this.tarifasService.calcularCostoEnvio(params, ctx);
|
||||
res.json({ data: resultado });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LANES
|
||||
// ============================================
|
||||
|
||||
private async findAllLanes(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const params: LaneSearchParams = {
|
||||
tenantId: ctx.tenantId,
|
||||
search: req.query.search as string,
|
||||
origenCiudad: req.query.origenCiudad as string,
|
||||
origenEstado: req.query.origenEstado as string,
|
||||
destinoCiudad: req.query.destinoCiudad as string,
|
||||
destinoEstado: req.query.destinoEstado as string,
|
||||
activo: req.query.activo === 'true' ? true : req.query.activo === 'false' ? false : undefined,
|
||||
limit: parseInt(req.query.limit as string) || 50,
|
||||
offset: parseInt(req.query.offset as string) || 0,
|
||||
};
|
||||
|
||||
const result = await this.lanesService.findAll(params);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async findOneLane(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const lane = await this.lanesService.findOne(id, ctx.tenantId);
|
||||
if (!lane) {
|
||||
res.status(404).json({ error: 'Lane no encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: lane });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getTarifasLane(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const tarifas = await this.tarifasService.getTarifasLane(id, ctx.tenantId);
|
||||
res.json({ data: tarifas, total: tarifas.length });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getLaneInversa(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const lane = await this.lanesService.getLaneInversa(id, ctx.tenantId);
|
||||
res.json({ data: lane });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getOrigenCiudades(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const ciudades = await this.lanesService.getOrigenCiudades(ctx.tenantId);
|
||||
res.json({ data: ciudades });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getDestinoCiudades(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const ciudades = await this.lanesService.getDestinoCiudades(ctx.tenantId);
|
||||
res.json({ data: ciudades });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getOrigenEstados(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const estados = await this.lanesService.getOrigenEstados(ctx.tenantId);
|
||||
res.json({ data: estados });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getDestinoEstados(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const estados = await this.lanesService.getDestinoEstados(ctx.tenantId);
|
||||
res.json({ data: estados });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async createLane(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const dto: CreateLaneDto = req.body;
|
||||
|
||||
const lane = await this.lanesService.create(ctx.tenantId, dto, ctx.userId);
|
||||
res.status(201).json({ data: lane });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async crearLaneInversa(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const lane = await this.lanesService.crearLaneInversa(id, ctx.tenantId, ctx.userId);
|
||||
if (!lane) {
|
||||
res.status(404).json({ error: 'Lane origen no encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(201).json({ data: lane });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateLane(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const { id } = req.params;
|
||||
const dto: UpdateLaneDto = req.body;
|
||||
|
||||
const lane = await this.lanesService.update(id, ctx.tenantId, dto, ctx.userId);
|
||||
if (!lane) {
|
||||
res.status(404).json({ error: 'Lane no encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: lane });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async activarLane(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const lane = await this.lanesService.activar(id, ctx.tenantId);
|
||||
if (!lane) {
|
||||
res.status(404).json({ error: 'Lane no encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: lane });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async desactivarLane(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const lane = await this.lanesService.desactivar(id, ctx.tenantId);
|
||||
if (!lane) {
|
||||
res.status(404).json({ error: 'Lane no encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: lane });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteLane(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const deleted = await this.lanesService.delete(id, ctx.tenantId);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Lane no encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// RECARGOS
|
||||
// ============================================
|
||||
|
||||
private async findAllRecargos(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const result = await this.recargosService.findAll({
|
||||
tenantId: ctx.tenantId,
|
||||
search: req.query.search as string,
|
||||
tipo: req.query.tipo as TipoRecargo,
|
||||
activo: req.query.activo === 'true' ? true : req.query.activo === 'false' ? false : undefined,
|
||||
aplicaAutomatico: req.query.aplicaAutomatico === 'true' ? true : req.query.aplicaAutomatico === 'false' ? false : undefined,
|
||||
limit: parseInt(req.query.limit as string) || 50,
|
||||
offset: parseInt(req.query.offset as string) || 0,
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getRecargosVigentes(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const recargos = await this.recargosService.getRecargosVigentes(ctx);
|
||||
res.json({ data: recargos, total: recargos.length });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getRecargosAutomaticos(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const recargos = await this.recargosService.getRecargosAutomaticos(ctx);
|
||||
res.json({ data: recargos, total: recargos.length });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async findOneRecargo(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const recargo = await this.recargosService.findOne(id, ctx.tenantId);
|
||||
if (!recargo) {
|
||||
res.status(404).json({ error: 'Recargo no encontrado' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: recargo });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async createRecargo(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const dto: CreateRecargoDto = req.body;
|
||||
|
||||
const recargo = await this.recargosService.create(dto, ctx);
|
||||
res.status(201).json({ data: recargo });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateRecargo(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const { id } = req.params;
|
||||
const dto: UpdateRecargoDto = req.body;
|
||||
|
||||
const recargo = await this.recargosService.update(id, dto, ctx);
|
||||
if (!recargo) {
|
||||
res.status(404).json({ error: 'Recargo no encontrado' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: recargo });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async activarRecargo(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const recargo = await this.recargosService.activar(id, ctx);
|
||||
if (!recargo) {
|
||||
res.status(404).json({ error: 'Recargo no encontrado' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: recargo });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async desactivarRecargo(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const recargo = await this.recargosService.desactivar(id, ctx);
|
||||
if (!recargo) {
|
||||
res.status(404).json({ error: 'Recargo no encontrado' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: recargo });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteRecargo(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const deleted = await this.recargosService.delete(id, ctx);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Recargo no encontrado' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async calcularRecargos(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const { montoBase, valorDeclarado, incluirFuelSurcharge } = req.body;
|
||||
|
||||
const resultado = await this.recargosService.calcularRecargos(montoBase, ctx, {
|
||||
valorDeclarado,
|
||||
incluirFuelSurcharge,
|
||||
});
|
||||
|
||||
res.json({ data: resultado });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// FUEL SURCHARGE
|
||||
// ============================================
|
||||
|
||||
private async findAllFuelSurcharges(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const result = await this.recargosService.findAllFuelSurcharges(
|
||||
ctx.tenantId,
|
||||
parseInt(req.query.limit as string) || 50,
|
||||
parseInt(req.query.offset as string) || 0
|
||||
);
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getFuelSurchargeVigente(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const fuelSurcharge = await this.recargosService.getFuelSurchargeVigente(ctx.tenantId);
|
||||
res.json({ data: fuelSurcharge });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async findOneFuelSurcharge(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const fuelSurcharge = await this.recargosService.findFuelSurcharge(id, ctx.tenantId);
|
||||
if (!fuelSurcharge) {
|
||||
res.status(404).json({ error: 'Fuel surcharge no encontrado' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: fuelSurcharge });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async createFuelSurcharge(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const dto: CreateFuelSurchargeDto = {
|
||||
...req.body,
|
||||
fechaInicio: new Date(req.body.fechaInicio),
|
||||
fechaFin: new Date(req.body.fechaFin),
|
||||
};
|
||||
|
||||
const fuelSurcharge = await this.recargosService.createFuelSurcharge(dto, ctx);
|
||||
res.status(201).json({ data: fuelSurcharge });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateFuelSurcharge(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const { id } = req.params;
|
||||
const dto: UpdateFuelSurchargeDto = {
|
||||
...req.body,
|
||||
fechaInicio: req.body.fechaInicio ? new Date(req.body.fechaInicio) : undefined,
|
||||
fechaFin: req.body.fechaFin ? new Date(req.body.fechaFin) : undefined,
|
||||
};
|
||||
|
||||
const fuelSurcharge = await this.recargosService.updateFuelSurcharge(id, dto, ctx);
|
||||
if (!fuelSurcharge) {
|
||||
res.status(404).json({ error: 'Fuel surcharge no encontrado' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: fuelSurcharge });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async desactivarFuelSurcharge(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const fuelSurcharge = await this.recargosService.desactivarFuelSurcharge(id, ctx);
|
||||
if (!fuelSurcharge) {
|
||||
res.status(404).json({ error: 'Fuel surcharge no encontrado' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: fuelSurcharge });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteFuelSurcharge(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const ctx = this.getContext(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const deleted = await this.recargosService.deleteFuelSurcharge(id, ctx);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Fuel surcharge no encontrado' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
112
src/modules/tarifas-transporte/dto/cotizacion.dto.ts
Normal file
112
src/modules/tarifas-transporte/dto/cotizacion.dto.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsNumber,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* DTO for finding tarifa by lane
|
||||
*/
|
||||
export class FindTarifaByLaneDto {
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
origenCiudad: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
destinoCiudad: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
tipoCarga: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
tipoEquipo?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for basic tarifa quotation
|
||||
*/
|
||||
export class CotizarTarifaDto {
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
km?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
toneladas?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
m3?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
pallets?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
horas?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for full shipping cost calculation
|
||||
*/
|
||||
export class CalcularCostoEnvioDto {
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
origenCiudad: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
destinoCiudad: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
tipoCarga: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
tipoEquipo: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
pesoKg?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
volumenM3?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
valorDeclarado?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for calculating surcharges
|
||||
*/
|
||||
export class CalcularRecargosDto {
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
montoBase: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
valorDeclarado?: number;
|
||||
|
||||
@IsOptional()
|
||||
incluirFuelSurcharge?: boolean;
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Tarifas DTOs
|
||||
*/
|
||||
// TODO: Implement DTOs
|
||||
// - create-tarifa.dto.ts
|
||||
// - create-recargo.dto.ts
|
||||
// - cotizacion.dto.ts
|
||||
export * from './tarifa.dto';
|
||||
export * from './lane.dto';
|
||||
export * from './recargo.dto';
|
||||
export * from './cotizacion.dto';
|
||||
|
||||
116
src/modules/tarifas-transporte/dto/lane.dto.ts
Normal file
116
src/modules/tarifas-transporte/dto/lane.dto.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* DTO for creating a lane
|
||||
*/
|
||||
export class CreateLaneDto {
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
codigo: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
nombre: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
origenCiudad: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
origenEstado: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10)
|
||||
origenCodigoPostal?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
destinoCiudad: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
destinoEstado: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10)
|
||||
destinoCodigoPostal?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
distanciaKm?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
tiempoEstimadoHoras?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for updating a lane
|
||||
*/
|
||||
export class UpdateLaneDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
codigo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
nombre?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
origenCiudad?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
origenEstado?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10)
|
||||
origenCodigoPostal?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
destinoCiudad?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
destinoEstado?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10)
|
||||
destinoCodigoPostal?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
distanciaKm?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
tiempoEstimadoHoras?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
activo?: boolean;
|
||||
}
|
||||
156
src/modules/tarifas-transporte/dto/recargo.dto.ts
Normal file
156
src/modules/tarifas-transporte/dto/recargo.dto.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
IsEnum,
|
||||
IsDateString,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { TipoRecargo } from '../entities';
|
||||
|
||||
/**
|
||||
* DTO for creating a recargo
|
||||
*/
|
||||
export class CreateRecargoDto {
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
codigo: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
nombre: string;
|
||||
|
||||
@IsEnum(TipoRecargo)
|
||||
tipo: TipoRecargo;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
descripcion?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
esPorcentaje?: boolean;
|
||||
|
||||
@IsNumber()
|
||||
monto: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(3)
|
||||
moneda?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
aplicaAutomatico?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
condicionAplicacion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for updating a recargo
|
||||
*/
|
||||
export class UpdateRecargoDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
codigo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
nombre?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(TipoRecargo)
|
||||
tipo?: TipoRecargo;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
descripcion?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
esPorcentaje?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
monto?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(3)
|
||||
moneda?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
aplicaAutomatico?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
condicionAplicacion?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
activo?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for creating a fuel surcharge
|
||||
*/
|
||||
export class CreateFuelSurchargeDto {
|
||||
@IsDateString()
|
||||
fechaInicio: string;
|
||||
|
||||
@IsDateString()
|
||||
fechaFin: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
precioDieselReferencia?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
precioDieselActual?: number;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
porcentajeSurcharge: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for updating a fuel surcharge
|
||||
*/
|
||||
export class UpdateFuelSurchargeDto {
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
fechaInicio?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
fechaFin?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
precioDieselReferencia?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
precioDieselActual?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
porcentajeSurcharge?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
activo?: boolean;
|
||||
}
|
||||
215
src/modules/tarifas-transporte/dto/tarifa.dto.ts
Normal file
215
src/modules/tarifas-transporte/dto/tarifa.dto.ts
Normal file
@ -0,0 +1,215 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
IsUUID,
|
||||
IsEnum,
|
||||
IsDateString,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { TipoTarifa } from '../entities';
|
||||
|
||||
/**
|
||||
* DTO for creating a tarifa
|
||||
*/
|
||||
export class CreateTarifaDto {
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
codigo: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
nombre: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
descripcion?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
clienteId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
laneId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
modalidadServicio?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
tipoEquipo?: string;
|
||||
|
||||
@IsEnum(TipoTarifa)
|
||||
tipoTarifa: TipoTarifa;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
tarifaBase: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
tarifaKm?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
tarifaTonelada?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
tarifaM3?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
tarifaPallet?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
tarifaHora?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
minimoFacturar?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
pesoMinimoKg?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(3)
|
||||
moneda?: string;
|
||||
|
||||
@IsDateString()
|
||||
fechaInicio: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
fechaFin?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for updating a tarifa
|
||||
*/
|
||||
export class UpdateTarifaDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
codigo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
nombre?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
descripcion?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
clienteId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
laneId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
modalidadServicio?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
tipoEquipo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(TipoTarifa)
|
||||
tipoTarifa?: TipoTarifa;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
tarifaBase?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
tarifaKm?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
tarifaTonelada?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
tarifaM3?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
tarifaPallet?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
tarifaHora?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
minimoFacturar?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
pesoMinimoKg?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(3)
|
||||
moneda?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
fechaInicio?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
fechaFin?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
activa?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for cloning a tarifa
|
||||
*/
|
||||
export class ClonarTarifaDto {
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
nuevoCodigo: string;
|
||||
|
||||
@IsDateString()
|
||||
nuevaFechaInicio: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
nuevaFechaFin?: string;
|
||||
}
|
||||
@ -2,7 +2,66 @@
|
||||
* Tarifas Transporte Module - MAI-002
|
||||
* Tarifas por lane, recargos, contratos
|
||||
*/
|
||||
|
||||
// Export entities
|
||||
export * from './entities';
|
||||
export * from './services';
|
||||
|
||||
// Export services with explicit naming
|
||||
export {
|
||||
TarifasService,
|
||||
TarifaSearchParams,
|
||||
CreateTarifaDto,
|
||||
UpdateTarifaDto,
|
||||
CotizarParams,
|
||||
CotizacionResult,
|
||||
ServiceContext,
|
||||
FindTarifaByLaneParams,
|
||||
CalcularCostoEnvioParams,
|
||||
RecargoAplicado,
|
||||
CostoEnvioResult,
|
||||
} from './services/tarifas.service';
|
||||
|
||||
export {
|
||||
LanesService,
|
||||
LaneSearchParams,
|
||||
CreateLaneDto as CreateLaneDtoInterface,
|
||||
UpdateLaneDto as UpdateLaneDtoInterface,
|
||||
} from './services/lanes.service';
|
||||
|
||||
export {
|
||||
RecargosService,
|
||||
RecargoSearchParams,
|
||||
CreateRecargoDto as CreateRecargoDtoInterface,
|
||||
UpdateRecargoDto as UpdateRecargoDtoInterface,
|
||||
CreateFuelSurchargeDto as CreateFuelSurchargeDtoInterface,
|
||||
UpdateFuelSurchargeDto as UpdateFuelSurchargeDtoInterface,
|
||||
} from './services/recargos.service';
|
||||
|
||||
// Export controllers
|
||||
export * from './controllers';
|
||||
export * from './dto';
|
||||
|
||||
// Export DTO classes (with class-validator decorators)
|
||||
export {
|
||||
CreateTarifaDto as CreateTarifaDtoClass,
|
||||
UpdateTarifaDto as UpdateTarifaDtoClass,
|
||||
ClonarTarifaDto,
|
||||
} from './dto/tarifa.dto';
|
||||
|
||||
export {
|
||||
CreateLaneDto as CreateLaneDtoClass,
|
||||
UpdateLaneDto as UpdateLaneDtoClass,
|
||||
} from './dto/lane.dto';
|
||||
|
||||
export {
|
||||
CreateRecargoDto as CreateRecargoDtoClass,
|
||||
UpdateRecargoDto as UpdateRecargoDtoClass,
|
||||
CreateFuelSurchargeDto as CreateFuelSurchargeDtoClass,
|
||||
UpdateFuelSurchargeDto as UpdateFuelSurchargeDtoClass,
|
||||
} from './dto/recargo.dto';
|
||||
|
||||
export {
|
||||
FindTarifaByLaneDto,
|
||||
CotizarTarifaDto,
|
||||
CalcularCostoEnvioDto,
|
||||
CalcularRecargosDto,
|
||||
} from './dto/cotizacion.dto';
|
||||
|
||||
@ -0,0 +1,957 @@
|
||||
import {
|
||||
Repository,
|
||||
FindOptionsWhere,
|
||||
ILike,
|
||||
In,
|
||||
LessThan,
|
||||
LessThanOrEqual,
|
||||
MoreThanOrEqual,
|
||||
Between,
|
||||
} from 'typeorm';
|
||||
import {
|
||||
FacturaTransporte,
|
||||
EstadoFactura,
|
||||
LineaFactura,
|
||||
RecargoCatalogo,
|
||||
} from '../entities';
|
||||
|
||||
// =====================================================
|
||||
// DTOs
|
||||
// =====================================================
|
||||
|
||||
export interface CreateFacturaDto {
|
||||
serie?: string;
|
||||
folio: string;
|
||||
clienteId: string;
|
||||
clienteRfc: string;
|
||||
clienteRazonSocial: string;
|
||||
clienteUsoCfdi?: string;
|
||||
fechaEmision: Date;
|
||||
fechaVencimiento?: Date;
|
||||
formaPago?: string;
|
||||
metodoPago?: string;
|
||||
condicionesPago?: string;
|
||||
moneda?: string;
|
||||
tipoCambio?: number;
|
||||
viajeIds?: string[];
|
||||
otIds?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateFacturaDto {
|
||||
serie?: string;
|
||||
clienteUsoCfdi?: string;
|
||||
fechaVencimiento?: Date;
|
||||
formaPago?: string;
|
||||
metodoPago?: string;
|
||||
condicionesPago?: string;
|
||||
moneda?: string;
|
||||
tipoCambio?: number;
|
||||
}
|
||||
|
||||
export interface CreateLineaFacturaDto {
|
||||
descripcion: string;
|
||||
claveProductoSat?: string;
|
||||
unidadSat?: string;
|
||||
cantidad: number;
|
||||
precioUnitario: number;
|
||||
descuento?: number;
|
||||
ivaTasa?: number;
|
||||
viajeId?: string;
|
||||
otId?: string;
|
||||
recargoId?: string;
|
||||
}
|
||||
|
||||
export interface FacturaSearchParams {
|
||||
tenantId: string;
|
||||
search?: string;
|
||||
clienteId?: string;
|
||||
estado?: EstadoFactura;
|
||||
fechaDesde?: Date;
|
||||
fechaHasta?: Date;
|
||||
moneda?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface RecargoAplicarDto {
|
||||
recargoId: string;
|
||||
descripcion?: string;
|
||||
monto?: number;
|
||||
cantidad?: number;
|
||||
}
|
||||
|
||||
export interface DescuentoAplicarDto {
|
||||
descripcion: string;
|
||||
porcentaje?: number;
|
||||
montoFijo?: number;
|
||||
lineaIds?: string[];
|
||||
}
|
||||
|
||||
export interface EstadoCuentaItem {
|
||||
facturaId: string;
|
||||
folioCompleto: string;
|
||||
fechaEmision: Date;
|
||||
fechaVencimiento: Date | null;
|
||||
total: number;
|
||||
montoPagado: number;
|
||||
saldoPendiente: number;
|
||||
diasVencida: number;
|
||||
estado: EstadoFactura;
|
||||
}
|
||||
|
||||
export interface EstadoCuentaResult {
|
||||
clienteId: string;
|
||||
clienteRazonSocial: string;
|
||||
facturas: EstadoCuentaItem[];
|
||||
totalFacturado: number;
|
||||
totalPagado: number;
|
||||
saldoTotal: number;
|
||||
facturasVencidas: number;
|
||||
montoVencido: number;
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// Constants
|
||||
// =====================================================
|
||||
|
||||
const IVA_TASA_DEFAULT = 16; // 16% IVA Mexico
|
||||
|
||||
// =====================================================
|
||||
// Service
|
||||
// =====================================================
|
||||
|
||||
export class FacturaTransporteService {
|
||||
constructor(
|
||||
private readonly facturaRepository: Repository<FacturaTransporte>,
|
||||
private readonly lineaRepository: Repository<LineaFactura>,
|
||||
private readonly recargoRepository: Repository<RecargoCatalogo>
|
||||
) {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRUD Operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a new transport invoice
|
||||
*/
|
||||
async create(
|
||||
tenantId: string,
|
||||
dto: CreateFacturaDto,
|
||||
createdBy: string
|
||||
): Promise<FacturaTransporte> {
|
||||
// Check if folio already exists for this series
|
||||
const existingFolio = await this.facturaRepository.findOne({
|
||||
where: {
|
||||
tenantId,
|
||||
...(dto.serie ? { serie: dto.serie } : {}),
|
||||
folio: dto.folio,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingFolio) {
|
||||
throw new Error(
|
||||
`Ya existe una factura con el folio "${dto.serie ? dto.serie + '-' : ''}${dto.folio}"`
|
||||
);
|
||||
}
|
||||
|
||||
const factura = this.facturaRepository.create({
|
||||
tenantId,
|
||||
serie: dto.serie ?? null,
|
||||
folio: dto.folio,
|
||||
clienteId: dto.clienteId,
|
||||
clienteRfc: dto.clienteRfc,
|
||||
clienteRazonSocial: dto.clienteRazonSocial,
|
||||
clienteUsoCfdi: dto.clienteUsoCfdi ?? null,
|
||||
fechaEmision: dto.fechaEmision,
|
||||
fechaVencimiento: dto.fechaVencimiento ?? null,
|
||||
formaPago: dto.formaPago ?? null,
|
||||
metodoPago: dto.metodoPago ?? null,
|
||||
condicionesPago: dto.condicionesPago ?? null,
|
||||
moneda: dto.moneda ?? 'MXN',
|
||||
tipoCambio: dto.tipoCambio ?? 1,
|
||||
viajeIds: dto.viajeIds ?? null,
|
||||
otIds: dto.otIds ?? null,
|
||||
subtotal: 0,
|
||||
descuento: 0,
|
||||
iva: 0,
|
||||
retencionIva: 0,
|
||||
retencionIsr: 0,
|
||||
total: 0,
|
||||
estado: EstadoFactura.BORRADOR,
|
||||
montoPagado: 0,
|
||||
createdById: createdBy,
|
||||
});
|
||||
|
||||
return this.facturaRepository.save(factura);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find invoice by ID
|
||||
*/
|
||||
async findById(tenantId: string, id: string): Promise<FacturaTransporte | null> {
|
||||
return this.facturaRepository.findOne({
|
||||
where: { id, tenantId },
|
||||
relations: ['lineas'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all invoices with filters
|
||||
*/
|
||||
async findAll(
|
||||
params: FacturaSearchParams
|
||||
): Promise<{ data: FacturaTransporte[]; total: number }> {
|
||||
const {
|
||||
tenantId,
|
||||
search,
|
||||
clienteId,
|
||||
estado,
|
||||
fechaDesde,
|
||||
fechaHasta,
|
||||
moneda,
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
} = params;
|
||||
|
||||
const qb = this.facturaRepository.createQueryBuilder('f');
|
||||
|
||||
qb.where('f.tenant_id = :tenantId', { tenantId });
|
||||
|
||||
if (clienteId) {
|
||||
qb.andWhere('f.cliente_id = :clienteId', { clienteId });
|
||||
}
|
||||
|
||||
if (estado) {
|
||||
qb.andWhere('f.estado = :estado', { estado });
|
||||
}
|
||||
|
||||
if (fechaDesde) {
|
||||
qb.andWhere('f.fecha_emision >= :fechaDesde', { fechaDesde });
|
||||
}
|
||||
|
||||
if (fechaHasta) {
|
||||
qb.andWhere('f.fecha_emision <= :fechaHasta', { fechaHasta });
|
||||
}
|
||||
|
||||
if (moneda) {
|
||||
qb.andWhere('f.moneda = :moneda', { moneda });
|
||||
}
|
||||
|
||||
if (search) {
|
||||
qb.andWhere(
|
||||
'(f.folio ILIKE :search OR f.cliente_razon_social ILIKE :search OR f.cliente_rfc ILIKE :search)',
|
||||
{ search: `%${search}%` }
|
||||
);
|
||||
}
|
||||
|
||||
qb.leftJoinAndSelect('f.lineas', 'lineas');
|
||||
qb.orderBy('f.fecha_emision', 'DESC');
|
||||
qb.addOrderBy('f.folio', 'DESC');
|
||||
qb.take(limit);
|
||||
qb.skip(offset);
|
||||
|
||||
const [data, total] = await qb.getManyAndCount();
|
||||
|
||||
return { data, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find invoices by client
|
||||
*/
|
||||
async findByCliente(
|
||||
tenantId: string,
|
||||
clienteId: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<{ data: FacturaTransporte[]; total: number }> {
|
||||
const [data, total] = await this.facturaRepository.findAndCount({
|
||||
where: { tenantId, clienteId },
|
||||
relations: ['lineas'],
|
||||
order: { fechaEmision: 'DESC' },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
return { data, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find invoices by status
|
||||
*/
|
||||
async findByEstado(
|
||||
tenantId: string,
|
||||
estado: EstadoFactura,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<{ data: FacturaTransporte[]; total: number }> {
|
||||
const [data, total] = await this.facturaRepository.findAndCount({
|
||||
where: { tenantId, estado },
|
||||
relations: ['lineas'],
|
||||
order: { fechaEmision: 'DESC' },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
return { data, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update invoice (only if in BORRADOR state)
|
||||
*/
|
||||
async update(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
dto: UpdateFacturaDto
|
||||
): Promise<FacturaTransporte | null> {
|
||||
const factura = await this.findById(tenantId, id);
|
||||
if (!factura) return null;
|
||||
|
||||
if (factura.estado !== EstadoFactura.BORRADOR) {
|
||||
throw new Error('Solo se pueden modificar facturas en estado BORRADOR');
|
||||
}
|
||||
|
||||
Object.assign(factura, {
|
||||
serie: dto.serie !== undefined ? dto.serie : factura.serie,
|
||||
clienteUsoCfdi:
|
||||
dto.clienteUsoCfdi !== undefined ? dto.clienteUsoCfdi : factura.clienteUsoCfdi,
|
||||
fechaVencimiento:
|
||||
dto.fechaVencimiento !== undefined ? dto.fechaVencimiento : factura.fechaVencimiento,
|
||||
formaPago: dto.formaPago !== undefined ? dto.formaPago : factura.formaPago,
|
||||
metodoPago: dto.metodoPago !== undefined ? dto.metodoPago : factura.metodoPago,
|
||||
condicionesPago:
|
||||
dto.condicionesPago !== undefined ? dto.condicionesPago : factura.condicionesPago,
|
||||
moneda: dto.moneda !== undefined ? dto.moneda : factura.moneda,
|
||||
tipoCambio: dto.tipoCambio !== undefined ? dto.tipoCambio : factura.tipoCambio,
|
||||
});
|
||||
|
||||
return this.facturaRepository.save(factura);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Line Items Management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Add line item to invoice
|
||||
*/
|
||||
async addLinea(
|
||||
tenantId: string,
|
||||
facturaId: string,
|
||||
lineaData: CreateLineaFacturaDto
|
||||
): Promise<LineaFactura> {
|
||||
const factura = await this.findById(tenantId, facturaId);
|
||||
if (!factura) {
|
||||
throw new Error('Factura no encontrada');
|
||||
}
|
||||
|
||||
if (factura.estado !== EstadoFactura.BORRADOR) {
|
||||
throw new Error('Solo se pueden agregar lineas a facturas en estado BORRADOR');
|
||||
}
|
||||
|
||||
// Get next line number
|
||||
const maxLinea = await this.lineaRepository
|
||||
.createQueryBuilder('l')
|
||||
.select('MAX(l.linea)', 'max')
|
||||
.where('l.factura_id = :facturaId', { facturaId })
|
||||
.getRawOne();
|
||||
|
||||
const lineaNumero = (maxLinea?.max ?? 0) + 1;
|
||||
|
||||
// Calculate importe
|
||||
const importe = lineaData.cantidad * lineaData.precioUnitario;
|
||||
const ivaTasa = lineaData.ivaTasa ?? IVA_TASA_DEFAULT;
|
||||
const descuento = lineaData.descuento ?? 0;
|
||||
const importeNeto = importe - descuento;
|
||||
const ivaMonto = importeNeto * (ivaTasa / 100);
|
||||
|
||||
const linea = this.lineaRepository.create({
|
||||
tenantId,
|
||||
facturaId,
|
||||
linea: lineaNumero,
|
||||
descripcion: lineaData.descripcion,
|
||||
claveProductoSat: lineaData.claveProductoSat ?? null,
|
||||
unidadSat: lineaData.unidadSat ?? null,
|
||||
cantidad: lineaData.cantidad,
|
||||
precioUnitario: lineaData.precioUnitario,
|
||||
descuento,
|
||||
importe,
|
||||
ivaTasa,
|
||||
ivaMonto,
|
||||
viajeId: lineaData.viajeId ?? null,
|
||||
otId: lineaData.otId ?? null,
|
||||
recargoId: lineaData.recargoId ?? null,
|
||||
});
|
||||
|
||||
const savedLinea = await this.lineaRepository.save(linea);
|
||||
|
||||
// Recalculate totals
|
||||
await this.calculateTotals(tenantId, facturaId);
|
||||
|
||||
return savedLinea;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove line item from invoice
|
||||
*/
|
||||
async removeLinea(
|
||||
tenantId: string,
|
||||
facturaId: string,
|
||||
lineaId: string
|
||||
): Promise<boolean> {
|
||||
const factura = await this.findById(tenantId, facturaId);
|
||||
if (!factura) {
|
||||
throw new Error('Factura no encontrada');
|
||||
}
|
||||
|
||||
if (factura.estado !== EstadoFactura.BORRADOR) {
|
||||
throw new Error('Solo se pueden eliminar lineas de facturas en estado BORRADOR');
|
||||
}
|
||||
|
||||
const linea = await this.lineaRepository.findOne({
|
||||
where: { id: lineaId, facturaId, tenantId },
|
||||
});
|
||||
|
||||
if (!linea) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.lineaRepository.delete(lineaId);
|
||||
|
||||
// Renumber remaining lines
|
||||
const remainingLineas = await this.lineaRepository.find({
|
||||
where: { facturaId },
|
||||
order: { linea: 'ASC' },
|
||||
});
|
||||
|
||||
for (let i = 0; i < remainingLineas.length; i++) {
|
||||
if (remainingLineas[i].linea !== i + 1) {
|
||||
remainingLineas[i].linea = i + 1;
|
||||
await this.lineaRepository.save(remainingLineas[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Recalculate totals
|
||||
await this.calculateTotals(tenantId, facturaId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Calculations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Recalculate invoice totals (subtotal, IVA 16%, total)
|
||||
*/
|
||||
async calculateTotals(tenantId: string, id: string): Promise<FacturaTransporte | null> {
|
||||
const factura = await this.facturaRepository.findOne({
|
||||
where: { id, tenantId },
|
||||
relations: ['lineas'],
|
||||
});
|
||||
|
||||
if (!factura) return null;
|
||||
|
||||
const lineas = factura.lineas ?? [];
|
||||
|
||||
let subtotal = 0;
|
||||
let descuentoTotal = 0;
|
||||
let ivaTotal = 0;
|
||||
|
||||
for (const linea of lineas) {
|
||||
subtotal += Number(linea.importe);
|
||||
descuentoTotal += Number(linea.descuento);
|
||||
ivaTotal += Number(linea.ivaMonto ?? 0);
|
||||
}
|
||||
|
||||
factura.subtotal = subtotal;
|
||||
factura.descuento = descuentoTotal;
|
||||
factura.iva = ivaTotal;
|
||||
factura.total = subtotal - descuentoTotal + ivaTotal - factura.retencionIva - factura.retencionIsr;
|
||||
|
||||
return this.facturaRepository.save(factura);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply surcharges to invoice
|
||||
*/
|
||||
async applyRecargos(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
recargos: RecargoAplicarDto[]
|
||||
): Promise<FacturaTransporte | null> {
|
||||
const factura = await this.findById(tenantId, id);
|
||||
if (!factura) {
|
||||
throw new Error('Factura no encontrada');
|
||||
}
|
||||
|
||||
if (factura.estado !== EstadoFactura.BORRADOR) {
|
||||
throw new Error('Solo se pueden aplicar recargos a facturas en estado BORRADOR');
|
||||
}
|
||||
|
||||
for (const recargoDto of recargos) {
|
||||
const recargo = await this.recargoRepository.findOne({
|
||||
where: { id: recargoDto.recargoId, tenantId, activo: true },
|
||||
});
|
||||
|
||||
if (!recargo) {
|
||||
throw new Error(`Recargo no encontrado: ${recargoDto.recargoId}`);
|
||||
}
|
||||
|
||||
// Calculate surcharge amount
|
||||
let montoRecargo = recargoDto.monto;
|
||||
if (montoRecargo === undefined) {
|
||||
if (recargo.esPorcentaje) {
|
||||
montoRecargo = factura.subtotal * (Number(recargo.monto) / 100);
|
||||
} else {
|
||||
montoRecargo = Number(recargo.monto);
|
||||
}
|
||||
}
|
||||
|
||||
const cantidad = recargoDto.cantidad ?? 1;
|
||||
|
||||
await this.addLinea(tenantId, id, {
|
||||
descripcion: recargoDto.descripcion ?? `${recargo.nombre}`,
|
||||
claveProductoSat: '78101800', // SAT code for transport services
|
||||
unidadSat: 'E48', // Unit of service
|
||||
cantidad,
|
||||
precioUnitario: montoRecargo,
|
||||
ivaTasa: IVA_TASA_DEFAULT,
|
||||
recargoId: recargo.id,
|
||||
});
|
||||
}
|
||||
|
||||
return this.findById(tenantId, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply discounts to invoice
|
||||
*/
|
||||
async applyDescuentos(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
descuentos: DescuentoAplicarDto[]
|
||||
): Promise<FacturaTransporte | null> {
|
||||
const factura = await this.findById(tenantId, id);
|
||||
if (!factura) {
|
||||
throw new Error('Factura no encontrada');
|
||||
}
|
||||
|
||||
if (factura.estado !== EstadoFactura.BORRADOR) {
|
||||
throw new Error('Solo se pueden aplicar descuentos a facturas en estado BORRADOR');
|
||||
}
|
||||
|
||||
const lineas = factura.lineas ?? [];
|
||||
|
||||
for (const descuentoDto of descuentos) {
|
||||
// Determine which lines to apply discount to
|
||||
const targetLineas = descuentoDto.lineaIds
|
||||
? lineas.filter((l) => descuentoDto.lineaIds!.includes(l.id))
|
||||
: lineas.filter((l) => !l.recargoId); // Exclude surcharge lines by default
|
||||
|
||||
if (targetLineas.length === 0) continue;
|
||||
|
||||
for (const linea of targetLineas) {
|
||||
let descuentoMonto = 0;
|
||||
|
||||
if (descuentoDto.porcentaje) {
|
||||
descuentoMonto = Number(linea.importe) * (descuentoDto.porcentaje / 100);
|
||||
} else if (descuentoDto.montoFijo) {
|
||||
// Distribute fixed amount proportionally
|
||||
const totalImporte = targetLineas.reduce(
|
||||
(sum, l) => sum + Number(l.importe),
|
||||
0
|
||||
);
|
||||
const proportion = Number(linea.importe) / totalImporte;
|
||||
descuentoMonto = descuentoDto.montoFijo * proportion;
|
||||
}
|
||||
|
||||
linea.descuento = Number(linea.descuento) + descuentoMonto;
|
||||
|
||||
// Recalculate IVA
|
||||
const importeNeto = Number(linea.importe) - Number(linea.descuento);
|
||||
linea.ivaMonto = importeNeto * (Number(linea.ivaTasa) / 100);
|
||||
|
||||
await this.lineaRepository.save(linea);
|
||||
}
|
||||
}
|
||||
|
||||
// Recalculate totals
|
||||
return this.calculateTotals(tenantId, id);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status Management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Mark invoice as sent to SAT (CFDI timbrado)
|
||||
*/
|
||||
async timbrar(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
cfdiData: { uuidCfdi: string; xmlCfdi: string; pdfUrl?: string }
|
||||
): Promise<FacturaTransporte | null> {
|
||||
const factura = await this.findById(tenantId, id);
|
||||
if (!factura) {
|
||||
throw new Error('Factura no encontrada');
|
||||
}
|
||||
|
||||
if (factura.estado !== EstadoFactura.BORRADOR && factura.estado !== EstadoFactura.EMITIDA) {
|
||||
throw new Error('Solo se pueden timbrar facturas en estado BORRADOR o EMITIDA');
|
||||
}
|
||||
|
||||
// Validate invoice has lines
|
||||
const lineas = factura.lineas ?? [];
|
||||
if (lineas.length === 0) {
|
||||
throw new Error('La factura debe tener al menos una linea para timbrar');
|
||||
}
|
||||
|
||||
// Validate totals are correct
|
||||
if (factura.total <= 0) {
|
||||
throw new Error('El total de la factura debe ser mayor a cero');
|
||||
}
|
||||
|
||||
factura.uuidCfdi = cfdiData.uuidCfdi;
|
||||
factura.xmlCfdi = cfdiData.xmlCfdi;
|
||||
factura.pdfUrl = cfdiData.pdfUrl ?? null;
|
||||
factura.estado = EstadoFactura.EMITIDA;
|
||||
|
||||
return this.facturaRepository.save(factura);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel invoice
|
||||
*/
|
||||
async cancelar(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
motivo: string
|
||||
): Promise<FacturaTransporte | null> {
|
||||
const factura = await this.findById(tenantId, id);
|
||||
if (!factura) {
|
||||
throw new Error('Factura no encontrada');
|
||||
}
|
||||
|
||||
if (factura.estado === EstadoFactura.CANCELADA) {
|
||||
throw new Error('La factura ya esta cancelada');
|
||||
}
|
||||
|
||||
if (factura.estado === EstadoFactura.PAGADA) {
|
||||
throw new Error('No se puede cancelar una factura que ya fue pagada completamente');
|
||||
}
|
||||
|
||||
factura.estado = EstadoFactura.CANCELADA;
|
||||
factura.fechaCancelacion = new Date();
|
||||
factura.motivoCancelacion = motivo;
|
||||
|
||||
return this.facturaRepository.save(factura);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Account Statements & Reports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get client account statement
|
||||
*/
|
||||
async getEstadoCuenta(
|
||||
tenantId: string,
|
||||
clienteId: string
|
||||
): Promise<EstadoCuentaResult> {
|
||||
const facturas = await this.facturaRepository.find({
|
||||
where: {
|
||||
tenantId,
|
||||
clienteId,
|
||||
estado: In([
|
||||
EstadoFactura.EMITIDA,
|
||||
EstadoFactura.ENVIADA,
|
||||
EstadoFactura.PARCIAL,
|
||||
EstadoFactura.VENCIDA,
|
||||
EstadoFactura.PAGADA,
|
||||
]),
|
||||
},
|
||||
order: { fechaEmision: 'DESC' },
|
||||
});
|
||||
|
||||
if (facturas.length === 0) {
|
||||
return {
|
||||
clienteId,
|
||||
clienteRazonSocial: '',
|
||||
facturas: [],
|
||||
totalFacturado: 0,
|
||||
totalPagado: 0,
|
||||
saldoTotal: 0,
|
||||
facturasVencidas: 0,
|
||||
montoVencido: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const clienteRazonSocial = facturas[0].clienteRazonSocial;
|
||||
const hoy = new Date();
|
||||
|
||||
let totalFacturado = 0;
|
||||
let totalPagado = 0;
|
||||
let saldoTotal = 0;
|
||||
let facturasVencidas = 0;
|
||||
let montoVencido = 0;
|
||||
|
||||
const items: EstadoCuentaItem[] = facturas.map((f) => {
|
||||
const total = Number(f.total);
|
||||
const montoPagado = Number(f.montoPagado);
|
||||
const saldoPendiente = total - montoPagado;
|
||||
|
||||
totalFacturado += total;
|
||||
totalPagado += montoPagado;
|
||||
saldoTotal += saldoPendiente;
|
||||
|
||||
let diasVencida = 0;
|
||||
if (f.fechaVencimiento && saldoPendiente > 0) {
|
||||
const vencimiento = new Date(f.fechaVencimiento);
|
||||
if (hoy > vencimiento) {
|
||||
diasVencida = Math.ceil(
|
||||
(hoy.getTime() - vencimiento.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
facturasVencidas++;
|
||||
montoVencido += saldoPendiente;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
facturaId: f.id,
|
||||
folioCompleto: f.folioCompleto,
|
||||
fechaEmision: f.fechaEmision,
|
||||
fechaVencimiento: f.fechaVencimiento,
|
||||
total,
|
||||
montoPagado,
|
||||
saldoPendiente,
|
||||
diasVencida,
|
||||
estado: f.estado,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
clienteId,
|
||||
clienteRazonSocial,
|
||||
facturas: items,
|
||||
totalFacturado,
|
||||
totalPagado,
|
||||
saldoTotal,
|
||||
facturasVencidas,
|
||||
montoVencido,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending invoices (emitted but not fully paid)
|
||||
*/
|
||||
async getFacturasPendientes(
|
||||
tenantId: string,
|
||||
limit: number = 100,
|
||||
offset: number = 0
|
||||
): Promise<{ data: FacturaTransporte[]; total: number }> {
|
||||
const [data, total] = await this.facturaRepository.findAndCount({
|
||||
where: {
|
||||
tenantId,
|
||||
estado: In([
|
||||
EstadoFactura.EMITIDA,
|
||||
EstadoFactura.ENVIADA,
|
||||
EstadoFactura.PARCIAL,
|
||||
EstadoFactura.VENCIDA,
|
||||
]),
|
||||
},
|
||||
relations: ['lineas'],
|
||||
order: { fechaEmision: 'ASC' },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
return { data, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get overdue invoices
|
||||
*/
|
||||
async getFacturasVencidas(
|
||||
tenantId: string,
|
||||
diasVencidas?: number,
|
||||
limit: number = 100,
|
||||
offset: number = 0
|
||||
): Promise<{ data: FacturaTransporte[]; total: number; montoTotal: number }> {
|
||||
const hoy = new Date();
|
||||
let fechaLimite = hoy;
|
||||
|
||||
if (diasVencidas !== undefined) {
|
||||
fechaLimite = new Date();
|
||||
fechaLimite.setDate(fechaLimite.getDate() - diasVencidas);
|
||||
}
|
||||
|
||||
const qb = this.facturaRepository.createQueryBuilder('f');
|
||||
|
||||
qb.where('f.tenant_id = :tenantId', { tenantId });
|
||||
qb.andWhere('f.estado IN (:...estados)', {
|
||||
estados: [EstadoFactura.EMITIDA, EstadoFactura.ENVIADA, EstadoFactura.PARCIAL, EstadoFactura.VENCIDA],
|
||||
});
|
||||
qb.andWhere('f.fecha_vencimiento IS NOT NULL');
|
||||
qb.andWhere('f.fecha_vencimiento < :hoy', { hoy });
|
||||
|
||||
if (diasVencidas !== undefined) {
|
||||
qb.andWhere('f.fecha_vencimiento <= :fechaLimite', { fechaLimite });
|
||||
}
|
||||
|
||||
qb.leftJoinAndSelect('f.lineas', 'lineas');
|
||||
qb.orderBy('f.fecha_vencimiento', 'ASC');
|
||||
qb.take(limit);
|
||||
qb.skip(offset);
|
||||
|
||||
const [data, total] = await qb.getManyAndCount();
|
||||
|
||||
const montoTotal = data.reduce(
|
||||
(sum, f) => sum + (Number(f.total) - Number(f.montoPagado)),
|
||||
0
|
||||
);
|
||||
|
||||
return { data, total, montoTotal };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Payment Registration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Register payment for invoice
|
||||
*/
|
||||
async registrarPago(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
montoPago: number,
|
||||
fechaPago: Date = new Date()
|
||||
): Promise<FacturaTransporte | null> {
|
||||
const factura = await this.findById(tenantId, id);
|
||||
if (!factura) {
|
||||
throw new Error('Factura no encontrada');
|
||||
}
|
||||
|
||||
if (factura.estado === EstadoFactura.CANCELADA) {
|
||||
throw new Error('No se pueden registrar pagos en facturas canceladas');
|
||||
}
|
||||
|
||||
if (factura.estado === EstadoFactura.BORRADOR) {
|
||||
throw new Error('La factura debe estar emitida para registrar pagos');
|
||||
}
|
||||
|
||||
const nuevoMontoPagado = Number(factura.montoPagado) + montoPago;
|
||||
const total = Number(factura.total);
|
||||
|
||||
if (nuevoMontoPagado > total) {
|
||||
throw new Error(
|
||||
`El monto de pago ($${montoPago}) excede el saldo pendiente ($${total - Number(factura.montoPagado)})`
|
||||
);
|
||||
}
|
||||
|
||||
factura.montoPagado = nuevoMontoPagado;
|
||||
factura.fechaPago = fechaPago;
|
||||
|
||||
// Update status based on payment
|
||||
if (nuevoMontoPagado >= total) {
|
||||
factura.estado = EstadoFactura.PAGADA;
|
||||
} else if (nuevoMontoPagado > 0) {
|
||||
factura.estado = EstadoFactura.PARCIAL;
|
||||
}
|
||||
|
||||
return this.facturaRepository.save(factura);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility Methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate next folio number for a series
|
||||
*/
|
||||
async generateNextFolio(tenantId: string, serie?: string): Promise<string> {
|
||||
const lastFactura = await this.facturaRepository.findOne({
|
||||
where: {
|
||||
tenantId,
|
||||
serie: serie ?? null as unknown as string,
|
||||
},
|
||||
order: { folio: 'DESC' },
|
||||
});
|
||||
|
||||
if (!lastFactura) {
|
||||
return '1';
|
||||
}
|
||||
|
||||
// Try to parse as number and increment
|
||||
const lastNumber = parseInt(lastFactura.folio, 10);
|
||||
if (!isNaN(lastNumber)) {
|
||||
return String(lastNumber + 1).padStart(lastFactura.folio.length, '0');
|
||||
}
|
||||
|
||||
// If not a number, append timestamp
|
||||
return `${Date.now()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get invoice summary for a period
|
||||
*/
|
||||
async getResumenPeriodo(
|
||||
tenantId: string,
|
||||
fechaDesde: Date,
|
||||
fechaHasta: Date
|
||||
): Promise<{
|
||||
totalFacturado: number;
|
||||
totalCobrado: number;
|
||||
totalPendiente: number;
|
||||
totalCancelado: number;
|
||||
cantidadFacturas: number;
|
||||
cantidadPagadas: number;
|
||||
cantidadPendientes: number;
|
||||
cantidadCanceladas: number;
|
||||
}> {
|
||||
const facturas = await this.facturaRepository.find({
|
||||
where: {
|
||||
tenantId,
|
||||
fechaEmision: Between(fechaDesde, fechaHasta),
|
||||
},
|
||||
});
|
||||
|
||||
let totalFacturado = 0;
|
||||
let totalCobrado = 0;
|
||||
let totalPendiente = 0;
|
||||
let totalCancelado = 0;
|
||||
let cantidadPagadas = 0;
|
||||
let cantidadPendientes = 0;
|
||||
let cantidadCanceladas = 0;
|
||||
|
||||
for (const f of facturas) {
|
||||
const total = Number(f.total);
|
||||
const pagado = Number(f.montoPagado);
|
||||
|
||||
if (f.estado === EstadoFactura.CANCELADA) {
|
||||
totalCancelado += total;
|
||||
cantidadCanceladas++;
|
||||
} else {
|
||||
totalFacturado += total;
|
||||
totalCobrado += pagado;
|
||||
totalPendiente += total - pagado;
|
||||
|
||||
if (f.estado === EstadoFactura.PAGADA) {
|
||||
cantidadPagadas++;
|
||||
} else {
|
||||
cantidadPendientes++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalFacturado,
|
||||
totalCobrado,
|
||||
totalPendiente,
|
||||
totalCancelado,
|
||||
cantidadFacturas: facturas.length,
|
||||
cantidadPagadas,
|
||||
cantidadPendientes,
|
||||
cantidadCanceladas,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,11 +1,19 @@
|
||||
/**
|
||||
* Tarifas de Transporte Services
|
||||
* Cotizacion, lanes, tarifas
|
||||
* Cotizacion, lanes, tarifas, facturacion, recargos
|
||||
*/
|
||||
export * from './tarifas.service';
|
||||
export * from './lanes.service';
|
||||
|
||||
// TODO: Implement additional services
|
||||
// - recargos.service.ts (RecargoCatalogo)
|
||||
// - fuel-surcharge.service.ts (FuelSurcharge)
|
||||
// - cotizador.service.ts (motor de cotizacion avanzado)
|
||||
export * from './factura-transporte.service';
|
||||
// Re-export recargos service excluding ServiceContext to avoid conflicts
|
||||
export {
|
||||
RecargosService,
|
||||
RecargoSearchParams,
|
||||
CreateRecargoDto,
|
||||
UpdateRecargoDto,
|
||||
CreateFuelSurchargeDto,
|
||||
UpdateFuelSurchargeDto,
|
||||
FuelSurchargeCalculation,
|
||||
DetentionCalculation,
|
||||
RecargoHistorialItem,
|
||||
} from './recargos.service';
|
||||
|
||||
717
src/modules/tarifas-transporte/services/recargos.service.ts
Normal file
717
src/modules/tarifas-transporte/services/recargos.service.ts
Normal file
@ -0,0 +1,717 @@
|
||||
import {
|
||||
Repository,
|
||||
FindOptionsWhere,
|
||||
ILike,
|
||||
LessThanOrEqual,
|
||||
MoreThanOrEqual,
|
||||
} from 'typeorm';
|
||||
import { RecargoCatalogo, TipoRecargo, FuelSurcharge, LineaFactura } from '../entities';
|
||||
|
||||
export interface ServiceContext {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface RecargoSearchParams {
|
||||
tenantId: string;
|
||||
search?: string;
|
||||
tipo?: TipoRecargo;
|
||||
activo?: boolean;
|
||||
aplicaAutomatico?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface CreateRecargoDto {
|
||||
codigo: string;
|
||||
nombre: string;
|
||||
tipo: TipoRecargo;
|
||||
descripcion?: string;
|
||||
esPorcentaje?: boolean;
|
||||
monto: number;
|
||||
moneda?: string;
|
||||
aplicaAutomatico?: boolean;
|
||||
condicionAplicacion?: string;
|
||||
}
|
||||
|
||||
export interface UpdateRecargoDto extends Partial<CreateRecargoDto> {
|
||||
activo?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateFuelSurchargeDto {
|
||||
fechaInicio: Date;
|
||||
fechaFin: Date;
|
||||
precioDieselReferencia?: number;
|
||||
precioDieselActual?: number;
|
||||
porcentajeSurcharge: number;
|
||||
}
|
||||
|
||||
export interface UpdateFuelSurchargeDto extends Partial<CreateFuelSurchargeDto> {
|
||||
activo?: boolean;
|
||||
}
|
||||
|
||||
// Additional calculation DTOs
|
||||
export interface FuelSurchargeCalculation {
|
||||
distanciaKm: number;
|
||||
precioLitro: number;
|
||||
rendimientoKmPorLitro: number;
|
||||
litrosEstimados: number;
|
||||
costoBaseEstimado: number;
|
||||
porcentajeSurcharge: number;
|
||||
montoSurcharge: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface DetentionCalculation {
|
||||
horasEspera: number;
|
||||
tarifaBase: number;
|
||||
horasLibres: number;
|
||||
horasCobrables: number;
|
||||
tarifaPorHora: number;
|
||||
montoDetention: number;
|
||||
}
|
||||
|
||||
export interface RecargoHistorialItem {
|
||||
id: string;
|
||||
fecha: Date;
|
||||
tipo: TipoRecargo | 'FUEL_SURCHARGE_PERIODO';
|
||||
descripcion: string;
|
||||
valor: number;
|
||||
esPorcentaje: boolean;
|
||||
}
|
||||
|
||||
// Constants
|
||||
const IVA_TASA_DEFAULT = 16; // 16% IVA Mexico
|
||||
const DEFAULT_RENDIMIENTO_KM_LITRO = 3.5; // Default km/liter for diesel trucks
|
||||
const DEFAULT_HORAS_LIBRES_DETENTION = 2; // Free hours before detention charges
|
||||
const DEFAULT_TARIFA_HORA_DETENTION = 500; // Default MXN per hour
|
||||
|
||||
export class RecargosService {
|
||||
constructor(
|
||||
private readonly recargoRepository: Repository<RecargoCatalogo>,
|
||||
private readonly fuelSurchargeRepository: Repository<FuelSurcharge>,
|
||||
private readonly lineaFacturaRepository?: Repository<LineaFactura>
|
||||
) {}
|
||||
|
||||
// ============================================
|
||||
// RECARGOS CATALOGO
|
||||
// ============================================
|
||||
|
||||
async findAll(params: RecargoSearchParams): Promise<{ data: RecargoCatalogo[]; total: number }> {
|
||||
const {
|
||||
tenantId,
|
||||
search,
|
||||
tipo,
|
||||
activo,
|
||||
aplicaAutomatico,
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
} = params;
|
||||
|
||||
const where: FindOptionsWhere<RecargoCatalogo>[] = [];
|
||||
const baseWhere: FindOptionsWhere<RecargoCatalogo> = { tenantId };
|
||||
|
||||
if (activo !== undefined) {
|
||||
baseWhere.activo = activo;
|
||||
}
|
||||
|
||||
if (tipo) {
|
||||
baseWhere.tipo = tipo;
|
||||
}
|
||||
|
||||
if (aplicaAutomatico !== undefined) {
|
||||
baseWhere.aplicaAutomatico = aplicaAutomatico;
|
||||
}
|
||||
|
||||
if (search) {
|
||||
where.push(
|
||||
{ ...baseWhere, codigo: ILike(`%${search}%`) },
|
||||
{ ...baseWhere, nombre: ILike(`%${search}%`) }
|
||||
);
|
||||
} else {
|
||||
where.push(baseWhere);
|
||||
}
|
||||
|
||||
const [data, total] = await this.recargoRepository.findAndCount({
|
||||
where,
|
||||
take: limit,
|
||||
skip: offset,
|
||||
order: { tipo: 'ASC', codigo: 'ASC' },
|
||||
});
|
||||
|
||||
return { data, total };
|
||||
}
|
||||
|
||||
async findOne(id: string, tenantId: string): Promise<RecargoCatalogo | null> {
|
||||
return this.recargoRepository.findOne({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
async findByCodigo(codigo: string, tenantId: string): Promise<RecargoCatalogo | null> {
|
||||
return this.recargoRepository.findOne({
|
||||
where: { codigo, tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
async findByTipo(tipo: TipoRecargo, tenantId: string): Promise<RecargoCatalogo[]> {
|
||||
return this.recargoRepository.find({
|
||||
where: { tipo, tenantId, activo: true },
|
||||
order: { codigo: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(dto: CreateRecargoDto, ctx: ServiceContext): Promise<RecargoCatalogo> {
|
||||
// Check for duplicate codigo
|
||||
const existing = await this.findByCodigo(dto.codigo, ctx.tenantId);
|
||||
if (existing) {
|
||||
throw new Error(`Ya existe un recargo con el codigo "${dto.codigo}"`);
|
||||
}
|
||||
|
||||
const recargo = this.recargoRepository.create({
|
||||
...dto,
|
||||
tenantId: ctx.tenantId,
|
||||
activo: true,
|
||||
createdById: ctx.userId,
|
||||
});
|
||||
|
||||
return this.recargoRepository.save(recargo);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateRecargoDto,
|
||||
ctx: ServiceContext
|
||||
): Promise<RecargoCatalogo | null> {
|
||||
const recargo = await this.findOne(id, ctx.tenantId);
|
||||
if (!recargo) return null;
|
||||
|
||||
// Check for codigo uniqueness if changing
|
||||
if (dto.codigo && dto.codigo !== recargo.codigo) {
|
||||
const existing = await this.findByCodigo(dto.codigo, ctx.tenantId);
|
||||
if (existing && existing.id !== id) {
|
||||
throw new Error(`Ya existe un recargo con el codigo "${dto.codigo}"`);
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(recargo, dto);
|
||||
return this.recargoRepository.save(recargo);
|
||||
}
|
||||
|
||||
async activar(id: string, ctx: ServiceContext): Promise<RecargoCatalogo | null> {
|
||||
const recargo = await this.findOne(id, ctx.tenantId);
|
||||
if (!recargo) return null;
|
||||
|
||||
recargo.activo = true;
|
||||
return this.recargoRepository.save(recargo);
|
||||
}
|
||||
|
||||
async desactivar(id: string, ctx: ServiceContext): Promise<RecargoCatalogo | null> {
|
||||
const recargo = await this.findOne(id, ctx.tenantId);
|
||||
if (!recargo) return null;
|
||||
|
||||
recargo.activo = false;
|
||||
return this.recargoRepository.save(recargo);
|
||||
}
|
||||
|
||||
async delete(id: string, ctx: ServiceContext): Promise<boolean> {
|
||||
const recargo = await this.findOne(id, ctx.tenantId);
|
||||
if (!recargo) return false;
|
||||
|
||||
const result = await this.recargoRepository.delete(id);
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
async getRecargosVigentes(ctx: ServiceContext): Promise<RecargoCatalogo[]> {
|
||||
return this.recargoRepository.find({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
activo: true,
|
||||
},
|
||||
order: { tipo: 'ASC', codigo: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async getRecargosAutomaticos(ctx: ServiceContext): Promise<RecargoCatalogo[]> {
|
||||
return this.recargoRepository.find({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
activo: true,
|
||||
aplicaAutomatico: true,
|
||||
},
|
||||
order: { tipo: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// FUEL SURCHARGE
|
||||
// ============================================
|
||||
|
||||
async findAllFuelSurcharges(
|
||||
tenantId: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<{ data: FuelSurcharge[]; total: number }> {
|
||||
const [data, total] = await this.fuelSurchargeRepository.findAndCount({
|
||||
where: { tenantId },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
order: { fechaInicio: 'DESC' },
|
||||
});
|
||||
|
||||
return { data, total };
|
||||
}
|
||||
|
||||
async findFuelSurcharge(id: string, tenantId: string): Promise<FuelSurcharge | null> {
|
||||
return this.fuelSurchargeRepository.findOne({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
async getFuelSurchargeVigente(tenantId: string): Promise<FuelSurcharge | null> {
|
||||
const hoy = new Date();
|
||||
return this.fuelSurchargeRepository
|
||||
.createQueryBuilder('fs')
|
||||
.where('fs.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('fs.activo = true')
|
||||
.andWhere('fs.fecha_inicio <= :hoy', { hoy })
|
||||
.andWhere('fs.fecha_fin >= :hoy', { hoy })
|
||||
.orderBy('fs.fecha_inicio', 'DESC')
|
||||
.getOne();
|
||||
}
|
||||
|
||||
async createFuelSurcharge(
|
||||
dto: CreateFuelSurchargeDto,
|
||||
ctx: ServiceContext
|
||||
): Promise<FuelSurcharge> {
|
||||
// Check for overlapping periods
|
||||
const overlapping = await this.fuelSurchargeRepository
|
||||
.createQueryBuilder('fs')
|
||||
.where('fs.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('fs.activo = true')
|
||||
.andWhere(
|
||||
'(fs.fecha_inicio <= :fechaFin AND fs.fecha_fin >= :fechaInicio)',
|
||||
{ fechaInicio: dto.fechaInicio, fechaFin: dto.fechaFin }
|
||||
)
|
||||
.getCount();
|
||||
|
||||
if (overlapping > 0) {
|
||||
throw new Error('Ya existe un fuel surcharge activo en el periodo especificado');
|
||||
}
|
||||
|
||||
const fuelSurcharge = this.fuelSurchargeRepository.create({
|
||||
...dto,
|
||||
tenantId: ctx.tenantId,
|
||||
activo: true,
|
||||
createdById: ctx.userId,
|
||||
});
|
||||
|
||||
return this.fuelSurchargeRepository.save(fuelSurcharge);
|
||||
}
|
||||
|
||||
async updateFuelSurcharge(
|
||||
id: string,
|
||||
dto: UpdateFuelSurchargeDto,
|
||||
ctx: ServiceContext
|
||||
): Promise<FuelSurcharge | null> {
|
||||
const fuelSurcharge = await this.findFuelSurcharge(id, ctx.tenantId);
|
||||
if (!fuelSurcharge) return null;
|
||||
|
||||
// Check for overlapping periods if dates changed
|
||||
if (dto.fechaInicio || dto.fechaFin) {
|
||||
const fechaInicio = dto.fechaInicio ?? fuelSurcharge.fechaInicio;
|
||||
const fechaFin = dto.fechaFin ?? fuelSurcharge.fechaFin;
|
||||
|
||||
const overlapping = await this.fuelSurchargeRepository
|
||||
.createQueryBuilder('fs')
|
||||
.where('fs.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('fs.id != :id', { id })
|
||||
.andWhere('fs.activo = true')
|
||||
.andWhere(
|
||||
'(fs.fecha_inicio <= :fechaFin AND fs.fecha_fin >= :fechaInicio)',
|
||||
{ fechaInicio, fechaFin }
|
||||
)
|
||||
.getCount();
|
||||
|
||||
if (overlapping > 0) {
|
||||
throw new Error('Ya existe un fuel surcharge activo en el periodo especificado');
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(fuelSurcharge, dto);
|
||||
return this.fuelSurchargeRepository.save(fuelSurcharge);
|
||||
}
|
||||
|
||||
async desactivarFuelSurcharge(id: string, ctx: ServiceContext): Promise<FuelSurcharge | null> {
|
||||
const fuelSurcharge = await this.findFuelSurcharge(id, ctx.tenantId);
|
||||
if (!fuelSurcharge) return null;
|
||||
|
||||
fuelSurcharge.activo = false;
|
||||
return this.fuelSurchargeRepository.save(fuelSurcharge);
|
||||
}
|
||||
|
||||
async deleteFuelSurcharge(id: string, ctx: ServiceContext): Promise<boolean> {
|
||||
const fuelSurcharge = await this.findFuelSurcharge(id, ctx.tenantId);
|
||||
if (!fuelSurcharge) return false;
|
||||
|
||||
const result = await this.fuelSurchargeRepository.delete(id);
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
// Calculate total surcharges for a given base amount
|
||||
async calcularRecargos(
|
||||
montoBase: number,
|
||||
ctx: ServiceContext,
|
||||
options?: {
|
||||
valorDeclarado?: number;
|
||||
incluirFuelSurcharge?: boolean;
|
||||
}
|
||||
): Promise<{
|
||||
recargos: Array<{
|
||||
tipo: TipoRecargo;
|
||||
nombre: string;
|
||||
monto: number;
|
||||
esPorcentaje: boolean;
|
||||
montoCalculado: number;
|
||||
}>;
|
||||
totalRecargos: number;
|
||||
}> {
|
||||
const { valorDeclarado, incluirFuelSurcharge = true } = options ?? {};
|
||||
const recargos: Array<{
|
||||
tipo: TipoRecargo;
|
||||
nombre: string;
|
||||
monto: number;
|
||||
esPorcentaje: boolean;
|
||||
montoCalculado: number;
|
||||
}> = [];
|
||||
let totalRecargos = 0;
|
||||
|
||||
// Get automatic surcharges
|
||||
const recargosAutomaticos = await this.getRecargosAutomaticos(ctx);
|
||||
|
||||
for (const recargo of recargosAutomaticos) {
|
||||
let montoCalculado = 0;
|
||||
|
||||
if (recargo.tipo === TipoRecargo.SEGURO_ADICIONAL && valorDeclarado) {
|
||||
// Insurance is calculated on declared value
|
||||
montoCalculado = recargo.esPorcentaje
|
||||
? valorDeclarado * (Number(recargo.monto) / 100)
|
||||
: Number(recargo.monto);
|
||||
} else if (recargo.tipo !== TipoRecargo.FUEL_SURCHARGE) {
|
||||
// Other surcharges calculated on base amount
|
||||
montoCalculado = recargo.esPorcentaje
|
||||
? montoBase * (Number(recargo.monto) / 100)
|
||||
: Number(recargo.monto);
|
||||
}
|
||||
|
||||
if (montoCalculado > 0) {
|
||||
recargos.push({
|
||||
tipo: recargo.tipo,
|
||||
nombre: recargo.nombre,
|
||||
monto: Number(recargo.monto),
|
||||
esPorcentaje: recargo.esPorcentaje,
|
||||
montoCalculado,
|
||||
});
|
||||
totalRecargos += montoCalculado;
|
||||
}
|
||||
}
|
||||
|
||||
// Add fuel surcharge if requested
|
||||
if (incluirFuelSurcharge) {
|
||||
const fuelSurcharge = await this.getFuelSurchargeVigente(ctx.tenantId);
|
||||
if (fuelSurcharge) {
|
||||
const montoFuel = montoBase * (Number(fuelSurcharge.porcentajeSurcharge) / 100);
|
||||
recargos.push({
|
||||
tipo: TipoRecargo.FUEL_SURCHARGE,
|
||||
nombre: `Fuel Surcharge (${fuelSurcharge.porcentajeSurcharge}%)`,
|
||||
monto: Number(fuelSurcharge.porcentajeSurcharge),
|
||||
esPorcentaje: true,
|
||||
montoCalculado: montoFuel,
|
||||
});
|
||||
totalRecargos += montoFuel;
|
||||
}
|
||||
}
|
||||
|
||||
return { recargos, totalRecargos };
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// FUEL SURCHARGE CALCULATIONS
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Calculate fuel surcharge based on distance and current fuel price
|
||||
*/
|
||||
async calculateFuelSurcharge(
|
||||
tenantId: string,
|
||||
distanciaKm: number,
|
||||
precioLitro: number,
|
||||
rendimientoKmPorLitro: number = DEFAULT_RENDIMIENTO_KM_LITRO
|
||||
): Promise<FuelSurchargeCalculation> {
|
||||
// Get current fuel surcharge configuration
|
||||
const fuelSurcharge = await this.getFuelSurchargeVigente(tenantId);
|
||||
|
||||
// Calculate estimated fuel cost
|
||||
const litrosEstimados = distanciaKm / rendimientoKmPorLitro;
|
||||
const costoBaseEstimado = litrosEstimados * precioLitro;
|
||||
|
||||
// Apply surcharge percentage
|
||||
const porcentajeSurcharge = fuelSurcharge
|
||||
? Number(fuelSurcharge.porcentajeSurcharge)
|
||||
: 0;
|
||||
const montoSurcharge = costoBaseEstimado * (porcentajeSurcharge / 100);
|
||||
|
||||
return {
|
||||
distanciaKm,
|
||||
precioLitro,
|
||||
rendimientoKmPorLitro,
|
||||
litrosEstimados: Math.round(litrosEstimados * 100) / 100,
|
||||
costoBaseEstimado: Math.round(costoBaseEstimado * 100) / 100,
|
||||
porcentajeSurcharge,
|
||||
montoSurcharge: Math.round(montoSurcharge * 100) / 100,
|
||||
total: Math.round((costoBaseEstimado + montoSurcharge) * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate detention charge
|
||||
*/
|
||||
async calculateDetention(
|
||||
tenantId: string,
|
||||
horasEspera: number,
|
||||
tarifaBase?: number
|
||||
): Promise<DetentionCalculation> {
|
||||
// Get detention recargo configuration
|
||||
const detentionRecargo = await this.recargoRepository.findOne({
|
||||
where: {
|
||||
tenantId,
|
||||
tipo: TipoRecargo.DETENTION,
|
||||
activo: true,
|
||||
},
|
||||
order: { monto: 'DESC' },
|
||||
});
|
||||
|
||||
const horasLibres = DEFAULT_HORAS_LIBRES_DETENTION;
|
||||
const tarifaPorHora = detentionRecargo
|
||||
? Number(detentionRecargo.monto)
|
||||
: DEFAULT_TARIFA_HORA_DETENTION;
|
||||
|
||||
// Calculate chargeable hours
|
||||
const horasCobrables = Math.max(0, horasEspera - horasLibres);
|
||||
|
||||
// Calculate detention amount
|
||||
let montoDetention = 0;
|
||||
if (detentionRecargo?.esPorcentaje && tarifaBase) {
|
||||
// If percentage-based, apply to tarifa base
|
||||
montoDetention = tarifaBase * (tarifaPorHora / 100) * horasCobrables;
|
||||
} else {
|
||||
// Fixed rate per hour
|
||||
montoDetention = tarifaPorHora * horasCobrables;
|
||||
}
|
||||
|
||||
return {
|
||||
horasEspera,
|
||||
tarifaBase: tarifaBase ?? 0,
|
||||
horasLibres,
|
||||
horasCobrables,
|
||||
tarifaPorHora,
|
||||
montoDetention: Math.round(montoDetention * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// APPLY TO FACTURA
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Apply surcharge to invoice line
|
||||
*/
|
||||
async applyToFactura(
|
||||
tenantId: string,
|
||||
facturaId: string,
|
||||
recargoId: string,
|
||||
monto: number,
|
||||
descripcion?: string
|
||||
): Promise<LineaFactura | null> {
|
||||
if (!this.lineaFacturaRepository) {
|
||||
throw new Error('LineaFactura repository not provided');
|
||||
}
|
||||
|
||||
const recargo = await this.findOne(recargoId, tenantId);
|
||||
if (!recargo) {
|
||||
throw new Error('Recargo no encontrado');
|
||||
}
|
||||
|
||||
// Get next line number
|
||||
const maxLinea = await this.lineaFacturaRepository
|
||||
.createQueryBuilder('l')
|
||||
.select('MAX(l.linea)', 'max')
|
||||
.where('l.factura_id = :facturaId', { facturaId })
|
||||
.getRawOne();
|
||||
|
||||
const lineaNumero = (maxLinea?.max ?? 0) + 1;
|
||||
|
||||
// Calculate IVA
|
||||
const ivaTasa = IVA_TASA_DEFAULT;
|
||||
const ivaMonto = monto * (ivaTasa / 100);
|
||||
|
||||
const linea = this.lineaFacturaRepository.create({
|
||||
tenantId,
|
||||
facturaId,
|
||||
linea: lineaNumero,
|
||||
descripcion: descripcion ?? recargo.nombre,
|
||||
claveProductoSat: '78101800', // SAT code for transport services
|
||||
unidadSat: 'E48', // Unit of service
|
||||
cantidad: 1,
|
||||
precioUnitario: monto,
|
||||
descuento: 0,
|
||||
importe: monto,
|
||||
ivaTasa,
|
||||
ivaMonto,
|
||||
recargoId: recargo.id,
|
||||
});
|
||||
|
||||
return this.lineaFacturaRepository.save(linea);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// HISTORIAL AND REPORTING
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Get surcharge price history
|
||||
*/
|
||||
async getHistorialPrecios(
|
||||
tenantId: string,
|
||||
tipo: TipoRecargo | 'FUEL_SURCHARGE',
|
||||
fechaDesde: Date,
|
||||
fechaHasta: Date
|
||||
): Promise<RecargoHistorialItem[]> {
|
||||
const historial: RecargoHistorialItem[] = [];
|
||||
|
||||
if (tipo === 'FUEL_SURCHARGE') {
|
||||
// Get fuel surcharge periods in date range
|
||||
const fuelPeriods = await this.fuelSurchargeRepository.find({
|
||||
where: {
|
||||
tenantId,
|
||||
fechaInicio: LessThanOrEqual(fechaHasta),
|
||||
fechaFin: MoreThanOrEqual(fechaDesde),
|
||||
},
|
||||
order: { fechaInicio: 'ASC' },
|
||||
});
|
||||
|
||||
for (const period of fuelPeriods) {
|
||||
historial.push({
|
||||
id: period.id,
|
||||
fecha: period.fechaInicio,
|
||||
tipo: 'FUEL_SURCHARGE_PERIODO',
|
||||
descripcion: `Fuel Surcharge: ${period.porcentajeSurcharge}% (${period.descripcionPeriodo})`,
|
||||
valor: Number(period.porcentajeSurcharge),
|
||||
esPorcentaje: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Get recargo catalog entries
|
||||
const recargos = await this.recargoRepository.find({
|
||||
where: { tenantId, tipo },
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
|
||||
for (const recargo of recargos) {
|
||||
if (recargo.createdAt >= fechaDesde && recargo.createdAt <= fechaHasta) {
|
||||
historial.push({
|
||||
id: recargo.id,
|
||||
fecha: recargo.createdAt,
|
||||
tipo: recargo.tipo,
|
||||
descripcion: `${recargo.nombre}: ${recargo.descripcionMonto}`,
|
||||
valor: Number(recargo.monto),
|
||||
esPorcentaje: recargo.esPorcentaje,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return historial.sort((a, b) => a.fecha.getTime() - b.fecha.getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate all applicable surcharges for a shipment
|
||||
*/
|
||||
async calculateAllSurcharges(
|
||||
tenantId: string,
|
||||
params: {
|
||||
tarifaBase: number;
|
||||
distanciaKm?: number;
|
||||
precioLitro?: number;
|
||||
horasEspera?: number;
|
||||
tiposAdicionales?: TipoRecargo[];
|
||||
}
|
||||
): Promise<{
|
||||
fuelSurcharge: FuelSurchargeCalculation | null;
|
||||
detention: DetentionCalculation | null;
|
||||
otrosRecargos: Array<{ recargo: RecargoCatalogo; monto: number }>;
|
||||
totalRecargos: number;
|
||||
}> {
|
||||
let fuelSurcharge: FuelSurchargeCalculation | null = null;
|
||||
let detention: DetentionCalculation | null = null;
|
||||
const otrosRecargos: Array<{ recargo: RecargoCatalogo; monto: number }> = [];
|
||||
let totalRecargos = 0;
|
||||
|
||||
// Calculate fuel surcharge if applicable
|
||||
if (params.distanciaKm && params.precioLitro) {
|
||||
fuelSurcharge = await this.calculateFuelSurcharge(
|
||||
tenantId,
|
||||
params.distanciaKm,
|
||||
params.precioLitro
|
||||
);
|
||||
totalRecargos += fuelSurcharge.montoSurcharge;
|
||||
}
|
||||
|
||||
// Calculate detention if applicable
|
||||
if (params.horasEspera && params.horasEspera > DEFAULT_HORAS_LIBRES_DETENTION) {
|
||||
detention = await this.calculateDetention(
|
||||
tenantId,
|
||||
params.horasEspera,
|
||||
params.tarifaBase
|
||||
);
|
||||
totalRecargos += detention.montoDetention;
|
||||
}
|
||||
|
||||
// Calculate additional surcharges
|
||||
if (params.tiposAdicionales && params.tiposAdicionales.length > 0) {
|
||||
for (const tipo of params.tiposAdicionales) {
|
||||
const recargos = await this.findByTipo(tipo, tenantId);
|
||||
for (const recargo of recargos) {
|
||||
const monto = recargo.calcularRecargo(params.tarifaBase);
|
||||
otrosRecargos.push({ recargo, monto });
|
||||
totalRecargos += monto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get automatic surcharges (excluding fuel and detention already calculated)
|
||||
const ctx = { tenantId, userId: '' };
|
||||
const automaticos = await this.getRecargosAutomaticos(ctx);
|
||||
for (const recargo of automaticos) {
|
||||
// Skip if already calculated
|
||||
if (
|
||||
recargo.tipo === TipoRecargo.FUEL_SURCHARGE ||
|
||||
recargo.tipo === TipoRecargo.DETENTION ||
|
||||
params.tiposAdicionales?.includes(recargo.tipo)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const monto = recargo.calcularRecargo(params.tarifaBase);
|
||||
otrosRecargos.push({ recargo, monto });
|
||||
totalRecargos += monto;
|
||||
}
|
||||
|
||||
return {
|
||||
fuelSurcharge,
|
||||
detention,
|
||||
otrosRecargos,
|
||||
totalRecargos: Math.round(totalRecargos * 100) / 100,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { Repository, FindOptionsWhere, ILike, In, LessThanOrEqual, MoreThanOrEqual, IsNull, Or } from 'typeorm';
|
||||
import { Tarifa, TipoTarifa, Lane } from '../entities';
|
||||
import { Tarifa, TipoTarifa, Lane, RecargoCatalogo, FuelSurcharge, TipoRecargo } from '../entities';
|
||||
|
||||
export interface TarifaSearchParams {
|
||||
tenantId: string;
|
||||
@ -58,10 +58,79 @@ export interface CotizacionResult {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Service Context - contains tenant and user info
|
||||
*/
|
||||
export interface ServiceContext {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for finding tarifa by lane
|
||||
*/
|
||||
export interface FindTarifaByLaneParams {
|
||||
origenCiudad: string;
|
||||
destinoCiudad: string;
|
||||
tipoCarga: string;
|
||||
tipoEquipo?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for calculating shipping cost
|
||||
*/
|
||||
export interface CalcularCostoEnvioParams {
|
||||
origenCiudad: string;
|
||||
destinoCiudad: string;
|
||||
tipoCarga: string;
|
||||
tipoEquipo: string;
|
||||
pesoKg?: number;
|
||||
volumenM3?: number;
|
||||
valorDeclarado?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recargo aplicado in desglose
|
||||
*/
|
||||
export interface RecargoAplicado {
|
||||
tipo: TipoRecargo;
|
||||
nombre: string;
|
||||
esPorcentaje: boolean;
|
||||
valorBase: number;
|
||||
montoCalculado: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of cost calculation
|
||||
*/
|
||||
export interface CostoEnvioResult {
|
||||
tarifaAplicada: Tarifa | null;
|
||||
laneEncontrada: Lane | null;
|
||||
tarifaBase: number;
|
||||
costoCalculadoPorUnidad: number;
|
||||
unidadCalculo: string;
|
||||
subtotalFlete: number;
|
||||
recargosAplicados: RecargoAplicado[];
|
||||
totalRecargos: number;
|
||||
subtotal: number;
|
||||
iva: number;
|
||||
total: number;
|
||||
moneda: string;
|
||||
desglose: {
|
||||
pesoKg: number | null;
|
||||
volumenM3: number | null;
|
||||
pesoVolumen: number | null;
|
||||
valorDeclarado: number | null;
|
||||
factorCobroUsado: 'peso' | 'volumen';
|
||||
};
|
||||
}
|
||||
|
||||
export class TarifasService {
|
||||
constructor(
|
||||
private readonly tarifaRepository: Repository<Tarifa>,
|
||||
private readonly laneRepository: Repository<Lane>,
|
||||
private readonly recargoRepository: Repository<RecargoCatalogo>,
|
||||
private readonly fuelSurchargeRepository: Repository<FuelSurcharge>,
|
||||
) {}
|
||||
|
||||
async findAll(params: TarifaSearchParams): Promise<{ data: Tarifa[]; total: number }> {
|
||||
@ -439,4 +508,461 @@ export class TarifasService {
|
||||
const result = await this.tarifaRepository.delete(id);
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MAI-002: METODOS ADICIONALES DE TARIFAS
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Find tarifa by lane (origin-destination combination)
|
||||
* Lane = origen_ciudad + destino_ciudad + tipo_carga + tipo_equipo
|
||||
* Falls back to generic tarifa if exact match not found
|
||||
*/
|
||||
async findTarifaByLane(
|
||||
params: FindTarifaByLaneParams,
|
||||
ctx: ServiceContext
|
||||
): Promise<Tarifa | null> {
|
||||
const { origenCiudad, destinoCiudad, tipoCarga, tipoEquipo } = params;
|
||||
const hoy = new Date();
|
||||
|
||||
// First, find the lane by origin-destination
|
||||
const lane = await this.laneRepository.findOne({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
origenCiudad: ILike(origenCiudad),
|
||||
destinoCiudad: ILike(destinoCiudad),
|
||||
activo: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Build base query for vigente tarifas
|
||||
const buildBaseQuery = () => {
|
||||
return this.tarifaRepository
|
||||
.createQueryBuilder('t')
|
||||
.where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('t.activa = true')
|
||||
.andWhere('t.fecha_inicio <= :hoy', { hoy })
|
||||
.andWhere('(t.fecha_fin IS NULL OR t.fecha_fin >= :hoy)', { hoy });
|
||||
};
|
||||
|
||||
// Try 1: Exact match - lane + tipo_carga + tipo_equipo
|
||||
if (lane && tipoEquipo) {
|
||||
const exactMatch = await buildBaseQuery()
|
||||
.andWhere('t.lane_id = :laneId', { laneId: lane.id })
|
||||
.andWhere('LOWER(t.modalidad_servicio) = LOWER(:tipoCarga)', { tipoCarga })
|
||||
.andWhere('LOWER(t.tipo_equipo) = LOWER(:tipoEquipo)', { tipoEquipo })
|
||||
.leftJoinAndSelect('t.lane', 'lane')
|
||||
.getOne();
|
||||
if (exactMatch) return exactMatch;
|
||||
}
|
||||
|
||||
// Try 2: Lane + tipo_carga (without tipo_equipo)
|
||||
if (lane) {
|
||||
const laneAndCarga = await buildBaseQuery()
|
||||
.andWhere('t.lane_id = :laneId', { laneId: lane.id })
|
||||
.andWhere('LOWER(t.modalidad_servicio) = LOWER(:tipoCarga)', { tipoCarga })
|
||||
.andWhere('t.tipo_equipo IS NULL')
|
||||
.leftJoinAndSelect('t.lane', 'lane')
|
||||
.getOne();
|
||||
if (laneAndCarga) return laneAndCarga;
|
||||
}
|
||||
|
||||
// Try 3: Lane only (any tipo_carga)
|
||||
if (lane) {
|
||||
const laneOnly = await buildBaseQuery()
|
||||
.andWhere('t.lane_id = :laneId', { laneId: lane.id })
|
||||
.leftJoinAndSelect('t.lane', 'lane')
|
||||
.orderBy('t.fecha_inicio', 'DESC')
|
||||
.getOne();
|
||||
if (laneOnly) return laneOnly;
|
||||
}
|
||||
|
||||
// Try 4: Generic tarifa with tipo_carga + tipo_equipo (no lane)
|
||||
if (tipoEquipo) {
|
||||
const genericWithEquipo = await buildBaseQuery()
|
||||
.andWhere('t.lane_id IS NULL')
|
||||
.andWhere('LOWER(t.modalidad_servicio) = LOWER(:tipoCarga)', { tipoCarga })
|
||||
.andWhere('LOWER(t.tipo_equipo) = LOWER(:tipoEquipo)', { tipoEquipo })
|
||||
.getOne();
|
||||
if (genericWithEquipo) return genericWithEquipo;
|
||||
}
|
||||
|
||||
// Try 5: Generic tarifa with tipo_carga only (no lane, no equipo)
|
||||
const genericCarga = await buildBaseQuery()
|
||||
.andWhere('t.lane_id IS NULL')
|
||||
.andWhere('LOWER(t.modalidad_servicio) = LOWER(:tipoCarga)', { tipoCarga })
|
||||
.andWhere('t.tipo_equipo IS NULL')
|
||||
.getOne();
|
||||
if (genericCarga) return genericCarga;
|
||||
|
||||
// Try 6: Any generic active tarifa as last resort
|
||||
const anyGeneric = await buildBaseQuery()
|
||||
.andWhere('t.lane_id IS NULL')
|
||||
.andWhere('t.cliente_id IS NULL')
|
||||
.orderBy('t.fecha_inicio', 'DESC')
|
||||
.getOne();
|
||||
|
||||
return anyGeneric;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate full shipping cost including surcharges
|
||||
*/
|
||||
async calcularCostoEnvio(
|
||||
params: CalcularCostoEnvioParams,
|
||||
ctx: ServiceContext
|
||||
): Promise<CostoEnvioResult> {
|
||||
const {
|
||||
origenCiudad,
|
||||
destinoCiudad,
|
||||
tipoCarga,
|
||||
tipoEquipo,
|
||||
pesoKg,
|
||||
volumenM3,
|
||||
valorDeclarado,
|
||||
} = params;
|
||||
|
||||
// Get the applicable tarifa
|
||||
const tarifa = await this.findTarifaByLane(
|
||||
{ origenCiudad, destinoCiudad, tipoCarga, tipoEquipo },
|
||||
ctx
|
||||
);
|
||||
|
||||
// Find the lane for reference
|
||||
const lane = await this.laneRepository.findOne({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
origenCiudad: ILike(origenCiudad),
|
||||
destinoCiudad: ILike(destinoCiudad),
|
||||
activo: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Calculate peso volumetrico (industria: 1m3 = 250kg)
|
||||
const FACTOR_VOLUMETRICO = 250;
|
||||
const pesoVolumetrico = volumenM3 ? volumenM3 * FACTOR_VOLUMETRICO : null;
|
||||
const pesoReal = pesoKg ?? 0;
|
||||
const pesoCobrar = Math.max(pesoReal, pesoVolumetrico ?? 0);
|
||||
const factorUsado: 'peso' | 'volumen' =
|
||||
pesoVolumetrico && pesoVolumetrico > pesoReal ? 'volumen' : 'peso';
|
||||
|
||||
// Initialize result
|
||||
const result: CostoEnvioResult = {
|
||||
tarifaAplicada: tarifa,
|
||||
laneEncontrada: lane,
|
||||
tarifaBase: 0,
|
||||
costoCalculadoPorUnidad: 0,
|
||||
unidadCalculo: 'viaje',
|
||||
subtotalFlete: 0,
|
||||
recargosAplicados: [],
|
||||
totalRecargos: 0,
|
||||
subtotal: 0,
|
||||
iva: 0,
|
||||
total: 0,
|
||||
moneda: tarifa?.moneda ?? 'MXN',
|
||||
desglose: {
|
||||
pesoKg: pesoKg ?? null,
|
||||
volumenM3: volumenM3 ?? null,
|
||||
pesoVolumen: pesoVolumetrico,
|
||||
valorDeclarado: valorDeclarado ?? null,
|
||||
factorCobroUsado: factorUsado,
|
||||
},
|
||||
};
|
||||
|
||||
if (!tarifa) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Calculate base fare
|
||||
result.tarifaBase = Number(tarifa.tarifaBase);
|
||||
let subtotalFlete = result.tarifaBase;
|
||||
|
||||
// Add variable costs based on tarifa type
|
||||
switch (tarifa.tipoTarifa) {
|
||||
case TipoTarifa.POR_KM:
|
||||
if (lane?.distanciaKm) {
|
||||
result.costoCalculadoPorUnidad = Number(tarifa.tarifaKm ?? 0);
|
||||
subtotalFlete += Number(lane.distanciaKm) * result.costoCalculadoPorUnidad;
|
||||
result.unidadCalculo = 'km';
|
||||
}
|
||||
break;
|
||||
case TipoTarifa.POR_TONELADA:
|
||||
result.costoCalculadoPorUnidad = Number(tarifa.tarifaTonelada ?? 0);
|
||||
subtotalFlete += (pesoCobrar / 1000) * result.costoCalculadoPorUnidad;
|
||||
result.unidadCalculo = 'tonelada';
|
||||
break;
|
||||
case TipoTarifa.POR_M3:
|
||||
result.costoCalculadoPorUnidad = Number(tarifa.tarifaM3 ?? 0);
|
||||
subtotalFlete += (volumenM3 ?? 0) * result.costoCalculadoPorUnidad;
|
||||
result.unidadCalculo = 'm3';
|
||||
break;
|
||||
case TipoTarifa.MIXTA:
|
||||
// km + peso
|
||||
if (lane?.distanciaKm && tarifa.tarifaKm) {
|
||||
subtotalFlete += Number(lane.distanciaKm) * Number(tarifa.tarifaKm);
|
||||
}
|
||||
if (tarifa.tarifaTonelada) {
|
||||
subtotalFlete += (pesoCobrar / 1000) * Number(tarifa.tarifaTonelada);
|
||||
}
|
||||
result.unidadCalculo = 'mixta';
|
||||
break;
|
||||
case TipoTarifa.POR_VIAJE:
|
||||
default:
|
||||
result.unidadCalculo = 'viaje';
|
||||
break;
|
||||
}
|
||||
|
||||
// Apply minimum if configured
|
||||
if (tarifa.minimoFacturar && subtotalFlete < Number(tarifa.minimoFacturar)) {
|
||||
subtotalFlete = Number(tarifa.minimoFacturar);
|
||||
}
|
||||
|
||||
result.subtotalFlete = subtotalFlete;
|
||||
|
||||
// Get and apply surcharges
|
||||
const recargosVigentes = await this.getRecargosVigentes(ctx);
|
||||
let totalRecargos = 0;
|
||||
|
||||
for (const recargo of recargosVigentes) {
|
||||
let montoRecargo = 0;
|
||||
|
||||
if (recargo.tipo === TipoRecargo.FUEL_SURCHARGE) {
|
||||
// Get current fuel surcharge percentage
|
||||
const fuelSurcharge = await this.getFuelSurchargeVigente(ctx.tenantId);
|
||||
if (fuelSurcharge) {
|
||||
montoRecargo = subtotalFlete * (Number(fuelSurcharge.porcentajeSurcharge) / 100);
|
||||
result.recargosAplicados.push({
|
||||
tipo: recargo.tipo,
|
||||
nombre: `Fuel Surcharge (${fuelSurcharge.porcentajeSurcharge}%)`,
|
||||
esPorcentaje: true,
|
||||
valorBase: Number(fuelSurcharge.porcentajeSurcharge),
|
||||
montoCalculado: montoRecargo,
|
||||
});
|
||||
totalRecargos += montoRecargo;
|
||||
}
|
||||
} else if (recargo.tipo === TipoRecargo.SEGURO_ADICIONAL && valorDeclarado) {
|
||||
// Insurance on declared value
|
||||
if (recargo.esPorcentaje) {
|
||||
montoRecargo = valorDeclarado * (Number(recargo.monto) / 100);
|
||||
} else {
|
||||
montoRecargo = Number(recargo.monto);
|
||||
}
|
||||
result.recargosAplicados.push({
|
||||
tipo: recargo.tipo,
|
||||
nombre: recargo.nombre,
|
||||
esPorcentaje: recargo.esPorcentaje,
|
||||
valorBase: Number(recargo.monto),
|
||||
montoCalculado: montoRecargo,
|
||||
});
|
||||
totalRecargos += montoRecargo;
|
||||
} else if (recargo.aplicaAutomatico) {
|
||||
// Other automatic surcharges
|
||||
if (recargo.esPorcentaje) {
|
||||
montoRecargo = subtotalFlete * (Number(recargo.monto) / 100);
|
||||
} else {
|
||||
montoRecargo = Number(recargo.monto);
|
||||
}
|
||||
result.recargosAplicados.push({
|
||||
tipo: recargo.tipo,
|
||||
nombre: recargo.nombre,
|
||||
esPorcentaje: recargo.esPorcentaje,
|
||||
valorBase: Number(recargo.monto),
|
||||
montoCalculado: montoRecargo,
|
||||
});
|
||||
totalRecargos += montoRecargo;
|
||||
}
|
||||
}
|
||||
|
||||
result.totalRecargos = totalRecargos;
|
||||
result.subtotal = subtotalFlete + totalRecargos;
|
||||
|
||||
// Calculate IVA (16% in Mexico)
|
||||
const IVA_RATE = 0.16;
|
||||
result.iva = result.subtotal * IVA_RATE;
|
||||
result.total = result.subtotal + result.iva;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active surcharges
|
||||
*/
|
||||
async getRecargosVigentes(ctx: ServiceContext): Promise<RecargoCatalogo[]> {
|
||||
return this.recargoRepository.find({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
activo: true,
|
||||
},
|
||||
order: { tipo: 'ASC', codigo: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current fuel surcharge
|
||||
*/
|
||||
async getFuelSurchargeVigente(tenantId: string): Promise<FuelSurcharge | null> {
|
||||
const hoy = new Date();
|
||||
return this.fuelSurchargeRepository.findOne({
|
||||
where: {
|
||||
tenantId,
|
||||
activo: true,
|
||||
fechaInicio: LessThanOrEqual(hoy),
|
||||
fechaFin: MoreThanOrEqual(hoy),
|
||||
},
|
||||
order: { fechaInicio: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for duplicate lane tarifa
|
||||
* Lane = origen_ciudad + destino_ciudad + tipo_carga + tipo_equipo
|
||||
*/
|
||||
async existsTarifaDuplicada(
|
||||
params: {
|
||||
laneId: string;
|
||||
tipoCarga?: string;
|
||||
tipoEquipo?: string;
|
||||
clienteId?: string;
|
||||
excludeId?: string;
|
||||
},
|
||||
ctx: ServiceContext
|
||||
): Promise<boolean> {
|
||||
const qb = this.tarifaRepository
|
||||
.createQueryBuilder('t')
|
||||
.where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('t.lane_id = :laneId', { laneId: params.laneId })
|
||||
.andWhere('t.activa = true');
|
||||
|
||||
if (params.tipoCarga) {
|
||||
qb.andWhere('LOWER(t.modalidad_servicio) = LOWER(:tipoCarga)', {
|
||||
tipoCarga: params.tipoCarga,
|
||||
});
|
||||
} else {
|
||||
qb.andWhere('t.modalidad_servicio IS NULL');
|
||||
}
|
||||
|
||||
if (params.tipoEquipo) {
|
||||
qb.andWhere('LOWER(t.tipo_equipo) = LOWER(:tipoEquipo)', {
|
||||
tipoEquipo: params.tipoEquipo,
|
||||
});
|
||||
} else {
|
||||
qb.andWhere('t.tipo_equipo IS NULL');
|
||||
}
|
||||
|
||||
if (params.clienteId) {
|
||||
qb.andWhere('t.cliente_id = :clienteId', { clienteId: params.clienteId });
|
||||
} else {
|
||||
qb.andWhere('t.cliente_id IS NULL');
|
||||
}
|
||||
|
||||
if (params.excludeId) {
|
||||
qb.andWhere('t.id != :excludeId', { excludeId: params.excludeId });
|
||||
}
|
||||
|
||||
const count = await qb.getCount();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create tarifa with duplicate validation
|
||||
*/
|
||||
async createWithValidation(
|
||||
dto: CreateTarifaDto,
|
||||
ctx: ServiceContext
|
||||
): Promise<Tarifa> {
|
||||
// Check for codigo duplicate
|
||||
const existingCodigo = await this.findByCodigo(dto.codigo, ctx.tenantId);
|
||||
if (existingCodigo) {
|
||||
throw new Error(`Ya existe una tarifa con el codigo "${dto.codigo}"`);
|
||||
}
|
||||
|
||||
// Check for lane duplicate if lane is specified
|
||||
if (dto.laneId) {
|
||||
const isDuplicate = await this.existsTarifaDuplicada(
|
||||
{
|
||||
laneId: dto.laneId,
|
||||
tipoCarga: dto.modalidadServicio,
|
||||
tipoEquipo: dto.tipoEquipo,
|
||||
clienteId: dto.clienteId,
|
||||
},
|
||||
ctx
|
||||
);
|
||||
if (isDuplicate) {
|
||||
throw new Error(
|
||||
'Ya existe una tarifa activa para este lane con la misma combinacion de tipo de carga y equipo'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate lane exists
|
||||
if (dto.laneId) {
|
||||
const lane = await this.laneRepository.findOne({
|
||||
where: { id: dto.laneId, tenantId: ctx.tenantId },
|
||||
});
|
||||
if (!lane) {
|
||||
throw new Error('Lane no encontrada');
|
||||
}
|
||||
}
|
||||
|
||||
const tarifa = this.tarifaRepository.create({
|
||||
...dto,
|
||||
tenantId: ctx.tenantId,
|
||||
activa: true,
|
||||
createdById: ctx.userId,
|
||||
});
|
||||
|
||||
return this.tarifaRepository.save(tarifa);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update tarifa with history tracking
|
||||
*/
|
||||
async updateWithHistory(
|
||||
id: string,
|
||||
dto: UpdateTarifaDto,
|
||||
ctx: ServiceContext
|
||||
): Promise<Tarifa | null> {
|
||||
const tarifa = await this.findOne(id, ctx.tenantId);
|
||||
if (!tarifa) return null;
|
||||
|
||||
// Check for codigo uniqueness if changing
|
||||
if (dto.codigo && dto.codigo !== tarifa.codigo) {
|
||||
const existingCodigo = await this.findByCodigo(dto.codigo, ctx.tenantId);
|
||||
if (existingCodigo && existingCodigo.id !== id) {
|
||||
throw new Error(`Ya existe una tarifa con el codigo "${dto.codigo}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for lane duplicate if lane changed
|
||||
if (dto.laneId && dto.laneId !== tarifa.laneId) {
|
||||
const isDuplicate = await this.existsTarifaDuplicada(
|
||||
{
|
||||
laneId: dto.laneId,
|
||||
tipoCarga: dto.modalidadServicio ?? tarifa.modalidadServicio ?? undefined,
|
||||
tipoEquipo: dto.tipoEquipo ?? tarifa.tipoEquipo ?? undefined,
|
||||
clienteId: dto.clienteId ?? tarifa.clienteId ?? undefined,
|
||||
excludeId: id,
|
||||
},
|
||||
ctx
|
||||
);
|
||||
if (isDuplicate) {
|
||||
throw new Error(
|
||||
'Ya existe una tarifa activa para este lane con la misma combinacion de tipo de carga y equipo'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate new lane if provided
|
||||
if (dto.laneId && dto.laneId !== tarifa.laneId) {
|
||||
const lane = await this.laneRepository.findOne({
|
||||
where: { id: dto.laneId, tenantId: ctx.tenantId },
|
||||
});
|
||||
if (!lane) {
|
||||
throw new Error('Lane no encontrada');
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(tarifa, dto);
|
||||
return this.tarifaRepository.save(tarifa);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user