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

@@ -379,7 +379,7 @@ def parts():
if use_nexpart_nav:
result = catalog_service.get_parts_for_nexpart_triple(
master, mye_id, nexpart_group, nexpart_subgroup, nexpart_part_type,
tenant, branch_id, _page, _per_page,
tenant, branch_id, _page, _per_page, tenant_id=g.tenant_id,
)
elif mode == 'local':
result = catalog_service.get_parts_local(
@@ -426,7 +426,7 @@ def search():
mye_id = request.args.get('mye_id', type=int)
def _do(master, tenant, branch_id):
allowed_brands = _get_allowed_brands(tenant) if tenant else None
data = catalog_service.smart_search(master, q, tenant, branch_id, limit, mye_id)
data = catalog_service.smart_search(master, q, tenant, branch_id, limit, mye_id, tenant_id=g.tenant_id)
if allowed_brands:
data = _filter_parts_by_allowed_brands(master, data, allowed_brands)
return jsonify({'data': data, 'allowed_brands': allowed_brands or []})

View File

@@ -13,15 +13,53 @@ config_bp = Blueprint('config', __name__, url_prefix='/pos/api/config')
def list_branches():
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT id, name, address, phone, is_active FROM branches ORDER BY id")
cur.execute("""
SELECT id, name, address, phone, is_active, is_main,
rfc, razon_social, regimen_fiscal, codigo_postal,
serie_cfdi, folio_inicial, licencia_fiscal
FROM branches ORDER BY id
""")
branches = []
for r in cur.fetchall():
branches.append({'id': r[0], 'name': r[1], 'address': r[2], 'phone': r[3], 'is_active': r[4]})
branches.append({
'id': r[0], 'name': r[1], 'address': r[2], 'phone': r[3],
'is_active': r[4], 'is_main': r[5],
'rfc': r[6], 'razon_social': r[7], 'regimen_fiscal': r[8],
'codigo_postal': r[9], 'serie_cfdi': r[10],
'folio_inicial': r[11], 'licencia_fiscal': r[12],
})
cur.close()
conn.close()
return jsonify({'data': branches})
@config_bp.route('/branches/<int:branch_id>', methods=['GET'])
@require_auth('config.view')
def get_branch(branch_id):
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT id, name, address, phone, is_active, is_main,
rfc, razon_social, regimen_fiscal, codigo_postal,
serie_cfdi, folio_inicial, licencia_fiscal,
certificado_pem, llave_pem
FROM branches WHERE id = %s
""", (branch_id,))
r = cur.fetchone()
cur.close()
conn.close()
if not r:
return jsonify({'error': 'Branch not found'}), 404
return jsonify({
'id': r[0], 'name': r[1], 'address': r[2], 'phone': r[3],
'is_active': r[4], 'is_main': r[5],
'rfc': r[6], 'razon_social': r[7], 'regimen_fiscal': r[8],
'codigo_postal': r[9], 'serie_cfdi': r[10],
'folio_inicial': r[11], 'licencia_fiscal': r[12],
'certificado_pem': r[13], 'llave_pem': r[14],
})
@config_bp.route('/branches', methods=['POST'])
@require_auth('config.edit')
def create_branch():
@@ -47,10 +85,25 @@ def create_branch():
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
# If setting as main, clear any existing main
if data.get('is_main'):
cur.execute("UPDATE branches SET is_main = false WHERE is_main = true")
cur.execute("""
INSERT INTO branches (name, address, phone)
VALUES (%s, %s, %s) RETURNING id
""", (data['name'], data.get('address'), data.get('phone')))
INSERT INTO branches (
name, address, phone, is_main,
rfc, razon_social, regimen_fiscal, codigo_postal,
serie_cfdi, folio_inicial, licencia_fiscal,
certificado_pem, llave_pem
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id
""", (
data['name'], data.get('address'), data.get('phone'), bool(data.get('is_main')),
data.get('rfc'), data.get('razon_social'), data.get('regimen_fiscal'), data.get('codigo_postal'),
data.get('serie_cfdi'), data.get('folio_inicial'), data.get('licencia_fiscal'),
data.get('certificado_pem'), data.get('llave_pem'),
))
branch_id = cur.fetchone()[0]
conn.commit()
cur.close()
@@ -58,6 +111,50 @@ def create_branch():
return jsonify({'id': branch_id, 'message': 'Branch created'}), 201
@config_bp.route('/branches/<int:branch_id>', methods=['PUT'])
@require_auth('config.edit')
def update_branch(branch_id):
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT id FROM branches WHERE id = %s", (branch_id,))
if not cur.fetchone():
cur.close(); conn.close()
return jsonify({'error': 'Branch not found'}), 404
# If setting as main, clear any existing main
if data.get('is_main'):
cur.execute("UPDATE branches SET is_main = false WHERE is_main = true AND id <> %s", (branch_id,))
updates = []
params = []
field_map = {
'name': 'name', 'address': 'address', 'phone': 'phone',
'is_active': 'is_active', 'is_main': 'is_main',
'rfc': 'rfc', 'razon_social': 'razon_social',
'regimen_fiscal': 'regimen_fiscal', 'codigo_postal': 'codigo_postal',
'serie_cfdi': 'serie_cfdi', 'folio_inicial': 'folio_inicial',
'licencia_fiscal': 'licencia_fiscal',
'certificado_pem': 'certificado_pem', 'llave_pem': 'llave_pem',
}
for json_key, col in field_map.items():
if json_key in data:
updates.append(f"{col} = %s")
params.append(data[json_key])
if not updates:
cur.close(); conn.close()
return jsonify({'error': 'Nothing to update'}), 400
params.append(branch_id)
cur.execute(f"UPDATE branches SET {', '.join(updates)} WHERE id = %s", params)
conn.commit()
cur.close()
conn.close()
return jsonify({'ok': True, 'message': 'Branch updated'})
@config_bp.route('/employees', methods=['GET'])
@require_auth('config.view')
def list_employees():

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 = []

View File

@@ -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],
})

View File

@@ -15,6 +15,7 @@ from services.pos_engine import (
process_sale, cancel_sale, calculate_totals,
get_price_for_customer, get_margin_info
)
from services.inventory_engine import get_stock
from services.audit import log_action
from config import JWT_SECRET
@@ -34,7 +35,7 @@ def _enrich_items(cur, items, customer_id=None):
# Batch fetch all inventory items in one query
cur.execute("""
SELECT id, part_number, name, cost, price_1, price_2, price_3,
tax_rate, branch_id
tax_rate
FROM inventory WHERE id = ANY(%s) AND is_active = true
""", (inv_ids,))
inv_map = {r[0]: r for r in cur.fetchall()}
@@ -75,7 +76,6 @@ def _enrich_items(cur, items, customer_id=None):
'unit_cost': float(inv[3]) if inv[3] else 0,
'discount_pct': discount_pct,
'tax_rate': tax_rate,
'branch_id': inv[8],
})
return enriched
@@ -103,6 +103,19 @@ def create_sale():
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
# Verify stock availability per item for the active branch
branch_id = data.get('branch_id', g.branch_id)
for item in data.get('items', []):
inv_id = item.get('inventory_id')
qty = int(item.get('quantity', 1))
if inv_id:
available = get_stock(conn, inv_id, branch_id)
if available < qty:
conn.close()
return jsonify({
'error': f'Insufficient stock for item {inv_id}. Available: {available}, requested: {qty}'
}), 400
try:
sale = process_sale(conn, data)
conn.commit()

View File

@@ -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,
})