[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
|
* Tarifas Controllers
|
||||||
*/
|
*/
|
||||||
// TODO: Implement controllers
|
export * from './tarifas.controller';
|
||||||
// - tarifas.controller.ts
|
|
||||||
|
|||||||
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
|
* Tarifas DTOs
|
||||||
*/
|
*/
|
||||||
// TODO: Implement DTOs
|
export * from './tarifa.dto';
|
||||||
// - create-tarifa.dto.ts
|
export * from './lane.dto';
|
||||||
// - create-recargo.dto.ts
|
export * from './recargo.dto';
|
||||||
// - cotizacion.dto.ts
|
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 Transporte Module - MAI-002
|
||||||
* Tarifas por lane, recargos, contratos
|
* Tarifas por lane, recargos, contratos
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Export entities
|
||||||
export * from './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 './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
|
* Tarifas de Transporte Services
|
||||||
* Cotizacion, lanes, tarifas
|
* Cotizacion, lanes, tarifas, facturacion, recargos
|
||||||
*/
|
*/
|
||||||
export * from './tarifas.service';
|
export * from './tarifas.service';
|
||||||
export * from './lanes.service';
|
export * from './lanes.service';
|
||||||
|
export * from './factura-transporte.service';
|
||||||
// TODO: Implement additional services
|
// Re-export recargos service excluding ServiceContext to avoid conflicts
|
||||||
// - recargos.service.ts (RecargoCatalogo)
|
export {
|
||||||
// - fuel-surcharge.service.ts (FuelSurcharge)
|
RecargosService,
|
||||||
// - cotizador.service.ts (motor de cotizacion avanzado)
|
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 { 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 {
|
export interface TarifaSearchParams {
|
||||||
tenantId: string;
|
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 {
|
export class TarifasService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly tarifaRepository: Repository<Tarifa>,
|
private readonly tarifaRepository: Repository<Tarifa>,
|
||||||
private readonly laneRepository: Repository<Lane>,
|
private readonly laneRepository: Repository<Lane>,
|
||||||
|
private readonly recargoRepository: Repository<RecargoCatalogo>,
|
||||||
|
private readonly fuelSurchargeRepository: Repository<FuelSurcharge>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(params: TarifaSearchParams): Promise<{ data: Tarifa[]; total: number }> {
|
async findAll(params: TarifaSearchParams): Promise<{ data: Tarifa[]; total: number }> {
|
||||||
@ -439,4 +508,461 @@ export class TarifasService {
|
|||||||
const result = await this.tarifaRepository.delete(id);
|
const result = await this.tarifaRepository.delete(id);
|
||||||
return (result.affected ?? 0) > 0;
|
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