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:
@@ -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 []})
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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