feat(facturacion): seccion CFDIs Relacionados para tipos I y E
This commit is contained in:
@@ -182,6 +182,13 @@ export async function emitir(req: Request, res: Response, next: NextFunction) {
|
|||||||
[parsed.rfcReceptor, parsed.nombreReceptor || null, parsed.regimenFiscalReceptor || null, req.body.customer?.zip || null],
|
[parsed.rfcReceptor, parsed.nombreReceptor || null, parsed.regimenFiscalReceptor || null, req.body.customer?.zip || null],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Extraer relaciones para persistencia en BD
|
||||||
|
const relatedDocs: Array<{ relationship: string; uuids: string[] }> = req.body.relatedDocuments || [];
|
||||||
|
const cfdiTipoRelacion = relatedDocs.length > 0 ? relatedDocs[0].relationship : null;
|
||||||
|
const cfdisRelacionados = relatedDocs.length > 0
|
||||||
|
? relatedDocs.flatMap(r => r.uuids).join('|')
|
||||||
|
: null;
|
||||||
|
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
INSERT INTO cfdis (
|
INSERT INTO cfdis (
|
||||||
year, month, type, uuid, serie, folio, status, fecha_emision, fecha_cert_sat,
|
year, month, type, uuid, serie, folio, status, fecha_emision, fecha_cert_sat,
|
||||||
@@ -192,7 +199,8 @@ export async function emitir(req: Request, res: Response, next: NextFunction) {
|
|||||||
iva_traslado, iva_traslado_mxn,
|
iva_traslado, iva_traslado_mxn,
|
||||||
iva_retencion, iva_retencion_mxn,
|
iva_retencion, iva_retencion_mxn,
|
||||||
source, facturapi_id,
|
source, facturapi_id,
|
||||||
contribuyente_id, xml_original
|
contribuyente_id, xml_original,
|
||||||
|
cfdi_tipo_relacion, cfdis_relacionados
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, 'EMITIDO', $3, $4, $5, 'Vigente', $6, $7,
|
$1, $2, 'EMITIDO', $3, $4, $5, 'Vigente', $6, $7,
|
||||||
$8, $9, $10, $11,
|
$8, $9, $10, $11,
|
||||||
@@ -202,7 +210,8 @@ export async function emitir(req: Request, res: Response, next: NextFunction) {
|
|||||||
$23, $23,
|
$23, $23,
|
||||||
$24, $24,
|
$24, $24,
|
||||||
'facturapi', $25,
|
'facturapi', $25,
|
||||||
$26, $27
|
$26, $27,
|
||||||
|
$28, $29
|
||||||
)
|
)
|
||||||
`, [
|
`, [
|
||||||
year, month, parsed.uuid, parsed.serie, parsed.folio, fecha, parsed.fechaCertSat,
|
year, month, parsed.uuid, parsed.serie, parsed.folio, fecha, parsed.fechaCertSat,
|
||||||
@@ -214,6 +223,7 @@ export async function emitir(req: Request, res: Response, next: NextFunction) {
|
|||||||
parsed.ivaRetencion,
|
parsed.ivaRetencion,
|
||||||
invoice.id,
|
invoice.id,
|
||||||
contribuyenteId ?? null, xmlString,
|
contribuyenteId ?? null, xmlString,
|
||||||
|
cfdiTipoRelacion, cfdisRelacionados,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Enviar por email si el receptor tiene email — ruteado a la org correcta
|
// Enviar por email si el receptor tiene email — ruteado a la org correcta
|
||||||
|
|||||||
@@ -308,10 +308,11 @@ export async function createInvoiceContribuyente(
|
|||||||
if (data.series) invoicePayload.series = data.series;
|
if (data.series) invoicePayload.series = data.series;
|
||||||
if (data.folioNumber) invoicePayload.folio_number = data.folioNumber;
|
if (data.folioNumber) invoicePayload.folio_number = data.folioNumber;
|
||||||
|
|
||||||
|
// Documentos relacionados (Ingreso / Egreso)
|
||||||
if (data.relatedDocuments?.length) {
|
if (data.relatedDocuments?.length) {
|
||||||
invoicePayload.related_documents = data.relatedDocuments.map((r: any) => ({
|
invoicePayload.related_documents = data.relatedDocuments.map((r: any) => ({
|
||||||
relationship: r.relationship,
|
relationship: r.relationship,
|
||||||
documents: [r.uuid],
|
documents: r.uuids || [r.uuid],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ export interface FacturapiInvoiceData {
|
|||||||
series?: string;
|
series?: string;
|
||||||
folioNumber?: number;
|
folioNumber?: number;
|
||||||
conditions?: string;
|
conditions?: string;
|
||||||
relatedDocuments?: Array<{ uuid: string; relationship: string }>;
|
relatedDocuments?: Array<{ relationship: string; uuids: string[] }>;
|
||||||
/**
|
/**
|
||||||
* Régimen fiscal del emisor (override del default de la organización).
|
* Régimen fiscal del emisor (override del default de la organización).
|
||||||
* Requerido cuando el contribuyente tiene múltiples régimenes y Facturapi
|
* Requerido cuando el contribuyente tiene múltiples régimenes y Facturapi
|
||||||
@@ -308,11 +308,11 @@ export async function createInvoice(
|
|||||||
if (data.series) invoiceData.series = data.series;
|
if (data.series) invoiceData.series = data.series;
|
||||||
if (data.folioNumber) invoiceData.folio_number = data.folioNumber;
|
if (data.folioNumber) invoiceData.folio_number = data.folioNumber;
|
||||||
|
|
||||||
// Documentos relacionados (Egreso)
|
// Documentos relacionados (Ingreso / Egreso)
|
||||||
if (data.relatedDocuments?.length) {
|
if (data.relatedDocuments?.length) {
|
||||||
invoiceData.related_documents = data.relatedDocuments.map(r => ({
|
invoiceData.related_documents = data.relatedDocuments.map(r => ({
|
||||||
relationship: r.relationship,
|
relationship: r.relationship,
|
||||||
documents: [r.uuid],
|
documents: r.uuids,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { searchClaveProdServ } from '@/lib/api/catalogos';
|
|||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import type { InvoiceData, InvoiceLineItem, RfcSearchResult, CfdiPpdPendiente, ConceptoPrevio } from '@/lib/api/facturacion';
|
import type { InvoiceData, InvoiceLineItem, RfcSearchResult, CfdiPpdPendiente, ConceptoPrevio } from '@/lib/api/facturacion';
|
||||||
import { searchRfcs, getCfdisPpd, searchConceptos } from '@/lib/api/facturacion';
|
import { searchRfcs, getCfdisPpd, searchConceptos } from '@/lib/api/facturacion';
|
||||||
import { Plus, Trash2, Send, Receipt, Search, Check, X, FileSearch, AlertTriangle } from 'lucide-react';
|
import { Plus, Trash2, Send, Receipt, Search, Check, X, FileSearch, AlertTriangle, Link2 } from 'lucide-react';
|
||||||
import { cn } from '@horux/shared-ui';
|
import { cn } from '@horux/shared-ui';
|
||||||
|
|
||||||
interface TaxLine {
|
interface TaxLine {
|
||||||
@@ -25,6 +25,29 @@ interface TaxLine {
|
|||||||
factor: string; // Tasa, Cuota, Exento
|
factor: string; // Tasa, Cuota, Exento
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RelatedDocForm {
|
||||||
|
relationship: string;
|
||||||
|
uuids: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const RELACION_OPTIONS: Record<string, { value: string; label: string }[]> = {
|
||||||
|
I: [
|
||||||
|
{ value: '04', label: '04 - Sustitución de CFDI previos' },
|
||||||
|
{ value: '05', label: '05 - Traslados de mercancias facturados previamente' },
|
||||||
|
{ value: '06', label: '06 - Factura generada por traslados previos' },
|
||||||
|
{ value: '07', label: '07 - CFDI por aplicación de anticipo' },
|
||||||
|
{ value: '08', label: '08 - Factura generada por pagos en parcialidades' },
|
||||||
|
{ value: '09', label: '09 - Factura generada por pagos diferidos' },
|
||||||
|
],
|
||||||
|
E: [
|
||||||
|
{ value: '01', label: '01 - Nota de crédito de documentos relacionados' },
|
||||||
|
{ value: '02', label: '02 - Nota de débito de documentos relacionados' },
|
||||||
|
{ value: '03', label: '03 - Devolución de mercancía sobre facturas o traslados previos' },
|
||||||
|
{ value: '04', label: '04 - Sustitución de los CFDI previos' },
|
||||||
|
{ value: '07', label: '07 - CFDI por aplicación de anticipo' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
interface ConceptoForm {
|
interface ConceptoForm {
|
||||||
description: string;
|
description: string;
|
||||||
productKey: string;
|
productKey: string;
|
||||||
@@ -188,7 +211,7 @@ const TIPO_CONFIG: Record<string, {
|
|||||||
defaultFormaPago: string;
|
defaultFormaPago: string;
|
||||||
defaultMetodoPago: string;
|
defaultMetodoPago: string;
|
||||||
}> = {
|
}> = {
|
||||||
I: { label: 'Ingreso', needsConceptos: true, needsPaymentComplement: false, needsRelated: false, defaultUso: 'G03', defaultFormaPago: '99', defaultMetodoPago: 'PUE' },
|
I: { label: 'Ingreso', needsConceptos: true, needsPaymentComplement: false, needsRelated: true, defaultUso: 'G03', defaultFormaPago: '99', defaultMetodoPago: 'PUE' },
|
||||||
E: { label: 'Egreso (Nota de Crédito)', needsConceptos: true, needsPaymentComplement: false, needsRelated: true, defaultUso: 'G02', defaultFormaPago: '99', defaultMetodoPago: 'PUE' },
|
E: { label: 'Egreso (Nota de Crédito)', needsConceptos: true, needsPaymentComplement: false, needsRelated: true, defaultUso: 'G02', defaultFormaPago: '99', defaultMetodoPago: 'PUE' },
|
||||||
P: { label: 'Pago', needsConceptos: false, needsPaymentComplement: true, needsRelated: false, defaultUso: 'CP01', defaultFormaPago: '99', defaultMetodoPago: 'PPD' },
|
P: { label: 'Pago', needsConceptos: false, needsPaymentComplement: true, needsRelated: false, defaultUso: 'CP01', defaultFormaPago: '99', defaultMetodoPago: 'PPD' },
|
||||||
T: { label: 'Traslado', needsConceptos: true, needsPaymentComplement: false, needsRelated: false, defaultUso: 'S01', defaultFormaPago: '99', defaultMetodoPago: 'PUE' },
|
T: { label: 'Traslado', needsConceptos: true, needsPaymentComplement: false, needsRelated: false, defaultUso: 'S01', defaultFormaPago: '99', defaultMetodoPago: 'PUE' },
|
||||||
@@ -306,9 +329,8 @@ export default function FacturacionPage() {
|
|||||||
// Conceptos
|
// Conceptos
|
||||||
const [conceptos, setConceptos] = useState<ConceptoForm[]>([{ ...emptyConcepto }]);
|
const [conceptos, setConceptos] = useState<ConceptoForm[]>([{ ...emptyConcepto }]);
|
||||||
|
|
||||||
// Documento relacionado (Egreso)
|
// CFDIs relacionados (Ingreso / Egreso)
|
||||||
const [relatedUuid, setRelatedUuid] = useState('');
|
const [relatedDocs, setRelatedDocs] = useState<RelatedDocForm[]>([]);
|
||||||
const [relatedRelationship, setRelatedRelationship] = useState('01');
|
|
||||||
|
|
||||||
// Complemento de pago
|
// Complemento de pago
|
||||||
const [pagoFecha, setPagoFecha] = useState(new Date().toISOString().slice(0, 10));
|
const [pagoFecha, setPagoFecha] = useState(new Date().toISOString().slice(0, 10));
|
||||||
@@ -344,8 +366,7 @@ export default function FacturacionPage() {
|
|||||||
setFolio('');
|
setFolio('');
|
||||||
setCondiciones('');
|
setCondiciones('');
|
||||||
setConceptos([{ ...emptyConcepto, unitKey: defaultUnit }]);
|
setConceptos([{ ...emptyConcepto, unitKey: defaultUnit }]);
|
||||||
setRelatedUuid('');
|
setRelatedDocs([]);
|
||||||
setRelatedRelationship('01');
|
|
||||||
setPagoUuid('');
|
setPagoUuid('');
|
||||||
setPagoMonto(0);
|
setPagoMonto(0);
|
||||||
setPagoParcialidad(1);
|
setPagoParcialidad(1);
|
||||||
@@ -635,8 +656,10 @@ export default function FacturacionPage() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.needsRelated && relatedUuid) {
|
if (config.needsRelated && relatedDocs.length > 0) {
|
||||||
data.relatedDocuments = [{ uuid: relatedUuid, relationship: relatedRelationship }];
|
data.relatedDocuments = relatedDocs
|
||||||
|
.filter(r => r.uuids.length > 0)
|
||||||
|
.map(r => ({ relationship: r.relationship, uuids: r.uuids.filter(u => u.trim() !== '') }));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.needsPaymentComplement) {
|
if (config.needsPaymentComplement) {
|
||||||
@@ -1118,36 +1141,6 @@ export default function FacturacionPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Documento Relacionado (Egreso) */}
|
|
||||||
{config.needsRelated && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">Documento Relacionado</CardTitle>
|
|
||||||
<CardDescription>UUID del CFDI de ingreso al que aplica esta nota de crédito</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>UUID del CFDI relacionado</Label>
|
|
||||||
<Input value={relatedUuid} onChange={e => setRelatedUuid(e.target.value.toUpperCase())} placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" required />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Tipo de Relación</Label>
|
|
||||||
<Select value={relatedRelationship} onValueChange={setRelatedRelationship}>
|
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="01">01 - Nota de crédito</SelectItem>
|
|
||||||
<SelectItem value="02">02 - Nota de débito</SelectItem>
|
|
||||||
<SelectItem value="03">03 - Devolución de mercancía</SelectItem>
|
|
||||||
<SelectItem value="04">04 - Sustitución de CFDI previo</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Complemento de Pago */}
|
{/* Complemento de Pago */}
|
||||||
{config.needsPaymentComplement && (
|
{config.needsPaymentComplement && (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -1438,6 +1431,116 @@ export default function FacturacionPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* CFDIs Relacionados */}
|
||||||
|
{config.needsRelated && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">CFDIs Relacionados</CardTitle>
|
||||||
|
<CardDescription>Relaciona esta factura con otros comprobantes previos</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setRelatedDocs([...relatedDocs, { relationship: tipoComprobante === 'E' ? '01' : '04', uuids: [''] }])}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1" /> Relación
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{relatedDocs.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">Sin relaciones. Agrega una si esta factura está vinculada a otros CFDI.</p>
|
||||||
|
)}
|
||||||
|
{relatedDocs.map((rel, rIdx) => (
|
||||||
|
<div key={rIdx} className="p-4 border rounded-lg space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link2 className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">Relación {rIdx + 1}</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
onClick={() => setRelatedDocs(relatedDocs.filter((_, i) => i !== rIdx))}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Tipo de Relación</Label>
|
||||||
|
<Select
|
||||||
|
value={rel.relationship}
|
||||||
|
onValueChange={v => {
|
||||||
|
const updated = [...relatedDocs];
|
||||||
|
updated[rIdx].relationship = v;
|
||||||
|
setRelatedDocs(updated);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{RELACION_OPTIONS[tipoComprobante]?.map(opt => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>UUIDs relacionados</Label>
|
||||||
|
{rel.uuids.map((uuid, uIdx) => (
|
||||||
|
<div key={uIdx} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={uuid}
|
||||||
|
onChange={e => {
|
||||||
|
const updated = [...relatedDocs];
|
||||||
|
updated[rIdx].uuids[uIdx] = e.target.value.toUpperCase();
|
||||||
|
setRelatedDocs(updated);
|
||||||
|
}}
|
||||||
|
placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10 text-destructive flex-shrink-0"
|
||||||
|
onClick={() => {
|
||||||
|
const updated = [...relatedDocs];
|
||||||
|
updated[rIdx].uuids = updated[rIdx].uuids.filter((_, i) => i !== uIdx);
|
||||||
|
setRelatedDocs(updated);
|
||||||
|
}}
|
||||||
|
disabled={rel.uuids.length === 1}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
const updated = [...relatedDocs];
|
||||||
|
updated[rIdx].uuids.push('');
|
||||||
|
setRelatedDocs(updated);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3 mr-1" /> Agregar UUID
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Resumen y Emitir */}
|
{/* Resumen y Emitir */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export interface InvoiceData {
|
|||||||
series?: string;
|
series?: string;
|
||||||
folioNumber?: number;
|
folioNumber?: number;
|
||||||
conditions?: string;
|
conditions?: string;
|
||||||
|
relatedDocuments?: Array<{ relationship: string; uuids: string[] }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InvoiceResult {
|
export interface InvoiceResult {
|
||||||
|
|||||||
Reference in New Issue
Block a user