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:
|
if use_nexpart_nav:
|
||||||
result = catalog_service.get_parts_for_nexpart_triple(
|
result = catalog_service.get_parts_for_nexpart_triple(
|
||||||
master, mye_id, nexpart_group, nexpart_subgroup, nexpart_part_type,
|
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':
|
elif mode == 'local':
|
||||||
result = catalog_service.get_parts_local(
|
result = catalog_service.get_parts_local(
|
||||||
@@ -426,7 +426,7 @@ def search():
|
|||||||
mye_id = request.args.get('mye_id', type=int)
|
mye_id = request.args.get('mye_id', type=int)
|
||||||
def _do(master, tenant, branch_id):
|
def _do(master, tenant, branch_id):
|
||||||
allowed_brands = _get_allowed_brands(tenant) if tenant else None
|
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:
|
if allowed_brands:
|
||||||
data = _filter_parts_by_allowed_brands(master, data, allowed_brands)
|
data = _filter_parts_by_allowed_brands(master, data, allowed_brands)
|
||||||
return jsonify({'data': data, 'allowed_brands': allowed_brands or []})
|
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():
|
def list_branches():
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
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 = []
|
branches = []
|
||||||
for r in cur.fetchall():
|
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()
|
cur.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({'data': branches})
|
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'])
|
@config_bp.route('/branches', methods=['POST'])
|
||||||
@require_auth('config.edit')
|
@require_auth('config.edit')
|
||||||
def create_branch():
|
def create_branch():
|
||||||
@@ -47,10 +85,25 @@ def create_branch():
|
|||||||
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
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("""
|
cur.execute("""
|
||||||
INSERT INTO branches (name, address, phone)
|
INSERT INTO branches (
|
||||||
VALUES (%s, %s, %s) RETURNING id
|
name, address, phone, is_main,
|
||||||
""", (data['name'], data.get('address'), data.get('phone')))
|
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]
|
branch_id = cur.fetchone()[0]
|
||||||
conn.commit()
|
conn.commit()
|
||||||
cur.close()
|
cur.close()
|
||||||
@@ -58,6 +111,50 @@ def create_branch():
|
|||||||
return jsonify({'id': branch_id, 'message': 'Branch created'}), 201
|
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'])
|
@config_bp.route('/employees', methods=['GET'])
|
||||||
@require_auth('config.view')
|
@require_auth('config.view')
|
||||||
def list_employees():
|
def list_employees():
|
||||||
|
|||||||
@@ -100,9 +100,8 @@ def list_items():
|
|||||||
where_clauses = ["i.is_active = true"]
|
where_clauses = ["i.is_active = true"]
|
||||||
params = []
|
params = []
|
||||||
|
|
||||||
if branch_id:
|
# branch_id no longer filters inventory rows (shared catalog).
|
||||||
where_clauses.append("i.branch_id = %s")
|
# It is used only to show per-branch stock.
|
||||||
params.append(branch_id)
|
|
||||||
if search:
|
if search:
|
||||||
where_clauses.append("(i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s)")
|
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}%'])
|
params.extend([f'%{search}%', f'%{search}%', f'%{search}%'])
|
||||||
@@ -116,93 +115,91 @@ def list_items():
|
|||||||
where = " AND ".join(where_clauses)
|
where = " AND ".join(where_clauses)
|
||||||
|
|
||||||
if low_stock:
|
if low_stock:
|
||||||
# low_stock filter: JOIN with stock subquery, filter items where stock < min_stock
|
# low_stock filter: JOIN with total stock summary
|
||||||
# This keeps pagination accurate because the filter is in the SQL WHERE clause.
|
|
||||||
count_sql = f"""
|
count_sql = f"""
|
||||||
SELECT count(*) FROM inventory i
|
SELECT count(*) FROM inventory i
|
||||||
LEFT JOIN (
|
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
|
||||||
SELECT inventory_id, COALESCE(SUM(quantity), 0) AS stock
|
|
||||||
FROM inventory_operations GROUP BY inventory_id
|
|
||||||
) s ON s.inventory_id = i.id
|
|
||||||
WHERE {where} AND i.min_stock IS NOT NULL AND i.min_stock > 0
|
WHERE {where} AND i.min_stock IS NOT NULL AND i.min_stock > 0
|
||||||
AND COALESCE(s.stock, 0) < i.min_stock
|
AND COALESCE(s.stock, 0) < i.min_stock
|
||||||
"""
|
"""
|
||||||
cur.execute(count_sql, params)
|
cur.execute(count_sql, params)
|
||||||
total = cur.fetchone()[0]
|
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"""
|
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.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,
|
||||||
COALESCE(s.stock, 0) AS stock
|
{stock_select}
|
||||||
FROM inventory i
|
FROM inventory i
|
||||||
LEFT JOIN (
|
{stock_join}
|
||||||
SELECT inventory_id, COALESCE(SUM(quantity), 0) AS stock
|
|
||||||
FROM inventory_operations GROUP BY inventory_id
|
|
||||||
) s ON s.inventory_id = i.id
|
|
||||||
WHERE {where} AND i.min_stock IS NOT NULL AND i.min_stock > 0
|
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
|
ORDER BY i.name
|
||||||
LIMIT %s OFFSET %s
|
LIMIT %s OFFSET %s
|
||||||
""", params + [per_page, (page - 1) * per_page])
|
""", stock_params + params + [per_page, (page - 1) * per_page])
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
for r in cur.fetchall():
|
for r in cur.fetchall():
|
||||||
items.append({
|
items.append({
|
||||||
'id': r[0], 'branch_id': r[1], 'part_number': r[2], 'barcode': r[3],
|
'id': r[0], 'part_number': r[1], 'barcode': r[2],
|
||||||
'name': r[4], 'description': r[5], 'category_id': r[6], 'brand': r[7],
|
'name': r[3], 'description': r[4], 'category_id': r[5], 'brand': r[6],
|
||||||
'unit': r[8], 'cost': float(r[9]) if r[9] else 0,
|
'unit': r[7], 'cost': float(r[8]) if r[8] else 0,
|
||||||
'price_1': float(r[10]) if r[10] else 0,
|
'price_1': float(r[9]) if r[9] else 0,
|
||||||
'price_2': float(r[11]) if r[11] else 0,
|
'price_2': float(r[10]) if r[10] else 0,
|
||||||
'price_3': float(r[12]) if r[12] else 0,
|
'price_3': float(r[11]) if r[11] else 0,
|
||||||
'tax_rate': float(r[13]) if r[13] else 0.16,
|
'tax_rate': float(r[12]) if r[12] else 0.16,
|
||||||
'min_stock': r[14], 'max_stock': r[15], 'location': r[16],
|
'min_stock': r[13], 'max_stock': r[14], 'location': r[15],
|
||||||
'image_url': r[17], 'catalog_part_id': r[18],
|
'image_url': r[16], 'catalog_part_id': r[17],
|
||||||
'stock': r[19]
|
'stock': r[18]
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
# Normal path: count, fetch items, then bulk-lookup stock
|
# Normal path: count, fetch items, then bulk-lookup stock
|
||||||
cur.execute(f"SELECT count(*) FROM inventory i WHERE {where}", params)
|
cur.execute(f"SELECT count(*) FROM inventory i WHERE {where}", params)
|
||||||
total = cur.fetchone()[0]
|
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"""
|
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.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
|
FROM inventory i
|
||||||
|
{stock_join}
|
||||||
WHERE {where}
|
WHERE {where}
|
||||||
ORDER BY i.name
|
ORDER BY i.name
|
||||||
LIMIT %s OFFSET %s
|
LIMIT %s OFFSET %s
|
||||||
""", params + [per_page, (page - 1) * per_page])
|
""", stock_params + 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()}
|
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
for r in items_raw:
|
for r in cur.fetchall():
|
||||||
stock = stock_map.get(r[0], 0)
|
|
||||||
items.append({
|
items.append({
|
||||||
'id': r[0], 'branch_id': r[1], 'part_number': r[2], 'barcode': r[3],
|
'id': r[0], 'part_number': r[1], 'barcode': r[2],
|
||||||
'name': r[4], 'description': r[5], 'category_id': r[6], 'brand': r[7],
|
'name': r[3], 'description': r[4], 'category_id': r[5], 'brand': r[6],
|
||||||
'unit': r[8], 'cost': float(r[9]) if r[9] else 0,
|
'unit': r[7], 'cost': float(r[8]) if r[8] else 0,
|
||||||
'price_1': float(r[10]) if r[10] else 0,
|
'price_1': float(r[9]) if r[9] else 0,
|
||||||
'price_2': float(r[11]) if r[11] else 0,
|
'price_2': float(r[10]) if r[10] else 0,
|
||||||
'price_3': float(r[12]) if r[12] else 0,
|
'price_3': float(r[11]) if r[11] else 0,
|
||||||
'tax_rate': float(r[13]) if r[13] else 0.16,
|
'tax_rate': float(r[12]) if r[12] else 0.16,
|
||||||
'min_stock': r[14], 'max_stock': r[15], 'location': r[16],
|
'min_stock': r[13], 'max_stock': r[14], 'location': r[15],
|
||||||
'image_url': r[17], 'catalog_part_id': r[18],
|
'image_url': r[16], 'catalog_part_id': r[17],
|
||||||
'stock': stock
|
'stock': r[18]
|
||||||
})
|
})
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
@@ -222,9 +219,8 @@ def get_item(item_id):
|
|||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT i.*, b.name as branch_name, c.name as category_name
|
SELECT i.*, c.name as category_name
|
||||||
FROM inventory i
|
FROM inventory i
|
||||||
LEFT JOIN branches b ON i.branch_id = b.id
|
|
||||||
LEFT JOIN categories c ON i.category_id = c.id
|
LEFT JOIN categories c ON i.category_id = c.id
|
||||||
WHERE i.id = %s
|
WHERE i.id = %s
|
||||||
""", (item_id,))
|
""", (item_id,))
|
||||||
@@ -240,7 +236,8 @@ def get_item(item_id):
|
|||||||
if item.get(k) is not None:
|
if item.get(k) is not None:
|
||||||
item[k] = float(item[k])
|
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)
|
item['history'] = get_movement_history(conn, item_id, limit=20)
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
@@ -259,8 +256,6 @@ def create_item():
|
|||||||
return jsonify({'error': f'{f} required'}), 400
|
return jsonify({'error': f'{f} required'}), 400
|
||||||
|
|
||||||
branch_id = data.get('branch_id', g.branch_id)
|
branch_id = data.get('branch_id', g.branch_id)
|
||||||
if not branch_id:
|
|
||||||
return jsonify({'error': 'branch_id required'}), 400
|
|
||||||
|
|
||||||
# Plan limit check
|
# Plan limit check
|
||||||
from services.billing import check_limit, next_plan, PLANS, get_plan
|
from services.billing import check_limit, next_plan, PLANS, get_plan
|
||||||
@@ -307,13 +302,13 @@ def create_item():
|
|||||||
try:
|
try:
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO inventory
|
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,
|
vehicle_compatibility, unit, cost, price_1, price_2, price_3, tax_rate,
|
||||||
min_stock, max_stock, location, image_url, catalog_part_id)
|
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
|
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'),
|
data.get('description'), data.get('category_id'), data.get('brand'),
|
||||||
json.dumps(data.get('vehicle_compatibility')) if data.get('vehicle_compatibility') else None,
|
json.dumps(data.get('vehicle_compatibility')) if data.get('vehicle_compatibility') else None,
|
||||||
data.get('unit', 'PZA'), data.get('cost', 0),
|
data.get('unit', 'PZA'), data.get('cost', 0),
|
||||||
@@ -324,9 +319,9 @@ def create_item():
|
|||||||
))
|
))
|
||||||
item_id = cur.fetchone()[0]
|
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)
|
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'))
|
record_initial(conn, item_id, branch_id, initial_stock, data.get('cost'))
|
||||||
|
|
||||||
# Insert SKU aliases if provided
|
# Insert SKU aliases if provided
|
||||||
@@ -409,8 +404,8 @@ def create_item():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
cur.close(); conn.close()
|
cur.close(); conn.close()
|
||||||
if 'idx_inventory_branch_part' in str(e):
|
if 'idx_inventory_part_unique' in str(e):
|
||||||
return jsonify({'error': 'Part number already exists in this branch'}), 409
|
return jsonify({'error': 'Part number already exists'}), 409
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@@ -555,8 +550,8 @@ def bulk_import_items():
|
|||||||
description = str(row.get('description', '')).strip()
|
description = str(row.get('description', '')).strip()
|
||||||
category = str(row.get('category', '')).strip()
|
category = str(row.get('category', '')).strip()
|
||||||
|
|
||||||
# Check if item already exists for this branch
|
# Check if item already exists (catalog is shared across branches)
|
||||||
cur.execute("SELECT id FROM inventory WHERE branch_id = %s AND part_number = %s", (branch_id, part_number))
|
cur.execute("SELECT id FROM inventory WHERE part_number = %s", (part_number,))
|
||||||
existing = cur.fetchone()
|
existing = cur.fetchone()
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
@@ -569,13 +564,12 @@ def bulk_import_items():
|
|||||||
brand = COALESCE(NULLIF(%s,''), brand),
|
brand = COALESCE(NULLIF(%s,''), brand),
|
||||||
cost = CASE WHEN %s > 0 THEN %s ELSE cost END,
|
cost = CASE WHEN %s > 0 THEN %s ELSE cost END,
|
||||||
price_1 = CASE WHEN %s > 0 THEN %s ELSE price_1 END,
|
price_1 = CASE WHEN %s > 0 THEN %s ELSE price_1 END,
|
||||||
stock = stock + %s,
|
|
||||||
location = COALESCE(NULLIF(%s,''), location),
|
location = COALESCE(NULLIF(%s,''), location),
|
||||||
description = COALESCE(NULLIF(%s,''), description),
|
description = COALESCE(NULLIF(%s,''), description),
|
||||||
category = COALESCE(NULLIF(%s,''), category)
|
category = COALESCE(NULLIF(%s,''), category)
|
||||||
WHERE id = %s
|
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
|
was_inserted = False
|
||||||
# Record stock adjustment for existing item if stock > 0
|
# Record stock adjustment for existing item if stock > 0
|
||||||
@@ -588,11 +582,11 @@ def bulk_import_items():
|
|||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO inventory
|
INSERT INTO inventory
|
||||||
(branch_id, part_number, barcode, name, brand, cost, price_1, location, description, category, unit)
|
(part_number, barcode, name, brand, cost, price_1, location, description, category, unit)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
RETURNING id
|
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]
|
item_id = cur.fetchone()[0]
|
||||||
was_inserted = True
|
was_inserted = True
|
||||||
@@ -1332,7 +1326,7 @@ def api_inventory_stats():
|
|||||||
branch_id = getattr(g, 'branch_id', None)
|
branch_id = getattr(g, 'branch_id', None)
|
||||||
|
|
||||||
# Stock count
|
# 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]
|
stock = cur.fetchone()[0]
|
||||||
|
|
||||||
# Operations counts by type
|
# Operations counts by type
|
||||||
@@ -1373,52 +1367,44 @@ def api_inventory_summary():
|
|||||||
"""Get high-level summary counts for the inventory dashboard badges."""
|
"""Get high-level summary counts for the inventory dashboard badges."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
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
|
# 1. Total active SKUs
|
||||||
cur.execute(f"""
|
cur.execute("""
|
||||||
SELECT COUNT(*) FROM inventory i
|
SELECT COUNT(*) FROM inventory i
|
||||||
WHERE i.is_active = true {where_branch}
|
WHERE i.is_active = true
|
||||||
""", params.copy())
|
""")
|
||||||
total_skus = cur.fetchone()[0] or 0
|
total_skus = cur.fetchone()[0] or 0
|
||||||
|
|
||||||
# 2. Total inventory value (cost * stock)
|
# 2. Total inventory value (cost * stock)
|
||||||
cur.execute(f"""
|
cur.execute("""
|
||||||
SELECT COALESCE(SUM(i.cost * COALESCE(s.stock, 0)), 0)
|
SELECT COALESCE(SUM(i.cost * COALESCE(s.stock, 0)), 0)
|
||||||
FROM inventory i
|
FROM inventory i
|
||||||
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
|
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
|
||||||
WHERE i.is_active = true {where_branch}
|
WHERE i.is_active = true
|
||||||
""", params.copy())
|
""")
|
||||||
total_value = float(cur.fetchone()[0] or 0)
|
total_value = float(cur.fetchone()[0] or 0)
|
||||||
|
|
||||||
# 3. Low stock count (below min_stock)
|
# 3. Low stock count (below min_stock)
|
||||||
cur.execute(f"""
|
cur.execute("""
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM inventory i
|
FROM inventory i
|
||||||
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
|
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 i.min_stock IS NOT NULL AND i.min_stock > 0
|
||||||
AND COALESCE(s.stock, 0) < i.min_stock
|
AND COALESCE(s.stock, 0) < i.min_stock
|
||||||
""", params.copy())
|
""")
|
||||||
low_stock = cur.fetchone()[0] or 0
|
low_stock = cur.fetchone()[0] or 0
|
||||||
|
|
||||||
# 4. No movement in last 60 days
|
# 4. No movement in last 60 days
|
||||||
cutoff = datetime.utcnow() - timedelta(days=60)
|
cutoff = datetime.utcnow() - timedelta(days=60)
|
||||||
cur.execute(f"""
|
cur.execute("""
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM inventory i
|
FROM inventory i
|
||||||
WHERE i.is_active = true {where_branch}
|
WHERE i.is_active = true
|
||||||
AND i.id NOT IN (
|
AND i.id NOT IN (
|
||||||
SELECT inventory_id FROM inventory_operations
|
SELECT inventory_id FROM inventory_operations
|
||||||
WHERE created_at > %s
|
WHERE created_at > %s
|
||||||
)
|
)
|
||||||
""", params + [cutoff])
|
""", (cutoff,))
|
||||||
no_movement = cur.fetchone()[0] or 0
|
no_movement = cur.fetchone()[0] or 0
|
||||||
|
|
||||||
cur.close(); conn.close()
|
cur.close(); conn.close()
|
||||||
@@ -1478,34 +1464,40 @@ def report_valuation():
|
|||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
branch_id = request.args.get('branch_id', g.branch_id)
|
branch_id = request.args.get('branch_id', g.branch_id)
|
||||||
|
|
||||||
where = "i.is_active = true"
|
|
||||||
params = []
|
|
||||||
if branch_id:
|
if branch_id:
|
||||||
where += " AND i.branch_id = %s"
|
sql = """
|
||||||
params.append(branch_id)
|
SELECT i.id, i.part_number, i.name, i.brand, i.cost,
|
||||||
|
COALESCE(ist.stock, 0) AS stock,
|
||||||
cur.execute(f"""
|
COALESCE(ist.stock, 0) * COALESCE(i.cost, 0) AS value
|
||||||
SELECT i.id, i.part_number, i.name, i.brand, i.cost, i.branch_id,
|
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) AS stock,
|
||||||
COALESCE(s.stock, 0) * COALESCE(i.cost, 0) AS value
|
COALESCE(s.stock, 0) * COALESCE(i.cost, 0) AS value
|
||||||
FROM inventory i
|
FROM inventory i
|
||||||
LEFT JOIN (
|
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
|
||||||
SELECT inventory_id, SUM(quantity) AS stock
|
WHERE i.is_active = true
|
||||||
FROM inventory_operations GROUP BY inventory_id
|
|
||||||
) s ON s.inventory_id = i.id
|
|
||||||
WHERE {where}
|
|
||||||
ORDER BY value DESC
|
ORDER BY value DESC
|
||||||
""", params)
|
"""
|
||||||
|
params = []
|
||||||
|
|
||||||
|
cur.execute(sql, params)
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
grand_total = 0
|
grand_total = 0
|
||||||
for r in cur.fetchall():
|
for r in cur.fetchall():
|
||||||
val = float(r[7])
|
val = float(r[6])
|
||||||
grand_total += val
|
grand_total += val
|
||||||
items.append({
|
items.append({
|
||||||
'id': r[0], 'part_number': r[1], 'name': r[2], 'brand': r[3],
|
'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],
|
'cost': float(r[4]) if r[4] else 0,
|
||||||
'stock': r[6], 'value': round(val, 2)
|
'stock': r[5], 'value': round(val, 2)
|
||||||
})
|
})
|
||||||
|
|
||||||
cur.close(); conn.close()
|
cur.close(); conn.close()
|
||||||
@@ -1581,32 +1573,22 @@ def report_no_movement():
|
|||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
days = int(request.args.get('days', 60))
|
days = int(request.args.get('days', 60))
|
||||||
branch_id = request.args.get('branch_id', g.branch_id)
|
|
||||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||||
|
|
||||||
where_branch = ""
|
cur.execute("""
|
||||||
params_main = []
|
|
||||||
if branch_id:
|
|
||||||
where_branch = "AND i.branch_id = %s"
|
|
||||||
params_main.append(branch_id)
|
|
||||||
|
|
||||||
cur.execute(f"""
|
|
||||||
SELECT i.id, i.part_number, i.name, i.brand, i.cost,
|
SELECT i.id, i.part_number, i.name, i.brand, i.cost,
|
||||||
COALESCE(s.stock, 0) AS stock,
|
COALESCE(s.stock, 0) AS stock,
|
||||||
last_op.last_date
|
last_op.last_date
|
||||||
FROM inventory i
|
FROM inventory i
|
||||||
LEFT JOIN (
|
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
|
||||||
SELECT inventory_id, SUM(quantity) AS stock
|
|
||||||
FROM inventory_operations GROUP BY inventory_id
|
|
||||||
) s ON s.inventory_id = i.id
|
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT inventory_id, MAX(created_at) AS last_date
|
SELECT inventory_id, MAX(created_at) AS last_date
|
||||||
FROM inventory_operations GROUP BY inventory_id
|
FROM inventory_operations GROUP BY inventory_id
|
||||||
) last_op ON last_op.inventory_id = i.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)
|
AND (last_op.last_date IS NULL OR last_op.last_date < %s)
|
||||||
ORDER BY last_op.last_date ASC NULLS FIRST
|
ORDER BY last_op.last_date ASC NULLS FIRST
|
||||||
""", params_main + [cutoff])
|
""", (cutoff,))
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
for r in cur.fetchall():
|
for r in cur.fetchall():
|
||||||
@@ -1626,28 +1608,17 @@ def report_low_stock():
|
|||||||
"""Items below their min_stock threshold."""
|
"""Items below their min_stock threshold."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
branch_id = request.args.get('branch_id', g.branch_id)
|
cur.execute("""
|
||||||
|
|
||||||
where_branch = ""
|
|
||||||
params = []
|
|
||||||
if branch_id:
|
|
||||||
where_branch = "AND i.branch_id = %s"
|
|
||||||
params.append(branch_id)
|
|
||||||
|
|
||||||
cur.execute(f"""
|
|
||||||
SELECT i.id, i.part_number, i.name, i.brand, i.min_stock,
|
SELECT i.id, i.part_number, i.name, i.brand, i.min_stock,
|
||||||
COALESCE(s.stock, 0) AS stock,
|
COALESCE(s.stock, 0) AS stock,
|
||||||
i.min_stock - COALESCE(s.stock, 0) AS deficit
|
i.min_stock - COALESCE(s.stock, 0) AS deficit
|
||||||
FROM inventory i
|
FROM inventory i
|
||||||
LEFT JOIN (
|
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
|
||||||
SELECT inventory_id, SUM(quantity) AS stock
|
WHERE i.is_active = true
|
||||||
FROM inventory_operations GROUP BY inventory_id
|
|
||||||
) s ON s.inventory_id = i.id
|
|
||||||
WHERE i.is_active = true {where_branch}
|
|
||||||
AND i.min_stock IS NOT NULL AND i.min_stock > 0
|
AND i.min_stock IS NOT NULL AND i.min_stock > 0
|
||||||
AND COALESCE(s.stock, 0) < i.min_stock
|
AND COALESCE(s.stock, 0) < i.min_stock
|
||||||
ORDER BY deficit DESC
|
ORDER BY deficit DESC
|
||||||
""", params)
|
""")
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
for r in cur.fetchall():
|
for r in cur.fetchall():
|
||||||
@@ -1668,15 +1639,13 @@ def report_branch_comparison():
|
|||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
cur.execute("""
|
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,
|
b.name AS branch_name,
|
||||||
COALESCE(s.stock, 0) AS stock
|
COALESCE(ist.stock, 0) AS stock
|
||||||
FROM inventory i
|
FROM inventory i
|
||||||
LEFT JOIN branches b ON i.branch_id = b.id
|
LEFT JOIN inventory_stock ist ON ist.inventory_id = i.id
|
||||||
LEFT JOIN (
|
LEFT JOIN branches b ON ist.branch_id = b.id
|
||||||
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 i.is_active = true
|
||||||
ORDER BY i.part_number, b.name
|
ORDER BY i.part_number, b.name
|
||||||
""")
|
""")
|
||||||
@@ -1687,6 +1656,7 @@ def report_branch_comparison():
|
|||||||
pn = r[1]
|
pn = r[1]
|
||||||
if pn not in by_part:
|
if pn not in by_part:
|
||||||
by_part[pn] = {'part_number': pn, 'name': r[2], 'brand': r[3], 'branches': []}
|
by_part[pn] = {'part_number': pn, 'name': r[2], 'brand': r[3], 'branches': []}
|
||||||
|
if r[4] is not None:
|
||||||
by_part[pn]['branches'].append({
|
by_part[pn]['branches'].append({
|
||||||
'inventory_id': r[0], 'branch_id': r[4],
|
'inventory_id': r[0], 'branch_id': r[4],
|
||||||
'branch_name': r[5], 'stock': r[6]
|
'branch_name': r[5], 'stock': r[6]
|
||||||
@@ -1753,12 +1723,11 @@ def api_stock_by_branch():
|
|||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT b.id, b.name, b.address,
|
SELECT b.id, b.name, b.address,
|
||||||
COALESCE(SUM(io.quantity), 0) as stock
|
COALESCE(ist.stock, 0) as stock
|
||||||
FROM branches b
|
FROM branches b
|
||||||
LEFT JOIN inventory_operations io
|
LEFT JOIN inventory_stock ist
|
||||||
ON io.branch_id = b.id AND io.inventory_id = %s
|
ON ist.branch_id = b.id AND ist.inventory_id = %s
|
||||||
WHERE b.is_active = true
|
WHERE b.is_active = true
|
||||||
GROUP BY b.id, b.name, b.address
|
|
||||||
ORDER BY b.name
|
ORDER BY b.name
|
||||||
""", (inventory_id,))
|
""", (inventory_id,))
|
||||||
data = []
|
data = []
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ This blueprint is the HTTP layer that validates input and returns JSON.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from datetime import datetime
|
||||||
from flask import Blueprint, request, jsonify, g
|
from flask import Blueprint, request, jsonify, g
|
||||||
from middleware import require_auth
|
from middleware import require_auth
|
||||||
from tenant_db import get_tenant_conn
|
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')
|
invoicing_bp = Blueprint('invoicing', __name__, url_prefix='/pos/api/invoicing')
|
||||||
|
|
||||||
|
|
||||||
def _get_tenant_config(cur):
|
def _get_issuer_config(cur, branch_id=None):
|
||||||
"""Load tenant CFDI configuration from tenant_config table.
|
"""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 = {}
|
config = {}
|
||||||
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'cfdi_%' OR key LIKE 'tenant_%'")
|
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'cfdi_%' OR key LIKE 'tenant_%'")
|
||||||
for row in cur.fetchall():
|
for row in cur.fetchall():
|
||||||
config[row[0]] = row[1]
|
config[row[0]] = row[1]
|
||||||
|
|
||||||
return {
|
result = {
|
||||||
'rfc': config.get('tenant_rfc', ''),
|
'rfc': config.get('tenant_rfc', ''),
|
||||||
'razon_social': config.get('tenant_razon_social', ''),
|
'razon_social': config.get('tenant_razon_social', ''),
|
||||||
'regimen_fiscal': config.get('cfdi_regimen_fiscal', '601'),
|
'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', ''),
|
'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):
|
def _get_sale_with_items(cur, sale_id):
|
||||||
"""Load a sale with its items for CFDI generation."""
|
"""Load a sale with its items for CFDI generation."""
|
||||||
@@ -134,14 +153,14 @@ def generate_invoice():
|
|||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
try:
|
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)
|
sale = _get_sale_with_items(cur, sale_id)
|
||||||
if not sale:
|
if not sale:
|
||||||
return jsonify({'error': 'Sale not found'}), 404
|
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':
|
if sale['status'] == 'cancelled':
|
||||||
return jsonify({'error': 'Cannot invoice a cancelled sale'}), 400
|
return jsonify({'error': 'Cannot invoice a cancelled sale'}), 400
|
||||||
|
|
||||||
@@ -261,7 +280,7 @@ def trigger_process_queue():
|
|||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tenant_config = _get_tenant_config(cur)
|
tenant_config = _get_issuer_config(cur)
|
||||||
horux_url = tenant_config.get('horux_api_url')
|
horux_url = tenant_config.get('horux_api_url')
|
||||||
horux_key = tenant_config.get('horux_api_key')
|
horux_key = tenant_config.get('horux_api_key')
|
||||||
|
|
||||||
@@ -316,7 +335,7 @@ def cancel_invoice(cfdi_id):
|
|||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tenant_config = _get_tenant_config(cur)
|
tenant_config = _get_issuer_config(cur)
|
||||||
result = cancel_cfdi(
|
result = cancel_cfdi(
|
||||||
conn, cfdi_id, motive, replacement_uuid,
|
conn, cfdi_id, motive, replacement_uuid,
|
||||||
tenant_config.get('horux_api_url'),
|
tenant_config.get('horux_api_url'),
|
||||||
@@ -362,7 +381,7 @@ def get_sale_pdf(sale_id):
|
|||||||
cur.close(); conn.close()
|
cur.close(); conn.close()
|
||||||
return jsonify({'error': 'Sale not found'}), 404
|
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'))
|
customer = _get_customer(cur, sale.get('customer_id'))
|
||||||
|
|
||||||
# Check if there's a stamped CFDI
|
# Check if there's a stamped CFDI
|
||||||
@@ -424,3 +443,102 @@ def api_invoicing_stats():
|
|||||||
'complementos': row[2] or 0,
|
'complementos': row[2] or 0,
|
||||||
'cancelaciones': row[3] 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,
|
process_sale, cancel_sale, calculate_totals,
|
||||||
get_price_for_customer, get_margin_info
|
get_price_for_customer, get_margin_info
|
||||||
)
|
)
|
||||||
|
from services.inventory_engine import get_stock
|
||||||
from services.audit import log_action
|
from services.audit import log_action
|
||||||
from config import JWT_SECRET
|
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
|
# Batch fetch all inventory items in one query
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT id, part_number, name, cost, price_1, price_2, price_3,
|
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
|
FROM inventory WHERE id = ANY(%s) AND is_active = true
|
||||||
""", (inv_ids,))
|
""", (inv_ids,))
|
||||||
inv_map = {r[0]: r for r in cur.fetchall()}
|
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,
|
'unit_cost': float(inv[3]) if inv[3] else 0,
|
||||||
'discount_pct': discount_pct,
|
'discount_pct': discount_pct,
|
||||||
'tax_rate': tax_rate,
|
'tax_rate': tax_rate,
|
||||||
'branch_id': inv[8],
|
|
||||||
})
|
})
|
||||||
return enriched
|
return enriched
|
||||||
|
|
||||||
@@ -103,6 +103,19 @@ def create_sale():
|
|||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
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:
|
try:
|
||||||
sale = process_sale(conn, data)
|
sale = process_sale(conn, data)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ Independent from inventory. Supports:
|
|||||||
- Bulk import via Excel
|
- Bulk import via Excel
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from datetime import date
|
||||||
from flask import Blueprint, request, jsonify, g, render_template
|
from flask import Blueprint, request, jsonify, g, render_template
|
||||||
from psycopg2.extras import RealDictCursor
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
@@ -276,3 +279,260 @@ def delete_item(item_id):
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
cur.close(); conn.close()
|
cur.close(); conn.close()
|
||||||
return jsonify({'success': True})
|
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,
|
||||||
|
})
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ MIGRATIONS = {
|
|||||||
'v3.1': 'v3.1_inventory_vehicle_compat.sql',
|
'v3.1': 'v3.1_inventory_vehicle_compat.sql',
|
||||||
'v3.2': 'v3.2_db_performance.sql',
|
'v3.2': 'v3.2_db_performance.sql',
|
||||||
'v3.8': 'v3.8_supplier_catalog.sql',
|
'v3.8': 'v3.8_supplier_catalog.sql',
|
||||||
|
'v3.9': 'v3.9_supplier_catalog_prices.sql',
|
||||||
|
'v4.0': 'v4.0_multi_branch.sql',
|
||||||
|
'v4.1': 'v4.1_global_invoice.sql',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
40
pos/migrations/v3.9_supplier_catalog_prices.sql
Normal file
40
pos/migrations/v3.9_supplier_catalog_prices.sql
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
-- v3.9_supplier_catalog_prices.sql
|
||||||
|
-- Per-tenant supplier pricing for items in the master supplier_catalog.
|
||||||
|
-- This table lives in the master DB and is joined by tenant_id.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS supplier_catalog_prices (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
catalog_id INTEGER NOT NULL REFERENCES supplier_catalog(id) ON DELETE CASCADE,
|
||||||
|
price NUMERIC(12,2) NOT NULL,
|
||||||
|
currency VARCHAR(3) DEFAULT 'MXN',
|
||||||
|
effective_from DATE DEFAULT CURRENT_DATE,
|
||||||
|
effective_to DATE,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(tenant_id, catalog_id, effective_from)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_catalog_prices_tenant_catalog
|
||||||
|
ON supplier_catalog_prices(tenant_id, catalog_id, effective_from DESC)
|
||||||
|
WHERE is_active = true;
|
||||||
|
|
||||||
|
-- Index for quick "latest active price" lookups per tenant+item.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_catalog_prices_lookup
|
||||||
|
ON supplier_catalog_prices(tenant_id, catalog_id, effective_from DESC, is_active);
|
||||||
|
|
||||||
|
-- Trigger to keep updated_at current on row changes.
|
||||||
|
CREATE OR REPLACE FUNCTION update_supplier_catalog_prices_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_supplier_catalog_prices_updated_at ON supplier_catalog_prices;
|
||||||
|
CREATE TRIGGER trg_supplier_catalog_prices_updated_at
|
||||||
|
BEFORE UPDATE ON supplier_catalog_prices
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_supplier_catalog_prices_updated_at();
|
||||||
246
pos/migrations/v4.0_multi_branch.sql
Normal file
246
pos/migrations/v4.0_multi_branch.sql
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
-- v4.0_multi_branch.sql
|
||||||
|
-- Multi-branch overhaul: branch fiscal data + shared inventory with per-branch stock.
|
||||||
|
-- WARNING: this migration restructures inventory data. A full DB backup is required.
|
||||||
|
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 1. BRANCHES: fiscal fields + main flag
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
ALTER TABLE branches
|
||||||
|
ADD COLUMN IF NOT EXISTS is_main BOOLEAN DEFAULT FALSE,
|
||||||
|
ADD COLUMN IF NOT EXISTS rfc VARCHAR(13),
|
||||||
|
ADD COLUMN IF NOT EXISTS razon_social VARCHAR(300),
|
||||||
|
ADD COLUMN IF NOT EXISTS regimen_fiscal VARCHAR(10),
|
||||||
|
ADD COLUMN IF NOT EXISTS cp VARCHAR(5),
|
||||||
|
ADD COLUMN IF NOT EXISTS direccion_fiscal TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS serie_cfdi VARCHAR(10) DEFAULT 'A',
|
||||||
|
ADD COLUMN IF NOT EXISTS folio_inicio INTEGER DEFAULT 1,
|
||||||
|
ADD COLUMN IF NOT EXISTS folio_actual INTEGER DEFAULT 1,
|
||||||
|
ADD COLUMN IF NOT EXISTS email VARCHAR(200);
|
||||||
|
|
||||||
|
-- Ensure at least one branch is marked main (the first one created).
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
main_branch_id INTEGER;
|
||||||
|
branch_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO branch_count FROM branches;
|
||||||
|
|
||||||
|
IF branch_count = 0 THEN
|
||||||
|
INSERT INTO branches (name, is_main)
|
||||||
|
VALUES ('Principal', TRUE);
|
||||||
|
ELSE
|
||||||
|
SELECT id INTO main_branch_id FROM branches ORDER BY id LIMIT 1;
|
||||||
|
|
||||||
|
UPDATE branches SET is_main = FALSE;
|
||||||
|
UPDATE branches SET is_main = TRUE WHERE id = main_branch_id;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Constraint: only one main branch per tenant.
|
||||||
|
-- Because this runs inside a single tenant DB, a simple partial unique index is enough.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_branches_single_main
|
||||||
|
ON branches (is_main)
|
||||||
|
WHERE is_main = TRUE;
|
||||||
|
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 2. INVENTORY STOCK: new per-branch stock table
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS inventory_stock (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
inventory_id INTEGER NOT NULL REFERENCES inventory(id) ON DELETE CASCADE,
|
||||||
|
branch_id INTEGER NOT NULL REFERENCES branches(id) ON DELETE CASCADE,
|
||||||
|
stock INTEGER DEFAULT 0,
|
||||||
|
location VARCHAR(50),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(inventory_id, branch_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_inventory_stock_branch ON inventory_stock(branch_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_inventory_stock_inventory ON inventory_stock(inventory_id);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION update_inventory_stock_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_inventory_stock_updated_at ON inventory_stock;
|
||||||
|
CREATE TRIGGER trg_inventory_stock_updated_at
|
||||||
|
BEFORE UPDATE ON inventory_stock
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_inventory_stock_updated_at();
|
||||||
|
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 3. INVENTORY: make branch_id nullable + prepare for consolidation
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
-- Drop the old unique constraint that forces one record per (branch, part_number).
|
||||||
|
DROP INDEX IF EXISTS idx_inventory_branch_part;
|
||||||
|
|
||||||
|
-- Make branch_id nullable so we can have master records without a branch.
|
||||||
|
ALTER TABLE inventory ALTER COLUMN branch_id DROP NOT NULL;
|
||||||
|
|
||||||
|
-- Add unique constraint on part_number at tenant level so a product exists once.
|
||||||
|
-- If duplicates still exist this will fail, so we consolidate below first.
|
||||||
|
-- We create it at the end of this migration after deduplication.
|
||||||
|
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 4. DATA MIGRATION: consolidate duplicated inventory rows by part_number
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
-- Build a mapping: for each duplicated part_number, choose the master record.
|
||||||
|
-- Master = record belonging to the main branch; fallback = oldest id.
|
||||||
|
CREATE TEMP TABLE _inventory_master_map AS
|
||||||
|
SELECT DISTINCT ON (part_number)
|
||||||
|
id AS master_id,
|
||||||
|
part_number
|
||||||
|
FROM inventory
|
||||||
|
ORDER BY part_number,
|
||||||
|
CASE WHEN branch_id = (SELECT id FROM branches WHERE is_main = TRUE LIMIT 1) THEN 0 ELSE 1 END,
|
||||||
|
id ASC;
|
||||||
|
|
||||||
|
-- Create temp table of duplicates (all rows that are NOT the master for their part_number).
|
||||||
|
CREATE TEMP TABLE _inventory_duplicates AS
|
||||||
|
SELECT i.id AS duplicate_id, m.master_id
|
||||||
|
FROM inventory i
|
||||||
|
JOIN _inventory_master_map m ON i.part_number = m.part_number
|
||||||
|
WHERE i.id <> m.master_id;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- Compute per-duplicate stock and insert into inventory_stock against master_id + duplicate's branch.
|
||||||
|
INSERT INTO inventory_stock (inventory_id, branch_id, stock, location)
|
||||||
|
SELECT
|
||||||
|
dups.master_id,
|
||||||
|
dups.branch_id,
|
||||||
|
GREATEST(0, COALESCE(stock_by_dup.stock, 0))::int,
|
||||||
|
dups.location
|
||||||
|
FROM (
|
||||||
|
SELECT d.master_id, d.duplicate_id, i.branch_id, i.location
|
||||||
|
FROM _inventory_duplicates d
|
||||||
|
JOIN inventory i ON i.id = d.duplicate_id
|
||||||
|
) dups
|
||||||
|
JOIN LATERAL (
|
||||||
|
SELECT COALESCE(SUM(quantity), 0) AS stock
|
||||||
|
FROM inventory_operations
|
||||||
|
WHERE inventory_id = dups.duplicate_id AND branch_id = dups.branch_id
|
||||||
|
) stock_by_dup ON TRUE
|
||||||
|
ON CONFLICT (inventory_id, branch_id) DO UPDATE
|
||||||
|
SET stock = inventory_stock.stock + EXCLUDED.stock;
|
||||||
|
|
||||||
|
-- Also migrate stock from master records themselves (they were already in inventory.branch_id).
|
||||||
|
INSERT INTO inventory_stock (inventory_id, branch_id, stock, location)
|
||||||
|
SELECT
|
||||||
|
i.id,
|
||||||
|
i.branch_id,
|
||||||
|
GREATEST(0, COALESCE(stock_by_inv.stock, 0))::int,
|
||||||
|
i.location
|
||||||
|
FROM inventory i
|
||||||
|
JOIN _inventory_master_map m ON i.id = m.master_id
|
||||||
|
JOIN LATERAL (
|
||||||
|
SELECT COALESCE(SUM(quantity), 0) AS stock
|
||||||
|
FROM inventory_operations
|
||||||
|
WHERE inventory_id = i.id AND branch_id = i.branch_id
|
||||||
|
) stock_by_inv ON TRUE
|
||||||
|
WHERE i.branch_id IS NOT NULL
|
||||||
|
ON CONFLICT (inventory_id, branch_id) DO UPDATE
|
||||||
|
SET stock = EXCLUDED.stock;
|
||||||
|
|
||||||
|
-- Handle inventory_stock_summary specially: it has PK on inventory_id.
|
||||||
|
-- If master already has a summary row, add duplicate's stock and remove duplicate row.
|
||||||
|
-- Otherwise repoint the duplicate row to master.
|
||||||
|
UPDATE inventory_stock_summary s
|
||||||
|
SET stock = s.stock + d.stock
|
||||||
|
FROM (
|
||||||
|
SELECT duplicate_id, master_id, stock FROM inventory_stock_summary ss
|
||||||
|
JOIN _inventory_duplicates m ON ss.inventory_id = m.duplicate_id
|
||||||
|
WHERE EXISTS (SELECT 1 FROM inventory_stock_summary sm WHERE sm.inventory_id = m.master_id)
|
||||||
|
) d
|
||||||
|
WHERE s.inventory_id = d.master_id;
|
||||||
|
|
||||||
|
DELETE FROM inventory_stock_summary
|
||||||
|
WHERE inventory_id IN (
|
||||||
|
SELECT m.duplicate_id
|
||||||
|
FROM _inventory_duplicates m
|
||||||
|
WHERE EXISTS (SELECT 1 FROM inventory_stock_summary sm WHERE sm.inventory_id = m.master_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE inventory_stock_summary
|
||||||
|
SET inventory_id = m.master_id
|
||||||
|
FROM _inventory_duplicates m
|
||||||
|
WHERE inventory_id = m.duplicate_id;
|
||||||
|
|
||||||
|
-- Update FK references from duplicate inventory rows to master inventory rows.
|
||||||
|
-- We use dynamic SQL to update every known referencing table.
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
rec RECORD;
|
||||||
|
fk_sql TEXT;
|
||||||
|
BEGIN
|
||||||
|
FOR rec IN
|
||||||
|
SELECT
|
||||||
|
tc.table_name,
|
||||||
|
kcu.column_name
|
||||||
|
FROM information_schema.table_constraints tc
|
||||||
|
JOIN information_schema.key_column_usage kcu
|
||||||
|
ON tc.constraint_name = kcu.constraint_name
|
||||||
|
JOIN information_schema.constraint_column_usage ccu
|
||||||
|
ON ccu.constraint_name = tc.constraint_name
|
||||||
|
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||||
|
AND ccu.table_name = 'inventory'
|
||||||
|
AND tc.table_name <> 'inventory_stock_summary'
|
||||||
|
LOOP
|
||||||
|
fk_sql := format(
|
||||||
|
'UPDATE %I SET %I = m.master_id FROM _inventory_duplicates m WHERE %I = m.duplicate_id',
|
||||||
|
rec.table_name, rec.column_name, rec.column_name
|
||||||
|
);
|
||||||
|
EXECUTE fk_sql;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Delete duplicate inventory rows now that FKs are repointed.
|
||||||
|
DELETE FROM inventory
|
||||||
|
WHERE id IN (SELECT duplicate_id FROM _inventory_duplicates);
|
||||||
|
|
||||||
|
-- Clean up master records: remove branch_id so they become shared catalog items.
|
||||||
|
UPDATE inventory SET branch_id = NULL WHERE branch_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Now safe to enforce uniqueness at tenant level.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_inventory_part_unique ON inventory (part_number);
|
||||||
|
|
||||||
|
-- Clean temp tables.
|
||||||
|
DROP TABLE IF EXISTS _inventory_master_map;
|
||||||
|
DROP TABLE IF EXISTS _inventory_duplicates;
|
||||||
|
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 5. CFDI_QUEUE: allow sale_id to be NULL for global invoices (Phase 3 prep)
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
ALTER TABLE cfdi_queue ALTER COLUMN sale_id DROP NOT NULL;
|
||||||
|
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 6. TRIGGER: Keep inventory_stock in sync with inventory_operations
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION update_inventory_stock()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO inventory_stock (inventory_id, branch_id, stock)
|
||||||
|
VALUES (NEW.inventory_id, NEW.branch_id, NEW.quantity)
|
||||||
|
ON CONFLICT (inventory_id, branch_id) DO UPDATE
|
||||||
|
SET stock = inventory_stock.stock + EXCLUDED.stock,
|
||||||
|
updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_update_inventory_stock ON inventory_operations;
|
||||||
|
CREATE TRIGGER trg_update_inventory_stock
|
||||||
|
AFTER INSERT ON inventory_operations
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_inventory_stock();
|
||||||
17
pos/migrations/v4.1_global_invoice.sql
Normal file
17
pos/migrations/v4.1_global_invoice.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
-- v4.1 — Global Invoice (Factura Global Mensual)
|
||||||
|
-- Supports grouping cash sales (<= $2,000) into a single monthly CFDI.
|
||||||
|
|
||||||
|
-- Link global invoices to their constituent sales
|
||||||
|
CREATE TABLE IF NOT EXISTS global_invoice_sales (
|
||||||
|
global_invoice_id INTEGER NOT NULL REFERENCES cfdi_queue(id) ON DELETE CASCADE,
|
||||||
|
sale_id INTEGER NOT NULL REFERENCES sales(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (global_invoice_id, sale_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gis_global ON global_invoice_sales(global_invoice_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gis_sale ON global_invoice_sales(sale_id);
|
||||||
|
|
||||||
|
-- Track which sales have been included in any global invoice
|
||||||
|
-- (quick lookup without joining global_invoice_sales)
|
||||||
|
ALTER TABLE sales ADD COLUMN IF NOT EXISTS global_invoiced_at TIMESTAMPTZ;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sales_global_invoiced_at ON sales(global_invoiced_at) WHERE global_invoiced_at IS NULL;
|
||||||
@@ -95,8 +95,8 @@ def _clean_model_name(name):
|
|||||||
s = re.sub(r'\s*\([^)]*\)\s*', '', s)
|
s = re.sub(r'\s*\([^)]*\)\s*', '', s)
|
||||||
# Remove Roman numeral generation suffixes: I, II, III, IV, V, VI, VII, VIII, IX, X
|
# Remove Roman numeral generation suffixes: I, II, III, IV, V, VI, VII, VIII, IX, X
|
||||||
s = re.sub(r'\s+(?:VIII|VII|VI|IV|IX|III|II|V|X|I)(?:\s|$)', ' ', s)
|
s = re.sub(r'\s+(?:VIII|VII|VI|IV|IX|III|II|V|X|I)(?:\s|$)', ' ', s)
|
||||||
# Remove body type suffixes
|
# Remove body type suffixes (keep Saloon/Hatchback/Sedan/Wagon as they distinguish variants)
|
||||||
s = re.sub(r'\s+(?:Estate|Saloon|Hatchback|Van|Coupe|Coupé|Convertible|Wagon|Pickup|Cab|Sedan|SUV|MPV|Kombi|Kasten|Bus|Box|Platform|Chassis)\b', '', s, flags=re.IGNORECASE)
|
s = re.sub(r'\s+(?:Estate|Van|Coupe|Coupé|Convertible|Pickup|Cab|SUV|MPV|Kombi|Kasten|Bus|Box|Platform|Chassis)\b', '', s, flags=re.IGNORECASE)
|
||||||
# Remove truck cab/bed suffixes: CREW, EXTENDED, STANDARD, HD, etc.
|
# Remove truck cab/bed suffixes: CREW, EXTENDED, STANDARD, HD, etc.
|
||||||
s = re.sub(r'\s+(?:CREW|EXTENDED|STANDARD|CUTAWAY|PASSENGER|CARGO)\b', '', s, flags=re.IGNORECASE)
|
s = re.sub(r'\s+(?:CREW|EXTENDED|STANDARD|CUTAWAY|PASSENGER|CARGO)\b', '', s, flags=re.IGNORECASE)
|
||||||
# Remove "HD", "&" suffixes that create fake variants
|
# Remove "HD", "&" suffixes that create fake variants
|
||||||
@@ -285,13 +285,15 @@ def get_models(master_conn, brand_id, year_id=None, brand_name=None, mye_ids=Non
|
|||||||
# Filter to North America models only, add clean display name, deduplicate
|
# Filter to North America models only, add clean display name, deduplicate
|
||||||
filtered = [r for r in rows if is_na_model(brand_name, r[1])]
|
filtered = [r for r in rows if is_na_model(brand_name, r[1])]
|
||||||
|
|
||||||
# Group by clean name — keep all id_models but show one display name
|
# Group by (display_name, raw name) so distinct body-style variants
|
||||||
seen = {} # display_name → first row
|
# (e.g. AVEO vs AVEO SALOON) remain selectable.
|
||||||
|
seen = set()
|
||||||
results = []
|
results = []
|
||||||
for r in filtered:
|
for r in filtered:
|
||||||
display = _clean_model_name(r[1])
|
display = _clean_model_name(r[1])
|
||||||
if display not in seen:
|
key = (display, r[1])
|
||||||
seen[display] = True
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
results.append({
|
results.append({
|
||||||
'id_model': r[0],
|
'id_model': r[0],
|
||||||
'name_model': r[1],
|
'name_model': r[1],
|
||||||
@@ -508,7 +510,7 @@ _SPANISH_KEYWORDS = [
|
|||||||
(("Steering & Suspension Parts", "Sway Bars, Stabilizer Bars, Strut Rods & Parts", "Suspension Stabilizer Bar Link"),
|
(("Steering & Suspension Parts", "Sway Bars, Stabilizer Bars, Strut Rods & Parts", "Suspension Stabilizer Bar Link"),
|
||||||
["bieleta", "estabilizador"]),
|
["bieleta", "estabilizador"]),
|
||||||
(("Steering & Suspension Parts", "Steering Linkages, Rods & Arms", "Steering Tie Rod End"),
|
(("Steering & Suspension Parts", "Steering Linkages, Rods & Arms", "Steering Tie Rod End"),
|
||||||
["terminal", "rotula direccion", "rotula de direccion"]),
|
["terminal", "rotula direccion", "rotula de direccion", "espiga"]),
|
||||||
(("Steering & Suspension Parts", "Ball Joints & Control Arms", "Suspension Ball Joint"),
|
(("Steering & Suspension Parts", "Ball Joints & Control Arms", "Suspension Ball Joint"),
|
||||||
["rotula"]),
|
["rotula"]),
|
||||||
(("Steering & Suspension Parts", "Ball Joints & Control Arms", "Suspension Control Arm Bushing"),
|
(("Steering & Suspension Parts", "Ball Joints & Control Arms", "Suspension Control Arm Bushing"),
|
||||||
@@ -617,7 +619,7 @@ def _spanish_name_to_nexpart(name, category=None):
|
|||||||
"""
|
"""
|
||||||
if not name:
|
if not name:
|
||||||
return None
|
return None
|
||||||
name_lower = name.lower().replace('_', ' ')
|
name_lower = name.lower().replace('_', ' ').replace('\n', ' ').replace('\r', '')
|
||||||
|
|
||||||
# 1. Keyword match (most specific first)
|
# 1. Keyword match (most specific first)
|
||||||
for triple, keywords in _SPANISH_KEYWORDS:
|
for triple, keywords in _SPANISH_KEYWORDS:
|
||||||
@@ -1134,7 +1136,7 @@ def _local_name_matches_part_type(name, part_type_slug):
|
|||||||
|
|
||||||
def get_parts_for_nexpart_triple(master_conn, mye_id, group_slug, subgroup_slug,
|
def get_parts_for_nexpart_triple(master_conn, mye_id, group_slug, subgroup_slug,
|
||||||
part_type_slug, tenant_conn, branch_id,
|
part_type_slug, tenant_conn, branch_id,
|
||||||
page=1, per_page=30):
|
page=1, per_page=30, tenant_id=None):
|
||||||
"""Local mode: fetch parts (aftermarket-prioritized) for a Nexpart triple.
|
"""Local mode: fetch parts (aftermarket-prioritized) for a Nexpart triple.
|
||||||
|
|
||||||
Steps:
|
Steps:
|
||||||
@@ -1242,9 +1244,14 @@ def get_parts_for_nexpart_triple(master_conn, mye_id, group_slug, subgroup_slug,
|
|||||||
WHERE id = ANY(%s)
|
WHERE id = ANY(%s)
|
||||||
ORDER BY name
|
ORDER BY name
|
||||||
""", (sc_id_values,))
|
""", (sc_id_values,))
|
||||||
for row in cur.fetchall():
|
sc_rows = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
sc_prices = _get_supplier_prices(master_conn, tenant_id, sc_id_values)
|
||||||
|
|
||||||
|
for row in sc_rows:
|
||||||
sc_id, supplier, sku, name, category, desc, img = row
|
sc_id, supplier, sku, name, category, desc, img = row
|
||||||
result['data'].append({
|
item = {
|
||||||
'id_part': f'sc:{sc_id}',
|
'id_part': f'sc:{sc_id}',
|
||||||
'id_aftermarket': None,
|
'id_aftermarket': None,
|
||||||
'oem_part_number': sku,
|
'oem_part_number': sku,
|
||||||
@@ -1262,8 +1269,12 @@ def get_parts_for_nexpart_triple(master_conn, mye_id, group_slug, subgroup_slug,
|
|||||||
'in_stock_network': False,
|
'in_stock_network': False,
|
||||||
'price_usd': None,
|
'price_usd': None,
|
||||||
'source': 'supplier_catalog',
|
'source': 'supplier_catalog',
|
||||||
})
|
}
|
||||||
cur.close()
|
p = sc_prices.get(sc_id)
|
||||||
|
if p:
|
||||||
|
item['supplier_price'] = p['price']
|
||||||
|
item['supplier_currency'] = p['currency']
|
||||||
|
result['data'].append(item)
|
||||||
|
|
||||||
# Sort combined list and paginate in Python
|
# Sort combined list and paginate in Python
|
||||||
all_items = result['data']
|
all_items = result['data']
|
||||||
@@ -1299,11 +1310,13 @@ def get_nexpart_part_types_for_vehicle(master_conn, mye_id, group_slug, subgroup
|
|||||||
if not subgroup_data:
|
if not subgroup_data:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Pull a sample image for each part type — single query, all part_ids at once
|
# Pull a sample image for each part type — single query, all OEM part_ids at once
|
||||||
|
# Only integer IDs exist in the TecDoc parts table; skip inv: and sc: prefixed IDs.
|
||||||
all_part_ids = [
|
all_part_ids = [
|
||||||
pid
|
pid
|
||||||
for pids in subgroup_data.values()
|
for pids in subgroup_data.values()
|
||||||
for pid in pids
|
for pid in pids
|
||||||
|
if isinstance(pid, int)
|
||||||
]
|
]
|
||||||
image_map = {}
|
image_map = {}
|
||||||
if all_part_ids:
|
if all_part_ids:
|
||||||
@@ -1902,7 +1915,7 @@ def _search_meili_fallback(master_conn, q, limit):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None):
|
def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None, tenant_id=None):
|
||||||
"""Search parts by part number or text. Enriches with local stock.
|
"""Search parts by part number or text. Enriches with local stock.
|
||||||
|
|
||||||
Strategy:
|
Strategy:
|
||||||
@@ -1945,8 +1958,8 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None):
|
|||||||
break
|
break
|
||||||
|
|
||||||
# ── Inject supplier catalog items ───────────────────────────────────────
|
# ── Inject supplier catalog items ───────────────────────────────────────
|
||||||
if tenant_conn:
|
if master_conn:
|
||||||
supplier_items = _search_supplier_catalog(tenant_conn, q, mye_id, limit)
|
supplier_items = _search_supplier_catalog(master_conn, q, mye_id, limit, tenant_id=tenant_id)
|
||||||
for si in supplier_items:
|
for si in supplier_items:
|
||||||
if f"sc:{si['id']}" in seen_local_ids:
|
if f"sc:{si['id']}" in seen_local_ids:
|
||||||
continue
|
continue
|
||||||
@@ -1957,6 +1970,8 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None):
|
|||||||
'image_url': si['image_url'],
|
'image_url': si['image_url'],
|
||||||
'local_stock': None,
|
'local_stock': None,
|
||||||
'local_price': None,
|
'local_price': None,
|
||||||
|
'supplier_price': si.get('supplier_price'),
|
||||||
|
'supplier_currency': si.get('supplier_currency'),
|
||||||
'vehicle_info': si['category'] or '',
|
'vehicle_info': si['category'] or '',
|
||||||
'source': 'supplier_catalog',
|
'source': 'supplier_catalog',
|
||||||
})
|
})
|
||||||
@@ -1967,14 +1982,36 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None):
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def _search_supplier_catalog(tenant_conn, q, mye_id, limit):
|
def _get_supplier_prices(master_conn, tenant_id, catalog_ids):
|
||||||
|
"""Return a dict catalog_id -> {price, currency} for the current active price."""
|
||||||
|
if master_conn is None or not tenant_id or not catalog_ids:
|
||||||
|
return {}
|
||||||
|
cur = master_conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT DISTINCT ON (catalog_id)
|
||||||
|
catalog_id, price, currency
|
||||||
|
FROM supplier_catalog_prices
|
||||||
|
WHERE tenant_id = %s AND catalog_id = ANY(%s) AND is_active = true
|
||||||
|
AND (effective_to IS NULL OR effective_to >= CURRENT_DATE)
|
||||||
|
ORDER BY catalog_id, effective_from DESC
|
||||||
|
""", (tenant_id, list(catalog_ids)))
|
||||||
|
prices = {}
|
||||||
|
for r in cur.fetchall():
|
||||||
|
prices[r[0]] = {'price': float(r[1]) if r[1] is not None else None,
|
||||||
|
'currency': r[2] or 'MXN'}
|
||||||
|
cur.close()
|
||||||
|
return prices
|
||||||
|
|
||||||
|
|
||||||
|
def _search_supplier_catalog(master_conn, q, mye_id, limit, tenant_id=None):
|
||||||
"""Search supplier catalog items by SKU or name.
|
"""Search supplier catalog items by SKU or name.
|
||||||
|
|
||||||
If mye_id is provided, only returns items compatible with that vehicle.
|
If mye_id is provided, only returns items compatible with that vehicle.
|
||||||
|
Enriches each item with the tenant-specific supplier price when tenant_id is given.
|
||||||
"""
|
"""
|
||||||
if tenant_conn is None:
|
if master_conn is None:
|
||||||
return []
|
return []
|
||||||
cur = tenant_conn.cursor()
|
cur = master_conn.cursor()
|
||||||
clean_q = q.replace(' ', '').upper()
|
clean_q = q.replace(' ', '').upper()
|
||||||
|
|
||||||
_SQL_UNACCENT = """
|
_SQL_UNACCENT = """
|
||||||
@@ -2016,10 +2053,19 @@ def _search_supplier_catalog(tenant_conn, q, mye_id, limit):
|
|||||||
|
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
cur.close()
|
cur.close()
|
||||||
return [
|
|
||||||
{'id': r[0], 'sku': r[1], 'name': r[2], 'image_url': r[3], 'category': r[4]}
|
catalog_ids = [r[0] for r in rows]
|
||||||
for r in rows
|
prices = _get_supplier_prices(master_conn, tenant_id, catalog_ids)
|
||||||
]
|
|
||||||
|
results = []
|
||||||
|
for r in rows:
|
||||||
|
item = {'id': r[0], 'sku': r[1], 'name': r[2], 'image_url': r[3], 'category': r[4]}
|
||||||
|
p = prices.get(r[0])
|
||||||
|
if p:
|
||||||
|
item['supplier_price'] = p['price']
|
||||||
|
item['supplier_currency'] = p['currency']
|
||||||
|
results.append(item)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
|
def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
|
||||||
|
|||||||
@@ -464,3 +464,130 @@ def build_pago_xml(payment, tenant_config, customer, original_uuid):
|
|||||||
|
|
||||||
return etree.tostring(root, xml_declaration=True, encoding='UTF-8',
|
return etree.tostring(root, xml_declaration=True, encoding='UTF-8',
|
||||||
pretty_print=True).decode('utf-8')
|
pretty_print=True).decode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
def build_global_invoice_xml(sales, tenant_config, year, month):
|
||||||
|
"""Build CFDI 4.0 XML for a monthly global invoice (Factura Global).
|
||||||
|
|
||||||
|
Groups multiple cash sales (PUE, <= $2,000 each, no individual CFDI)
|
||||||
|
into a single CFDI tipo Ingreso with InformacionGlobal.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sales: list of dicts with keys:
|
||||||
|
id, subtotal, discount_total, tax_total, total,
|
||||||
|
items: [{name, quantity, unit_price, discount_amount,
|
||||||
|
tax_rate, tax_amount, subtotal,
|
||||||
|
clave_prod_serv, clave_unidad}]
|
||||||
|
tenant_config: dict with keys:
|
||||||
|
rfc, razon_social, regimen_fiscal, cp, serie (optional)
|
||||||
|
year: int, e.g. 2026
|
||||||
|
month: int, e.g. 6
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: XML string (unsigned, ready for Horux)
|
||||||
|
"""
|
||||||
|
nsmap = {
|
||||||
|
'cfdi': CFDI_NS,
|
||||||
|
'xsi': XSI_NS,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Aggregate totals
|
||||||
|
total_subtotal = Decimal('0')
|
||||||
|
total_discount = Decimal('0')
|
||||||
|
total_tax = Decimal('0')
|
||||||
|
total_total = Decimal('0')
|
||||||
|
for sale in sales:
|
||||||
|
total_subtotal += _to_dec(sale.get('subtotal', 0))
|
||||||
|
total_discount += _to_dec(sale.get('discount_total', 0))
|
||||||
|
total_tax += _to_dec(sale.get('tax_total', 0))
|
||||||
|
total_total += _to_dec(sale.get('total', 0))
|
||||||
|
|
||||||
|
root = etree.Element(f'{{{CFDI_NS}}}Comprobante', nsmap=nsmap)
|
||||||
|
root.set(f'{{{XSI_NS}}}schemaLocation', CFDI_SCHEMA_LOCATION)
|
||||||
|
root.set('Version', '4.0')
|
||||||
|
root.set('Serie', tenant_config.get('serie', 'FG'))
|
||||||
|
root.set('Folio', f'{year}{month:02d}')
|
||||||
|
root.set('Fecha', datetime.now().strftime('%Y-%m-%dT%H:%M:%S'))
|
||||||
|
root.set('FormaPago', '01') # Efectivo (most common for global)
|
||||||
|
root.set('SubTotal', _format_amount(total_subtotal))
|
||||||
|
|
||||||
|
if total_discount > 0:
|
||||||
|
root.set('Descuento', _format_amount(total_discount))
|
||||||
|
|
||||||
|
root.set('Moneda', 'MXN')
|
||||||
|
root.set('Total', _format_amount(total_total))
|
||||||
|
root.set('TipoDeComprobante', 'I') # Ingreso
|
||||||
|
root.set('Exportacion', '01')
|
||||||
|
root.set('MetodoPago', 'PUE')
|
||||||
|
root.set('LugarExpedicion', tenant_config.get('cp', '00000'))
|
||||||
|
|
||||||
|
# InformacionGlobal (monthly global invoice)
|
||||||
|
info_global = _make_element(root, 'InformacionGlobal')
|
||||||
|
info_global.set('Periodicidad', '04') # Mensual
|
||||||
|
info_global.set('Meses', f'{month:02d}')
|
||||||
|
info_global.set('Anio', str(year))
|
||||||
|
|
||||||
|
# Emisor
|
||||||
|
emisor = _make_element(root, 'Emisor')
|
||||||
|
emisor.set('Rfc', tenant_config['rfc'])
|
||||||
|
emisor.set('Nombre', tenant_config['razon_social'])
|
||||||
|
emisor.set('RegimenFiscal', tenant_config.get('regimen_fiscal', '601'))
|
||||||
|
|
||||||
|
# Receptor: Publico en general
|
||||||
|
receptor = _make_element(root, 'Receptor')
|
||||||
|
receptor.set('Rfc', RFC_PUBLICO_GENERAL)
|
||||||
|
receptor.set('Nombre', 'PUBLICO EN GENERAL')
|
||||||
|
receptor.set('DomicilioFiscalReceptor', tenant_config.get('cp', '00000'))
|
||||||
|
receptor.set('RegimenFiscalReceptor', '616')
|
||||||
|
receptor.set('UsoCFDI', 'S01')
|
||||||
|
|
||||||
|
# Conceptos: one per sale item (simplified)
|
||||||
|
conceptos = _make_element(root, 'Conceptos')
|
||||||
|
|
||||||
|
for sale in sales:
|
||||||
|
for item in sale.get('items', []):
|
||||||
|
qty = int(item.get('quantity', 1))
|
||||||
|
unit_price = _to_dec(item.get('unit_price', 0))
|
||||||
|
discount_amount = _to_dec(item.get('discount_amount', 0))
|
||||||
|
tax_rate = _to_dec(item.get('tax_rate', '0.16'))
|
||||||
|
tax_amount = _to_dec(item.get('tax_amount', 0))
|
||||||
|
|
||||||
|
importe = (unit_price * qty).quantize(TWO, ROUND_HALF_UP)
|
||||||
|
base = (importe - discount_amount).quantize(TWO, ROUND_HALF_UP)
|
||||||
|
|
||||||
|
concepto = _make_element(conceptos, 'Concepto')
|
||||||
|
concepto.set('ClaveProdServ', item.get('clave_prod_serv') or '25174800')
|
||||||
|
concepto.set('NoIdentificacion', item.get('part_number') or str(sale['id']))
|
||||||
|
concepto.set('Cantidad', str(qty))
|
||||||
|
concepto.set('ClaveUnidad', item.get('clave_unidad') or 'H87')
|
||||||
|
concepto.set('Unidad', 'PZA')
|
||||||
|
concepto.set('Descripcion', item.get('name') or 'Autoparte')
|
||||||
|
concepto.set('ValorUnitario', _format_amount(unit_price))
|
||||||
|
concepto.set('Importe', _format_amount(importe))
|
||||||
|
concepto.set('ObjetoImp', '02')
|
||||||
|
|
||||||
|
if discount_amount > 0:
|
||||||
|
concepto.set('Descuento', _format_amount(discount_amount))
|
||||||
|
|
||||||
|
impuestos_concepto = _make_element(concepto, 'Impuestos')
|
||||||
|
traslados_concepto = _make_element(impuestos_concepto, 'Traslados')
|
||||||
|
traslado = _make_element(traslados_concepto, 'Traslado')
|
||||||
|
traslado.set('Base', _format_amount(base))
|
||||||
|
traslado.set('Impuesto', '002')
|
||||||
|
traslado.set('TipoFactor', 'Tasa')
|
||||||
|
traslado.set('TasaOCuota', _format_rate(tax_rate))
|
||||||
|
traslado.set('Importe', _format_amount(tax_amount))
|
||||||
|
|
||||||
|
# Impuestos totales
|
||||||
|
impuestos = _make_element(root, 'Impuestos')
|
||||||
|
impuestos.set('TotalImpuestosTrasladados', _format_amount(total_tax))
|
||||||
|
traslados = _make_element(impuestos, 'Traslados')
|
||||||
|
traslado_total = _make_element(traslados, 'Traslado')
|
||||||
|
traslado_total.set('Base', _format_amount(total_subtotal))
|
||||||
|
traslado_total.set('Impuesto', '002')
|
||||||
|
traslado_total.set('TipoFactor', 'Tasa')
|
||||||
|
traslado_total.set('TasaOCuota', '0.160000')
|
||||||
|
traslado_total.set('Importe', _format_amount(total_tax))
|
||||||
|
|
||||||
|
return etree.tostring(root, xml_declaration=True, encoding='UTF-8',
|
||||||
|
pretty_print=True).decode('utf-8')
|
||||||
|
|||||||
210
pos/services/global_invoice.py
Normal file
210
pos/services/global_invoice.py
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
# /home/Autopartes/pos/services/global_invoice.py
|
||||||
|
"""Global invoice (Factura Global) service.
|
||||||
|
|
||||||
|
Groups cash sales (PUE, <= $2,000, no individual CFDI) into a single
|
||||||
|
monthly CFDI with InformacionGlobal per SAT requirements.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from services.cfdi_builder import build_global_invoice_xml
|
||||||
|
from services.cfdi_queue import enqueue_cfdi, _generate_provisional_folio
|
||||||
|
|
||||||
|
|
||||||
|
def get_eligible_sales(conn, year, month, branch_id=None, max_total=2000):
|
||||||
|
"""Find sales eligible for global invoicing.
|
||||||
|
|
||||||
|
Criteria:
|
||||||
|
- Payment method: PUE (paid in full)
|
||||||
|
- Total <= max_total
|
||||||
|
- No individual CFDI stamped
|
||||||
|
- Not already included in a global invoice
|
||||||
|
- Created in the given year/month
|
||||||
|
- Optionally filtered by branch_id
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list of sale dicts with items
|
||||||
|
"""
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Find eligible sale IDs
|
||||||
|
sql = """
|
||||||
|
SELECT s.id
|
||||||
|
FROM sales s
|
||||||
|
WHERE s.metodo_pago_sat = 'PUE'
|
||||||
|
AND s.total <= %s
|
||||||
|
AND s.status = 'completed'
|
||||||
|
AND s.global_invoiced_at IS NULL
|
||||||
|
AND EXTRACT(YEAR FROM s.created_at) = %s
|
||||||
|
AND EXTRACT(MONTH FROM s.created_at) = %s
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM cfdi_queue c
|
||||||
|
WHERE c.sale_id = s.id AND c.status = 'stamped'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
params = [max_total, year, month]
|
||||||
|
|
||||||
|
if branch_id:
|
||||||
|
sql += " AND s.branch_id = %s"
|
||||||
|
params.append(branch_id)
|
||||||
|
|
||||||
|
sql += " ORDER BY s.created_at ASC"
|
||||||
|
|
||||||
|
cur.execute(sql, params)
|
||||||
|
sale_ids = [r[0] for r in cur.fetchall()]
|
||||||
|
|
||||||
|
if not sale_ids:
|
||||||
|
cur.close()
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Load sale details with items
|
||||||
|
sales = []
|
||||||
|
for sale_id in sale_ids:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, branch_id, customer_id, employee_id, sale_type,
|
||||||
|
payment_method, subtotal, discount_total, tax_total, total,
|
||||||
|
metodo_pago_sat, forma_pago_sat, status, created_at
|
||||||
|
FROM sales WHERE id = %s
|
||||||
|
""", (sale_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
continue
|
||||||
|
|
||||||
|
sale = {
|
||||||
|
'id': row[0], 'branch_id': row[1], 'customer_id': row[2],
|
||||||
|
'employee_id': row[3], 'sale_type': row[4],
|
||||||
|
'payment_method': row[5],
|
||||||
|
'subtotal': float(row[6]) if row[6] else 0,
|
||||||
|
'discount_total': float(row[7]) if row[7] else 0,
|
||||||
|
'tax_total': float(row[8]) if row[8] else 0,
|
||||||
|
'total': float(row[9]) if row[9] else 0,
|
||||||
|
'metodo_pago_sat': row[10] or 'PUE',
|
||||||
|
'forma_pago_sat': row[11] or '01',
|
||||||
|
'status': row[12],
|
||||||
|
'created_at': str(row[13]),
|
||||||
|
'items': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, inventory_id, part_number, name, quantity, unit_price,
|
||||||
|
unit_cost, discount_pct, discount_amount, tax_rate, tax_amount,
|
||||||
|
subtotal, clave_prod_serv, clave_unidad
|
||||||
|
FROM sale_items WHERE sale_id = %s ORDER BY id
|
||||||
|
""", (sale_id,))
|
||||||
|
|
||||||
|
for r in cur.fetchall():
|
||||||
|
sale['items'].append({
|
||||||
|
'id': r[0], 'inventory_id': r[1], 'part_number': r[2],
|
||||||
|
'name': r[3], 'quantity': r[4],
|
||||||
|
'unit_price': float(r[5]) if r[5] else 0,
|
||||||
|
'unit_cost': float(r[6]) if r[6] else 0,
|
||||||
|
'discount_pct': float(r[7]) if r[7] else 0,
|
||||||
|
'discount_amount': float(r[8]) if r[8] else 0,
|
||||||
|
'tax_rate': float(r[9]) if r[9] else 0.16,
|
||||||
|
'tax_amount': float(r[10]) if r[10] else 0,
|
||||||
|
'subtotal': float(r[11]) if r[11] else 0,
|
||||||
|
'clave_prod_serv': r[12] or '25174800',
|
||||||
|
'clave_unidad': r[13] or 'H87',
|
||||||
|
})
|
||||||
|
|
||||||
|
sales.append(sale)
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
return sales
|
||||||
|
|
||||||
|
|
||||||
|
def generate_global_invoice(conn, tenant_config, year, month, branch_id=None,
|
||||||
|
max_total=2000, employee_id=None):
|
||||||
|
"""Generate a global invoice for the given month.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn: psycopg2 connection
|
||||||
|
tenant_config: dict with rfc, razon_social, regimen_fiscal, cp, serie
|
||||||
|
year: int
|
||||||
|
month: int
|
||||||
|
branch_id: optional branch filter
|
||||||
|
max_total: max sale total to include (default $2,000)
|
||||||
|
employee_id: optional employee ID for audit
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {id, status, sales_count, total, xml, provisional_folio}
|
||||||
|
or {error, message} if no eligible sales
|
||||||
|
"""
|
||||||
|
sales = get_eligible_sales(conn, year, month, branch_id, max_total)
|
||||||
|
|
||||||
|
if not sales:
|
||||||
|
return {'error': 'NO_ELIGIBLE_SALES',
|
||||||
|
'message': f'No hay ventas elegibles para factura global de {month:02d}/{year}'}
|
||||||
|
|
||||||
|
xml = build_global_invoice_xml(sales, tenant_config, year, month)
|
||||||
|
|
||||||
|
# Enqueue with sale_id=NULL (global invoice)
|
||||||
|
result = enqueue_cfdi(conn, None, 'ingreso', xml)
|
||||||
|
cfdi_id = result['id']
|
||||||
|
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Link sales to global invoice
|
||||||
|
for sale in sales:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO global_invoice_sales (global_invoice_id, sale_id)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
""", (cfdi_id, sale['id']))
|
||||||
|
|
||||||
|
# Mark sale as globally invoiced
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE sales SET global_invoiced_at = NOW() WHERE id = %s
|
||||||
|
""", (sale['id'],))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'id': cfdi_id,
|
||||||
|
'status': 'pending',
|
||||||
|
'sales_count': len(sales),
|
||||||
|
'total': sum(s['total'] for s in sales),
|
||||||
|
'provisional_folio': result['provisional_folio'],
|
||||||
|
'xml': xml,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_global_invoice_status(conn, cfdi_id):
|
||||||
|
"""Get status of a global invoice including linked sales."""
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, status, uuid_fiscal, provisional_folio, error_message,
|
||||||
|
created_at, stamped_at
|
||||||
|
FROM cfdi_queue WHERE id = %s
|
||||||
|
""", (cfdi_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
cur.close()
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'id': row[0], 'status': row[1], 'uuid_fiscal': row[2],
|
||||||
|
'provisional_folio': row[3], 'error_message': row[4],
|
||||||
|
'created_at': str(row[5]), 'stamped_at': str(row[6]) if row[6] else None,
|
||||||
|
'sales': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT s.id, s.total, s.created_at
|
||||||
|
FROM global_invoice_sales gis
|
||||||
|
JOIN sales s ON s.id = gis.sale_id
|
||||||
|
WHERE gis.global_invoice_id = %s
|
||||||
|
ORDER BY s.created_at ASC
|
||||||
|
""", (cfdi_id,))
|
||||||
|
|
||||||
|
for r in cur.fetchall():
|
||||||
|
result['sales'].append({
|
||||||
|
'id': r[0], 'total': float(r[1]) if r[1] else 0,
|
||||||
|
'created_at': str(r[2]),
|
||||||
|
})
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
return result
|
||||||
@@ -25,22 +25,23 @@ def _safe_g(attr, default=None):
|
|||||||
def get_stock(conn, inventory_id, branch_id=None):
|
def get_stock(conn, inventory_id, branch_id=None):
|
||||||
"""Get current stock for an inventory item. Optionally filter by branch.
|
"""Get current stock for an inventory item. Optionally filter by branch.
|
||||||
|
|
||||||
Uses Redis cache first, then inventory_stock_summary, falls back to
|
Uses Redis cache first, then inventory_stock (per-branch) or
|
||||||
PostgreSQL SUM query.
|
inventory_stock_summary (total), falls back to PostgreSQL SUM query.
|
||||||
"""
|
"""
|
||||||
# Try Redis first
|
# Try Redis first
|
||||||
cached = get_cached_stock(inventory_id, branch_id)
|
cached = get_cached_stock(inventory_id, branch_id)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
# Use inventory_stock_summary (O(1) lookup)
|
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
if branch_id:
|
if branch_id:
|
||||||
|
# Per-branch stock from inventory_stock
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT stock FROM inventory_stock_summary WHERE inventory_id = %s AND branch_id = %s",
|
"SELECT stock FROM inventory_stock WHERE inventory_id = %s AND branch_id = %s",
|
||||||
(inventory_id, branch_id)
|
(inventory_id, branch_id)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
# Total stock from inventory_stock_summary
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT stock FROM inventory_stock_summary WHERE inventory_id = %s",
|
"SELECT stock FROM inventory_stock_summary WHERE inventory_id = %s",
|
||||||
(inventory_id,)
|
(inventory_id,)
|
||||||
@@ -73,13 +74,14 @@ def get_stock(conn, inventory_id, branch_id=None):
|
|||||||
def get_stock_bulk(conn, branch_id=None):
|
def get_stock_bulk(conn, branch_id=None):
|
||||||
"""Get stock for all items. Returns dict {inventory_id: stock_quantity}.
|
"""Get stock for all items. Returns dict {inventory_id: stock_quantity}.
|
||||||
|
|
||||||
Uses inventory_stock_summary for O(1) bulk lookup.
|
Uses inventory_stock (per-branch) or inventory_stock_summary (total)
|
||||||
|
for O(1) bulk lookup.
|
||||||
"""
|
"""
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
if branch_id:
|
if branch_id:
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT inventory_id, stock
|
SELECT inventory_id, stock
|
||||||
FROM inventory_stock_summary WHERE branch_id = %s
|
FROM inventory_stock WHERE branch_id = %s
|
||||||
""", (branch_id,))
|
""", (branch_id,))
|
||||||
else:
|
else:
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
|
|||||||
@@ -46,6 +46,11 @@
|
|||||||
var checkoutBtn = document.getElementById('checkoutBtn');
|
var checkoutBtn = document.getElementById('checkoutBtn');
|
||||||
var cartFab = document.getElementById('cartFab');
|
var cartFab = document.getElementById('cartFab');
|
||||||
var cartCloseBtn = document.getElementById('cartCloseBtn');
|
var cartCloseBtn = document.getElementById('cartCloseBtn');
|
||||||
|
// Supplier prices upload
|
||||||
|
var uploadPricesBtn = document.getElementById('uploadPricesBtn');
|
||||||
|
var uploadPricesModal= document.getElementById('uploadPricesModal');
|
||||||
|
var uploadPricesFile = document.getElementById('uploadPricesFile');
|
||||||
|
var uploadPricesStatus=document.getElementById('uploadPricesStatus');
|
||||||
|
|
||||||
// ─── Navigation State ───
|
// ─── Navigation State ───
|
||||||
var nav = {
|
var nav = {
|
||||||
@@ -1053,6 +1058,7 @@
|
|||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="part-card__footer">' +
|
'<div class="part-card__footer">' +
|
||||||
(p.local_price ? '<span class="part-card__price">$' + fmt(p.local_price) + '</span>' : '<span class="part-card__price" style="color:var(--color-text-muted);">Sin precio</span>') +
|
(p.local_price ? '<span class="part-card__price">$' + fmt(p.local_price) + '</span>' : '<span class="part-card__price" style="color:var(--color-text-muted);">Sin precio</span>') +
|
||||||
|
(p.supplier_price ? '<span class="part-card__price" style="color:#2d7d46;font-size:0.85em;">Prov: $' + fmt(p.supplier_price) + '</span>' : '') +
|
||||||
stockBadge +
|
stockBadge +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</article>';
|
'</article>';
|
||||||
@@ -2105,6 +2111,53 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Supplier prices upload ─────────────────────────────────────────────
|
||||||
|
function openUploadPricesModal() {
|
||||||
|
if (uploadPricesModal) uploadPricesModal.style.display = 'flex';
|
||||||
|
if (uploadPricesStatus) uploadPricesStatus.innerHTML = '';
|
||||||
|
if (uploadPricesFile) uploadPricesFile.value = '';
|
||||||
|
}
|
||||||
|
function closeUploadPricesModal() {
|
||||||
|
if (uploadPricesModal) uploadPricesModal.style.display = 'none';
|
||||||
|
}
|
||||||
|
async function submitUploadPrices() {
|
||||||
|
if (!uploadPricesFile || !uploadPricesFile.files || !uploadPricesFile.files[0]) {
|
||||||
|
if (uploadPricesStatus) uploadPricesStatus.innerHTML = '<span style="color:var(--color-error);">Selecciona un archivo primero.</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var form = new FormData();
|
||||||
|
form.append('file', uploadPricesFile.files[0]);
|
||||||
|
if (uploadPricesStatus) uploadPricesStatus.innerHTML = 'Subiendo...';
|
||||||
|
try {
|
||||||
|
var res = await fetch('/pos/api/supplier-catalog/prices/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token },
|
||||||
|
body: form
|
||||||
|
});
|
||||||
|
var data = await res.json();
|
||||||
|
if (res.ok && data.success) {
|
||||||
|
if (uploadPricesStatus) uploadPricesStatus.innerHTML = '<span style="color:var(--color-success);">✓ Precios actualizados: ' + data.processed + ' (insertados: ' + data.inserted + ', actualizados: ' + data.updated + ')</span>';
|
||||||
|
uploadPricesFile.value = '';
|
||||||
|
} else {
|
||||||
|
var msg = data.error || 'Error al subir precios';
|
||||||
|
var details = (data.details || []).join('<br>');
|
||||||
|
if (uploadPricesStatus) uploadPricesStatus.innerHTML = '<span style="color:var(--color-error);">' + esc(msg) + '</span>' + (details ? '<div style="margin-top:4px;font-size:0.9em;">' + details + '</div>' : '');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (uploadPricesStatus) uploadPricesStatus.innerHTML = '<span style="color:var(--color-error);">Error de red: ' + esc(e.message) + '</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowUploadPricesButton() {
|
||||||
|
try {
|
||||||
|
var user = JSON.parse(localStorage.getItem('pos_employee') || '{}');
|
||||||
|
return user.role === 'owner' || user.role === 'admin';
|
||||||
|
} catch (e) { return false; }
|
||||||
|
}
|
||||||
|
if (uploadPricesBtn && shouldShowUploadPricesButton()) {
|
||||||
|
uploadPricesBtn.style.display = 'inline-flex';
|
||||||
|
}
|
||||||
|
|
||||||
window.CatalogApp = {
|
window.CatalogApp = {
|
||||||
toggleCart: toggleCart,
|
toggleCart: toggleCart,
|
||||||
goToCheckout: goToCheckout,
|
goToCheckout: goToCheckout,
|
||||||
@@ -2124,6 +2177,9 @@
|
|||||||
togglePlate: togglePlate,
|
togglePlate: togglePlate,
|
||||||
lookupPlate: lookupPlate,
|
lookupPlate: lookupPlate,
|
||||||
setMode: setCatalogMode,
|
setMode: setCatalogMode,
|
||||||
|
openUploadPricesModal: openUploadPricesModal,
|
||||||
|
closeUploadPricesModal: closeUploadPricesModal,
|
||||||
|
submitUploadPrices: submitUploadPrices,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── INIT ───
|
// ─── INIT ───
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ const Config = (() => {
|
|||||||
|
|
||||||
_branches.forEach(function(b, idx) {
|
_branches.forEach(function(b, idx) {
|
||||||
var statusBadge = b.is_active
|
var statusBadge = b.is_active
|
||||||
? '<span class="badge badge--ok" style="padding:0 4px;font-size:0.625rem;">' + (idx === 0 ? 'Principal' : 'Activa') + '</span>'
|
? '<span class="badge badge--ok" style="padding:0 4px;font-size:0.625rem;">' + (b.is_main ? 'Principal' : 'Activa') + '</span>'
|
||||||
: '<span class="badge badge--inactive" style="padding:0 4px;font-size:0.625rem;">Inactiva</span>';
|
: '<span class="badge badge--inactive" style="padding:0 4px;font-size:0.625rem;">Inactiva</span>';
|
||||||
|
|
||||||
html += '<div class="device-card">'
|
html += '<div class="device-card">'
|
||||||
@@ -170,14 +170,20 @@ const Config = (() => {
|
|||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '<div class="device-card__body">'
|
+ '<div class="device-card__body">'
|
||||||
+ '<div class="device-card__name">' + escHtml(b.name) + '</div>'
|
+ '<div class="device-card__name">' + escHtml(b.name) + '</div>'
|
||||||
+ '<div class="device-card__detail">' + statusBadge + '</div>'
|
+ '<div class="device-card__detail">' + statusBadge
|
||||||
|
+ (b.rfc ? ' · RFC: ' + escHtml(b.rfc) : '')
|
||||||
|
+ (b.codigo_postal ? ' · CP: ' + escHtml(b.codigo_postal) : '')
|
||||||
|
+ '</div>'
|
||||||
+ (b.address ? '<div class="device-card__detail">' + escHtml(b.address) + '</div>' : '')
|
+ (b.address ? '<div class="device-card__detail">' + escHtml(b.address) + '</div>' : '')
|
||||||
+ (b.phone ? '<div class="device-card__detail">' + escHtml(b.phone) + '</div>' : '')
|
+ (b.phone ? '<div class="device-card__detail">' + escHtml(b.phone) + '</div>' : '')
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="device-card__actions">'
|
||||||
|
+ '<button class="btn btn--ghost btn--sm" onclick="Config.editBranch(' + b.id + ')">Editar</button>'
|
||||||
+ '</div></div>';
|
+ '</div></div>';
|
||||||
});
|
});
|
||||||
|
|
||||||
// "Agregar Sucursal" card
|
// "Agregar Sucursal" card
|
||||||
html += '<div class="device-card" style="border-style:dashed;cursor:pointer;" onclick="Config.openModal(\'modal-branch\')">'
|
html += '<div class="device-card" style="border-style:dashed;cursor:pointer;" onclick="Config.openBranchModal()">'
|
||||||
+ '<div class="device-card__icon" style="background:transparent;border:2px dashed var(--color-border);">'
|
+ '<div class="device-card__icon" style="background:transparent;border:2px dashed var(--color-border);">'
|
||||||
+ '<svg viewBox="0 0 24 24" style="stroke:var(--color-text-muted);"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>'
|
+ '<svg viewBox="0 0 24 24" style="stroke:var(--color-text-muted);"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>'
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
@@ -203,9 +209,36 @@ const Config = (() => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openBranchModal(branch) {
|
||||||
|
document.getElementById('branch-modal-title').textContent = branch ? 'Editar Sucursal' : 'Nueva Sucursal';
|
||||||
|
document.getElementById('branch-id').value = branch ? branch.id : '';
|
||||||
|
document.getElementById('branch-name').value = branch ? branch.name : '';
|
||||||
|
document.getElementById('branch-rfc').value = branch ? (branch.rfc || '') : '';
|
||||||
|
document.getElementById('branch-razon').value = branch ? (branch.razon_social || '') : '';
|
||||||
|
document.getElementById('branch-regimen').value = branch ? (branch.regimen_fiscal || '') : '';
|
||||||
|
document.getElementById('branch-cp').value = branch ? (branch.codigo_postal || '') : '';
|
||||||
|
document.getElementById('branch-serie').value = branch ? (branch.serie_cfdi || '') : '';
|
||||||
|
document.getElementById('branch-folio').value = branch ? (branch.folio_inicial || '') : '';
|
||||||
|
document.getElementById('branch-licencia').value = branch ? (branch.licencia_fiscal || '') : '';
|
||||||
|
document.getElementById('branch-address').value = branch ? (branch.address || '') : '';
|
||||||
|
document.getElementById('branch-phone').value = branch ? (branch.phone || '') : '';
|
||||||
|
document.getElementById('branch-main').checked = branch ? !!branch.is_main : false;
|
||||||
|
document.getElementById('branch-cert').value = branch ? (branch.certificado_pem || '') : '';
|
||||||
|
document.getElementById('branch-key').value = branch ? (branch.llave_pem || '') : '';
|
||||||
|
openModal('modal-branch');
|
||||||
|
}
|
||||||
|
|
||||||
|
function editBranch(branchId) {
|
||||||
|
var b = _branches.find(function(x) { return x.id === branchId; });
|
||||||
|
if (!b) { toast('Sucursal no encontrada', 'error'); return; }
|
||||||
|
openBranchModal(b);
|
||||||
|
}
|
||||||
|
|
||||||
async function saveBranch(data) {
|
async function saveBranch(data) {
|
||||||
var res = await fetch(API + '/branches', {
|
var branchId = document.getElementById('branch-id').value;
|
||||||
method: 'POST',
|
var url = API + '/branches' + (branchId ? '/' + branchId : '');
|
||||||
|
var res = await fetch(url, {
|
||||||
|
method: branchId ? 'PUT' : 'POST',
|
||||||
headers: headers(),
|
headers: headers(),
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data)
|
||||||
});
|
});
|
||||||
@@ -429,14 +462,36 @@ const Config = (() => {
|
|||||||
try {
|
try {
|
||||||
await saveBranch({
|
await saveBranch({
|
||||||
name: name,
|
name: name,
|
||||||
address: document.getElementById('branch-address').value.trim(),
|
rfc: document.getElementById('branch-rfc').value.trim() || null,
|
||||||
phone: document.getElementById('branch-phone').value.trim()
|
razon_social: document.getElementById('branch-razon').value.trim() || null,
|
||||||
|
regimen_fiscal: document.getElementById('branch-regimen').value.trim() || null,
|
||||||
|
codigo_postal: document.getElementById('branch-cp').value.trim() || null,
|
||||||
|
serie_cfdi: document.getElementById('branch-serie').value.trim() || null,
|
||||||
|
folio_inicial: document.getElementById('branch-folio').value ? parseInt(document.getElementById('branch-folio').value, 10) : null,
|
||||||
|
licencia_fiscal: document.getElementById('branch-licencia').value.trim() || null,
|
||||||
|
address: document.getElementById('branch-address').value.trim() || null,
|
||||||
|
phone: document.getElementById('branch-phone').value.trim() || null,
|
||||||
|
is_main: document.getElementById('branch-main').checked,
|
||||||
|
certificado_pem: document.getElementById('branch-cert').value.trim() || null,
|
||||||
|
llave_pem: document.getElementById('branch-key').value.trim() || null,
|
||||||
});
|
});
|
||||||
toast('Sucursal creada');
|
toast('Sucursal guardada');
|
||||||
closeModal('modal-branch');
|
closeModal('modal-branch');
|
||||||
|
// Reset form
|
||||||
|
document.getElementById('branch-id').value = '';
|
||||||
document.getElementById('branch-name').value = '';
|
document.getElementById('branch-name').value = '';
|
||||||
|
document.getElementById('branch-rfc').value = '';
|
||||||
|
document.getElementById('branch-razon').value = '';
|
||||||
|
document.getElementById('branch-regimen').value = '';
|
||||||
|
document.getElementById('branch-cp').value = '';
|
||||||
|
document.getElementById('branch-serie').value = '';
|
||||||
|
document.getElementById('branch-folio').value = '';
|
||||||
|
document.getElementById('branch-licencia').value = '';
|
||||||
document.getElementById('branch-address').value = '';
|
document.getElementById('branch-address').value = '';
|
||||||
document.getElementById('branch-phone').value = '';
|
document.getElementById('branch-phone').value = '';
|
||||||
|
document.getElementById('branch-main').checked = false;
|
||||||
|
document.getElementById('branch-cert').value = '';
|
||||||
|
document.getElementById('branch-key').value = '';
|
||||||
await loadBranches();
|
await loadBranches();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e.message, 'error');
|
toast(e.message, 'error');
|
||||||
@@ -805,7 +860,7 @@ const Config = (() => {
|
|||||||
loadBusiness, saveBusiness, saveTaxParams,
|
loadBusiness, saveBusiness, saveTaxParams,
|
||||||
loadCurrency, saveCurrency,
|
loadCurrency, saveCurrency,
|
||||||
loadModules, saveModules,
|
loadModules, saveModules,
|
||||||
openModal, closeModal
|
openModal, closeModal, openBranchModal, editBranch
|
||||||
};
|
};
|
||||||
// Register Cmd+K items
|
// Register Cmd+K items
|
||||||
if (typeof registerCmdKItem === "function") {
|
if (typeof registerCmdKItem === "function") {
|
||||||
|
|||||||
@@ -478,6 +478,51 @@ const Invoicing = (() => {
|
|||||||
alert('Nota de credito: proximamente');
|
alert('Nota de credito: proximamente');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Global Invoice ----
|
||||||
|
function openGlobalInvoiceModal() {
|
||||||
|
const now = new Date();
|
||||||
|
document.getElementById('global-year').value = now.getFullYear();
|
||||||
|
document.getElementById('global-month').value = now.getMonth() + 1;
|
||||||
|
document.getElementById('global-preview').innerHTML = 'Presiona "Vista previa" para ver ventas elegibles.';
|
||||||
|
document.getElementById('modalGlobalInvoice').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function previewGlobalInvoice() {
|
||||||
|
const year = document.getElementById('global-year').value;
|
||||||
|
const month = document.getElementById('global-month').value;
|
||||||
|
const preview = document.getElementById('global-preview');
|
||||||
|
preview.innerHTML = 'Cargando...';
|
||||||
|
try {
|
||||||
|
const res = await api(`/global-invoice/eligible-sales?year=${year}&month=${month}`);
|
||||||
|
preview.innerHTML = `<strong>${res.count} ventas elegibles</strong> — Total: $${fmt(res.total)}<br><small>${res.sales.map(s => '#' + s.id).join(', ')}</small>`;
|
||||||
|
} catch (e) {
|
||||||
|
preview.innerHTML = '<span style="color:var(--color-error);">Error: ' + e.message + '</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateGlobalInvoice() {
|
||||||
|
const year = parseInt(document.getElementById('global-year').value, 10);
|
||||||
|
const month = parseInt(document.getElementById('global-month').value, 10);
|
||||||
|
const btn = document.querySelector('#modalGlobalInvoice .btn--primary');
|
||||||
|
const originalText = btn.textContent;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Generando...';
|
||||||
|
try {
|
||||||
|
const res = await api('/global-invoice', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ year, month })
|
||||||
|
});
|
||||||
|
alert(`Factura global generada: ${res.provisional_folio} (${res.sales_count} ventas, $${fmt(res.total)})`);
|
||||||
|
document.getElementById('modalGlobalInvoice').style.display = 'none';
|
||||||
|
loadFacturas();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = originalText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Expose switchTab globally for onclick handlers in HTML
|
// Expose switchTab globally for onclick handlers in HTML
|
||||||
window.switchTab = switchTab;
|
window.switchTab = switchTab;
|
||||||
window.showNewInvoiceModal = showNewInvoiceModal;
|
window.showNewInvoiceModal = showNewInvoiceModal;
|
||||||
@@ -489,6 +534,7 @@ const Invoicing = (() => {
|
|||||||
switchTab, loadFacturas, loadNotas, loadComplementos, loadCancelaciones,
|
switchTab, loadFacturas, loadNotas, loadComplementos, loadCancelaciones,
|
||||||
showDetail, showCancelModal, confirmCancel, processQueue,
|
showDetail, showCancelModal, confirmCancel, processQueue,
|
||||||
showNewInvoiceModal, closeNewInvoiceModal, submitNewInvoice, notaCreditoPlaceholder,
|
showNewInvoiceModal, closeNewInvoiceModal, submitNewInvoice, notaCreditoPlaceholder,
|
||||||
|
openGlobalInvoiceModal, previewGlobalInvoice, generateGlobalInvoice,
|
||||||
};
|
};
|
||||||
// Register Cmd+K items
|
// Register Cmd+K items
|
||||||
if (typeof registerCmdKItem === "function") {
|
if (typeof registerCmdKItem === "function") {
|
||||||
|
|||||||
@@ -321,6 +321,21 @@ const POS = (() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function modifyPrice() {
|
||||||
|
if (selectedRow < 0 || selectedRow >= cart.length) {
|
||||||
|
showToast('Selecciona un articulo primero', 'warn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const p = prompt('Nuevo precio unitario:', cart[selectedRow].unit_price);
|
||||||
|
if (p !== null) {
|
||||||
|
const n = parseFloat(p);
|
||||||
|
if (n >= 0) {
|
||||||
|
cart[selectedRow].unit_price = n;
|
||||||
|
renderCart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Wire confirm-cancel button
|
// Wire confirm-cancel button
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
var btn = document.getElementById('btnConfirmCancel');
|
var btn = document.getElementById('btnConfirmCancel');
|
||||||
@@ -1363,7 +1378,7 @@ const POS = (() => {
|
|||||||
connectThermal, thermalPrint,
|
connectThermal, thermalPrint,
|
||||||
showOpenRegisterModal, closeOpenRegisterModal, openRegister,
|
showOpenRegisterModal, closeOpenRegisterModal, openRegister,
|
||||||
showCutZModal, closeCutZModal, loadCutX, confirmCutZ,
|
showCutZModal, closeCutZModal, loadCutX, confirmCutZ,
|
||||||
openCancelModal, closeCancelModal, changeQuantity, applyDiscount,
|
openCancelModal, closeCancelModal, changeQuantity, applyDiscount, modifyPrice,
|
||||||
};
|
};
|
||||||
// Register Cmd+K items
|
// Register Cmd+K items
|
||||||
if (typeof registerCmdKItem === "function") {
|
if (typeof registerCmdKItem === "function") {
|
||||||
|
|||||||
@@ -113,6 +113,9 @@
|
|||||||
<span class="breadcrumb__current">Catalogo</span>
|
<span class="breadcrumb__current">Catalogo</span>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="header-actions" style="position:relative;">
|
<div class="header-actions" style="position:relative;">
|
||||||
|
<button class="btn btn--sm" id="uploadPricesBtn" onclick="CatalogApp.openUploadPricesModal()" title="Subir precios de proveedor" style="margin-right:var(--space-2);display:none;">
|
||||||
|
💰 Precios proveedor
|
||||||
|
</button>
|
||||||
<div class="mode-toggle" id="modeToggle" title="Cambiar entre catalogo OEM (TecDoc), marcas locales, por marca de vehiculo y consumibles">
|
<div class="mode-toggle" id="modeToggle" title="Cambiar entre catalogo OEM (TecDoc), marcas locales, por marca de vehiculo y consumibles">
|
||||||
<button data-mode="oem" onclick="CatalogApp.setMode('oem')" disabled style="opacity:0.5;cursor:not-allowed;" title="Próximamente">OEM 🔒</button>
|
<button data-mode="oem" onclick="CatalogApp.setMode('oem')" disabled style="opacity:0.5;cursor:not-allowed;" title="Próximamente">OEM 🔒</button>
|
||||||
<button data-mode="local" onclick="CatalogApp.setMode('local')">Local</button>
|
<button data-mode="local" onclick="CatalogApp.setMode('local')">Local</button>
|
||||||
@@ -275,6 +278,29 @@
|
|||||||
<button class="banner__dismiss" onclick="document.getElementById('offlineBanner').style.display='none'" aria-label="Cerrar">×</button>
|
<button class="banner__dismiss" onclick="document.getElementById('offlineBanner').style.display='none'" aria-label="Cerrar">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload Supplier Prices Modal -->
|
||||||
|
<div id="uploadPricesModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;z-index:9500;background:rgba(0,0,0,0.6);align-items:center;justify-content:center;padding:var(--space-4);">
|
||||||
|
<div style="background:var(--color-surface-1);border:1px solid var(--color-border);border-radius:var(--radius-md);max-width:520px;width:100%;max-height:90vh;overflow:auto;padding:var(--space-5);">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-4);">
|
||||||
|
<h2 style="margin:0;font-family:var(--font-heading);font-size:var(--text-h4);">Subir precios de proveedor</h2>
|
||||||
|
<button onclick="CatalogApp.closeUploadPricesModal()" style="background:none;border:none;cursor:pointer;font-size:1.4rem;color:var(--color-text-secondary);">✕</button>
|
||||||
|
</div>
|
||||||
|
<p style="color:var(--color-text-secondary);font-size:var(--text-body-sm);margin-bottom:var(--space-3);">
|
||||||
|
Sube un CSV o Excel con las columnas: <code>supplier_name, sku, price, currency, effective_from</code>.<br>
|
||||||
|
El precio se mostrará en el catálogo junto a cada parte del proveedor.
|
||||||
|
</p>
|
||||||
|
<div style="margin-bottom:var(--space-3);">
|
||||||
|
<label style="display:block;font-size:var(--text-caption);color:var(--color-text-muted);margin-bottom:4px;">Archivo CSV / Excel</label>
|
||||||
|
<input type="file" id="uploadPricesFile" accept=".csv,.xlsx,.xls" style="width:100%;" />
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:var(--space-2);justify-content:flex-end;">
|
||||||
|
<a href="/pos/api/supplier-catalog/prices/template" class="btn btn--ghost" style="text-decoration:none;">Descargar plantilla</a>
|
||||||
|
<button class="btn btn-primary" onclick="CatalogApp.submitUploadPrices()">Subir precios</button>
|
||||||
|
</div>
|
||||||
|
<div id="uploadPricesStatus" style="margin-top:var(--space-3);font-size:var(--text-body-sm);"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Brand Catalog Overlay (full-screen overlay for brand-first browsing) -->
|
<!-- Brand Catalog Overlay (full-screen overlay for brand-first browsing) -->
|
||||||
<div id="brandCatalogOverlay" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;z-index:9000;background:var(--color-bg-base);overflow:auto;padding:var(--space-4);">
|
<div id="brandCatalogOverlay" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;z-index:9000;background:var(--color-bg-base);overflow:auto;padding:var(--space-4);">
|
||||||
<div style="max-width:1200px;margin:0 auto;">
|
<div style="max-width:1200px;margin:0 auto;">
|
||||||
@@ -295,7 +321,7 @@
|
|||||||
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
|
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
|
||||||
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
|
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
|
||||||
<script src="/pos/static/js/sidebar.js" defer></script>
|
<script src="/pos/static/js/sidebar.js" defer></script>
|
||||||
<script src="/pos/static/js/catalog.js?v=5" defer></script>
|
<script src="/pos/static/js/catalog.js?v=6" defer></script>
|
||||||
<script src="/pos/static/js/offline-banner.js" defer></script>
|
<script src="/pos/static/js/offline-banner.js" defer></script>
|
||||||
<script src="/pos/static/js/chat.js" defer></script>
|
<script src="/pos/static/js/chat.js" defer></script>
|
||||||
<script src="/pos/static/js/sync-engine.js" defer></script>
|
<script src="/pos/static/js/sync-engine.js" defer></script>
|
||||||
|
|||||||
@@ -718,19 +718,48 @@
|
|||||||
MODALS
|
MODALS
|
||||||
===================================================================== -->
|
===================================================================== -->
|
||||||
|
|
||||||
<!-- Modal: Nueva Sucursal -->
|
<!-- Modal: Nueva / Editar Sucursal -->
|
||||||
<div class="cfg-modal-overlay" id="modal-branch" style="display:none;">
|
<div class="cfg-modal-overlay" id="modal-branch" style="display:none;">
|
||||||
<div class="cfg-modal">
|
<div class="cfg-modal" style="max-width:640px;">
|
||||||
<div class="cfg-modal__header">
|
<div class="cfg-modal__header">
|
||||||
<h3 class="cfg-modal__title">Nueva Sucursal</h3>
|
<h3 class="cfg-modal__title" id="branch-modal-title">Nueva Sucursal</h3>
|
||||||
<button class="cfg-modal__close" onclick="Config.closeModal('modal-branch')">×</button>
|
<button class="cfg-modal__close" onclick="Config.closeModal('modal-branch')">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="cfg-modal__body">
|
<div class="cfg-modal__body">
|
||||||
|
<input type="hidden" id="branch-id" value="" />
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-group form-group--full">
|
<div class="form-group form-group--full">
|
||||||
<label class="form-label">Nombre</label>
|
<label class="form-label">Nombre *</label>
|
||||||
<input class="form-input" id="branch-name" type="text" placeholder="Ej. Sucursal Norte" />
|
<input class="form-input" id="branch-name" type="text" placeholder="Ej. Sucursal Norte" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">RFC</label>
|
||||||
|
<input class="form-input" id="branch-rfc" type="text" placeholder="ABC010101ABC" maxlength="13" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Razon Social</label>
|
||||||
|
<input class="form-input" id="branch-razon" type="text" placeholder="Razon social fiscal" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Regimen Fiscal</label>
|
||||||
|
<input class="form-input" id="branch-regimen" type="text" placeholder="601" maxlength="10" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Codigo Postal</label>
|
||||||
|
<input class="form-input" id="branch-cp" type="text" placeholder="00000" maxlength="5" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Serie CFDI</label>
|
||||||
|
<input class="form-input" id="branch-serie" type="text" placeholder="A" maxlength="10" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Folio Inicial</label>
|
||||||
|
<input class="form-input" id="branch-folio" type="number" placeholder="1" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Licencia Fiscal</label>
|
||||||
|
<input class="form-input" id="branch-licencia" type="text" placeholder="Opcional" />
|
||||||
|
</div>
|
||||||
<div class="form-group form-group--full">
|
<div class="form-group form-group--full">
|
||||||
<label class="form-label">Direccion</label>
|
<label class="form-label">Direccion</label>
|
||||||
<input class="form-input" id="branch-address" type="text" placeholder="Calle, Colonia, Ciudad" />
|
<input class="form-input" id="branch-address" type="text" placeholder="Calle, Colonia, Ciudad" />
|
||||||
@@ -739,6 +768,20 @@
|
|||||||
<label class="form-label">Telefono</label>
|
<label class="form-label">Telefono</label>
|
||||||
<input class="form-input" id="branch-phone" type="tel" placeholder="(55) 1234-5678" />
|
<input class="form-input" id="branch-phone" type="tel" placeholder="(55) 1234-5678" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group form-group--full">
|
||||||
|
<label class="form-check" style="display:flex;align-items:center;gap:8px;cursor:pointer;">
|
||||||
|
<input type="checkbox" id="branch-main" style="width:auto;" />
|
||||||
|
<span>Sucursal principal (datos fiscales por defecto)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-group form-group--full">
|
||||||
|
<label class="form-label">Certificado PEM (opcional)</label>
|
||||||
|
<textarea class="form-input" id="branch-cert" rows="3" placeholder="-----BEGIN CERTIFICATE-----"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group form-group--full">
|
||||||
|
<label class="form-label">Llave PEM (opcional)</label>
|
||||||
|
<textarea class="form-input" id="branch-key" rows="3" placeholder="-----BEGIN PRIVATE KEY-----"></textarea>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="cfg-modal__footer">
|
<div class="cfg-modal__footer">
|
||||||
@@ -808,7 +851,7 @@
|
|||||||
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
|
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
|
||||||
<script src="/pos/static/js/sidebar.js" defer></script>
|
<script src="/pos/static/js/sidebar.js" defer></script>
|
||||||
<script src="/pos/static/js/kiosk.js" defer></script>
|
<script src="/pos/static/js/kiosk.js" defer></script>
|
||||||
<script src="/pos/static/js/config.js?v=2" defer></script>
|
<script src="/pos/static/js/config.js?v=3" defer></script>
|
||||||
<script src="/pos/static/js/sync-engine.js" defer></script>
|
<script src="/pos/static/js/sync-engine.js" defer></script>
|
||||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||||
<script src="/pos/static/js/pwa-install.js" defer></script>
|
<script src="/pos/static/js/pwa-install.js" defer></script>
|
||||||
|
|||||||
@@ -356,6 +356,16 @@
|
|||||||
|
|
||||||
<div class="toolbar__spacer"></div>
|
<div class="toolbar__spacer"></div>
|
||||||
|
|
||||||
|
<button class="btn btn--primary" onclick="Invoicing.openGlobalInvoiceModal()">
|
||||||
|
<svg viewBox="0 0 24 24" style="width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2;">
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
||||||
|
<line x1="16" y1="2" x2="16" y2="6"/>
|
||||||
|
<line x1="8" y1="2" x2="8" y2="6"/>
|
||||||
|
<line x1="3" y1="10" x2="21" y2="10"/>
|
||||||
|
</svg>
|
||||||
|
Factura Global
|
||||||
|
</button>
|
||||||
|
|
||||||
<button class="btn btn--ghost">
|
<button class="btn btn--ghost">
|
||||||
<svg viewBox="0 0 24 24">
|
<svg viewBox="0 0 24 24">
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||||
@@ -1057,7 +1067,7 @@
|
|||||||
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
|
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
|
||||||
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
|
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
|
||||||
<script src="/pos/static/js/sidebar.js" defer></script>
|
<script src="/pos/static/js/sidebar.js" defer></script>
|
||||||
<script src="/pos/static/js/invoicing.js" defer></script>
|
<script src="/pos/static/js/invoicing.js?v=2" defer></script>
|
||||||
<script src="/pos/static/js/sync-engine.js" defer></script>
|
<script src="/pos/static/js/sync-engine.js" defer></script>
|
||||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||||
<script src="/pos/static/js/pwa-install.js" defer></script>
|
<script src="/pos/static/js/pwa-install.js" defer></script>
|
||||||
@@ -1091,5 +1101,40 @@
|
|||||||
window.loadInvoicingStats = loadInvoicingStats;
|
window.loadInvoicingStats = loadInvoicingStats;
|
||||||
loadInvoicingStats();
|
loadInvoicingStats();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- =====================================================================
|
||||||
|
MODAL: FACTURA GLOBAL
|
||||||
|
===================================================================== -->
|
||||||
|
<div class="modal-overlay" id="modalGlobalInvoice" style="display:none; position:fixed; inset:0; z-index:var(--z-modal); background:var(--overlay-backdrop); align-items:center; justify-content:center;">
|
||||||
|
<div class="modal-card" style="background:var(--color-bg-elevated); border:1px solid var(--color-border); border-radius:var(--radius-lg); width:480px; max-width:95vw; box-shadow:var(--shadow-xl);">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; padding:var(--space-5) var(--space-6); border-bottom:1px solid var(--color-border);">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:var(--font-weight-semibold); font-size:var(--text-body-lg);">Generar Factura Global</div>
|
||||||
|
<div style="font-size:var(--text-caption); color:var(--color-text-muted); margin-top:2px;">Agrupa ventas de contado no facturadas</div>
|
||||||
|
</div>
|
||||||
|
<button onclick="document.getElementById('modalGlobalInvoice').style.display='none'" style="background:none; border:none; color:var(--color-text-muted); font-size:1.4rem; cursor:pointer; padding:var(--space-2);">✕</button>
|
||||||
|
</div>
|
||||||
|
<div style="padding:var(--space-5) var(--space-6);">
|
||||||
|
<div style="display:flex; gap:var(--space-3); margin-bottom:var(--space-4);">
|
||||||
|
<div style="flex:1;">
|
||||||
|
<label style="font-size:var(--text-caption); color:var(--color-text-muted); display:block; margin-bottom:var(--space-1);">Año</label>
|
||||||
|
<input type="number" id="global-year" class="form-input" style="width:100%;" />
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;">
|
||||||
|
<label style="font-size:var(--text-caption); color:var(--color-text-muted); display:block; margin-bottom:var(--space-1);">Mes</label>
|
||||||
|
<input type="number" id="global-month" class="form-input" style="width:100%;" min="1" max="12" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="global-preview" style="background:var(--color-surface); border-radius:var(--radius-md); padding:var(--space-3); font-size:var(--text-caption); color:var(--color-text-muted);">
|
||||||
|
Presiona "Vista previa" para ver ventas elegibles.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:var(--space-3); justify-content:flex-end; padding:var(--space-4) var(--space-6); border-top:1px solid var(--color-border);">
|
||||||
|
<button onclick="document.getElementById('modalGlobalInvoice').style.display='none'" class="btn btn--ghost">Cancelar</button>
|
||||||
|
<button onclick="Invoicing.previewGlobalInvoice()" class="btn btn--ghost">Vista previa</button>
|
||||||
|
<button onclick="Invoicing.generateGlobalInvoice()" class="btn btn--primary">Generar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -206,6 +206,7 @@
|
|||||||
|
|
||||||
<!-- Secondary Actions -->
|
<!-- Secondary Actions -->
|
||||||
<div class="secondary-actions" role="toolbar" aria-label="Acciones secundarias">
|
<div class="secondary-actions" role="toolbar" aria-label="Acciones secundarias">
|
||||||
|
<button class="btn-secondary-action" onclick="POS.modifyPrice()" title="Modificar precio">Mod.Precio</button>
|
||||||
<button class="btn-secondary-action" onclick="POS.saveQuotation()" title="Cotizacion (F4)">Cotizar</button>
|
<button class="btn-secondary-action" onclick="POS.saveQuotation()" title="Cotizacion (F4)">Cotizar</button>
|
||||||
<button class="btn-secondary-action" onclick="POS.showLastSale()" title="Ultima venta (F5)">Ult.Venta</button>
|
<button class="btn-secondary-action" onclick="POS.showLastSale()" title="Ultima venta (F5)">Ult.Venta</button>
|
||||||
<button class="btn-secondary-action" onclick="POS.showCutZModal()" title="Corte Z - Cerrar caja">Corte Z</button>
|
<button class="btn-secondary-action" onclick="POS.showCutZModal()" title="Corte Z - Cerrar caja">Corte Z</button>
|
||||||
@@ -570,7 +571,7 @@
|
|||||||
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
|
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
|
||||||
<script src="/pos/static/js/push.js" defer></script>
|
<script src="/pos/static/js/push.js" defer></script>
|
||||||
<script src="/pos/static/js/printer.js" defer></script>
|
<script src="/pos/static/js/printer.js" defer></script>
|
||||||
<script src="/pos/static/js/pos.js?v=6" defer></script>
|
<script src="/pos/static/js/pos.js?v=7" defer></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Cancel sale button wiring
|
// Cancel sale button wiring
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ def main():
|
|||||||
# Prepare UPSERT statements
|
# Prepare UPSERT statements
|
||||||
upsert_catalog_sql = """
|
upsert_catalog_sql = """
|
||||||
INSERT INTO supplier_catalog (supplier_name, sku, name, category)
|
INSERT INTO supplier_catalog (supplier_name, sku, name, category)
|
||||||
VALUES (%s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s)
|
||||||
ON CONFLICT (supplier_name, sku, category) DO UPDATE SET
|
ON CONFLICT (supplier_name, sku, category) DO UPDATE SET
|
||||||
name = EXCLUDED.name,
|
name = EXCLUDED.name,
|
||||||
category = EXCLUDED.category
|
category = EXCLUDED.category
|
||||||
@@ -311,8 +311,8 @@ def main():
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
sku = str(row[1]).strip()
|
sku = str(row[1]).strip()
|
||||||
name = str(row[14]).strip() if row[14] else ''
|
name = str(row[14]).strip().replace('\n', ' ').replace('\r', '') if row[14] else ''
|
||||||
vehicle_raw = str(row[15]).strip() if row[15] else ''
|
vehicle_raw = str(row[15]).strip().replace('\n', ' ').replace('\r', '') if row[15] else ''
|
||||||
|
|
||||||
if not sku or not name:
|
if not sku or not name:
|
||||||
continue
|
continue
|
||||||
|
|||||||
Reference in New Issue
Block a user