feat: Fase 1-3 completas - precios proveedor, multi-sucursal, factura global
Fase 1: Lista de precios de proveedor - Tabla supplier_catalog_prices en master DB - Endpoints GET/POST/PUT/DELETE /supplier-catalog/prices - Upload CSV/Excel de precios de proveedor - Visualizacion de supplier_price en catalogo y POS Fase 2: Multi-sucursal completo - Migracion v4.0: inventory.branch_id=NULL, tabla inventory_stock - Campos fiscales en branches (RFC, regimen, CP, serie CFDI, certificados) - Trigger trg_update_inventory_stock para sincronizar stock por sucursal - Backend config_bp.py con CRUD de sucursales fiscales - Backend inventory_bp.py y pos_bp.py refactorizados para inventario compartido - Backend invoicing_bp.py usa datos fiscales de la sucursal de la venta - Frontend config.html/js con modal de sucursales expandido Fase 3: Factura global mensual - Migracion v4.1: tablas global_invoice_sales, sales.global_invoiced_at - build_global_invoice_xml() con InformacionGlobal SAT-compliant - Servicio global_invoice.py para agrupar ventas PUE <=000 - Endpoints POST/GET /global-invoice y /global-invoice/eligible-sales - Frontend invoicing.html/js con boton y modal de factura global
This commit is contained in:
@@ -464,3 +464,130 @@ def build_pago_xml(payment, tenant_config, customer, original_uuid):
|
||||
|
||||
return etree.tostring(root, xml_declaration=True, encoding='UTF-8',
|
||||
pretty_print=True).decode('utf-8')
|
||||
|
||||
|
||||
def build_global_invoice_xml(sales, tenant_config, year, month):
|
||||
"""Build CFDI 4.0 XML for a monthly global invoice (Factura Global).
|
||||
|
||||
Groups multiple cash sales (PUE, <= $2,000 each, no individual CFDI)
|
||||
into a single CFDI tipo Ingreso with InformacionGlobal.
|
||||
|
||||
Args:
|
||||
sales: list of dicts with keys:
|
||||
id, subtotal, discount_total, tax_total, total,
|
||||
items: [{name, quantity, unit_price, discount_amount,
|
||||
tax_rate, tax_amount, subtotal,
|
||||
clave_prod_serv, clave_unidad}]
|
||||
tenant_config: dict with keys:
|
||||
rfc, razon_social, regimen_fiscal, cp, serie (optional)
|
||||
year: int, e.g. 2026
|
||||
month: int, e.g. 6
|
||||
|
||||
Returns:
|
||||
str: XML string (unsigned, ready for Horux)
|
||||
"""
|
||||
nsmap = {
|
||||
'cfdi': CFDI_NS,
|
||||
'xsi': XSI_NS,
|
||||
}
|
||||
|
||||
# Aggregate totals
|
||||
total_subtotal = Decimal('0')
|
||||
total_discount = Decimal('0')
|
||||
total_tax = Decimal('0')
|
||||
total_total = Decimal('0')
|
||||
for sale in sales:
|
||||
total_subtotal += _to_dec(sale.get('subtotal', 0))
|
||||
total_discount += _to_dec(sale.get('discount_total', 0))
|
||||
total_tax += _to_dec(sale.get('tax_total', 0))
|
||||
total_total += _to_dec(sale.get('total', 0))
|
||||
|
||||
root = etree.Element(f'{{{CFDI_NS}}}Comprobante', nsmap=nsmap)
|
||||
root.set(f'{{{XSI_NS}}}schemaLocation', CFDI_SCHEMA_LOCATION)
|
||||
root.set('Version', '4.0')
|
||||
root.set('Serie', tenant_config.get('serie', 'FG'))
|
||||
root.set('Folio', f'{year}{month:02d}')
|
||||
root.set('Fecha', datetime.now().strftime('%Y-%m-%dT%H:%M:%S'))
|
||||
root.set('FormaPago', '01') # Efectivo (most common for global)
|
||||
root.set('SubTotal', _format_amount(total_subtotal))
|
||||
|
||||
if total_discount > 0:
|
||||
root.set('Descuento', _format_amount(total_discount))
|
||||
|
||||
root.set('Moneda', 'MXN')
|
||||
root.set('Total', _format_amount(total_total))
|
||||
root.set('TipoDeComprobante', 'I') # Ingreso
|
||||
root.set('Exportacion', '01')
|
||||
root.set('MetodoPago', 'PUE')
|
||||
root.set('LugarExpedicion', tenant_config.get('cp', '00000'))
|
||||
|
||||
# InformacionGlobal (monthly global invoice)
|
||||
info_global = _make_element(root, 'InformacionGlobal')
|
||||
info_global.set('Periodicidad', '04') # Mensual
|
||||
info_global.set('Meses', f'{month:02d}')
|
||||
info_global.set('Anio', str(year))
|
||||
|
||||
# Emisor
|
||||
emisor = _make_element(root, 'Emisor')
|
||||
emisor.set('Rfc', tenant_config['rfc'])
|
||||
emisor.set('Nombre', tenant_config['razon_social'])
|
||||
emisor.set('RegimenFiscal', tenant_config.get('regimen_fiscal', '601'))
|
||||
|
||||
# Receptor: Publico en general
|
||||
receptor = _make_element(root, 'Receptor')
|
||||
receptor.set('Rfc', RFC_PUBLICO_GENERAL)
|
||||
receptor.set('Nombre', 'PUBLICO EN GENERAL')
|
||||
receptor.set('DomicilioFiscalReceptor', tenant_config.get('cp', '00000'))
|
||||
receptor.set('RegimenFiscalReceptor', '616')
|
||||
receptor.set('UsoCFDI', 'S01')
|
||||
|
||||
# Conceptos: one per sale item (simplified)
|
||||
conceptos = _make_element(root, 'Conceptos')
|
||||
|
||||
for sale in sales:
|
||||
for item in sale.get('items', []):
|
||||
qty = int(item.get('quantity', 1))
|
||||
unit_price = _to_dec(item.get('unit_price', 0))
|
||||
discount_amount = _to_dec(item.get('discount_amount', 0))
|
||||
tax_rate = _to_dec(item.get('tax_rate', '0.16'))
|
||||
tax_amount = _to_dec(item.get('tax_amount', 0))
|
||||
|
||||
importe = (unit_price * qty).quantize(TWO, ROUND_HALF_UP)
|
||||
base = (importe - discount_amount).quantize(TWO, ROUND_HALF_UP)
|
||||
|
||||
concepto = _make_element(conceptos, 'Concepto')
|
||||
concepto.set('ClaveProdServ', item.get('clave_prod_serv') or '25174800')
|
||||
concepto.set('NoIdentificacion', item.get('part_number') or str(sale['id']))
|
||||
concepto.set('Cantidad', str(qty))
|
||||
concepto.set('ClaveUnidad', item.get('clave_unidad') or 'H87')
|
||||
concepto.set('Unidad', 'PZA')
|
||||
concepto.set('Descripcion', item.get('name') or 'Autoparte')
|
||||
concepto.set('ValorUnitario', _format_amount(unit_price))
|
||||
concepto.set('Importe', _format_amount(importe))
|
||||
concepto.set('ObjetoImp', '02')
|
||||
|
||||
if discount_amount > 0:
|
||||
concepto.set('Descuento', _format_amount(discount_amount))
|
||||
|
||||
impuestos_concepto = _make_element(concepto, 'Impuestos')
|
||||
traslados_concepto = _make_element(impuestos_concepto, 'Traslados')
|
||||
traslado = _make_element(traslados_concepto, 'Traslado')
|
||||
traslado.set('Base', _format_amount(base))
|
||||
traslado.set('Impuesto', '002')
|
||||
traslado.set('TipoFactor', 'Tasa')
|
||||
traslado.set('TasaOCuota', _format_rate(tax_rate))
|
||||
traslado.set('Importe', _format_amount(tax_amount))
|
||||
|
||||
# Impuestos totales
|
||||
impuestos = _make_element(root, 'Impuestos')
|
||||
impuestos.set('TotalImpuestosTrasladados', _format_amount(total_tax))
|
||||
traslados = _make_element(impuestos, 'Traslados')
|
||||
traslado_total = _make_element(traslados, 'Traslado')
|
||||
traslado_total.set('Base', _format_amount(total_subtotal))
|
||||
traslado_total.set('Impuesto', '002')
|
||||
traslado_total.set('TipoFactor', 'Tasa')
|
||||
traslado_total.set('TasaOCuota', '0.160000')
|
||||
traslado_total.set('Importe', _format_amount(total_tax))
|
||||
|
||||
return etree.tostring(root, xml_declaration=True, encoding='UTF-8',
|
||||
pretty_print=True).decode('utf-8')
|
||||
|
||||
Reference in New Issue
Block a user