From 48bb0c8d58c5c063735b287a9acbfc62c254e72f Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Tue, 3 Feb 2026 02:51:05 -0600 Subject: [PATCH] [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 --- .../tarifas-transporte/controllers/index.ts | 3 +- .../controllers/tarifas.controller.ts | 789 +++++++++++++++ .../tarifas-transporte/dto/cotizacion.dto.ts | 112 ++ src/modules/tarifas-transporte/dto/index.ts | 8 +- .../tarifas-transporte/dto/lane.dto.ts | 116 +++ .../tarifas-transporte/dto/recargo.dto.ts | 156 +++ .../tarifas-transporte/dto/tarifa.dto.ts | 215 ++++ src/modules/tarifas-transporte/index.ts | 63 +- .../services/factura-transporte.service.ts | 957 ++++++++++++++++++ .../tarifas-transporte/services/index.ts | 20 +- .../services/recargos.service.ts | 717 +++++++++++++ .../services/tarifas.service.ts | 528 +++++++++- 12 files changed, 3669 insertions(+), 15 deletions(-) create mode 100644 src/modules/tarifas-transporte/controllers/tarifas.controller.ts create mode 100644 src/modules/tarifas-transporte/dto/cotizacion.dto.ts create mode 100644 src/modules/tarifas-transporte/dto/lane.dto.ts create mode 100644 src/modules/tarifas-transporte/dto/recargo.dto.ts create mode 100644 src/modules/tarifas-transporte/dto/tarifa.dto.ts create mode 100644 src/modules/tarifas-transporte/services/factura-transporte.service.ts create mode 100644 src/modules/tarifas-transporte/services/recargos.service.ts diff --git a/src/modules/tarifas-transporte/controllers/index.ts b/src/modules/tarifas-transporte/controllers/index.ts index b2746f6..461110c 100644 --- a/src/modules/tarifas-transporte/controllers/index.ts +++ b/src/modules/tarifas-transporte/controllers/index.ts @@ -1,5 +1,4 @@ /** * Tarifas Controllers */ -// TODO: Implement controllers -// - tarifas.controller.ts +export * from './tarifas.controller'; diff --git a/src/modules/tarifas-transporte/controllers/tarifas.controller.ts b/src/modules/tarifas-transporte/controllers/tarifas.controller.ts new file mode 100644 index 0000000..593e1fd --- /dev/null +++ b/src/modules/tarifas-transporte/controllers/tarifas.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + } + } +} diff --git a/src/modules/tarifas-transporte/dto/cotizacion.dto.ts b/src/modules/tarifas-transporte/dto/cotizacion.dto.ts new file mode 100644 index 0000000..f263c07 --- /dev/null +++ b/src/modules/tarifas-transporte/dto/cotizacion.dto.ts @@ -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; +} diff --git a/src/modules/tarifas-transporte/dto/index.ts b/src/modules/tarifas-transporte/dto/index.ts index 9038722..9adc9f6 100644 --- a/src/modules/tarifas-transporte/dto/index.ts +++ b/src/modules/tarifas-transporte/dto/index.ts @@ -1,7 +1,7 @@ /** * Tarifas DTOs */ -// TODO: Implement DTOs -// - create-tarifa.dto.ts -// - create-recargo.dto.ts -// - cotizacion.dto.ts +export * from './tarifa.dto'; +export * from './lane.dto'; +export * from './recargo.dto'; +export * from './cotizacion.dto'; diff --git a/src/modules/tarifas-transporte/dto/lane.dto.ts b/src/modules/tarifas-transporte/dto/lane.dto.ts new file mode 100644 index 0000000..4025fb5 --- /dev/null +++ b/src/modules/tarifas-transporte/dto/lane.dto.ts @@ -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; +} diff --git a/src/modules/tarifas-transporte/dto/recargo.dto.ts b/src/modules/tarifas-transporte/dto/recargo.dto.ts new file mode 100644 index 0000000..11cf037 --- /dev/null +++ b/src/modules/tarifas-transporte/dto/recargo.dto.ts @@ -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; +} diff --git a/src/modules/tarifas-transporte/dto/tarifa.dto.ts b/src/modules/tarifas-transporte/dto/tarifa.dto.ts new file mode 100644 index 0000000..ca90166 --- /dev/null +++ b/src/modules/tarifas-transporte/dto/tarifa.dto.ts @@ -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; +} diff --git a/src/modules/tarifas-transporte/index.ts b/src/modules/tarifas-transporte/index.ts index 8a0b014..86ca253 100644 --- a/src/modules/tarifas-transporte/index.ts +++ b/src/modules/tarifas-transporte/index.ts @@ -2,7 +2,66 @@ * Tarifas Transporte Module - MAI-002 * Tarifas por lane, recargos, contratos */ + +// Export entities export * from './entities'; -export * from './services'; + +// Export services with explicit naming +export { + TarifasService, + TarifaSearchParams, + CreateTarifaDto, + UpdateTarifaDto, + CotizarParams, + CotizacionResult, + ServiceContext, + FindTarifaByLaneParams, + CalcularCostoEnvioParams, + RecargoAplicado, + CostoEnvioResult, +} from './services/tarifas.service'; + +export { + LanesService, + LaneSearchParams, + CreateLaneDto as CreateLaneDtoInterface, + UpdateLaneDto as UpdateLaneDtoInterface, +} from './services/lanes.service'; + +export { + RecargosService, + RecargoSearchParams, + CreateRecargoDto as CreateRecargoDtoInterface, + UpdateRecargoDto as UpdateRecargoDtoInterface, + CreateFuelSurchargeDto as CreateFuelSurchargeDtoInterface, + UpdateFuelSurchargeDto as UpdateFuelSurchargeDtoInterface, +} from './services/recargos.service'; + +// Export controllers export * from './controllers'; -export * from './dto'; + +// Export DTO classes (with class-validator decorators) +export { + CreateTarifaDto as CreateTarifaDtoClass, + UpdateTarifaDto as UpdateTarifaDtoClass, + ClonarTarifaDto, +} from './dto/tarifa.dto'; + +export { + CreateLaneDto as CreateLaneDtoClass, + UpdateLaneDto as UpdateLaneDtoClass, +} from './dto/lane.dto'; + +export { + CreateRecargoDto as CreateRecargoDtoClass, + UpdateRecargoDto as UpdateRecargoDtoClass, + CreateFuelSurchargeDto as CreateFuelSurchargeDtoClass, + UpdateFuelSurchargeDto as UpdateFuelSurchargeDtoClass, +} from './dto/recargo.dto'; + +export { + FindTarifaByLaneDto, + CotizarTarifaDto, + CalcularCostoEnvioDto, + CalcularRecargosDto, +} from './dto/cotizacion.dto'; diff --git a/src/modules/tarifas-transporte/services/factura-transporte.service.ts b/src/modules/tarifas-transporte/services/factura-transporte.service.ts new file mode 100644 index 0000000..38e2056 --- /dev/null +++ b/src/modules/tarifas-transporte/services/factura-transporte.service.ts @@ -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, + private readonly lineaRepository: Repository, + private readonly recargoRepository: Repository + ) {} + + // --------------------------------------------------------------------------- + // CRUD Operations + // --------------------------------------------------------------------------- + + /** + * Create a new transport invoice + */ + async create( + tenantId: string, + dto: CreateFacturaDto, + createdBy: string + ): Promise { + // 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + }; + } +} diff --git a/src/modules/tarifas-transporte/services/index.ts b/src/modules/tarifas-transporte/services/index.ts index b9541ac..39427ee 100644 --- a/src/modules/tarifas-transporte/services/index.ts +++ b/src/modules/tarifas-transporte/services/index.ts @@ -1,11 +1,19 @@ /** * Tarifas de Transporte Services - * Cotizacion, lanes, tarifas + * Cotizacion, lanes, tarifas, facturacion, recargos */ export * from './tarifas.service'; export * from './lanes.service'; - -// TODO: Implement additional services -// - recargos.service.ts (RecargoCatalogo) -// - fuel-surcharge.service.ts (FuelSurcharge) -// - cotizador.service.ts (motor de cotizacion avanzado) +export * from './factura-transporte.service'; +// Re-export recargos service excluding ServiceContext to avoid conflicts +export { + RecargosService, + RecargoSearchParams, + CreateRecargoDto, + UpdateRecargoDto, + CreateFuelSurchargeDto, + UpdateFuelSurchargeDto, + FuelSurchargeCalculation, + DetentionCalculation, + RecargoHistorialItem, +} from './recargos.service'; diff --git a/src/modules/tarifas-transporte/services/recargos.service.ts b/src/modules/tarifas-transporte/services/recargos.service.ts new file mode 100644 index 0000000..e3910c4 --- /dev/null +++ b/src/modules/tarifas-transporte/services/recargos.service.ts @@ -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 { + activo?: boolean; +} + +export interface CreateFuelSurchargeDto { + fechaInicio: Date; + fechaFin: Date; + precioDieselReferencia?: number; + precioDieselActual?: number; + porcentajeSurcharge: number; +} + +export interface UpdateFuelSurchargeDto extends Partial { + 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, + private readonly fuelSurchargeRepository: Repository, + private readonly lineaFacturaRepository?: Repository + ) {} + + // ============================================ + // 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[] = []; + const baseWhere: FindOptionsWhere = { 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 { + return this.recargoRepository.findOne({ + where: { id, tenantId }, + }); + } + + async findByCodigo(codigo: string, tenantId: string): Promise { + return this.recargoRepository.findOne({ + where: { codigo, tenantId }, + }); + } + + async findByTipo(tipo: TipoRecargo, tenantId: string): Promise { + return this.recargoRepository.find({ + where: { tipo, tenantId, activo: true }, + order: { codigo: 'ASC' }, + }); + } + + async create(dto: CreateRecargoDto, ctx: ServiceContext): Promise { + // 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 { + 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 { + 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 { + 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 { + 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 { + return this.recargoRepository.find({ + where: { + tenantId: ctx.tenantId, + activo: true, + }, + order: { tipo: 'ASC', codigo: 'ASC' }, + }); + } + + async getRecargosAutomaticos(ctx: ServiceContext): Promise { + 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 { + return this.fuelSurchargeRepository.findOne({ + where: { id, tenantId }, + }); + } + + async getFuelSurchargeVigente(tenantId: string): Promise { + 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 { + // 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 { + 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 { + 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 { + 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 { + // 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 { + // 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 { + 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 { + 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, + }; + } +} diff --git a/src/modules/tarifas-transporte/services/tarifas.service.ts b/src/modules/tarifas-transporte/services/tarifas.service.ts index 033efd1..5b19524 100644 --- a/src/modules/tarifas-transporte/services/tarifas.service.ts +++ b/src/modules/tarifas-transporte/services/tarifas.service.ts @@ -1,5 +1,5 @@ import { Repository, FindOptionsWhere, ILike, In, LessThanOrEqual, MoreThanOrEqual, IsNull, Or } from 'typeorm'; -import { Tarifa, TipoTarifa, Lane } from '../entities'; +import { Tarifa, TipoTarifa, Lane, RecargoCatalogo, FuelSurcharge, TipoRecargo } from '../entities'; export interface TarifaSearchParams { tenantId: string; @@ -58,10 +58,79 @@ export interface CotizacionResult { }; } +/** + * Service Context - contains tenant and user info + */ +export interface ServiceContext { + tenantId: string; + userId: string; +} + +/** + * Parameters for finding tarifa by lane + */ +export interface FindTarifaByLaneParams { + origenCiudad: string; + destinoCiudad: string; + tipoCarga: string; + tipoEquipo?: string; +} + +/** + * Parameters for calculating shipping cost + */ +export interface CalcularCostoEnvioParams { + origenCiudad: string; + destinoCiudad: string; + tipoCarga: string; + tipoEquipo: string; + pesoKg?: number; + volumenM3?: number; + valorDeclarado?: number; +} + +/** + * Recargo aplicado in desglose + */ +export interface RecargoAplicado { + tipo: TipoRecargo; + nombre: string; + esPorcentaje: boolean; + valorBase: number; + montoCalculado: number; +} + +/** + * Result of cost calculation + */ +export interface CostoEnvioResult { + tarifaAplicada: Tarifa | null; + laneEncontrada: Lane | null; + tarifaBase: number; + costoCalculadoPorUnidad: number; + unidadCalculo: string; + subtotalFlete: number; + recargosAplicados: RecargoAplicado[]; + totalRecargos: number; + subtotal: number; + iva: number; + total: number; + moneda: string; + desglose: { + pesoKg: number | null; + volumenM3: number | null; + pesoVolumen: number | null; + valorDeclarado: number | null; + factorCobroUsado: 'peso' | 'volumen'; + }; +} + export class TarifasService { constructor( private readonly tarifaRepository: Repository, private readonly laneRepository: Repository, + private readonly recargoRepository: Repository, + private readonly fuelSurchargeRepository: Repository, ) {} async findAll(params: TarifaSearchParams): Promise<{ data: Tarifa[]; total: number }> { @@ -439,4 +508,461 @@ export class TarifasService { const result = await this.tarifaRepository.delete(id); return (result.affected ?? 0) > 0; } + + // ============================================ + // MAI-002: METODOS ADICIONALES DE TARIFAS + // ============================================ + + /** + * Find tarifa by lane (origin-destination combination) + * Lane = origen_ciudad + destino_ciudad + tipo_carga + tipo_equipo + * Falls back to generic tarifa if exact match not found + */ + async findTarifaByLane( + params: FindTarifaByLaneParams, + ctx: ServiceContext + ): Promise { + 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 { + 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 { + return this.recargoRepository.find({ + where: { + tenantId: ctx.tenantId, + activo: true, + }, + order: { tipo: 'ASC', codigo: 'ASC' }, + }); + } + + /** + * Get current fuel surcharge + */ + async getFuelSurchargeVigente(tenantId: string): Promise { + 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 { + 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 { + // 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 { + 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); + } }