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:
@@ -7,6 +7,9 @@ Independent from inventory. Supports:
|
||||
- Bulk import via Excel
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
from datetime import date
|
||||
from flask import Blueprint, request, jsonify, g, render_template
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
@@ -276,3 +279,260 @@ def delete_item(item_id):
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
# ─── Prices ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_latest_prices(master_conn, tenant_id, catalog_ids):
|
||||
"""Return a dict catalog_id -> price row for the latest active price per item."""
|
||||
if not catalog_ids:
|
||||
return {}
|
||||
cur = master_conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT DISTINCT ON (catalog_id)
|
||||
catalog_id, price, currency, effective_from, effective_to
|
||||
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',
|
||||
'effective_from': str(r[3]) if r[3] else None,
|
||||
'effective_to': str(r[4]) if r[4] else None,
|
||||
}
|
||||
cur.close()
|
||||
return prices
|
||||
|
||||
|
||||
@supplier_catalog_bp.route('/prices', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def list_prices():
|
||||
"""List active supplier prices for the current tenant."""
|
||||
supplier = (request.args.get('supplier') or '').strip()
|
||||
q = (request.args.get('q') or '').strip()
|
||||
page = max(1, request.args.get('page', 1, type=int))
|
||||
per_page = min(200, request.args.get('per_page', 50, type=int))
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
conn = _get_master_conn()
|
||||
cur = conn.cursor()
|
||||
|
||||
where_parts = ["sc.is_active = true", "scp.tenant_id = %s"]
|
||||
params = [g.tenant_id]
|
||||
|
||||
if supplier:
|
||||
where_parts.append("sc.supplier_name = %s")
|
||||
params.append(supplier)
|
||||
if q:
|
||||
where_parts.append("(sc.sku ILIKE %s OR sc.name ILIKE %s)")
|
||||
like_q = f'%{q}%'
|
||||
params.extend([like_q, like_q])
|
||||
|
||||
where_sql = " AND ".join(where_parts)
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT COUNT(DISTINCT sc.id)
|
||||
FROM supplier_catalog sc
|
||||
JOIN supplier_catalog_prices scp ON scp.catalog_id = sc.id
|
||||
WHERE {where_sql}
|
||||
AND scp.is_active = true
|
||||
AND (scp.effective_to IS NULL OR scp.effective_to >= CURRENT_DATE)
|
||||
""", params)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT DISTINCT ON (sc.id)
|
||||
sc.id, sc.supplier_name, sc.sku, sc.name, sc.category,
|
||||
scp.price, scp.currency, scp.effective_from, scp.effective_to
|
||||
FROM supplier_catalog sc
|
||||
JOIN supplier_catalog_prices scp ON scp.catalog_id = sc.id
|
||||
WHERE {where_sql}
|
||||
AND scp.is_active = true
|
||||
AND (scp.effective_to IS NULL OR scp.effective_to >= CURRENT_DATE)
|
||||
ORDER BY sc.id, scp.effective_from DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""", params + [per_page, offset])
|
||||
|
||||
items = []
|
||||
for r in cur.fetchall():
|
||||
items.append({
|
||||
'catalog_id': r[0],
|
||||
'supplier_name': r[1],
|
||||
'sku': r[2],
|
||||
'name': r[3],
|
||||
'category': r[4],
|
||||
'price': float(r[5]) if r[5] is not None else None,
|
||||
'currency': r[6] or 'MXN',
|
||||
'effective_from': str(r[7]) if r[7] else None,
|
||||
'effective_to': str(r[8]) if r[8] else None,
|
||||
})
|
||||
|
||||
cur.close(); conn.close()
|
||||
return jsonify({
|
||||
'data': items,
|
||||
'pagination': {'page': page, 'per_page': per_page, 'total': total,
|
||||
'total_pages': (total + per_page - 1) // per_page}
|
||||
})
|
||||
|
||||
|
||||
@supplier_catalog_bp.route('/prices/template', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def download_price_template():
|
||||
"""Return a CSV template for uploading supplier prices."""
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(['supplier_name', 'sku', 'price', 'currency', 'effective_from'])
|
||||
writer.writerow(['YOKOMITSU', 'DENK070A', '1250.00', 'MXN', '2026-01-01'])
|
||||
output.seek(0)
|
||||
return (output.getvalue(), 200, {
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': 'attachment; filename="supplier_prices_template.csv"'
|
||||
})
|
||||
|
||||
|
||||
def _read_upload_file(file_storage):
|
||||
"""Read CSV or Excel upload and return list of dict rows."""
|
||||
filename = (file_storage.filename or '').lower()
|
||||
content = file_storage.read()
|
||||
if filename.endswith('.csv'):
|
||||
text = content.decode('utf-8-sig')
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
return [row for row in reader]
|
||||
if filename.endswith(('.xlsx', '.xls')):
|
||||
try:
|
||||
import openpyxl
|
||||
except ImportError as e:
|
||||
raise RuntimeError('openpyxl no instalado; sube CSV o instala openpyxl') from e
|
||||
wb = openpyxl.load_workbook(io.BytesIO(content), data_only=True)
|
||||
ws = wb.active
|
||||
rows = list(ws.iter_rows(values_only=True))
|
||||
if not rows:
|
||||
return []
|
||||
headers = [str(c).strip().lower() if c else '' for c in rows[0]]
|
||||
return [
|
||||
dict(zip(headers, row))
|
||||
for row in rows[1:] if any(cell is not None and str(cell).strip() for cell in row)
|
||||
]
|
||||
raise ValueError('Formato no soportado. Usa CSV o Excel (.xlsx)')
|
||||
|
||||
|
||||
@supplier_catalog_bp.route('/prices/upload', methods=['POST'])
|
||||
@require_auth('inventory.edit')
|
||||
def upload_prices():
|
||||
"""Bulk upload/upsert supplier prices for the current tenant.
|
||||
|
||||
Expected columns: supplier_name, sku, price, [currency], [effective_from]
|
||||
"""
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'error': 'Archivo requerido'}), 400
|
||||
file_storage = request.files['file']
|
||||
if not file_storage or not file_storage.filename:
|
||||
return jsonify({'error': 'Archivo requerido'}), 400
|
||||
|
||||
try:
|
||||
rows = _read_upload_file(file_storage)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
|
||||
if not rows:
|
||||
return jsonify({'error': 'El archivo esta vacio o no tiene filas validas'}), 400
|
||||
|
||||
conn = _get_master_conn()
|
||||
cur = conn.cursor()
|
||||
|
||||
# Build a lookup of supplier+sku -> catalog_id
|
||||
# We expect all rows to refer to existing catalog items.
|
||||
normalized_rows = []
|
||||
errors = []
|
||||
for idx, row in enumerate(rows, start=2):
|
||||
supplier = str(row.get('supplier_name') or '').strip()
|
||||
sku = str(row.get('sku') or '').strip()
|
||||
price_raw = row.get('price')
|
||||
currency = str(row.get('currency') or 'MXN').strip().upper() or 'MXN'
|
||||
eff_from_raw = row.get('effective_from')
|
||||
|
||||
if not supplier or not sku:
|
||||
errors.append(f'Fila {idx}: supplier_name y sku son requeridos')
|
||||
continue
|
||||
|
||||
try:
|
||||
price = float(str(price_raw).replace(',', '').strip())
|
||||
except Exception:
|
||||
errors.append(f'Fila {idx}: precio invalido para {supplier}/{sku}')
|
||||
continue
|
||||
|
||||
eff_from = date.today()
|
||||
if eff_from_raw:
|
||||
try:
|
||||
eff_from = date.fromisoformat(str(eff_from_raw).strip())
|
||||
except Exception:
|
||||
errors.append(f'Fila {idx}: effective_from invalido (use YYYY-MM-DD)')
|
||||
continue
|
||||
|
||||
normalized_rows.append((supplier, sku, price, currency, eff_from))
|
||||
|
||||
if errors:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Errores de validacion', 'details': errors}), 400
|
||||
|
||||
# Bulk lookup catalog IDs
|
||||
catalog_lookup = {}
|
||||
for supplier, sku, *_ in normalized_rows:
|
||||
catalog_lookup[(supplier, sku)] = None
|
||||
|
||||
if catalog_lookup:
|
||||
keys = list(catalog_lookup.keys())
|
||||
# Batch query using unnest
|
||||
cur.execute("""
|
||||
SELECT supplier_name, sku, id
|
||||
FROM supplier_catalog
|
||||
WHERE is_active = true
|
||||
AND (supplier_name, sku) = ANY(%s)
|
||||
""", (keys,))
|
||||
for r in cur.fetchall():
|
||||
catalog_lookup[(r[0], r[1])] = r[2]
|
||||
|
||||
upserts = []
|
||||
for idx, (supplier, sku, price, currency, eff_from) in enumerate(normalized_rows, start=2):
|
||||
catalog_id = catalog_lookup.get((supplier, sku))
|
||||
if not catalog_id:
|
||||
errors.append(f'Fila {idx}: SKU {supplier}/{sku} no existe en el catalogo')
|
||||
continue
|
||||
upserts.append((g.tenant_id, catalog_id, price, currency, eff_from))
|
||||
|
||||
if errors:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Errores de validacion', 'details': errors}), 400
|
||||
|
||||
inserted = 0
|
||||
updated = 0
|
||||
for tenant_id, catalog_id, price, currency, eff_from in upserts:
|
||||
# Try update existing row with same (tenant_id, catalog_id, effective_from)
|
||||
cur.execute("""
|
||||
UPDATE supplier_catalog_prices
|
||||
SET price = %s, currency = %s, is_active = true, updated_at = NOW()
|
||||
WHERE tenant_id = %s AND catalog_id = %s AND effective_from = %s
|
||||
RETURNING id
|
||||
""", (price, currency, tenant_id, catalog_id, eff_from))
|
||||
if cur.fetchone():
|
||||
updated += 1
|
||||
else:
|
||||
cur.execute("""
|
||||
INSERT INTO supplier_catalog_prices
|
||||
(tenant_id, catalog_id, price, currency, effective_from, is_active)
|
||||
VALUES (%s, %s, %s, %s, %s, true)
|
||||
""", (tenant_id, catalog_id, price, currency, eff_from))
|
||||
inserted += 1
|
||||
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'processed': len(upserts),
|
||||
'inserted': inserted,
|
||||
'updated': updated,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user