[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:
Adrian Flores Cortes 2026-02-03 02:51:05 -06:00
parent 5d0db6d5fc
commit 48bb0c8d58
12 changed files with 3669 additions and 15 deletions

View File

@ -1,5 +1,4 @@
/**
* Tarifas Controllers
*/
// TODO: Implement controllers
// - tarifas.controller.ts
export * from './tarifas.controller';

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

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

View File

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

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

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

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

View File

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

View File

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

View File

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

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

View File

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