[SYNC] feat: Add Carta Porte DTOs and controller
- Add carta-porte controller with CRUD operations - Add DTOs: create, update, cancelar, timbrar - Add DTOs: autotransporte, mercancia, ubicacion, figura-transporte - Add common utilities folder - Add ordenes-transporte service tests - Update index exports Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
d94a84593f
commit
825e349f37
1
src/common/index.ts
Normal file
1
src/common/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './partial-type';
|
||||
35
src/common/partial-type.ts
Normal file
35
src/common/partial-type.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
/**
|
||||
* Makes all properties of T optional
|
||||
* Similar to NestJS mapped-types PartialType but for plain classes
|
||||
*/
|
||||
export function PartialType<T extends new (...args: any[]) => any>(classRef: T): T {
|
||||
abstract class PartialTypeClass {
|
||||
constructor() {
|
||||
return Object.assign(this, new classRef());
|
||||
}
|
||||
}
|
||||
|
||||
const propertyKeys = Object.getOwnPropertyNames(new classRef());
|
||||
propertyKeys.forEach((key) => {
|
||||
const decorators = Reflect.getMetadataKeys(classRef.prototype, key);
|
||||
decorators.forEach((decoratorKey) => {
|
||||
const metadata = Reflect.getMetadata(decoratorKey, classRef.prototype, key);
|
||||
Reflect.defineMetadata(decoratorKey, metadata, PartialTypeClass.prototype, key);
|
||||
});
|
||||
});
|
||||
|
||||
return PartialTypeClass as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type helper for partial types
|
||||
*/
|
||||
export type DeepPartial<T> = {
|
||||
[P in keyof T]?: T[P] extends Array<infer U>
|
||||
? Array<DeepPartial<U>>
|
||||
: T[P] extends ReadonlyArray<infer U>
|
||||
? ReadonlyArray<DeepPartial<U>>
|
||||
: DeepPartial<T[P]>;
|
||||
};
|
||||
695
src/modules/carta-porte/controllers/carta-porte.controller.ts
Normal file
695
src/modules/carta-porte/controllers/carta-porte.controller.ts
Normal file
@ -0,0 +1,695 @@
|
||||
import { Request, Response, NextFunction, Router } from 'express';
|
||||
import {
|
||||
CartaPorteService,
|
||||
CartaPorteSearchParams,
|
||||
CreateCartaPorteDto,
|
||||
UpdateCartaPorteDto,
|
||||
TimbrarCartaPorteDto,
|
||||
CancelarCartaPorteDto,
|
||||
} from '../services/carta-porte.service';
|
||||
import { CartaPorte, EstadoCartaPorte, TipoCfdiCartaPorte } from '../entities';
|
||||
|
||||
/**
|
||||
* Response interfaces for API documentation
|
||||
*/
|
||||
export interface CartaPorteResponse {
|
||||
data: CartaPorte;
|
||||
}
|
||||
|
||||
export interface CartaPorteListResponse {
|
||||
data: CartaPorte[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface CartaPorteValidationResponse {
|
||||
data: {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExpedienteFiscalResponse {
|
||||
data: {
|
||||
viajeId: string;
|
||||
cartasPorte: CartaPorte[];
|
||||
total: number;
|
||||
timbradas: number;
|
||||
pendientes: number;
|
||||
canceladas: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PendientesTimbrarResponse {
|
||||
data: CartaPorte[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Controlador principal para Carta Porte
|
||||
* CFDI 3.1 Compliance - Gestion completa de Cartas Porte
|
||||
*
|
||||
* @apiTags CartaPorte
|
||||
*
|
||||
* Endpoints:
|
||||
* - POST /carta-porte - Crear nueva Carta Porte desde viaje
|
||||
* - GET /carta-porte - Listar con filtros
|
||||
* - GET /carta-porte/:id - Obtener detalle con relaciones
|
||||
* - PUT /carta-porte/:id - Actualizar (solo BORRADOR)
|
||||
* - DELETE /carta-porte/:id - Eliminar (solo BORRADOR)
|
||||
* - POST /carta-porte/:id/validar - Validar campos SAT
|
||||
* - POST /carta-porte/:id/timbrar - Timbrar con PAC
|
||||
* - POST /carta-porte/:id/cancelar - Cancelar con motivo
|
||||
* - GET /carta-porte/:id/xml - Descargar XML
|
||||
* - GET /carta-porte/:id/pdf - Descargar PDF
|
||||
* - GET /carta-porte/expediente/:viajeId - Obtener expediente fiscal por viaje
|
||||
* - GET /carta-porte/pendientes-timbrar - Listar listas para timbrar
|
||||
*/
|
||||
export class CartaPorteController {
|
||||
public router: Router;
|
||||
|
||||
constructor(private readonly cartaPorteService: CartaPorteService) {
|
||||
this.router = Router();
|
||||
this.initializeRoutes();
|
||||
}
|
||||
|
||||
private initializeRoutes(): void {
|
||||
// Special routes (must be before :id routes)
|
||||
this.router.get('/pendientes-timbrar', this.getPendientesTimbrar.bind(this));
|
||||
this.router.get('/expediente/:viajeId', this.getExpedienteFiscal.bind(this));
|
||||
|
||||
// CRUD basico
|
||||
this.router.post('/', this.create.bind(this));
|
||||
this.router.get('/', this.findAll.bind(this));
|
||||
this.router.get('/:id', this.findOne.bind(this));
|
||||
this.router.put('/:id', this.update.bind(this));
|
||||
this.router.delete('/:id', this.delete.bind(this));
|
||||
|
||||
// Workflow: Validacion, Timbrado, Cancelacion
|
||||
this.router.post('/:id/validar', this.validar.bind(this));
|
||||
this.router.post('/:id/timbrar', this.timbrar.bind(this));
|
||||
this.router.post('/:id/cancelar', this.cancelar.bind(this));
|
||||
|
||||
// Documentos: XML y PDF
|
||||
this.router.get('/:id/xml', this.downloadXml.bind(this));
|
||||
this.router.get('/:id/pdf', this.downloadPdf.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST / - Crear nueva Carta Porte
|
||||
* @apiOperation Crear Carta Porte desde viaje
|
||||
* @apiResponse 201 Carta Porte creada exitosamente
|
||||
* @apiResponse 400 Datos invalidos o tenant no especificado
|
||||
* @apiResponse 500 Error interno del servidor
|
||||
*/
|
||||
private async create(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Tenant ID is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = req.headers['x-user-id'] as string;
|
||||
if (!userId) {
|
||||
res.status(400).json({ error: 'User ID is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dto: CreateCartaPorteDto = req.body;
|
||||
|
||||
// Validar campos requeridos
|
||||
if (!dto.viajeId) {
|
||||
res.status(400).json({ error: 'viajeId es requerido' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dto.tipoCfdi) {
|
||||
res.status(400).json({ error: 'tipoCfdi es requerido (INGRESO o TRASLADO)' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dto.emisorRfc || !dto.emisorNombre) {
|
||||
res.status(400).json({ error: 'Datos del emisor son requeridos (emisorRfc, emisorNombre)' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dto.receptorRfc || !dto.receptorNombre) {
|
||||
res.status(400).json({ error: 'Datos del receptor son requeridos (receptorRfc, receptorNombre)' });
|
||||
return;
|
||||
}
|
||||
|
||||
const cartaPorte = await this.cartaPorteService.create(tenantId, dto, userId);
|
||||
|
||||
res.status(201).json({ data: cartaPorte } as CartaPorteResponse);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET / - Listar Cartas Porte con filtros
|
||||
* @apiOperation Listar Cartas Porte
|
||||
* @apiQuery estado - Filtrar por estado (BORRADOR, VALIDADA, TIMBRADA, CANCELADA)
|
||||
* @apiQuery tipoCfdi - Filtrar por tipo CFDI (INGRESO, TRASLADO)
|
||||
* @apiQuery viajeId - Filtrar por ID de viaje
|
||||
* @apiQuery fechaDesde - Fecha inicio (ISO 8601)
|
||||
* @apiQuery fechaHasta - Fecha fin (ISO 8601)
|
||||
* @apiQuery search - Busqueda en folio, serie, emisor, receptor
|
||||
* @apiQuery limit - Limite de resultados (default 50)
|
||||
* @apiQuery offset - Offset para paginacion (default 0)
|
||||
* @apiResponse 200 Lista de Cartas Porte
|
||||
* @apiResponse 400 Tenant no especificado
|
||||
*/
|
||||
private async findAll(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Tenant ID is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
estado,
|
||||
tipoCfdi,
|
||||
viajeId,
|
||||
fechaDesde,
|
||||
fechaHasta,
|
||||
search,
|
||||
emisorRfc,
|
||||
receptorRfc,
|
||||
limit = '50',
|
||||
offset = '0',
|
||||
} = req.query;
|
||||
|
||||
// Validar estado si se proporciona
|
||||
if (estado && !Object.values(EstadoCartaPorte).includes(estado as EstadoCartaPorte)) {
|
||||
res.status(400).json({
|
||||
error: `estado debe ser uno de: ${Object.values(EstadoCartaPorte).join(', ')}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validar tipoCfdi si se proporciona
|
||||
if (tipoCfdi && !Object.values(TipoCfdiCartaPorte).includes(tipoCfdi as TipoCfdiCartaPorte)) {
|
||||
res.status(400).json({
|
||||
error: `tipoCfdi debe ser uno de: ${Object.values(TipoCfdiCartaPorte).join(', ')}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const params: CartaPorteSearchParams = {
|
||||
tenantId,
|
||||
estado: estado as EstadoCartaPorte | undefined,
|
||||
tipoCfdi: tipoCfdi as TipoCfdiCartaPorte | undefined,
|
||||
viajeId: viajeId as string | undefined,
|
||||
emisorRfc: emisorRfc as string | undefined,
|
||||
receptorRfc: receptorRfc as string | undefined,
|
||||
fechaDesde: fechaDesde ? new Date(fechaDesde as string) : undefined,
|
||||
fechaHasta: fechaHasta ? new Date(fechaHasta as string) : undefined,
|
||||
search: search as string | undefined,
|
||||
limit: parseInt(limit as string, 10),
|
||||
offset: parseInt(offset as string, 10),
|
||||
};
|
||||
|
||||
const { data, total } = await this.cartaPorteService.findAll(params);
|
||||
|
||||
res.json({
|
||||
data,
|
||||
total,
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
} as CartaPorteListResponse);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /:id - Obtener Carta Porte por ID con todas las relaciones
|
||||
* @apiOperation Obtener detalle de Carta Porte
|
||||
* @apiParam id - UUID de la Carta Porte
|
||||
* @apiResponse 200 Carta Porte con ubicaciones, mercancias, figuras y autotransporte
|
||||
* @apiResponse 404 Carta Porte no encontrada
|
||||
*/
|
||||
private async findOne(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Tenant ID is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const cartaPorte = await this.cartaPorteService.findOne(id, tenantId);
|
||||
|
||||
if (!cartaPorte) {
|
||||
res.status(404).json({ error: 'Carta Porte no encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: cartaPorte } as CartaPorteResponse);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /:id - Actualizar Carta Porte
|
||||
* @apiOperation Actualizar Carta Porte (solo estado BORRADOR)
|
||||
* @apiParam id - UUID de la Carta Porte
|
||||
* @apiResponse 200 Carta Porte actualizada
|
||||
* @apiResponse 400 Solo se puede actualizar en estado BORRADOR
|
||||
* @apiResponse 404 Carta Porte no encontrada
|
||||
*/
|
||||
private async update(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Tenant ID is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = req.headers['x-user-id'] as string;
|
||||
if (!userId) {
|
||||
res.status(400).json({ error: 'User ID is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const dto: UpdateCartaPorteDto = req.body;
|
||||
|
||||
// Verificar existencia y estado
|
||||
const existing = await this.cartaPorteService.findOne(id, tenantId);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Carta Porte no encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing.estado !== EstadoCartaPorte.BORRADOR) {
|
||||
res.status(400).json({
|
||||
error: `Solo se puede actualizar Carta Porte en estado BORRADOR. Estado actual: ${existing.estado}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const cartaPorte = await this.cartaPorteService.update(id, tenantId, dto, userId);
|
||||
|
||||
res.json({ data: cartaPorte } as CartaPorteResponse);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('No se puede modificar')) {
|
||||
res.status(400).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /:id - Eliminar Carta Porte
|
||||
* @apiOperation Eliminar Carta Porte (solo estado BORRADOR)
|
||||
* @apiParam id - UUID de la Carta Porte
|
||||
* @apiResponse 204 Carta Porte eliminada
|
||||
* @apiResponse 400 Solo se puede eliminar en estado BORRADOR
|
||||
* @apiResponse 404 Carta Porte no encontrada
|
||||
*/
|
||||
private async delete(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Tenant ID is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
|
||||
// Verificar existencia y estado
|
||||
const existing = await this.cartaPorteService.findOne(id, tenantId);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Carta Porte no encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing.estado !== EstadoCartaPorte.BORRADOR) {
|
||||
res.status(400).json({
|
||||
error: `Solo se puede eliminar Carta Porte en estado BORRADOR. Estado actual: ${existing.estado}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = await this.cartaPorteService.delete(id, tenantId);
|
||||
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Carta Porte no encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('Solo se pueden eliminar')) {
|
||||
res.status(400).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /:id/validar - Validar campos SAT de Carta Porte
|
||||
* @apiOperation Validar Carta Porte para timbrado
|
||||
* @apiParam id - UUID de la Carta Porte
|
||||
* @apiResponse 200 Resultado de validacion (valid: boolean, errors: string[])
|
||||
* @apiResponse 404 Carta Porte no encontrada
|
||||
*/
|
||||
private async validar(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Tenant ID is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const { marcarValidada = false } = req.body;
|
||||
|
||||
// Verificar existencia
|
||||
const existing = await this.cartaPorteService.findOne(id, tenantId);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Carta Porte no encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validar campos requeridos
|
||||
const validation = await this.cartaPorteService.validar(id, tenantId);
|
||||
|
||||
// Si se solicita marcar como validada y la validacion es exitosa
|
||||
if (marcarValidada && validation.valid) {
|
||||
const updated = await this.cartaPorteService.marcarValidada(id, tenantId);
|
||||
res.json({
|
||||
data: {
|
||||
valid: validation.valid,
|
||||
errors: validation.errors,
|
||||
cartaPorte: updated,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: validation } as CartaPorteValidationResponse);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('no valida')) {
|
||||
res.status(400).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /:id/timbrar - Timbrar Carta Porte con PAC
|
||||
* @apiOperation Timbrar CFDI con Complemento Carta Porte
|
||||
* @apiParam id - UUID de la Carta Porte
|
||||
* @apiBody uuidCfdi - UUID del CFDI timbrado (retornado por PAC)
|
||||
* @apiBody fechaTimbrado - Fecha de timbrado
|
||||
* @apiBody xmlCfdi - XML del CFDI completo
|
||||
* @apiBody xmlCartaPorte - XML del complemento Carta Porte
|
||||
* @apiBody pdfUrl - URL del PDF generado (opcional)
|
||||
* @apiBody qrUrl - URL del codigo QR (opcional)
|
||||
* @apiResponse 200 Carta Porte timbrada exitosamente
|
||||
* @apiResponse 400 Carta Porte debe estar en estado VALIDADA
|
||||
* @apiResponse 404 Carta Porte no encontrada
|
||||
*
|
||||
* NOTA: Esta implementacion es solo la estructura.
|
||||
* La integracion real con PAC debe implementarse en el servicio.
|
||||
*/
|
||||
private async timbrar(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Tenant ID is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
|
||||
// Verificar existencia
|
||||
const existing = await this.cartaPorteService.findOne(id, tenantId);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Carta Porte no encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Verificar estado
|
||||
if (existing.estado !== EstadoCartaPorte.VALIDADA) {
|
||||
res.status(400).json({
|
||||
error: `La Carta Porte debe estar en estado VALIDADA para timbrar. Estado actual: ${existing.estado}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Datos del timbrado (vienen del PAC o de proceso de timbrado)
|
||||
const dto: TimbrarCartaPorteDto = req.body;
|
||||
|
||||
// Validar campos requeridos para timbrado
|
||||
if (!dto.uuidCfdi || !dto.fechaTimbrado || !dto.xmlCfdi || !dto.xmlCartaPorte) {
|
||||
res.status(400).json({
|
||||
error: 'Datos de timbrado incompletos. Se requiere: uuidCfdi, fechaTimbrado, xmlCfdi, xmlCartaPorte',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const cartaPorte = await this.cartaPorteService.timbrar(id, tenantId, dto);
|
||||
|
||||
res.json({
|
||||
data: cartaPorte,
|
||||
message: 'Carta Porte timbrada exitosamente',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('debe estar validada')) {
|
||||
res.status(400).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /:id/cancelar - Cancelar Carta Porte ante SAT
|
||||
* @apiOperation Cancelar CFDI timbrado
|
||||
* @apiParam id - UUID de la Carta Porte
|
||||
* @apiBody motivoCancelacion - Motivo de cancelacion (requerido)
|
||||
* @apiBody uuidSustitucion - UUID del CFDI sustituto (opcional, para tipo 01)
|
||||
* @apiResponse 200 Carta Porte cancelada exitosamente
|
||||
* @apiResponse 400 Solo se pueden cancelar Cartas Porte timbradas
|
||||
* @apiResponse 404 Carta Porte no encontrada
|
||||
*/
|
||||
private async cancelar(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Tenant ID is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const dto: CancelarCartaPorteDto = req.body;
|
||||
|
||||
// Verificar existencia
|
||||
const existing = await this.cartaPorteService.findOne(id, tenantId);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Carta Porte no encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Verificar estado
|
||||
if (existing.estado !== EstadoCartaPorte.TIMBRADA) {
|
||||
res.status(400).json({
|
||||
error: `Solo se pueden cancelar Cartas Porte timbradas. Estado actual: ${existing.estado}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validar motivo de cancelacion
|
||||
if (!dto.motivoCancelacion) {
|
||||
res.status(400).json({ error: 'motivoCancelacion es requerido' });
|
||||
return;
|
||||
}
|
||||
|
||||
const cartaPorte = await this.cartaPorteService.cancelar(id, tenantId, dto);
|
||||
|
||||
res.json({
|
||||
data: cartaPorte,
|
||||
message: 'Carta Porte cancelada exitosamente',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('Solo se pueden cancelar')) {
|
||||
res.status(400).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /:id/xml - Descargar XML del CFDI
|
||||
* @apiOperation Obtener XML del CFDI timbrado
|
||||
* @apiParam id - UUID de la Carta Porte
|
||||
* @apiResponse 200 XML del CFDI (application/xml)
|
||||
* @apiResponse 400 Carta Porte no timbrada
|
||||
* @apiResponse 404 Carta Porte no encontrada
|
||||
*/
|
||||
private async downloadXml(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Tenant ID is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const cartaPorte = await this.cartaPorteService.findOne(id, tenantId);
|
||||
|
||||
if (!cartaPorte) {
|
||||
res.status(404).json({ error: 'Carta Porte no encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cartaPorte.xmlCfdi) {
|
||||
res.status(400).json({
|
||||
error: 'La Carta Porte no tiene XML. Debe estar timbrada para descargar XML.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Construir nombre del archivo
|
||||
const filename = cartaPorte.uuidCfdi
|
||||
? `carta-porte-${cartaPorte.uuidCfdi}.xml`
|
||||
: `carta-porte-${cartaPorte.id}.xml`;
|
||||
|
||||
res.setHeader('Content-Type', 'application/xml');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send(cartaPorte.xmlCfdi);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /:id/pdf - Descargar PDF de la Carta Porte
|
||||
* @apiOperation Obtener PDF del CFDI timbrado
|
||||
* @apiParam id - UUID de la Carta Porte
|
||||
* @apiResponse 200 Redireccion a URL del PDF o PDF generado
|
||||
* @apiResponse 400 Carta Porte no timbrada o PDF no disponible
|
||||
* @apiResponse 404 Carta Porte no encontrada
|
||||
*
|
||||
* NOTA: Esta implementacion es un placeholder.
|
||||
* La generacion real del PDF debe implementarse con una libreria PDF.
|
||||
*/
|
||||
private async downloadPdf(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Tenant ID is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const cartaPorte = await this.cartaPorteService.findOne(id, tenantId);
|
||||
|
||||
if (!cartaPorte) {
|
||||
res.status(404).json({ error: 'Carta Porte no encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (cartaPorte.estado !== EstadoCartaPorte.TIMBRADA) {
|
||||
res.status(400).json({
|
||||
error: 'Solo se puede descargar PDF de Cartas Porte timbradas.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Si existe URL del PDF, redirigir
|
||||
if (cartaPorte.pdfUrl) {
|
||||
res.redirect(cartaPorte.pdfUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// Placeholder: En implementacion real, generar PDF aqui
|
||||
// Por ahora retornamos mensaje indicando que PDF no esta disponible
|
||||
res.status(501).json({
|
||||
error: 'Generacion de PDF no implementada',
|
||||
message: 'La Carta Porte esta timbrada pero la generacion de PDF no esta disponible. ' +
|
||||
'Utilice el XML para generar una representacion impresa.',
|
||||
cartaPorteId: cartaPorte.id,
|
||||
uuidCfdi: cartaPorte.uuidCfdi,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /expediente/:viajeId - Obtener expediente fiscal del viaje
|
||||
* @apiOperation Obtener todas las Cartas Porte de un viaje
|
||||
* @apiParam viajeId - UUID del viaje
|
||||
* @apiResponse 200 Expediente fiscal con estadisticas
|
||||
* @apiResponse 404 No se encontraron Cartas Porte para el viaje
|
||||
*/
|
||||
private async getExpedienteFiscal(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Tenant ID is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { viajeId } = req.params;
|
||||
const cartasPorte = await this.cartaPorteService.findByViaje(viajeId, tenantId);
|
||||
|
||||
// Calcular estadisticas
|
||||
const timbradas = cartasPorte.filter((cp) => cp.estado === EstadoCartaPorte.TIMBRADA).length;
|
||||
const pendientes = cartasPorte.filter(
|
||||
(cp) => cp.estado === EstadoCartaPorte.BORRADOR || cp.estado === EstadoCartaPorte.VALIDADA
|
||||
).length;
|
||||
const canceladas = cartasPorte.filter((cp) => cp.estado === EstadoCartaPorte.CANCELADA).length;
|
||||
|
||||
res.json({
|
||||
data: {
|
||||
viajeId,
|
||||
cartasPorte,
|
||||
total: cartasPorte.length,
|
||||
timbradas,
|
||||
pendientes,
|
||||
canceladas,
|
||||
},
|
||||
} as ExpedienteFiscalResponse);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /pendientes-timbrar - Listar Cartas Porte listas para timbrar
|
||||
* @apiOperation Obtener Cartas Porte en estado BORRADOR o VALIDADA
|
||||
* @apiResponse 200 Lista de Cartas Porte pendientes de timbrar
|
||||
*/
|
||||
private async getPendientesTimbrar(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Tenant ID is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const cartasPorte = await this.cartaPorteService.getPendientesTimbrar(tenantId);
|
||||
|
||||
res.json({
|
||||
data: cartasPorte,
|
||||
total: cartasPorte.length,
|
||||
} as PendientesTimbrarResponse);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,9 @@
|
||||
* CFDI con Complemento Carta Porte 3.1
|
||||
*/
|
||||
|
||||
// Main Carta Porte Controller
|
||||
export * from './carta-porte.controller';
|
||||
|
||||
// Mercancia (Cargo/Merchandise) Controller
|
||||
export * from './mercancia.controller';
|
||||
|
||||
@ -14,7 +17,3 @@ export * from './figura-transporte.controller';
|
||||
|
||||
// Inspeccion Pre-Viaje (Pre-trip Inspection) Controller
|
||||
export * from './inspeccion-pre-viaje.controller';
|
||||
|
||||
// TODO: Implement additional controllers
|
||||
// - carta-porte.controller.ts (main carta porte CRUD)
|
||||
// - cfdi.controller.ts (timbrado y cancelacion)
|
||||
|
||||
120
src/modules/carta-porte/dto/autotransporte-carta-porte.dto.ts
Normal file
120
src/modules/carta-porte/dto/autotransporte-carta-porte.dto.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsNumber,
|
||||
IsArray,
|
||||
ValidateNested,
|
||||
Length,
|
||||
Min,
|
||||
Max,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
/**
|
||||
* DTO para remolque/semirremolque
|
||||
*/
|
||||
export class RemolqueDto {
|
||||
/**
|
||||
* Subtipo de remolque (catalogo c_SubTipoRem)
|
||||
*/
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(4, 4)
|
||||
subTipoRem!: string;
|
||||
|
||||
/**
|
||||
* Placa del remolque
|
||||
*/
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(6, 8)
|
||||
placa!: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO para agregar datos de autotransporte a Carta Porte
|
||||
*/
|
||||
export class CreateAutotransporteDto {
|
||||
/**
|
||||
* Tipo de permiso SCT (catalogo c_TipoPermiso)
|
||||
* TPAF01 = Autotransporte Federal de Carga General
|
||||
* TPAF02 = Autotransporte Federal de Mudanzas
|
||||
* etc.
|
||||
*/
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'permSCT es requerido' })
|
||||
@Length(4, 10)
|
||||
permSCT!: string;
|
||||
|
||||
/**
|
||||
* Numero de permiso SCT
|
||||
*/
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'numPermisoSCT es requerido' })
|
||||
@Length(1, 50)
|
||||
numPermisoSCT!: string;
|
||||
|
||||
/**
|
||||
* Configuracion vehicular (catalogo c_ConfigAutotransporte)
|
||||
* C2 = Camion Unitario (2 llantas eje delantero, 4-8 trasero)
|
||||
* C3 = Camion Unitario (3 ejes)
|
||||
* T3S2 = Tractocamion con semirremolque
|
||||
* etc.
|
||||
*/
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'configVehicular es requerida' })
|
||||
configVehicular!: string;
|
||||
|
||||
/**
|
||||
* Placa del vehiculo motor
|
||||
*/
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'placaVM es requerida' })
|
||||
@Length(6, 8, { message: 'placaVM debe tener entre 6 y 8 caracteres' })
|
||||
placaVM!: string;
|
||||
|
||||
/**
|
||||
* Año modelo del vehiculo
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1990)
|
||||
@Max(2100)
|
||||
anioModeloVM?: number;
|
||||
|
||||
/**
|
||||
* Remolques (maximo 2)
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => RemolqueDto)
|
||||
remolques?: RemolqueDto[];
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO para actualizar autotransporte
|
||||
*/
|
||||
export class UpdateAutotransporteDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 50)
|
||||
numPermisoSCT?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(6, 8)
|
||||
placaVM?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1990)
|
||||
anioModeloVM?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => RemolqueDto)
|
||||
remolques?: RemolqueDto[];
|
||||
}
|
||||
78
src/modules/carta-porte/dto/cancelar-carta-porte.dto.ts
Normal file
78
src/modules/carta-porte/dto/cancelar-carta-porte.dto.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsEnum,
|
||||
IsUUID,
|
||||
Length,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* Motivos de cancelacion segun SAT
|
||||
*/
|
||||
export enum MotivoCancelacion {
|
||||
/** 01 - Comprobante emitido con errores con relacion */
|
||||
ERRORES_CON_RELACION = '01',
|
||||
/** 02 - Comprobante emitido con errores sin relacion */
|
||||
ERRORES_SIN_RELACION = '02',
|
||||
/** 03 - No se llevo a cabo la operacion */
|
||||
NO_OPERACION = '03',
|
||||
/** 04 - Operacion nominativa relacionada en factura global */
|
||||
NOMINATIVA_GLOBAL = '04',
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO para cancelar Carta Porte timbrada
|
||||
*/
|
||||
export class CancelarCartaPorteDto {
|
||||
/**
|
||||
* Motivo de cancelacion (catalogo SAT)
|
||||
* 01 = Comprobante con errores CON relacion (requiere UUID sustituto)
|
||||
* 02 = Comprobante con errores SIN relacion
|
||||
* 03 = No se llevo a cabo la operacion
|
||||
* 04 = Operacion nominativa relacionada en factura global
|
||||
*/
|
||||
@IsEnum(MotivoCancelacion, {
|
||||
message: 'motivoCancelacion debe ser 01, 02, 03 o 04',
|
||||
})
|
||||
@IsNotEmpty({ message: 'motivoCancelacion es requerido' })
|
||||
motivoCancelacion!: MotivoCancelacion;
|
||||
|
||||
/**
|
||||
* UUID del CFDI que sustituye (requerido si motivo = 01)
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
uuidSustitucion?: string;
|
||||
|
||||
/**
|
||||
* Observaciones adicionales sobre la cancelacion
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 500)
|
||||
observaciones?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response de cancelacion del PAC
|
||||
*/
|
||||
export class CancelacionResponseDto {
|
||||
/**
|
||||
* Acuse de cancelacion del SAT
|
||||
*/
|
||||
@IsString()
|
||||
acuseCancelacion!: string;
|
||||
|
||||
/**
|
||||
* Fecha de cancelacion
|
||||
*/
|
||||
@IsString()
|
||||
fechaCancelacion!: string;
|
||||
|
||||
/**
|
||||
* Estatus de cancelacion
|
||||
*/
|
||||
@IsString()
|
||||
estatusCancelacion!: string;
|
||||
}
|
||||
271
src/modules/carta-porte/dto/create-carta-porte.dto.ts
Normal file
271
src/modules/carta-porte/dto/create-carta-porte.dto.ts
Normal file
@ -0,0 +1,271 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsNumber,
|
||||
IsBoolean,
|
||||
IsUUID,
|
||||
Length,
|
||||
Matches,
|
||||
Min,
|
||||
Max,
|
||||
} from 'class-validator';
|
||||
import { TipoCfdiCartaPorte } from '../entities';
|
||||
|
||||
/**
|
||||
* DTO para crear una nueva Carta Porte
|
||||
* Cumple con requerimientos SAT CFDI 3.1
|
||||
*/
|
||||
export class CreateCartaPorteDto {
|
||||
/**
|
||||
* ID del viaje asociado
|
||||
*/
|
||||
@IsUUID()
|
||||
@IsNotEmpty({ message: 'viajeId es requerido' })
|
||||
viajeId!: string;
|
||||
|
||||
/**
|
||||
* Tipo de CFDI: INGRESO (servicio cobrado) o TRASLADO (propio)
|
||||
*/
|
||||
@IsEnum(TipoCfdiCartaPorte, { message: 'tipoCfdi debe ser INGRESO o TRASLADO' })
|
||||
@IsNotEmpty({ message: 'tipoCfdi es requerido' })
|
||||
tipoCfdi!: TipoCfdiCartaPorte;
|
||||
|
||||
/**
|
||||
* Version del complemento Carta Porte (default 3.1)
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
versionCartaPorte?: string;
|
||||
|
||||
/**
|
||||
* Serie del CFDI (opcional)
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 25, { message: 'serie debe tener entre 1 y 25 caracteres' })
|
||||
serie?: string;
|
||||
|
||||
// ========== DATOS DEL EMISOR ==========
|
||||
|
||||
/**
|
||||
* RFC del emisor (12-13 caracteres)
|
||||
*/
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'emisorRfc es requerido' })
|
||||
@Matches(/^[A-Z&Ñ]{3,4}[0-9]{6}[A-Z0-9]{3}$/, {
|
||||
message: 'emisorRfc debe ser un RFC valido',
|
||||
})
|
||||
emisorRfc!: string;
|
||||
|
||||
/**
|
||||
* Razon social o nombre del emisor
|
||||
*/
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'emisorNombre es requerido' })
|
||||
@Length(1, 300)
|
||||
emisorNombre!: string;
|
||||
|
||||
/**
|
||||
* Regimen fiscal del emisor (catalogo SAT c_RegimenFiscal)
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(3, 3, { message: 'emisorRegimenFiscal debe ser codigo de 3 digitos' })
|
||||
emisorRegimenFiscal?: string;
|
||||
|
||||
// ========== DATOS DEL RECEPTOR ==========
|
||||
|
||||
/**
|
||||
* RFC del receptor
|
||||
*/
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'receptorRfc es requerido' })
|
||||
@Matches(/^([A-Z&Ñ]{3,4}[0-9]{6}[A-Z0-9]{3}|XAXX010101000|XEXX010101000)$/, {
|
||||
message: 'receptorRfc debe ser un RFC valido o generico (XAXX/XEXX)',
|
||||
})
|
||||
receptorRfc!: string;
|
||||
|
||||
/**
|
||||
* Nombre o razon social del receptor
|
||||
*/
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'receptorNombre es requerido' })
|
||||
@Length(1, 300)
|
||||
receptorNombre!: string;
|
||||
|
||||
/**
|
||||
* Uso del CFDI (catalogo SAT c_UsoCFDI)
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(3, 4)
|
||||
receptorUsoCfdi?: string;
|
||||
|
||||
/**
|
||||
* Codigo postal del domicilio fiscal del receptor
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(5, 5, { message: 'receptorDomicilioFiscalCp debe ser de 5 digitos' })
|
||||
receptorDomicilioFiscalCp?: string;
|
||||
|
||||
// ========== TOTALES (requeridos para CFDI INGRESO) ==========
|
||||
|
||||
/**
|
||||
* Subtotal del CFDI (sin impuestos)
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsNumber({}, { message: 'subtotal debe ser un numero' })
|
||||
@Min(0)
|
||||
subtotal?: number;
|
||||
|
||||
/**
|
||||
* Total del CFDI (con impuestos)
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsNumber({}, { message: 'total debe ser un numero' })
|
||||
@Min(0)
|
||||
total?: number;
|
||||
|
||||
/**
|
||||
* Moneda (default MXN)
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(3, 3)
|
||||
moneda?: string;
|
||||
|
||||
// ========== TRANSPORTE INTERNACIONAL ==========
|
||||
|
||||
/**
|
||||
* Indica si es transporte internacional
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
transporteInternacional?: boolean;
|
||||
|
||||
/**
|
||||
* Entrada o Salida de mercancia (Entrada/Salida)
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsEnum(['Entrada', 'Salida'], { message: 'entradaSalidaMerc debe ser Entrada o Salida' })
|
||||
entradaSalidaMerc?: string;
|
||||
|
||||
/**
|
||||
* Pais de origen o destino (ISO 3166-1 alpha-3)
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(3, 3)
|
||||
paisOrigenDestino?: string;
|
||||
|
||||
// ========== DATOS AUTOTRANSPORTE FEDERAL ==========
|
||||
|
||||
/**
|
||||
* Tipo de permiso SCT (catalogo c_TipoPermiso)
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
permisoSct?: string;
|
||||
|
||||
/**
|
||||
* Numero de permiso SCT
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 50)
|
||||
numPermisoSct?: string;
|
||||
|
||||
/**
|
||||
* Configuracion vehicular (catalogo c_ConfigAutotransporte)
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
configVehicular?: string;
|
||||
|
||||
/**
|
||||
* Peso bruto total en KG
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(100000)
|
||||
pesoBrutoTotal?: number;
|
||||
|
||||
/**
|
||||
* Unidad de peso (catalogo c_ClaveUnidadPeso, default KGM)
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(2, 3)
|
||||
unidadPeso?: string;
|
||||
|
||||
/**
|
||||
* Numero total de mercancias transportadas
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
numTotalMercancias?: number;
|
||||
|
||||
// ========== SEGUROS ==========
|
||||
|
||||
/**
|
||||
* Nombre de la aseguradora de responsabilidad civil
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 100)
|
||||
aseguraRespCivil?: string;
|
||||
|
||||
/**
|
||||
* Numero de poliza de responsabilidad civil
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 50)
|
||||
polizaRespCivil?: string;
|
||||
|
||||
/**
|
||||
* Nombre de la aseguradora de medio ambiente
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 100)
|
||||
aseguraMedAmbiente?: string;
|
||||
|
||||
/**
|
||||
* Numero de poliza de medio ambiente
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 50)
|
||||
polizaMedAmbiente?: string;
|
||||
|
||||
/**
|
||||
* Nombre de la aseguradora de carga
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 100)
|
||||
aseguraCarga?: string;
|
||||
|
||||
/**
|
||||
* Numero de poliza de carga
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 50)
|
||||
polizaCarga?: string;
|
||||
|
||||
/**
|
||||
* Prima del seguro de carga
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
primaSeguro?: number;
|
||||
}
|
||||
167
src/modules/carta-porte/dto/figura-transporte.dto.ts
Normal file
167
src/modules/carta-porte/dto/figura-transporte.dto.ts
Normal file
@ -0,0 +1,167 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsArray,
|
||||
ValidateNested,
|
||||
Length,
|
||||
Matches,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { TipoFiguraTransporte } from '../entities';
|
||||
|
||||
/**
|
||||
* DTO para partes de transporte (propietario/arrendador)
|
||||
*/
|
||||
export class PartesTransporteDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
parteTransporte!: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO para domicilio de figura de transporte
|
||||
*/
|
||||
export class DomicilioFiguraDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(2, 3)
|
||||
pais?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(2, 3)
|
||||
estado?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(5, 5)
|
||||
codigoPostal?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 100)
|
||||
calle?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 55)
|
||||
numExterior?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 30)
|
||||
numInterior?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 100)
|
||||
colonia?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 100)
|
||||
municipio?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 250)
|
||||
referencia?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO para agregar figura de transporte a Carta Porte
|
||||
* Tipos: Operador (01), Propietario (02), Arrendador (03), Notificado (04)
|
||||
*/
|
||||
export class CreateFiguraTransporteDto {
|
||||
/**
|
||||
* Tipo de figura de transporte (catalogo c_TipoFiguraTransporte)
|
||||
* 01 = Operador
|
||||
* 02 = Propietario
|
||||
* 03 = Arrendador
|
||||
* 04 = Notificado
|
||||
*/
|
||||
@IsEnum(TipoFiguraTransporte, { message: 'tipoFigura debe ser 01, 02, 03 o 04' })
|
||||
@IsNotEmpty()
|
||||
tipoFigura!: TipoFiguraTransporte;
|
||||
|
||||
/**
|
||||
* RFC de la figura (opcional para operadores extranjeros)
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^([A-Z&Ñ]{3,4}[0-9]{6}[A-Z0-9]{3}|XEXX010101000)$/, {
|
||||
message: 'rfcFigura debe ser un RFC valido o extranjero generico',
|
||||
})
|
||||
rfcFigura?: string;
|
||||
|
||||
/**
|
||||
* Nombre de la figura de transporte
|
||||
*/
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'nombreFigura es requerido' })
|
||||
@Length(1, 254)
|
||||
nombreFigura!: string;
|
||||
|
||||
/**
|
||||
* Numero de licencia (requerido para operadores tipo 01)
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 16)
|
||||
numLicencia?: string;
|
||||
|
||||
/**
|
||||
* Numero de registro tributario (para extranjeros)
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 40)
|
||||
numRegIdTribFigura?: string;
|
||||
|
||||
/**
|
||||
* Residencia fiscal (ISO 3166-1 alpha-3, para extranjeros)
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(3, 3)
|
||||
residenciaFiscalFigura?: string;
|
||||
|
||||
/**
|
||||
* Partes de transporte (para propietario/arrendador)
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PartesTransporteDto)
|
||||
partesTransporte?: PartesTransporteDto[];
|
||||
|
||||
/**
|
||||
* Domicilio de la figura
|
||||
*/
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => DomicilioFiguraDto)
|
||||
domicilio?: DomicilioFiguraDto;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO para actualizar figura de transporte
|
||||
*/
|
||||
export class UpdateFiguraTransporteDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 254)
|
||||
nombreFigura?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 16)
|
||||
numLicencia?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => DomicilioFiguraDto)
|
||||
domicilio?: DomicilioFiguraDto;
|
||||
}
|
||||
@ -1,8 +1,24 @@
|
||||
/**
|
||||
* Carta Porte DTOs
|
||||
* CFDI 3.1 Compliance - SAT Mexico
|
||||
*/
|
||||
// TODO: Implement DTOs
|
||||
// - create-carta-porte.dto.ts
|
||||
// - ubicacion-carta-porte.dto.ts
|
||||
// - mercancia-carta-porte.dto.ts
|
||||
// - timbrado-response.dto.ts
|
||||
|
||||
// Carta Porte principal
|
||||
export * from './create-carta-porte.dto';
|
||||
export * from './update-carta-porte.dto';
|
||||
|
||||
// Ubicaciones (origen/destino)
|
||||
export * from './ubicacion-carta-porte.dto';
|
||||
|
||||
// Mercancias
|
||||
export * from './mercancia-carta-porte.dto';
|
||||
|
||||
// Figuras de transporte (operador, propietario, etc)
|
||||
export * from './figura-transporte.dto';
|
||||
|
||||
// Autotransporte federal
|
||||
export * from './autotransporte-carta-porte.dto';
|
||||
|
||||
// Operaciones de timbrado y cancelacion
|
||||
export * from './timbrar-carta-porte.dto';
|
||||
export * from './cancelar-carta-porte.dto';
|
||||
|
||||
214
src/modules/carta-porte/dto/mercancia-carta-porte.dto.ts
Normal file
214
src/modules/carta-porte/dto/mercancia-carta-porte.dto.ts
Normal file
@ -0,0 +1,214 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsNumber,
|
||||
IsBoolean,
|
||||
Length,
|
||||
Min,
|
||||
Max,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* DTO para agregar mercancia a Carta Porte
|
||||
* Segun especificacion SAT CFDI 3.1
|
||||
*/
|
||||
export class CreateMercanciaCartaPorteDto {
|
||||
/**
|
||||
* Clave de bienes transportados (catalogo SAT c_ClaveProdServCP)
|
||||
*/
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'bienesTransp es requerido' })
|
||||
@Length(6, 8, { message: 'bienesTransp debe ser codigo SAT de 6-8 caracteres' })
|
||||
bienesTransp!: string;
|
||||
|
||||
/**
|
||||
* Descripcion de la mercancia
|
||||
*/
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'descripcion es requerida' })
|
||||
@Length(1, 1000)
|
||||
descripcion!: string;
|
||||
|
||||
/**
|
||||
* Cantidad
|
||||
*/
|
||||
@IsNumber()
|
||||
@IsNotEmpty()
|
||||
@Min(0.001)
|
||||
cantidad!: number;
|
||||
|
||||
/**
|
||||
* Clave de unidad SAT (catalogo c_ClaveUnidad)
|
||||
*/
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'claveUnidad es requerida' })
|
||||
@Length(2, 3)
|
||||
claveUnidad!: string;
|
||||
|
||||
/**
|
||||
* Descripcion de la unidad
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 100)
|
||||
unidad?: string;
|
||||
|
||||
/**
|
||||
* Peso en KG
|
||||
*/
|
||||
@IsNumber()
|
||||
@IsNotEmpty({ message: 'pesoEnKg es requerido' })
|
||||
@Min(0.001)
|
||||
@Max(100000)
|
||||
pesoEnKg!: number;
|
||||
|
||||
/**
|
||||
* Valor de la mercancia
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
valorMercancia?: number;
|
||||
|
||||
/**
|
||||
* Moneda del valor (default MXN)
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(3, 3)
|
||||
moneda?: string;
|
||||
|
||||
// ========== DIMENSIONES (para calculo de volumen) ==========
|
||||
|
||||
/**
|
||||
* Longitud en cm
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
dimensionesLargo?: number;
|
||||
|
||||
/**
|
||||
* Ancho en cm
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
dimensionesAncho?: number;
|
||||
|
||||
/**
|
||||
* Alto en cm
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
dimensionesAlto?: number;
|
||||
|
||||
// ========== MATERIAL PELIGROSO ==========
|
||||
|
||||
/**
|
||||
* Indica si es material peligroso
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
materialPeligroso?: boolean;
|
||||
|
||||
/**
|
||||
* Clave de material peligroso (catalogo c_MaterialPeligroso)
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(4, 4)
|
||||
cveMaterialPeligroso?: string;
|
||||
|
||||
/**
|
||||
* Tipo de embalaje (catalogo c_TipoEmbalaje)
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
embalaje?: string;
|
||||
|
||||
/**
|
||||
* Descripcion del embalaje
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 100)
|
||||
descripEmbalaje?: string;
|
||||
|
||||
// ========== COMERCIO EXTERIOR ==========
|
||||
|
||||
/**
|
||||
* Fraccion arancelaria
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(8, 10)
|
||||
fraccionArancelaria?: string;
|
||||
|
||||
/**
|
||||
* UUID de comercio exterior
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
uuidComercioExt?: string;
|
||||
|
||||
// ========== GUIAS ==========
|
||||
|
||||
/**
|
||||
* Numero de guia o conocimiento de embarque
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 30)
|
||||
guiaIdentificacion?: string;
|
||||
|
||||
/**
|
||||
* Descripcion de guia
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
guiaDescripcion?: string;
|
||||
|
||||
/**
|
||||
* Peso de guia en KG
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
guiaPesoKg?: number;
|
||||
|
||||
/**
|
||||
* Numero de secuencia de la mercancia
|
||||
*/
|
||||
@IsNumber()
|
||||
@IsNotEmpty()
|
||||
@Min(1)
|
||||
numeroSecuencia!: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO para actualizar mercancia
|
||||
*/
|
||||
export class UpdateMercanciaCartaPorteDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 1000)
|
||||
descripcion?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0.001)
|
||||
cantidad?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0.001)
|
||||
pesoEnKg?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
valorMercancia?: number;
|
||||
}
|
||||
91
src/modules/carta-porte/dto/timbrar-carta-porte.dto.ts
Normal file
91
src/modules/carta-porte/dto/timbrar-carta-porte.dto.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsDateString,
|
||||
IsUrl,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* DTO para registrar el timbrado de Carta Porte
|
||||
* Datos retornados por el PAC despues de timbrar
|
||||
*/
|
||||
export class TimbrarCartaPorteDto {
|
||||
/**
|
||||
* UUID del CFDI timbrado (Timbre Fiscal Digital)
|
||||
*/
|
||||
@IsUUID()
|
||||
@IsNotEmpty({ message: 'uuidCfdi es requerido' })
|
||||
uuidCfdi!: string;
|
||||
|
||||
/**
|
||||
* Fecha y hora del timbrado (ISO 8601)
|
||||
*/
|
||||
@IsDateString()
|
||||
@IsNotEmpty({ message: 'fechaTimbrado es requerida' })
|
||||
fechaTimbrado!: string;
|
||||
|
||||
/**
|
||||
* XML completo del CFDI timbrado
|
||||
*/
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'xmlCfdi es requerido' })
|
||||
xmlCfdi!: string;
|
||||
|
||||
/**
|
||||
* XML del complemento Carta Porte
|
||||
*/
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'xmlCartaPorte es requerido' })
|
||||
xmlCartaPorte!: string;
|
||||
|
||||
/**
|
||||
* URL del PDF generado (representacion impresa)
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
pdfUrl?: string;
|
||||
|
||||
/**
|
||||
* URL del codigo QR
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
qrUrl?: string;
|
||||
|
||||
/**
|
||||
* Sello digital del CFDI
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
selloCfdi?: string;
|
||||
|
||||
/**
|
||||
* Sello del SAT
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
selloSat?: string;
|
||||
|
||||
/**
|
||||
* Numero de certificado del emisor
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
noCertificado?: string;
|
||||
|
||||
/**
|
||||
* Numero de certificado del SAT
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
noCertificadoSat?: string;
|
||||
|
||||
/**
|
||||
* Cadena original del timbre
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cadenaOriginalTimbre?: string;
|
||||
}
|
||||
176
src/modules/carta-porte/dto/ubicacion-carta-porte.dto.ts
Normal file
176
src/modules/carta-porte/dto/ubicacion-carta-porte.dto.ts
Normal file
@ -0,0 +1,176 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsNumber,
|
||||
IsDateString,
|
||||
Length,
|
||||
Min,
|
||||
Max,
|
||||
} from 'class-validator';
|
||||
import { TipoUbicacionCartaPorte } from '../entities';
|
||||
|
||||
/**
|
||||
* DTO para agregar ubicacion a Carta Porte
|
||||
* Soporta origen, destinos intermedios y destino final
|
||||
*/
|
||||
export class CreateUbicacionCartaPorteDto {
|
||||
/**
|
||||
* Tipo de ubicacion: Origen o Destino
|
||||
*/
|
||||
@IsEnum(TipoUbicacionCartaPorte, { message: 'tipoUbicacion debe ser Origen o Destino' })
|
||||
@IsNotEmpty()
|
||||
tipoUbicacion!: TipoUbicacionCartaPorte;
|
||||
|
||||
/**
|
||||
* ID de ubicacion (catalogo SAT c_CodigoPostal)
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
idUbicacion?: string;
|
||||
|
||||
/**
|
||||
* RFC del remitente o destinatario
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(12, 13)
|
||||
rfcRemitenteDestinatario?: string;
|
||||
|
||||
/**
|
||||
* Nombre del remitente o destinatario
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 254)
|
||||
nombreRemitenteDestinatario?: string;
|
||||
|
||||
/**
|
||||
* Numero de estacion (para transporte ferroviario)
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
numEstacion?: string;
|
||||
|
||||
/**
|
||||
* Nombre de la estacion
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nombreEstacion?: string;
|
||||
|
||||
/**
|
||||
* Fecha y hora de salida (para origen)
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
fechaHoraSalidaLlegada?: string;
|
||||
|
||||
/**
|
||||
* Distancia recorrida en KM (0 para origen)
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(10000)
|
||||
distanciaRecorrida?: number;
|
||||
|
||||
// ========== DOMICILIO ==========
|
||||
|
||||
/**
|
||||
* Codigo postal (catalogo SAT, requerido)
|
||||
*/
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'codigoPostal es requerido' })
|
||||
@Length(5, 5, { message: 'codigoPostal debe ser de 5 digitos' })
|
||||
codigoPostal!: string;
|
||||
|
||||
/**
|
||||
* Estado (catalogo SAT c_Estado)
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(2, 3)
|
||||
estado?: string;
|
||||
|
||||
/**
|
||||
* Municipio (catalogo SAT c_Municipio)
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
municipio?: string;
|
||||
|
||||
/**
|
||||
* Localidad (catalogo SAT c_Localidad)
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
localidad?: string;
|
||||
|
||||
/**
|
||||
* Colonia (catalogo SAT c_Colonia)
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
colonia?: string;
|
||||
|
||||
/**
|
||||
* Calle
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 100)
|
||||
calle?: string;
|
||||
|
||||
/**
|
||||
* Numero exterior
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 55)
|
||||
numExterior?: string;
|
||||
|
||||
/**
|
||||
* Numero interior
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 30)
|
||||
numInterior?: string;
|
||||
|
||||
/**
|
||||
* Referencia geografica
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 250)
|
||||
referencia?: string;
|
||||
|
||||
/**
|
||||
* Numero de secuencia (orden de visita, 1=origen)
|
||||
*/
|
||||
@IsNumber()
|
||||
@IsNotEmpty()
|
||||
@Min(1)
|
||||
numeroSecuencia!: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO para actualizar ubicacion
|
||||
*/
|
||||
export class UpdateUbicacionCartaPorteDto {
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
fechaHoraSalidaLlegada?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
distanciaRecorrida?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 250)
|
||||
referencia?: string;
|
||||
}
|
||||
9
src/modules/carta-porte/dto/update-carta-porte.dto.ts
Normal file
9
src/modules/carta-porte/dto/update-carta-porte.dto.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { PartialType } from '../../../common/partial-type';
|
||||
import { CreateCartaPorteDto } from './create-carta-porte.dto';
|
||||
|
||||
/**
|
||||
* DTO para actualizar Carta Porte
|
||||
* Todos los campos son opcionales
|
||||
* Solo se puede actualizar en estado BORRADOR
|
||||
*/
|
||||
export class UpdateCartaPorteDto extends PartialType(CreateCartaPorteDto) {}
|
||||
462
src/modules/carta-porte/services/carta-porte-xml.service.ts
Normal file
462
src/modules/carta-porte/services/carta-porte-xml.service.ts
Normal file
@ -0,0 +1,462 @@
|
||||
import {
|
||||
CartaPorte,
|
||||
TipoCfdiCartaPorte,
|
||||
UbicacionCartaPorte,
|
||||
MercanciaCartaPorte,
|
||||
FiguraTransporte,
|
||||
AutotransporteCartaPorte,
|
||||
TipoUbicacionCartaPorte,
|
||||
} from '../entities';
|
||||
|
||||
/**
|
||||
* Namespaces CFDI 4.0 y Carta Porte 3.1
|
||||
*/
|
||||
const CFDI_NAMESPACE = 'http://www.sat.gob.mx/cfd/4';
|
||||
const CARTA_PORTE_NAMESPACE = 'http://www.sat.gob.mx/CartaPorte31';
|
||||
const XSI_NAMESPACE = 'http://www.w3.org/2001/XMLSchema-instance';
|
||||
|
||||
const CFDI_SCHEMA_LOCATION = 'http://www.sat.gob.mx/cfd/4 http://www.sat.gob.mx/sitio_internet/cfd/4/cfdv40.xsd';
|
||||
const CARTA_PORTE_SCHEMA_LOCATION = 'http://www.sat.gob.mx/CartaPorte31 http://www.sat.gob.mx/sitio_internet/cfd/CartaPorte/CartaPorte31.xsd';
|
||||
|
||||
/**
|
||||
* Servicio para generar XML de Carta Porte CFDI 3.1
|
||||
* Cumple con especificacion SAT vigente desde 2024-07-17
|
||||
*/
|
||||
export class CartaPorteXmlService {
|
||||
/**
|
||||
* Genera XML del CFDI con complemento Carta Porte 3.1
|
||||
* NOTA: Este XML es para sellado previo al timbrado con PAC
|
||||
*/
|
||||
generatePreTimbradoXml(cartaPorte: CartaPorte): string {
|
||||
const xmlParts: string[] = [];
|
||||
|
||||
// Declaracion XML
|
||||
xmlParts.push('<?xml version="1.0" encoding="UTF-8"?>');
|
||||
|
||||
// Elemento raiz: cfdi:Comprobante
|
||||
xmlParts.push(this.buildComprobanteElement(cartaPorte));
|
||||
|
||||
return xmlParts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera solo el complemento Carta Porte 3.1 (sin CFDI wrapper)
|
||||
*/
|
||||
generateCartaPorteComplemento(cartaPorte: CartaPorte): string {
|
||||
return this.buildCartaPorteElement(cartaPorte);
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye el elemento cfdi:Comprobante
|
||||
*/
|
||||
private buildComprobanteElement(cp: CartaPorte): string {
|
||||
const attrs: string[] = [];
|
||||
|
||||
// Namespaces
|
||||
attrs.push(`xmlns:cfdi="${CFDI_NAMESPACE}"`);
|
||||
attrs.push(`xmlns:cartaporte31="${CARTA_PORTE_NAMESPACE}"`);
|
||||
attrs.push(`xmlns:xsi="${XSI_NAMESPACE}"`);
|
||||
attrs.push(`xsi:schemaLocation="${CFDI_SCHEMA_LOCATION} ${CARTA_PORTE_SCHEMA_LOCATION}"`);
|
||||
|
||||
// Atributos del comprobante
|
||||
attrs.push('Version="4.0"');
|
||||
if (cp.serie) attrs.push(`Serie="${this.escapeXml(cp.serie)}"`);
|
||||
if (cp.folio) attrs.push(`Folio="${this.escapeXml(cp.folio)}"`);
|
||||
attrs.push(`Fecha="${this.formatDateTime(new Date())}"`);
|
||||
|
||||
// Forma de pago (99 = Por definir, usado en traslados)
|
||||
attrs.push('FormaPago="99"');
|
||||
|
||||
// Sello y Certificado (placeholders, el PAC los llena)
|
||||
attrs.push('Sello=""');
|
||||
attrs.push('NoCertificado=""');
|
||||
attrs.push('Certificado=""');
|
||||
|
||||
// Subtotal y Total
|
||||
const subtotal = cp.subtotal ?? 0;
|
||||
const total = cp.total ?? 0;
|
||||
attrs.push(`SubTotal="${subtotal.toFixed(2)}"`);
|
||||
attrs.push(`Total="${total.toFixed(2)}"`);
|
||||
|
||||
// Moneda
|
||||
attrs.push(`Moneda="${cp.moneda || 'MXN'}"`);
|
||||
|
||||
// Tipo de comprobante
|
||||
const tipoComprobante = cp.tipoCfdi === TipoCfdiCartaPorte.INGRESO ? 'I' : 'T';
|
||||
attrs.push(`TipoDeComprobante="${tipoComprobante}"`);
|
||||
|
||||
// Exportacion (01 = No aplica)
|
||||
attrs.push('Exportacion="01"');
|
||||
|
||||
// Metodo de pago (PUE = Pago en una sola exhibicion)
|
||||
if (cp.tipoCfdi === TipoCfdiCartaPorte.INGRESO) {
|
||||
attrs.push('MetodoPago="PUE"');
|
||||
}
|
||||
|
||||
// Lugar de expedicion (CP del emisor)
|
||||
attrs.push(`LugarExpedicion="${cp.emisorDomicilioFiscalCp || '00000'}"`);
|
||||
|
||||
// Construir XML
|
||||
let xml = `<cfdi:Comprobante ${attrs.join(' ')}>\n`;
|
||||
|
||||
// Emisor
|
||||
xml += this.buildEmisorElement(cp);
|
||||
|
||||
// Receptor
|
||||
xml += this.buildReceptorElement(cp);
|
||||
|
||||
// Conceptos
|
||||
xml += this.buildConceptosElement(cp);
|
||||
|
||||
// Complemento (Carta Porte 3.1)
|
||||
xml += ' <cfdi:Complemento>\n';
|
||||
xml += this.buildCartaPorteElement(cp);
|
||||
xml += ' </cfdi:Complemento>\n';
|
||||
|
||||
xml += '</cfdi:Comprobante>';
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye el elemento cfdi:Emisor
|
||||
*/
|
||||
private buildEmisorElement(cp: CartaPorte): string {
|
||||
const attrs: string[] = [];
|
||||
attrs.push(`Rfc="${this.escapeXml(cp.emisorRfc)}"`);
|
||||
attrs.push(`Nombre="${this.escapeXml(cp.emisorNombre)}"`);
|
||||
attrs.push(`RegimenFiscal="${cp.emisorRegimenFiscal || '601'}"`);
|
||||
|
||||
return ` <cfdi:Emisor ${attrs.join(' ')}/>\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye el elemento cfdi:Receptor
|
||||
*/
|
||||
private buildReceptorElement(cp: CartaPorte): string {
|
||||
const attrs: string[] = [];
|
||||
attrs.push(`Rfc="${this.escapeXml(cp.receptorRfc)}"`);
|
||||
attrs.push(`Nombre="${this.escapeXml(cp.receptorNombre)}"`);
|
||||
|
||||
// Uso CFDI (S01 = Sin efectos fiscales para traslados)
|
||||
const usoCfdi = cp.receptorUsoCfdi || (cp.tipoCfdi === TipoCfdiCartaPorte.TRASLADO ? 'S01' : 'G03');
|
||||
attrs.push(`UsoCFDI="${usoCfdi}"`);
|
||||
|
||||
// Domicilio fiscal CP
|
||||
if (cp.receptorDomicilioFiscalCp) {
|
||||
attrs.push(`DomicilioFiscalReceptor="${cp.receptorDomicilioFiscalCp}"`);
|
||||
}
|
||||
|
||||
// Regimen fiscal receptor
|
||||
attrs.push('RegimenFiscalReceptor="616"'); // Sin obligaciones fiscales (default)
|
||||
|
||||
return ` <cfdi:Receptor ${attrs.join(' ')}/>\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye el elemento cfdi:Conceptos
|
||||
* Para Carta Porte: 1 concepto con clave SAT 78101800 (Servicio de transporte)
|
||||
*/
|
||||
private buildConceptosElement(cp: CartaPorte): string {
|
||||
let xml = ' <cfdi:Conceptos>\n';
|
||||
|
||||
const cantidad = 1;
|
||||
const valorUnitario = cp.subtotal ?? 0;
|
||||
const importe = cp.total ?? 0;
|
||||
|
||||
// Clave de producto/servicio SAT para transporte de carga
|
||||
const claveProdServ = '78101800';
|
||||
const claveUnidad = 'E48'; // Unidad de servicio
|
||||
const descripcion = 'Servicio de transporte de carga';
|
||||
|
||||
xml += ' <cfdi:Concepto ';
|
||||
xml += `ClaveProdServ="${claveProdServ}" `;
|
||||
xml += `Cantidad="${cantidad}" `;
|
||||
xml += `ClaveUnidad="${claveUnidad}" `;
|
||||
xml += `Unidad="Servicio" `;
|
||||
xml += `Descripcion="${descripcion}" `;
|
||||
xml += `ValorUnitario="${valorUnitario.toFixed(2)}" `;
|
||||
xml += `Importe="${importe.toFixed(2)}" `;
|
||||
xml += `ObjetoImp="01"/>\n`; // 01 = No objeto de impuesto
|
||||
|
||||
xml += ' </cfdi:Conceptos>\n';
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye el complemento cartaporte31:CartaPorte
|
||||
*/
|
||||
private buildCartaPorteElement(cp: CartaPorte): string {
|
||||
const attrs: string[] = [];
|
||||
attrs.push('Version="3.1"');
|
||||
|
||||
// Transporte internacional
|
||||
const transpInt = cp.transporteInternacional ? 'Sí' : 'No';
|
||||
attrs.push(`TranspInternac="${transpInt}"`);
|
||||
|
||||
if (cp.transporteInternacional) {
|
||||
if (cp.entradaSalidaMerc) attrs.push(`EntradaSalidaMerc="${cp.entradaSalidaMerc}"`);
|
||||
if (cp.paisOrigenDestino) attrs.push(`PaisOrigenDestino="${cp.paisOrigenDestino}"`);
|
||||
}
|
||||
|
||||
// Totales de mercancias
|
||||
if (cp.pesoBrutoTotal) attrs.push(`TotalDistRec="${cp.pesoBrutoTotal.toFixed(3)}"`);
|
||||
|
||||
let xml = ` <cartaporte31:CartaPorte ${attrs.join(' ')}>\n`;
|
||||
|
||||
// Ubicaciones
|
||||
xml += this.buildUbicacionesElement(cp.ubicaciones || []);
|
||||
|
||||
// Mercancias
|
||||
xml += this.buildMercanciasElement(cp);
|
||||
|
||||
// Figuras de transporte
|
||||
xml += this.buildFigurasTransporteElement(cp.figuras || []);
|
||||
|
||||
xml += ' </cartaporte31:CartaPorte>\n';
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye el elemento cartaporte31:Ubicaciones
|
||||
*/
|
||||
private buildUbicacionesElement(ubicaciones: UbicacionCartaPorte[]): string {
|
||||
if (ubicaciones.length === 0) return '';
|
||||
|
||||
let xml = ' <cartaporte31:Ubicaciones>\n';
|
||||
|
||||
// Ordenar por secuencia
|
||||
const sorted = [...ubicaciones].sort((a, b) => a.numeroSecuencia - b.numeroSecuencia);
|
||||
|
||||
for (const ub of sorted) {
|
||||
const tipoUbicacion = ub.tipoUbicacion === TipoUbicacionCartaPorte.ORIGEN ? 'Origen' : 'Destino';
|
||||
const attrs: string[] = [];
|
||||
|
||||
attrs.push(`TipoUbicacion="${tipoUbicacion}"`);
|
||||
if (ub.idUbicacion) attrs.push(`IDUbicacion="${this.escapeXml(ub.idUbicacion)}"`);
|
||||
if (ub.rfcRemitenteDestinatario) attrs.push(`RFCRemitenteDestinatario="${ub.rfcRemitenteDestinatario}"`);
|
||||
if (ub.nombreRemitenteDestinatario) attrs.push(`NombreRemitenteDestinatario="${this.escapeXml(ub.nombreRemitenteDestinatario)}"`);
|
||||
|
||||
if (ub.fechaHoraSalidaLlegada) {
|
||||
const fechaStr = this.formatDateTime(new Date(ub.fechaHoraSalidaLlegada));
|
||||
attrs.push(`FechaHoraSalidaLlegada="${fechaStr}"`);
|
||||
}
|
||||
|
||||
if (ub.distanciaRecorrida !== undefined && ub.distanciaRecorrida > 0) {
|
||||
attrs.push(`DistanciaRecorrida="${ub.distanciaRecorrida.toFixed(2)}"`);
|
||||
}
|
||||
|
||||
xml += ` <cartaporte31:Ubicacion ${attrs.join(' ')}>\n`;
|
||||
|
||||
// Domicilio
|
||||
xml += this.buildDomicilioElement(ub);
|
||||
|
||||
xml += ' </cartaporte31:Ubicacion>\n';
|
||||
}
|
||||
|
||||
xml += ' </cartaporte31:Ubicaciones>\n';
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye el elemento cartaporte31:Domicilio
|
||||
*/
|
||||
private buildDomicilioElement(ub: UbicacionCartaPorte): string {
|
||||
const attrs: string[] = [];
|
||||
|
||||
if (ub.calle) attrs.push(`Calle="${this.escapeXml(ub.calle)}"`);
|
||||
if (ub.numExterior) attrs.push(`NumeroExterior="${this.escapeXml(ub.numExterior)}"`);
|
||||
if (ub.numInterior) attrs.push(`NumeroInterior="${this.escapeXml(ub.numInterior)}"`);
|
||||
if (ub.colonia) attrs.push(`Colonia="${ub.colonia}"`);
|
||||
if (ub.localidad) attrs.push(`Localidad="${ub.localidad}"`);
|
||||
if (ub.referencia) attrs.push(`Referencia="${this.escapeXml(ub.referencia)}"`);
|
||||
if (ub.municipio) attrs.push(`Municipio="${ub.municipio}"`);
|
||||
if (ub.estado) attrs.push(`Estado="${ub.estado}"`);
|
||||
attrs.push('Pais="MEX"');
|
||||
attrs.push(`CodigoPostal="${ub.codigoPostal}"`);
|
||||
|
||||
return ` <cartaporte31:Domicilio ${attrs.join(' ')}/>\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye el elemento cartaporte31:Mercancias
|
||||
*/
|
||||
private buildMercanciasElement(cp: CartaPorte): string {
|
||||
const mercancias = cp.mercancias || [];
|
||||
if (mercancias.length === 0) return '';
|
||||
|
||||
// Calcular totales
|
||||
const pesoTotal = mercancias.reduce((sum, m) => sum + (m.pesoEnKg || 0), 0);
|
||||
const numMercancias = mercancias.length;
|
||||
|
||||
const attrs: string[] = [];
|
||||
attrs.push(`PesoBrutoTotal="${pesoTotal.toFixed(3)}"`);
|
||||
attrs.push(`UnidadPeso="${cp.unidadPeso || 'KGM'}"`);
|
||||
attrs.push(`NumTotalMercancias="${numMercancias}"`);
|
||||
|
||||
let xml = ` <cartaporte31:Mercancias ${attrs.join(' ')}>\n`;
|
||||
|
||||
// Cada mercancia
|
||||
for (const merc of mercancias) {
|
||||
xml += this.buildMercanciaElement(merc);
|
||||
}
|
||||
|
||||
// Autotransporte
|
||||
xml += this.buildAutotransporteElement(cp);
|
||||
|
||||
xml += ' </cartaporte31:Mercancias>\n';
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye el elemento cartaporte31:Mercancia
|
||||
*/
|
||||
private buildMercanciaElement(merc: MercanciaCartaPorte): string {
|
||||
const attrs: string[] = [];
|
||||
|
||||
attrs.push(`BienesTransp="${merc.bienesTransp}"`);
|
||||
attrs.push(`Descripcion="${this.escapeXml(merc.descripcion)}"`);
|
||||
attrs.push(`Cantidad="${merc.cantidad.toFixed(4)}"`);
|
||||
attrs.push(`ClaveUnidad="${merc.claveUnidad}"`);
|
||||
if (merc.unidad) attrs.push(`Unidad="${this.escapeXml(merc.unidad)}"`);
|
||||
|
||||
if (merc.dimensionesLargo && merc.dimensionesAncho && merc.dimensionesAlto) {
|
||||
const dims = `${merc.dimensionesLargo}/${merc.dimensionesAncho}/${merc.dimensionesAlto}cm`;
|
||||
attrs.push(`Dimensiones="${dims}"`);
|
||||
}
|
||||
|
||||
if (merc.materialPeligroso) {
|
||||
attrs.push('MaterialPeligroso="Sí"');
|
||||
if (merc.cveMaterialPeligroso) attrs.push(`CveMaterialPeligroso="${merc.cveMaterialPeligroso}"`);
|
||||
if (merc.embalaje) attrs.push(`Embalaje="${merc.embalaje}"`);
|
||||
if (merc.descripEmbalaje) attrs.push(`DescripEmbalaje="${this.escapeXml(merc.descripEmbalaje)}"`);
|
||||
} else {
|
||||
attrs.push('MaterialPeligroso="No"');
|
||||
}
|
||||
|
||||
attrs.push(`PesoEnKg="${merc.pesoEnKg.toFixed(3)}"`);
|
||||
|
||||
if (merc.valorMercancia) {
|
||||
attrs.push(`ValorMercancia="${merc.valorMercancia.toFixed(2)}"`);
|
||||
attrs.push(`Moneda="${merc.moneda || 'MXN'}"`);
|
||||
}
|
||||
|
||||
if (merc.fraccionArancelaria) attrs.push(`FraccionArancelaria="${merc.fraccionArancelaria}"`);
|
||||
if (merc.uuidComercioExt) attrs.push(`UUIDComercioExt="${merc.uuidComercioExt}"`);
|
||||
|
||||
return ` <cartaporte31:Mercancia ${attrs.join(' ')}/>\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye el elemento cartaporte31:Autotransporte
|
||||
*/
|
||||
private buildAutotransporteElement(cp: CartaPorte): string {
|
||||
const autos = cp.autotransporte || [];
|
||||
if (autos.length === 0) return '';
|
||||
|
||||
const auto = autos[0]; // Usualmente solo hay uno
|
||||
|
||||
const attrs: string[] = [];
|
||||
attrs.push(`PermSCT="${auto.permSCT}"`);
|
||||
attrs.push(`NumPermisoSCT="${auto.numPermisoSCT}"`);
|
||||
|
||||
let xml = ` <cartaporte31:Autotransporte ${attrs.join(' ')}>\n`;
|
||||
|
||||
// Identificacion vehicular
|
||||
const idVehAttrs: string[] = [];
|
||||
idVehAttrs.push(`ConfigVehicular="${auto.configVehicular}"`);
|
||||
idVehAttrs.push(`PlacaVM="${auto.placaVM}"`);
|
||||
if (auto.anioModeloVM) idVehAttrs.push(`AnioModeloVM="${auto.anioModeloVM}"`);
|
||||
|
||||
xml += ` <cartaporte31:IdentificacionVehicular ${idVehAttrs.join(' ')}/>\n`;
|
||||
|
||||
// Seguros
|
||||
if (cp.aseguraRespCivil) {
|
||||
xml += ` <cartaporte31:Seguros `;
|
||||
xml += `AseguraRespCivil="${this.escapeXml(cp.aseguraRespCivil)}" `;
|
||||
xml += `PolizaRespCivil="${this.escapeXml(cp.polizaRespCivil || '')}"`;
|
||||
if (cp.aseguraMedAmbiente) {
|
||||
xml += ` AseguraMedAmbiente="${this.escapeXml(cp.aseguraMedAmbiente)}"`;
|
||||
xml += ` PolizaMedAmbiente="${this.escapeXml(cp.polizaMedAmbiente || '')}"`;
|
||||
}
|
||||
if (cp.aseguraCarga) {
|
||||
xml += ` AseguraCarga="${this.escapeXml(cp.aseguraCarga)}"`;
|
||||
xml += ` PolizaCarga="${this.escapeXml(cp.polizaCarga || '')}"`;
|
||||
if (cp.primaSeguro) xml += ` PrimaSeguro="${cp.primaSeguro.toFixed(2)}"`;
|
||||
}
|
||||
xml += `/>\n`;
|
||||
}
|
||||
|
||||
// Remolques
|
||||
if (auto.remolques && auto.remolques.length > 0) {
|
||||
xml += ' <cartaporte31:Remolques>\n';
|
||||
for (const rem of auto.remolques) {
|
||||
xml += ` <cartaporte31:Remolque SubTipoRem="${rem.subTipoRem}" Placa="${rem.placa}"/>\n`;
|
||||
}
|
||||
xml += ' </cartaporte31:Remolques>\n';
|
||||
}
|
||||
|
||||
xml += ' </cartaporte31:Autotransporte>\n';
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye el elemento cartaporte31:FiguraTransporte
|
||||
*/
|
||||
private buildFigurasTransporteElement(figuras: FiguraTransporte[]): string {
|
||||
if (figuras.length === 0) return '';
|
||||
|
||||
let xml = ' <cartaporte31:FiguraTransporte>\n';
|
||||
|
||||
for (const fig of figuras) {
|
||||
const attrs: string[] = [];
|
||||
attrs.push(`TipoFigura="${fig.tipoFigura}"`);
|
||||
if (fig.rfcFigura) attrs.push(`RFCFigura="${fig.rfcFigura}"`);
|
||||
attrs.push(`NombreFigura="${this.escapeXml(fig.nombreFigura)}"`);
|
||||
if (fig.numLicencia) attrs.push(`NumLicencia="${fig.numLicencia}"`);
|
||||
if (fig.numRegIdTribFigura) attrs.push(`NumRegIdTribFigura="${fig.numRegIdTribFigura}"`);
|
||||
if (fig.residenciaFiscalFigura) attrs.push(`ResidenciaFiscalFigura="${fig.residenciaFiscalFigura}"`);
|
||||
|
||||
xml += ` <cartaporte31:TiposFigura ${attrs.join(' ')}`;
|
||||
|
||||
// Partes de transporte (para propietario/arrendador)
|
||||
if (fig.partesTransporte && fig.partesTransporte.length > 0) {
|
||||
xml += '>\n';
|
||||
for (const parte of fig.partesTransporte) {
|
||||
xml += ` <cartaporte31:PartesTransporte ParteTransporte="${this.escapeXml(parte.parteTransporte)}"/>\n`;
|
||||
}
|
||||
xml += ' </cartaporte31:TiposFigura>\n';
|
||||
} else {
|
||||
xml += '/>\n';
|
||||
}
|
||||
}
|
||||
|
||||
xml += ' </cartaporte31:FiguraTransporte>\n';
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapa caracteres especiales XML
|
||||
*/
|
||||
private escapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea fecha en formato ISO 8601 para SAT
|
||||
*/
|
||||
private formatDateTime(date: Date): string {
|
||||
return date.toISOString().replace(/\.\d{3}Z$/, '');
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user