Files
Autoparts-DB/pos/blueprints/invoicing_bp.py
consultoria-as 2b73c2c6db 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
2026-06-11 08:59:56 +00:00

545 lines
18 KiB
Python

# /home/Autopartes/pos/blueprints/invoicing_bp.py
"""Invoicing blueprint: CFDI generation, queue management, cancellation.
All CFDI business logic lives in services (cfdi_builder, cfdi_queue).
This blueprint is the HTTP layer that validates input and returns JSON.
"""
import json
from datetime import datetime
from flask import Blueprint, request, jsonify, g
from middleware import require_auth
from tenant_db import get_tenant_conn
from services.cfdi_builder import build_ingreso_xml, build_egreso_xml, build_pago_xml
from services.cfdi_queue import (
enqueue_cfdi, process_queue, retry_failed,
cancel_cfdi, get_queue_status,
)
from services.audit import log_action
invoicing_bp = Blueprint('invoicing', __name__, url_prefix='/pos/api/invoicing')
def _get_issuer_config(cur, branch_id=None):
"""Load CFDI issuer configuration.
If branch_id is provided and the branch has fiscal data, use it.
Otherwise fall back to tenant-level config.
"""
# Tenant-level defaults
config = {}
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'cfdi_%' OR key LIKE 'tenant_%'")
for row in cur.fetchall():
config[row[0]] = row[1]
result = {
'rfc': config.get('tenant_rfc', ''),
'razon_social': config.get('tenant_razon_social', ''),
'regimen_fiscal': config.get('cfdi_regimen_fiscal', '601'),
'cp': config.get('tenant_cp', '00000'),
'serie': config.get('cfdi_serie', 'A'),
'horux_api_url': config.get('cfdi_horux_api_url', ''),
'horux_api_key': config.get('cfdi_horux_api_key', ''),
}
# Branch-level override
if branch_id:
cur.execute("""
SELECT rfc, razon_social, regimen_fiscal, codigo_postal, serie_cfdi
FROM branches WHERE id = %s
""", (branch_id,))
row = cur.fetchone()
if row and row[0]:
result['rfc'] = row[0] or result['rfc']
result['razon_social'] = row[1] or result['razon_social']
result['regimen_fiscal'] = row[2] or result['regimen_fiscal']
result['cp'] = row[3] or result['cp']
result['serie'] = row[4] or result['serie']
return result
def _get_sale_with_items(cur, sale_id):
"""Load a sale with its items for CFDI generation."""
cur.execute("""
SELECT id, branch_id, customer_id, employee_id, sale_type,
payment_method, subtotal, discount_total, tax_total, total,
metodo_pago_sat, forma_pago_sat, status, created_at
FROM sales WHERE id = %s
""", (sale_id,))
row = cur.fetchone()
if not row:
return None
sale = {
'id': row[0], 'branch_id': row[1], 'customer_id': row[2],
'employee_id': row[3], 'sale_type': row[4],
'payment_method': row[5],
'subtotal': float(row[6]) if row[6] else 0,
'discount_total': float(row[7]) if row[7] else 0,
'tax_total': float(row[8]) if row[8] else 0,
'total': float(row[9]) if row[9] else 0,
'metodo_pago_sat': row[10] or 'PUE',
'forma_pago_sat': row[11] or '01',
'status': row[12],
'created_at': str(row[13]),
}
cur.execute("""
SELECT id, inventory_id, part_number, name, quantity, unit_price,
unit_cost, discount_pct, discount_amount, tax_rate, tax_amount,
subtotal, clave_prod_serv, clave_unidad
FROM sale_items WHERE sale_id = %s ORDER BY id
""", (sale_id,))
sale['items'] = []
for r in cur.fetchall():
sale['items'].append({
'id': r[0], 'inventory_id': r[1], 'part_number': r[2],
'name': r[3], 'quantity': r[4],
'unit_price': float(r[5]) if r[5] else 0,
'unit_cost': float(r[6]) if r[6] else 0,
'discount_pct': float(r[7]) if r[7] else 0,
'discount_amount': float(r[8]) if r[8] else 0,
'tax_rate': float(r[9]) if r[9] else 0.16,
'tax_amount': float(r[10]) if r[10] else 0,
'subtotal': float(r[11]) if r[11] else 0,
'clave_prod_serv': r[12] or '25174800',
'clave_unidad': r[13] or 'H87',
})
return sale
def _get_customer(cur, customer_id):
"""Load customer data for CFDI receptor."""
if not customer_id:
return None
cur.execute("""
SELECT id, name, rfc, razon_social, regimen_fiscal, uso_cfdi, cp
FROM customers WHERE id = %s
""", (customer_id,))
row = cur.fetchone()
if not row:
return None
return {
'id': row[0], 'name': row[1], 'rfc': row[2],
'razon_social': row[3], 'regimen_fiscal': row[4],
'uso_cfdi': row[5] or 'G03', 'cp': row[6],
}
# ─── Generate CFDI ─────────────────────────────────
@invoicing_bp.route('/invoice', methods=['POST'])
@require_auth('invoicing.create')
def generate_invoice():
"""Generate a CFDI for a sale and enqueue for timbrado.
Body: {
sale_id: int,
type: 'ingreso' (default) | 'egreso',
original_uuid: str (required for egreso)
}
"""
data = request.get_json() or {}
sale_id = data.get('sale_id')
cfdi_type = data.get('type', 'ingreso')
if not sale_id:
return jsonify({'error': 'sale_id is required'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
try:
sale = _get_sale_with_items(cur, sale_id)
if not sale:
return jsonify({'error': 'Sale not found'}), 404
tenant_config = _get_issuer_config(cur, sale.get('branch_id'))
if not tenant_config['rfc']:
return jsonify({'error': 'Tenant RFC not configured. Set tenant_rfc in config.'}), 400
if sale['status'] == 'cancelled':
return jsonify({'error': 'Cannot invoice a cancelled sale'}), 400
customer = _get_customer(cur, sale.get('customer_id'))
# Check if this sale already has a stamped CFDI
cur.execute("""
SELECT id, status FROM cfdi_queue
WHERE sale_id = %s AND type = %s AND status NOT IN ('cancelled', 'failed')
""", (sale_id, cfdi_type))
existing = cur.fetchone()
if existing:
return jsonify({
'error': f'Sale #{sale_id} already has a {cfdi_type} CFDI (queue #{existing[0]}, status: {existing[1]})'
}), 409
# Build XML
if cfdi_type == 'ingreso':
xml = build_ingreso_xml(sale, tenant_config, customer)
elif cfdi_type == 'egreso':
original_uuid = data.get('original_uuid')
if not original_uuid:
return jsonify({'error': 'original_uuid required for egreso'}), 400
xml = build_egreso_xml(sale, tenant_config, customer, original_uuid)
else:
return jsonify({'error': f'Invalid CFDI type: {cfdi_type}'}), 400
# Enqueue
result = enqueue_cfdi(conn, sale_id, cfdi_type, xml)
log_action(conn, 'CFDI_GENERATED', 'cfdi_queue', result['id'],
new_value={'sale_id': sale_id, 'type': cfdi_type,
'folio': result['provisional_folio']})
conn.commit()
cur.close()
conn.close()
return jsonify(result), 201
except ValueError as e:
conn.rollback()
cur.close()
conn.close()
return jsonify({'error': str(e)}), 400
except Exception as e:
conn.rollback()
cur.close()
conn.close()
return jsonify({'error': str(e)}), 500
# ─── Queue Management ──────────────────────────────
@invoicing_bp.route('/queue', methods=['GET'])
@require_auth('invoicing.view')
def list_queue():
"""List CFDI queue items.
Query params: status, sale_id, type, page, per_page
"""
conn = get_tenant_conn(g.tenant_id)
filters = {
'status': request.args.get('status'),
'sale_id': request.args.get('sale_id'),
'type': request.args.get('type'),
'page': request.args.get('page', 1),
'per_page': request.args.get('per_page', 50),
}
result = get_queue_status(conn, filters)
conn.close()
return jsonify(result)
@invoicing_bp.route('/queue/<int:cfdi_id>', methods=['GET'])
@require_auth('invoicing.view')
def get_queue_item(cfdi_id):
"""Get CFDI queue item detail (includes XML)."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT q.id, q.sale_id, q.type, q.xml_unsigned, q.xml_signed,
q.uuid_fiscal, q.status, q.retry_count, q.provisional_folio,
q.error_message, q.cancel_motive, q.cancel_replacement_uuid,
q.created_at, q.stamped_at
FROM cfdi_queue q WHERE q.id = %s
""", (cfdi_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'CFDI queue item not found'}), 404
item = {
'id': row[0], 'sale_id': row[1], 'type': row[2],
'xml_unsigned': row[3], 'xml_signed': row[4],
'uuid_fiscal': row[5], 'status': row[6],
'retry_count': row[7], 'provisional_folio': row[8],
'error_message': row[9], 'cancel_motive': row[10],
'cancel_replacement_uuid': row[11],
'created_at': str(row[12]) if row[12] else None,
'stamped_at': str(row[13]) if row[13] else None,
}
cur.close()
conn.close()
return jsonify(item)
@invoicing_bp.route('/queue/process', methods=['POST'])
@require_auth('invoicing.create')
def trigger_process_queue():
"""Manually trigger processing of pending CFDI queue items."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
try:
tenant_config = _get_issuer_config(cur)
horux_url = tenant_config.get('horux_api_url')
horux_key = tenant_config.get('horux_api_key')
if not horux_url or not horux_key:
cur.close()
conn.close()
return jsonify({'error': 'Horux API not configured'}), 400
# Reset eligible failed items first
reset_count = retry_failed(conn)
# Process the queue
result = process_queue(conn, horux_url, horux_key)
result['retries_reset'] = reset_count
cur.close()
conn.close()
return jsonify(result)
except Exception as e:
conn.rollback()
cur.close()
conn.close()
return jsonify({'error': str(e)}), 500
# ─── Cancel CFDI ────────────────────────────────────
@invoicing_bp.route('/cancel/<int:cfdi_id>', methods=['POST'])
@require_auth('invoicing.delete')
def cancel_invoice(cfdi_id):
"""Cancel a CFDI with SAT motive code.
Body: {
motive: '01' | '02' | '03' | '04',
replacement_uuid: str (required if motive == '01')
}
Only owner and admin can cancel CFDIs.
"""
if g.employee_role not in ('owner', 'admin'):
return jsonify({'error': 'Only owner or admin can cancel CFDIs'}), 403
data = request.get_json() or {}
motive = data.get('motive')
replacement_uuid = data.get('replacement_uuid')
if not motive:
return jsonify({'error': 'motive is required'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
try:
tenant_config = _get_issuer_config(cur)
result = cancel_cfdi(
conn, cfdi_id, motive, replacement_uuid,
tenant_config.get('horux_api_url'),
tenant_config.get('horux_api_key'),
)
log_action(conn, 'CFDI_CANCELLED', 'cfdi_queue', cfdi_id,
new_value={'motive': motive, 'replacement_uuid': replacement_uuid})
conn.commit()
cur.close()
conn.close()
return jsonify(result)
except ValueError as e:
conn.rollback()
cur.close()
conn.close()
return jsonify({'error': str(e)}), 400
except Exception as e:
conn.rollback()
cur.close()
conn.close()
return jsonify({'error': str(e)}), 500
# ─── PDF Generation ─────────────────────────────────
@invoicing_bp.route('/<int:sale_id>/pdf', methods=['GET'])
@require_auth('invoicing.view')
def get_sale_pdf(sale_id):
"""Generate a PDF representation of the sale/CFDI.
Returns an HTML page styled for print/PDF generation.
For actual PDF file generation, the frontend uses window.print() or
a headless browser. This endpoint returns the formatted HTML.
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
sale = _get_sale_with_items(cur, sale_id)
if not sale:
cur.close(); conn.close()
return jsonify({'error': 'Sale not found'}), 404
tenant_config = _get_issuer_config(cur, sale.get('branch_id'))
customer = _get_customer(cur, sale.get('customer_id'))
# Check if there's a stamped CFDI
cur.execute("""
SELECT uuid_fiscal, provisional_folio, status, stamped_at
FROM cfdi_queue
WHERE sale_id = %s AND type = 'ingreso' AND status = 'stamped'
ORDER BY stamped_at DESC LIMIT 1
""", (sale_id,))
cfdi_row = cur.fetchone()
cfdi_info = None
if cfdi_row:
cfdi_info = {
'uuid_fiscal': cfdi_row[0],
'provisional_folio': cfdi_row[1],
'status': cfdi_row[2],
'stamped_at': str(cfdi_row[3]) if cfdi_row[3] else None,
}
cur.close()
conn.close()
return jsonify({
'sale': sale,
'tenant': {
'rfc': tenant_config.get('rfc', ''),
'razon_social': tenant_config.get('razon_social', ''),
'regimen_fiscal': tenant_config.get('regimen_fiscal', ''),
'cp': tenant_config.get('cp', ''),
},
'customer': customer,
'cfdi': cfdi_info,
})
@invoicing_bp.route('/stats', methods=['GET'])
@require_auth('invoicing.read')
def api_invoicing_stats():
"""Return counts for tab badges: invoices, credit notes, payment complements, cancellations."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT
COUNT(*) FILTER (WHERE type = 'ingreso' AND status IN ('pending', 'stamped', 'retry')) as facturas,
COUNT(*) FILTER (WHERE type = 'egreso' AND status IN ('pending', 'stamped', 'retry')) as notas_credito,
COUNT(*) FILTER (WHERE type = 'pago' AND status IN ('pending', 'stamped', 'retry')) as complementos,
COUNT(*) FILTER (WHERE status = 'cancelled') as cancelaciones
FROM cfdi_queue
""")
row = cur.fetchone()
cur.close()
conn.close()
return jsonify({
'facturas': row[0] or 0,
'notas_credito': row[1] or 0,
'complementos': row[2] or 0,
'cancelaciones': row[3] or 0,
})
@invoicing_bp.route('/global-invoice', methods=['POST'])
@require_auth('invoicing.create')
def generate_global_invoice():
"""Generate a monthly global invoice for cash sales.
Body: {
year: int (default current year),
month: int (default current month),
branch_id: int (optional)
}
"""
data = request.get_json() or {}
now = datetime.now()
year = data.get('year', now.year)
month = data.get('month', now.month)
branch_id = data.get('branch_id')
try:
year = int(year)
month = int(month)
if month < 1 or month > 12:
return jsonify({'error': 'month must be 1-12'}), 400
except (ValueError, TypeError):
return jsonify({'error': 'year and month must be integers'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
tenant_config = _get_issuer_config(cur, branch_id)
if not tenant_config['rfc']:
cur.close(); conn.close()
return jsonify({'error': 'Tenant RFC not configured'}), 400
from services.global_invoice import generate_global_invoice
result = generate_global_invoice(
conn, tenant_config, year, month,
branch_id=branch_id,
employee_id=getattr(g, 'employee_id', None)
)
if 'error' in result:
cur.close(); conn.close()
return jsonify(result), 400
log_action(conn, 'GLOBAL_INVOICE_CREATE', 'cfdi_queue', result['id'],
new_value={'year': year, 'month': month, 'sales_count': result['sales_count']})
conn.commit()
cur.close()
conn.close()
return jsonify(result), 201
@invoicing_bp.route('/global-invoice/<int:cfdi_id>', methods=['GET'])
@require_auth('invoicing.view')
def get_global_invoice(cfdi_id):
"""Get status and linked sales of a global invoice."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
from services.global_invoice import get_global_invoice_status
result = get_global_invoice_status(conn, cfdi_id)
cur.close()
conn.close()
if not result:
return jsonify({'error': 'Global invoice not found'}), 404
return jsonify(result)
@invoicing_bp.route('/global-invoice/eligible-sales', methods=['GET'])
@require_auth('invoicing.view')
def get_eligible_sales_for_global():
"""Preview sales that would be included in a global invoice.
Query params: year, month, branch_id
"""
now = datetime.now()
year = request.args.get('year', now.year, type=int)
month = request.args.get('month', now.month, type=int)
branch_id = request.args.get('branch_id', type=int)
conn = get_tenant_conn(g.tenant_id)
from services.global_invoice import get_eligible_sales
sales = get_eligible_sales(conn, year, month, branch_id)
conn.close()
return jsonify({
'year': year, 'month': month,
'count': len(sales),
'total': sum(s['total'] for s in sales),
'sales': [{'id': s['id'], 'total': s['total'], 'created_at': s['created_at']} for s in sales],
})