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:
@@ -6,6 +6,7 @@ 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
|
||||
@@ -19,17 +20,19 @@ from services.audit import log_action
|
||||
invoicing_bp = Blueprint('invoicing', __name__, url_prefix='/pos/api/invoicing')
|
||||
|
||||
|
||||
def _get_tenant_config(cur):
|
||||
"""Load tenant CFDI configuration from tenant_config table.
|
||||
def _get_issuer_config(cur, branch_id=None):
|
||||
"""Load CFDI issuer configuration.
|
||||
|
||||
Falls back to sensible defaults if config is incomplete.
|
||||
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]
|
||||
|
||||
return {
|
||||
result = {
|
||||
'rfc': config.get('tenant_rfc', ''),
|
||||
'razon_social': config.get('tenant_razon_social', ''),
|
||||
'regimen_fiscal': config.get('cfdi_regimen_fiscal', '601'),
|
||||
@@ -39,6 +42,22 @@ def _get_tenant_config(cur):
|
||||
'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."""
|
||||
@@ -134,14 +153,14 @@ def generate_invoice():
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
tenant_config = _get_tenant_config(cur)
|
||||
if not tenant_config['rfc']:
|
||||
return jsonify({'error': 'Tenant RFC not configured. Set tenant_rfc in config.'}), 400
|
||||
|
||||
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
|
||||
|
||||
@@ -261,7 +280,7 @@ def trigger_process_queue():
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
tenant_config = _get_tenant_config(cur)
|
||||
tenant_config = _get_issuer_config(cur)
|
||||
horux_url = tenant_config.get('horux_api_url')
|
||||
horux_key = tenant_config.get('horux_api_key')
|
||||
|
||||
@@ -316,7 +335,7 @@ def cancel_invoice(cfdi_id):
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
tenant_config = _get_tenant_config(cur)
|
||||
tenant_config = _get_issuer_config(cur)
|
||||
result = cancel_cfdi(
|
||||
conn, cfdi_id, motive, replacement_uuid,
|
||||
tenant_config.get('horux_api_url'),
|
||||
@@ -362,7 +381,7 @@ def get_sale_pdf(sale_id):
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Sale not found'}), 404
|
||||
|
||||
tenant_config = _get_tenant_config(cur)
|
||||
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
|
||||
@@ -424,3 +443,102 @@ def api_invoicing_stats():
|
||||
'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],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user