[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:
Adrian Flores Cortes 2026-02-03 08:11:29 -06:00
parent d94a84593f
commit 825e349f37
15 changed files with 3578 additions and 9 deletions

1
src/common/index.ts Normal file
View File

@ -0,0 +1 @@
export * from './partial-type';

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

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

View File

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

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

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

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

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

View File

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

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

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

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

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

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* 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