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:
2026-06-11 08:59:56 +00:00
parent ea29cc31c0
commit 2b73c2c6db
23 changed files with 1665 additions and 230 deletions

View File

@@ -100,9 +100,8 @@ def list_items():
where_clauses = ["i.is_active = true"]
params = []
if branch_id:
where_clauses.append("i.branch_id = %s")
params.append(branch_id)
# branch_id no longer filters inventory rows (shared catalog).
# It is used only to show per-branch stock.
if search:
where_clauses.append("(i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s)")
params.extend([f'%{search}%', f'%{search}%', f'%{search}%'])
@@ -116,93 +115,91 @@ def list_items():
where = " AND ".join(where_clauses)
if low_stock:
# low_stock filter: JOIN with stock subquery, filter items where stock < min_stock
# This keeps pagination accurate because the filter is in the SQL WHERE clause.
# low_stock filter: JOIN with total stock summary
count_sql = f"""
SELECT count(*) FROM inventory i
LEFT JOIN (
SELECT inventory_id, COALESCE(SUM(quantity), 0) AS stock
FROM inventory_operations GROUP BY inventory_id
) s ON s.inventory_id = i.id
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
WHERE {where} AND i.min_stock IS NOT NULL AND i.min_stock > 0
AND COALESCE(s.stock, 0) < i.min_stock
"""
cur.execute(count_sql, params)
total = cur.fetchone()[0]
stock_join = """
LEFT JOIN inventory_stock ist ON ist.inventory_id = i.id AND ist.branch_id = %s
""" if branch_id else """
LEFT JOIN inventory_stock_summary ist ON ist.inventory_id = i.id
"""
stock_select = "COALESCE(ist.stock, 0) AS stock"
stock_params = [int(branch_id)] if branch_id else []
cur.execute(f"""
SELECT i.id, i.branch_id, i.part_number, i.barcode, i.name, i.description,
SELECT i.id, i.part_number, i.barcode, i.name, i.description,
i.category_id, i.brand, i.unit, i.cost, i.price_1, i.price_2, i.price_3,
i.tax_rate, i.min_stock, i.max_stock, i.location, i.image_url, i.catalog_part_id,
COALESCE(s.stock, 0) AS stock
{stock_select}
FROM inventory i
LEFT JOIN (
SELECT inventory_id, COALESCE(SUM(quantity), 0) AS stock
FROM inventory_operations GROUP BY inventory_id
) s ON s.inventory_id = i.id
{stock_join}
WHERE {where} AND i.min_stock IS NOT NULL AND i.min_stock > 0
AND COALESCE(s.stock, 0) < i.min_stock
AND COALESCE(
(SELECT stock FROM inventory_stock_summary WHERE inventory_id = i.id), 0
) < i.min_stock
ORDER BY i.name
LIMIT %s OFFSET %s
""", params + [per_page, (page - 1) * per_page])
""", stock_params + params + [per_page, (page - 1) * per_page])
items = []
for r in cur.fetchall():
items.append({
'id': r[0], 'branch_id': r[1], 'part_number': r[2], 'barcode': r[3],
'name': r[4], 'description': r[5], 'category_id': r[6], 'brand': r[7],
'unit': r[8], 'cost': float(r[9]) if r[9] else 0,
'price_1': float(r[10]) if r[10] else 0,
'price_2': float(r[11]) if r[11] else 0,
'price_3': float(r[12]) if r[12] else 0,
'tax_rate': float(r[13]) if r[13] else 0.16,
'min_stock': r[14], 'max_stock': r[15], 'location': r[16],
'image_url': r[17], 'catalog_part_id': r[18],
'stock': r[19]
'id': r[0], 'part_number': r[1], 'barcode': r[2],
'name': r[3], 'description': r[4], 'category_id': r[5], 'brand': r[6],
'unit': r[7], 'cost': float(r[8]) if r[8] else 0,
'price_1': float(r[9]) if r[9] else 0,
'price_2': float(r[10]) if r[10] else 0,
'price_3': float(r[11]) if r[11] else 0,
'tax_rate': float(r[12]) if r[12] else 0.16,
'min_stock': r[13], 'max_stock': r[14], 'location': r[15],
'image_url': r[16], 'catalog_part_id': r[17],
'stock': r[18]
})
else:
# Normal path: count, fetch items, then bulk-lookup stock
cur.execute(f"SELECT count(*) FROM inventory i WHERE {where}", params)
total = cur.fetchone()[0]
stock_join = """
LEFT JOIN inventory_stock ist ON ist.inventory_id = i.id AND ist.branch_id = %s
""" if branch_id else """
LEFT JOIN inventory_stock_summary ist ON ist.inventory_id = i.id
"""
stock_select = "COALESCE(ist.stock, 0) AS stock"
stock_params = [int(branch_id)] if branch_id else []
cur.execute(f"""
SELECT i.id, i.branch_id, i.part_number, i.barcode, i.name, i.description,
SELECT i.id, i.part_number, i.barcode, i.name, i.description,
i.category_id, i.brand, i.unit, i.cost, i.price_1, i.price_2, i.price_3,
i.tax_rate, i.min_stock, i.max_stock, i.location, i.image_url, i.catalog_part_id
i.tax_rate, i.min_stock, i.max_stock, i.location, i.image_url, i.catalog_part_id,
{stock_select}
FROM inventory i
{stock_join}
WHERE {where}
ORDER BY i.name
LIMIT %s OFFSET %s
""", params + [per_page, (page - 1) * per_page])
items_raw = cur.fetchall()
# Get stock for all returned items
inv_ids = [r[0] for r in items_raw]
stock_map = {}
if inv_ids:
cur.execute("""
SELECT inventory_id, COALESCE(SUM(quantity), 0)
FROM inventory_operations
WHERE inventory_id = ANY(%s)
GROUP BY inventory_id
""", (inv_ids,))
stock_map = {r[0]: r[1] for r in cur.fetchall()}
""", stock_params + params + [per_page, (page - 1) * per_page])
items = []
for r in items_raw:
stock = stock_map.get(r[0], 0)
for r in cur.fetchall():
items.append({
'id': r[0], 'branch_id': r[1], 'part_number': r[2], 'barcode': r[3],
'name': r[4], 'description': r[5], 'category_id': r[6], 'brand': r[7],
'unit': r[8], 'cost': float(r[9]) if r[9] else 0,
'price_1': float(r[10]) if r[10] else 0,
'price_2': float(r[11]) if r[11] else 0,
'price_3': float(r[12]) if r[12] else 0,
'tax_rate': float(r[13]) if r[13] else 0.16,
'min_stock': r[14], 'max_stock': r[15], 'location': r[16],
'image_url': r[17], 'catalog_part_id': r[18],
'stock': stock
'id': r[0], 'part_number': r[1], 'barcode': r[2],
'name': r[3], 'description': r[4], 'category_id': r[5], 'brand': r[6],
'unit': r[7], 'cost': float(r[8]) if r[8] else 0,
'price_1': float(r[9]) if r[9] else 0,
'price_2': float(r[10]) if r[10] else 0,
'price_3': float(r[11]) if r[11] else 0,
'tax_rate': float(r[12]) if r[12] else 0.16,
'min_stock': r[13], 'max_stock': r[14], 'location': r[15],
'image_url': r[16], 'catalog_part_id': r[17],
'stock': r[18]
})
cur.close()
@@ -222,9 +219,8 @@ def get_item(item_id):
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT i.*, b.name as branch_name, c.name as category_name
SELECT i.*, c.name as category_name
FROM inventory i
LEFT JOIN branches b ON i.branch_id = b.id
LEFT JOIN categories c ON i.category_id = c.id
WHERE i.id = %s
""", (item_id,))
@@ -240,7 +236,8 @@ def get_item(item_id):
if item.get(k) is not None:
item[k] = float(item[k])
item['stock'] = get_stock(conn, item_id, item.get('branch_id'))
branch_id = request.args.get('branch_id', g.branch_id)
item['stock'] = get_stock(conn, item_id, branch_id)
item['history'] = get_movement_history(conn, item_id, limit=20)
cur.close()
@@ -259,8 +256,6 @@ def create_item():
return jsonify({'error': f'{f} required'}), 400
branch_id = data.get('branch_id', g.branch_id)
if not branch_id:
return jsonify({'error': 'branch_id required'}), 400
# Plan limit check
from services.billing import check_limit, next_plan, PLANS, get_plan
@@ -307,13 +302,13 @@ def create_item():
try:
cur.execute("""
INSERT INTO inventory
(branch_id, part_number, barcode, name, description, category_id, brand,
(part_number, barcode, name, description, category_id, brand,
vehicle_compatibility, unit, cost, price_1, price_2, price_3, tax_rate,
min_stock, max_stock, location, image_url, catalog_part_id)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id
""", (
branch_id, data['part_number'], barcode, data['name'],
data['part_number'], barcode, data['name'],
data.get('description'), data.get('category_id'), data.get('brand'),
json.dumps(data.get('vehicle_compatibility')) if data.get('vehicle_compatibility') else None,
data.get('unit', 'PZA'), data.get('cost', 0),
@@ -324,9 +319,9 @@ def create_item():
))
item_id = cur.fetchone()[0]
# Record initial stock if provided
# Record initial stock if provided (requires branch_id)
initial_stock = data.get('initial_stock', 0)
if initial_stock > 0:
if initial_stock > 0 and branch_id:
record_initial(conn, item_id, branch_id, initial_stock, data.get('cost'))
# Insert SKU aliases if provided
@@ -409,8 +404,8 @@ def create_item():
except Exception as e:
conn.rollback()
cur.close(); conn.close()
if 'idx_inventory_branch_part' in str(e):
return jsonify({'error': 'Part number already exists in this branch'}), 409
if 'idx_inventory_part_unique' in str(e):
return jsonify({'error': 'Part number already exists'}), 409
return jsonify({'error': str(e)}), 500
@@ -555,8 +550,8 @@ def bulk_import_items():
description = str(row.get('description', '')).strip()
category = str(row.get('category', '')).strip()
# Check if item already exists for this branch
cur.execute("SELECT id FROM inventory WHERE branch_id = %s AND part_number = %s", (branch_id, part_number))
# Check if item already exists (catalog is shared across branches)
cur.execute("SELECT id FROM inventory WHERE part_number = %s", (part_number,))
existing = cur.fetchone()
if existing:
@@ -569,13 +564,12 @@ def bulk_import_items():
brand = COALESCE(NULLIF(%s,''), brand),
cost = CASE WHEN %s > 0 THEN %s ELSE cost END,
price_1 = CASE WHEN %s > 0 THEN %s ELSE price_1 END,
stock = stock + %s,
location = COALESCE(NULLIF(%s,''), location),
description = COALESCE(NULLIF(%s,''), description),
category = COALESCE(NULLIF(%s,''), category)
WHERE id = %s
""",
(name, brand, cost, cost, price_1, price_1, stock, location, description, category, item_id)
(name, brand, cost, cost, price_1, price_1, location, description, category, item_id)
)
was_inserted = False
# Record stock adjustment for existing item if stock > 0
@@ -588,11 +582,11 @@ def bulk_import_items():
cur.execute(
"""
INSERT INTO inventory
(branch_id, part_number, barcode, name, brand, cost, price_1, location, description, category, unit)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
(part_number, barcode, name, brand, cost, price_1, location, description, category, unit)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(branch_id, part_number, barcode, name, brand, cost, price_1, location, description, category, 'PZA')
(part_number, barcode, name, brand, cost, price_1, location, description, category, 'PZA')
)
item_id = cur.fetchone()[0]
was_inserted = True
@@ -1332,7 +1326,7 @@ def api_inventory_stats():
branch_id = getattr(g, 'branch_id', None)
# Stock count
cur.execute("SELECT COUNT(*) FROM inventory WHERE is_active = true AND (branch_id = %s OR %s IS NULL)", (branch_id, branch_id))
cur.execute("SELECT COUNT(*) FROM inventory WHERE is_active = true")
stock = cur.fetchone()[0]
# Operations counts by type
@@ -1373,52 +1367,44 @@ def api_inventory_summary():
"""Get high-level summary counts for the inventory dashboard badges."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
branch_id = getattr(g, 'branch_id', None)
where_branch = ""
params = []
if branch_id:
where_branch = "AND i.branch_id = %s"
params.append(branch_id)
# 1. Total active SKUs
cur.execute(f"""
cur.execute("""
SELECT COUNT(*) FROM inventory i
WHERE i.is_active = true {where_branch}
""", params.copy())
WHERE i.is_active = true
""")
total_skus = cur.fetchone()[0] or 0
# 2. Total inventory value (cost * stock)
cur.execute(f"""
cur.execute("""
SELECT COALESCE(SUM(i.cost * COALESCE(s.stock, 0)), 0)
FROM inventory i
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
WHERE i.is_active = true {where_branch}
""", params.copy())
WHERE i.is_active = true
""")
total_value = float(cur.fetchone()[0] or 0)
# 3. Low stock count (below min_stock)
cur.execute(f"""
cur.execute("""
SELECT COUNT(*)
FROM inventory i
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
WHERE i.is_active = true {where_branch}
WHERE i.is_active = true
AND i.min_stock IS NOT NULL AND i.min_stock > 0
AND COALESCE(s.stock, 0) < i.min_stock
""", params.copy())
""")
low_stock = cur.fetchone()[0] or 0
# 4. No movement in last 60 days
cutoff = datetime.utcnow() - timedelta(days=60)
cur.execute(f"""
cur.execute("""
SELECT COUNT(*)
FROM inventory i
WHERE i.is_active = true {where_branch}
WHERE i.is_active = true
AND i.id NOT IN (
SELECT inventory_id FROM inventory_operations
WHERE created_at > %s
)
""", params + [cutoff])
""", (cutoff,))
no_movement = cur.fetchone()[0] or 0
cur.close(); conn.close()
@@ -1478,34 +1464,40 @@ def report_valuation():
cur = conn.cursor()
branch_id = request.args.get('branch_id', g.branch_id)
where = "i.is_active = true"
params = []
if branch_id:
where += " AND i.branch_id = %s"
params.append(branch_id)
sql = """
SELECT i.id, i.part_number, i.name, i.brand, i.cost,
COALESCE(ist.stock, 0) AS stock,
COALESCE(ist.stock, 0) * COALESCE(i.cost, 0) AS value
FROM inventory i
LEFT JOIN inventory_stock ist ON ist.inventory_id = i.id AND ist.branch_id = %s
WHERE i.is_active = true
ORDER BY value DESC
"""
params = [branch_id]
else:
sql = """
SELECT i.id, i.part_number, i.name, i.brand, i.cost,
COALESCE(s.stock, 0) AS stock,
COALESCE(s.stock, 0) * COALESCE(i.cost, 0) AS value
FROM inventory i
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
WHERE i.is_active = true
ORDER BY value DESC
"""
params = []
cur.execute(f"""
SELECT i.id, i.part_number, i.name, i.brand, i.cost, i.branch_id,
COALESCE(s.stock, 0) AS stock,
COALESCE(s.stock, 0) * COALESCE(i.cost, 0) AS value
FROM inventory i
LEFT JOIN (
SELECT inventory_id, SUM(quantity) AS stock
FROM inventory_operations GROUP BY inventory_id
) s ON s.inventory_id = i.id
WHERE {where}
ORDER BY value DESC
""", params)
cur.execute(sql, params)
items = []
grand_total = 0
for r in cur.fetchall():
val = float(r[7])
val = float(r[6])
grand_total += val
items.append({
'id': r[0], 'part_number': r[1], 'name': r[2], 'brand': r[3],
'cost': float(r[4]) if r[4] else 0, 'branch_id': r[5],
'stock': r[6], 'value': round(val, 2)
'cost': float(r[4]) if r[4] else 0,
'stock': r[5], 'value': round(val, 2)
})
cur.close(); conn.close()
@@ -1581,32 +1573,22 @@ def report_no_movement():
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
days = int(request.args.get('days', 60))
branch_id = request.args.get('branch_id', g.branch_id)
cutoff = datetime.utcnow() - timedelta(days=days)
where_branch = ""
params_main = []
if branch_id:
where_branch = "AND i.branch_id = %s"
params_main.append(branch_id)
cur.execute(f"""
cur.execute("""
SELECT i.id, i.part_number, i.name, i.brand, i.cost,
COALESCE(s.stock, 0) AS stock,
last_op.last_date
FROM inventory i
LEFT JOIN (
SELECT inventory_id, SUM(quantity) AS stock
FROM inventory_operations GROUP BY inventory_id
) s ON s.inventory_id = i.id
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
LEFT JOIN (
SELECT inventory_id, MAX(created_at) AS last_date
FROM inventory_operations GROUP BY inventory_id
) last_op ON last_op.inventory_id = i.id
WHERE i.is_active = true {where_branch}
WHERE i.is_active = true
AND (last_op.last_date IS NULL OR last_op.last_date < %s)
ORDER BY last_op.last_date ASC NULLS FIRST
""", params_main + [cutoff])
""", (cutoff,))
items = []
for r in cur.fetchall():
@@ -1626,28 +1608,17 @@ def report_low_stock():
"""Items below their min_stock threshold."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
branch_id = request.args.get('branch_id', g.branch_id)
where_branch = ""
params = []
if branch_id:
where_branch = "AND i.branch_id = %s"
params.append(branch_id)
cur.execute(f"""
cur.execute("""
SELECT i.id, i.part_number, i.name, i.brand, i.min_stock,
COALESCE(s.stock, 0) AS stock,
i.min_stock - COALESCE(s.stock, 0) AS deficit
FROM inventory i
LEFT JOIN (
SELECT inventory_id, SUM(quantity) AS stock
FROM inventory_operations GROUP BY inventory_id
) s ON s.inventory_id = i.id
WHERE i.is_active = true {where_branch}
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
WHERE i.is_active = true
AND i.min_stock IS NOT NULL AND i.min_stock > 0
AND COALESCE(s.stock, 0) < i.min_stock
ORDER BY deficit DESC
""", params)
""")
items = []
for r in cur.fetchall():
@@ -1668,15 +1639,13 @@ def report_branch_comparison():
cur = conn.cursor()
cur.execute("""
SELECT i.id, i.part_number, i.name, i.brand, i.branch_id,
SELECT i.id, i.part_number, i.name, i.brand,
ist.branch_id,
b.name AS branch_name,
COALESCE(s.stock, 0) AS stock
COALESCE(ist.stock, 0) AS stock
FROM inventory i
LEFT JOIN branches b ON i.branch_id = b.id
LEFT JOIN (
SELECT inventory_id, SUM(quantity) AS stock
FROM inventory_operations GROUP BY inventory_id
) s ON s.inventory_id = i.id
LEFT JOIN inventory_stock ist ON ist.inventory_id = i.id
LEFT JOIN branches b ON ist.branch_id = b.id
WHERE i.is_active = true
ORDER BY i.part_number, b.name
""")
@@ -1687,10 +1656,11 @@ def report_branch_comparison():
pn = r[1]
if pn not in by_part:
by_part[pn] = {'part_number': pn, 'name': r[2], 'brand': r[3], 'branches': []}
by_part[pn]['branches'].append({
'inventory_id': r[0], 'branch_id': r[4],
'branch_name': r[5], 'stock': r[6]
})
if r[4] is not None:
by_part[pn]['branches'].append({
'inventory_id': r[0], 'branch_id': r[4],
'branch_name': r[5], 'stock': r[6]
})
cur.close(); conn.close()
items = list(by_part.values())
@@ -1753,12 +1723,11 @@ def api_stock_by_branch():
cur = conn.cursor()
cur.execute("""
SELECT b.id, b.name, b.address,
COALESCE(SUM(io.quantity), 0) as stock
COALESCE(ist.stock, 0) as stock
FROM branches b
LEFT JOIN inventory_operations io
ON io.branch_id = b.id AND io.inventory_id = %s
LEFT JOIN inventory_stock ist
ON ist.branch_id = b.id AND ist.inventory_id = %s
WHERE b.is_active = true
GROUP BY b.id, b.name, b.address
ORDER BY b.name
""", (inventory_id,))
data = []