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:
@@ -95,8 +95,8 @@ def _clean_model_name(name):
|
||||
s = re.sub(r'\s*\([^)]*\)\s*', '', s)
|
||||
# Remove Roman numeral generation suffixes: I, II, III, IV, V, VI, VII, VIII, IX, X
|
||||
s = re.sub(r'\s+(?:VIII|VII|VI|IV|IX|III|II|V|X|I)(?:\s|$)', ' ', s)
|
||||
# Remove body type suffixes
|
||||
s = re.sub(r'\s+(?:Estate|Saloon|Hatchback|Van|Coupe|Coupé|Convertible|Wagon|Pickup|Cab|Sedan|SUV|MPV|Kombi|Kasten|Bus|Box|Platform|Chassis)\b', '', s, flags=re.IGNORECASE)
|
||||
# Remove body type suffixes (keep Saloon/Hatchback/Sedan/Wagon as they distinguish variants)
|
||||
s = re.sub(r'\s+(?:Estate|Van|Coupe|Coupé|Convertible|Pickup|Cab|SUV|MPV|Kombi|Kasten|Bus|Box|Platform|Chassis)\b', '', s, flags=re.IGNORECASE)
|
||||
# Remove truck cab/bed suffixes: CREW, EXTENDED, STANDARD, HD, etc.
|
||||
s = re.sub(r'\s+(?:CREW|EXTENDED|STANDARD|CUTAWAY|PASSENGER|CARGO)\b', '', s, flags=re.IGNORECASE)
|
||||
# Remove "HD", "&" suffixes that create fake variants
|
||||
@@ -285,13 +285,15 @@ def get_models(master_conn, brand_id, year_id=None, brand_name=None, mye_ids=Non
|
||||
# Filter to North America models only, add clean display name, deduplicate
|
||||
filtered = [r for r in rows if is_na_model(brand_name, r[1])]
|
||||
|
||||
# Group by clean name — keep all id_models but show one display name
|
||||
seen = {} # display_name → first row
|
||||
# Group by (display_name, raw name) so distinct body-style variants
|
||||
# (e.g. AVEO vs AVEO SALOON) remain selectable.
|
||||
seen = set()
|
||||
results = []
|
||||
for r in filtered:
|
||||
display = _clean_model_name(r[1])
|
||||
if display not in seen:
|
||||
seen[display] = True
|
||||
key = (display, r[1])
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
results.append({
|
||||
'id_model': r[0],
|
||||
'name_model': r[1],
|
||||
@@ -508,7 +510,7 @@ _SPANISH_KEYWORDS = [
|
||||
(("Steering & Suspension Parts", "Sway Bars, Stabilizer Bars, Strut Rods & Parts", "Suspension Stabilizer Bar Link"),
|
||||
["bieleta", "estabilizador"]),
|
||||
(("Steering & Suspension Parts", "Steering Linkages, Rods & Arms", "Steering Tie Rod End"),
|
||||
["terminal", "rotula direccion", "rotula de direccion"]),
|
||||
["terminal", "rotula direccion", "rotula de direccion", "espiga"]),
|
||||
(("Steering & Suspension Parts", "Ball Joints & Control Arms", "Suspension Ball Joint"),
|
||||
["rotula"]),
|
||||
(("Steering & Suspension Parts", "Ball Joints & Control Arms", "Suspension Control Arm Bushing"),
|
||||
@@ -617,7 +619,7 @@ def _spanish_name_to_nexpart(name, category=None):
|
||||
"""
|
||||
if not name:
|
||||
return None
|
||||
name_lower = name.lower().replace('_', ' ')
|
||||
name_lower = name.lower().replace('_', ' ').replace('\n', ' ').replace('\r', '')
|
||||
|
||||
# 1. Keyword match (most specific first)
|
||||
for triple, keywords in _SPANISH_KEYWORDS:
|
||||
@@ -1134,7 +1136,7 @@ def _local_name_matches_part_type(name, part_type_slug):
|
||||
|
||||
def get_parts_for_nexpart_triple(master_conn, mye_id, group_slug, subgroup_slug,
|
||||
part_type_slug, tenant_conn, branch_id,
|
||||
page=1, per_page=30):
|
||||
page=1, per_page=30, tenant_id=None):
|
||||
"""Local mode: fetch parts (aftermarket-prioritized) for a Nexpart triple.
|
||||
|
||||
Steps:
|
||||
@@ -1242,9 +1244,14 @@ def get_parts_for_nexpart_triple(master_conn, mye_id, group_slug, subgroup_slug,
|
||||
WHERE id = ANY(%s)
|
||||
ORDER BY name
|
||||
""", (sc_id_values,))
|
||||
for row in cur.fetchall():
|
||||
sc_rows = cur.fetchall()
|
||||
cur.close()
|
||||
|
||||
sc_prices = _get_supplier_prices(master_conn, tenant_id, sc_id_values)
|
||||
|
||||
for row in sc_rows:
|
||||
sc_id, supplier, sku, name, category, desc, img = row
|
||||
result['data'].append({
|
||||
item = {
|
||||
'id_part': f'sc:{sc_id}',
|
||||
'id_aftermarket': None,
|
||||
'oem_part_number': sku,
|
||||
@@ -1262,8 +1269,12 @@ def get_parts_for_nexpart_triple(master_conn, mye_id, group_slug, subgroup_slug,
|
||||
'in_stock_network': False,
|
||||
'price_usd': None,
|
||||
'source': 'supplier_catalog',
|
||||
})
|
||||
cur.close()
|
||||
}
|
||||
p = sc_prices.get(sc_id)
|
||||
if p:
|
||||
item['supplier_price'] = p['price']
|
||||
item['supplier_currency'] = p['currency']
|
||||
result['data'].append(item)
|
||||
|
||||
# Sort combined list and paginate in Python
|
||||
all_items = result['data']
|
||||
@@ -1299,11 +1310,13 @@ def get_nexpart_part_types_for_vehicle(master_conn, mye_id, group_slug, subgroup
|
||||
if not subgroup_data:
|
||||
return []
|
||||
|
||||
# Pull a sample image for each part type — single query, all part_ids at once
|
||||
# Pull a sample image for each part type — single query, all OEM part_ids at once
|
||||
# Only integer IDs exist in the TecDoc parts table; skip inv: and sc: prefixed IDs.
|
||||
all_part_ids = [
|
||||
pid
|
||||
for pids in subgroup_data.values()
|
||||
for pid in pids
|
||||
if isinstance(pid, int)
|
||||
]
|
||||
image_map = {}
|
||||
if all_part_ids:
|
||||
@@ -1902,7 +1915,7 @@ def _search_meili_fallback(master_conn, q, limit):
|
||||
return None
|
||||
|
||||
|
||||
def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None):
|
||||
def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None, tenant_id=None):
|
||||
"""Search parts by part number or text. Enriches with local stock.
|
||||
|
||||
Strategy:
|
||||
@@ -1945,8 +1958,8 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None):
|
||||
break
|
||||
|
||||
# ── Inject supplier catalog items ───────────────────────────────────────
|
||||
if tenant_conn:
|
||||
supplier_items = _search_supplier_catalog(tenant_conn, q, mye_id, limit)
|
||||
if master_conn:
|
||||
supplier_items = _search_supplier_catalog(master_conn, q, mye_id, limit, tenant_id=tenant_id)
|
||||
for si in supplier_items:
|
||||
if f"sc:{si['id']}" in seen_local_ids:
|
||||
continue
|
||||
@@ -1957,6 +1970,8 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None):
|
||||
'image_url': si['image_url'],
|
||||
'local_stock': None,
|
||||
'local_price': None,
|
||||
'supplier_price': si.get('supplier_price'),
|
||||
'supplier_currency': si.get('supplier_currency'),
|
||||
'vehicle_info': si['category'] or '',
|
||||
'source': 'supplier_catalog',
|
||||
})
|
||||
@@ -1967,14 +1982,36 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None):
|
||||
return results
|
||||
|
||||
|
||||
def _search_supplier_catalog(tenant_conn, q, mye_id, limit):
|
||||
def _get_supplier_prices(master_conn, tenant_id, catalog_ids):
|
||||
"""Return a dict catalog_id -> {price, currency} for the current active price."""
|
||||
if master_conn is None or not tenant_id or not catalog_ids:
|
||||
return {}
|
||||
cur = master_conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT DISTINCT ON (catalog_id)
|
||||
catalog_id, price, currency
|
||||
FROM supplier_catalog_prices
|
||||
WHERE tenant_id = %s AND catalog_id = ANY(%s) AND is_active = true
|
||||
AND (effective_to IS NULL OR effective_to >= CURRENT_DATE)
|
||||
ORDER BY catalog_id, effective_from DESC
|
||||
""", (tenant_id, list(catalog_ids)))
|
||||
prices = {}
|
||||
for r in cur.fetchall():
|
||||
prices[r[0]] = {'price': float(r[1]) if r[1] is not None else None,
|
||||
'currency': r[2] or 'MXN'}
|
||||
cur.close()
|
||||
return prices
|
||||
|
||||
|
||||
def _search_supplier_catalog(master_conn, q, mye_id, limit, tenant_id=None):
|
||||
"""Search supplier catalog items by SKU or name.
|
||||
|
||||
If mye_id is provided, only returns items compatible with that vehicle.
|
||||
Enriches each item with the tenant-specific supplier price when tenant_id is given.
|
||||
"""
|
||||
if tenant_conn is None:
|
||||
if master_conn is None:
|
||||
return []
|
||||
cur = tenant_conn.cursor()
|
||||
cur = master_conn.cursor()
|
||||
clean_q = q.replace(' ', '').upper()
|
||||
|
||||
_SQL_UNACCENT = """
|
||||
@@ -2016,10 +2053,19 @@ def _search_supplier_catalog(tenant_conn, q, mye_id, limit):
|
||||
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
return [
|
||||
{'id': r[0], 'sku': r[1], 'name': r[2], 'image_url': r[3], 'category': r[4]}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
catalog_ids = [r[0] for r in rows]
|
||||
prices = _get_supplier_prices(master_conn, tenant_id, catalog_ids)
|
||||
|
||||
results = []
|
||||
for r in rows:
|
||||
item = {'id': r[0], 'sku': r[1], 'name': r[2], 'image_url': r[3], 'category': r[4]}
|
||||
p = prices.get(r[0])
|
||||
if p:
|
||||
item['supplier_price'] = p['price']
|
||||
item['supplier_currency'] = p['currency']
|
||||
results.append(item)
|
||||
return results
|
||||
|
||||
|
||||
def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
|
||||
|
||||
@@ -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')
|
||||
|
||||
210
pos/services/global_invoice.py
Normal file
210
pos/services/global_invoice.py
Normal file
@@ -0,0 +1,210 @@
|
||||
# /home/Autopartes/pos/services/global_invoice.py
|
||||
"""Global invoice (Factura Global) service.
|
||||
|
||||
Groups cash sales (PUE, <= $2,000, no individual CFDI) into a single
|
||||
monthly CFDI with InformacionGlobal per SAT requirements.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from services.cfdi_builder import build_global_invoice_xml
|
||||
from services.cfdi_queue import enqueue_cfdi, _generate_provisional_folio
|
||||
|
||||
|
||||
def get_eligible_sales(conn, year, month, branch_id=None, max_total=2000):
|
||||
"""Find sales eligible for global invoicing.
|
||||
|
||||
Criteria:
|
||||
- Payment method: PUE (paid in full)
|
||||
- Total <= max_total
|
||||
- No individual CFDI stamped
|
||||
- Not already included in a global invoice
|
||||
- Created in the given year/month
|
||||
- Optionally filtered by branch_id
|
||||
|
||||
Returns:
|
||||
list of sale dicts with items
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
|
||||
# Find eligible sale IDs
|
||||
sql = """
|
||||
SELECT s.id
|
||||
FROM sales s
|
||||
WHERE s.metodo_pago_sat = 'PUE'
|
||||
AND s.total <= %s
|
||||
AND s.status = 'completed'
|
||||
AND s.global_invoiced_at IS NULL
|
||||
AND EXTRACT(YEAR FROM s.created_at) = %s
|
||||
AND EXTRACT(MONTH FROM s.created_at) = %s
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM cfdi_queue c
|
||||
WHERE c.sale_id = s.id AND c.status = 'stamped'
|
||||
)
|
||||
"""
|
||||
params = [max_total, year, month]
|
||||
|
||||
if branch_id:
|
||||
sql += " AND s.branch_id = %s"
|
||||
params.append(branch_id)
|
||||
|
||||
sql += " ORDER BY s.created_at ASC"
|
||||
|
||||
cur.execute(sql, params)
|
||||
sale_ids = [r[0] for r in cur.fetchall()]
|
||||
|
||||
if not sale_ids:
|
||||
cur.close()
|
||||
return []
|
||||
|
||||
# Load sale details with items
|
||||
sales = []
|
||||
for sale_id in sale_ids:
|
||||
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:
|
||||
continue
|
||||
|
||||
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]),
|
||||
'items': [],
|
||||
}
|
||||
|
||||
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,))
|
||||
|
||||
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',
|
||||
})
|
||||
|
||||
sales.append(sale)
|
||||
|
||||
cur.close()
|
||||
return sales
|
||||
|
||||
|
||||
def generate_global_invoice(conn, tenant_config, year, month, branch_id=None,
|
||||
max_total=2000, employee_id=None):
|
||||
"""Generate a global invoice for the given month.
|
||||
|
||||
Args:
|
||||
conn: psycopg2 connection
|
||||
tenant_config: dict with rfc, razon_social, regimen_fiscal, cp, serie
|
||||
year: int
|
||||
month: int
|
||||
branch_id: optional branch filter
|
||||
max_total: max sale total to include (default $2,000)
|
||||
employee_id: optional employee ID for audit
|
||||
|
||||
Returns:
|
||||
dict: {id, status, sales_count, total, xml, provisional_folio}
|
||||
or {error, message} if no eligible sales
|
||||
"""
|
||||
sales = get_eligible_sales(conn, year, month, branch_id, max_total)
|
||||
|
||||
if not sales:
|
||||
return {'error': 'NO_ELIGIBLE_SALES',
|
||||
'message': f'No hay ventas elegibles para factura global de {month:02d}/{year}'}
|
||||
|
||||
xml = build_global_invoice_xml(sales, tenant_config, year, month)
|
||||
|
||||
# Enqueue with sale_id=NULL (global invoice)
|
||||
result = enqueue_cfdi(conn, None, 'ingreso', xml)
|
||||
cfdi_id = result['id']
|
||||
|
||||
cur = conn.cursor()
|
||||
|
||||
# Link sales to global invoice
|
||||
for sale in sales:
|
||||
cur.execute("""
|
||||
INSERT INTO global_invoice_sales (global_invoice_id, sale_id)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT DO NOTHING
|
||||
""", (cfdi_id, sale['id']))
|
||||
|
||||
# Mark sale as globally invoiced
|
||||
cur.execute("""
|
||||
UPDATE sales SET global_invoiced_at = NOW() WHERE id = %s
|
||||
""", (sale['id'],))
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
|
||||
return {
|
||||
'id': cfdi_id,
|
||||
'status': 'pending',
|
||||
'sales_count': len(sales),
|
||||
'total': sum(s['total'] for s in sales),
|
||||
'provisional_folio': result['provisional_folio'],
|
||||
'xml': xml,
|
||||
}
|
||||
|
||||
|
||||
def get_global_invoice_status(conn, cfdi_id):
|
||||
"""Get status of a global invoice including linked sales."""
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT id, status, uuid_fiscal, provisional_folio, error_message,
|
||||
created_at, stamped_at
|
||||
FROM cfdi_queue WHERE id = %s
|
||||
""", (cfdi_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close()
|
||||
return None
|
||||
|
||||
result = {
|
||||
'id': row[0], 'status': row[1], 'uuid_fiscal': row[2],
|
||||
'provisional_folio': row[3], 'error_message': row[4],
|
||||
'created_at': str(row[5]), 'stamped_at': str(row[6]) if row[6] else None,
|
||||
'sales': [],
|
||||
}
|
||||
|
||||
cur.execute("""
|
||||
SELECT s.id, s.total, s.created_at
|
||||
FROM global_invoice_sales gis
|
||||
JOIN sales s ON s.id = gis.sale_id
|
||||
WHERE gis.global_invoice_id = %s
|
||||
ORDER BY s.created_at ASC
|
||||
""", (cfdi_id,))
|
||||
|
||||
for r in cur.fetchall():
|
||||
result['sales'].append({
|
||||
'id': r[0], 'total': float(r[1]) if r[1] else 0,
|
||||
'created_at': str(r[2]),
|
||||
})
|
||||
|
||||
cur.close()
|
||||
return result
|
||||
@@ -25,22 +25,23 @@ def _safe_g(attr, default=None):
|
||||
def get_stock(conn, inventory_id, branch_id=None):
|
||||
"""Get current stock for an inventory item. Optionally filter by branch.
|
||||
|
||||
Uses Redis cache first, then inventory_stock_summary, falls back to
|
||||
PostgreSQL SUM query.
|
||||
Uses Redis cache first, then inventory_stock (per-branch) or
|
||||
inventory_stock_summary (total), falls back to PostgreSQL SUM query.
|
||||
"""
|
||||
# Try Redis first
|
||||
cached = get_cached_stock(inventory_id, branch_id)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# Use inventory_stock_summary (O(1) lookup)
|
||||
cur = conn.cursor()
|
||||
if branch_id:
|
||||
# Per-branch stock from inventory_stock
|
||||
cur.execute(
|
||||
"SELECT stock FROM inventory_stock_summary WHERE inventory_id = %s AND branch_id = %s",
|
||||
"SELECT stock FROM inventory_stock WHERE inventory_id = %s AND branch_id = %s",
|
||||
(inventory_id, branch_id)
|
||||
)
|
||||
else:
|
||||
# Total stock from inventory_stock_summary
|
||||
cur.execute(
|
||||
"SELECT stock FROM inventory_stock_summary WHERE inventory_id = %s",
|
||||
(inventory_id,)
|
||||
@@ -73,13 +74,14 @@ def get_stock(conn, inventory_id, branch_id=None):
|
||||
def get_stock_bulk(conn, branch_id=None):
|
||||
"""Get stock for all items. Returns dict {inventory_id: stock_quantity}.
|
||||
|
||||
Uses inventory_stock_summary for O(1) bulk lookup.
|
||||
Uses inventory_stock (per-branch) or inventory_stock_summary (total)
|
||||
for O(1) bulk lookup.
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
if branch_id:
|
||||
cur.execute("""
|
||||
SELECT inventory_id, stock
|
||||
FROM inventory_stock_summary WHERE branch_id = %s
|
||||
FROM inventory_stock WHERE branch_id = %s
|
||||
""", (branch_id,))
|
||||
else:
|
||||
cur.execute("""
|
||||
|
||||
Reference in New Issue
Block a user