feat: Fase 1-3 completas - precios proveedor, multi-sucursal, factura global

Fase 1: Lista de precios de proveedor
- Tabla supplier_catalog_prices en master DB
- Endpoints GET/POST/PUT/DELETE /supplier-catalog/prices
- Upload CSV/Excel de precios de proveedor
- Visualizacion de supplier_price en catalogo y POS

Fase 2: Multi-sucursal completo
- Migracion v4.0: inventory.branch_id=NULL, tabla inventory_stock
- Campos fiscales en branches (RFC, regimen, CP, serie CFDI, certificados)
- Trigger trg_update_inventory_stock para sincronizar stock por sucursal
- Backend config_bp.py con CRUD de sucursales fiscales
- Backend inventory_bp.py y pos_bp.py refactorizados para inventario compartido
- Backend invoicing_bp.py usa datos fiscales de la sucursal de la venta
- Frontend config.html/js con modal de sucursales expandido

Fase 3: Factura global mensual
- Migracion v4.1: tablas global_invoice_sales, sales.global_invoiced_at
- build_global_invoice_xml() con InformacionGlobal SAT-compliant
- Servicio global_invoice.py para agrupar ventas PUE <=000
- Endpoints POST/GET /global-invoice y /global-invoice/eligible-sales
- Frontend invoicing.html/js con boton y modal de factura global
This commit is contained in:
2026-06-11 08:59:56 +00:00
parent ea29cc31c0
commit 2b73c2c6db
23 changed files with 1665 additions and 230 deletions

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ This blueprint is the HTTP layer that validates input and returns JSON.
"""
import json
from datetime import datetime
from flask import Blueprint, request, jsonify, g
from middleware import require_auth
from tenant_db import get_tenant_conn
@@ -19,17 +20,19 @@ from services.audit import log_action
invoicing_bp = Blueprint('invoicing', __name__, url_prefix='/pos/api/invoicing')
def _get_tenant_config(cur):
"""Load tenant CFDI configuration from tenant_config table.
def _get_issuer_config(cur, branch_id=None):
"""Load CFDI issuer configuration.
Falls back to sensible defaults if config is incomplete.
If branch_id is provided and the branch has fiscal data, use it.
Otherwise fall back to tenant-level config.
"""
# Tenant-level defaults
config = {}
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'cfdi_%' OR key LIKE 'tenant_%'")
for row in cur.fetchall():
config[row[0]] = row[1]
return {
result = {
'rfc': config.get('tenant_rfc', ''),
'razon_social': config.get('tenant_razon_social', ''),
'regimen_fiscal': config.get('cfdi_regimen_fiscal', '601'),
@@ -39,6 +42,22 @@ def _get_tenant_config(cur):
'horux_api_key': config.get('cfdi_horux_api_key', ''),
}
# Branch-level override
if branch_id:
cur.execute("""
SELECT rfc, razon_social, regimen_fiscal, codigo_postal, serie_cfdi
FROM branches WHERE id = %s
""", (branch_id,))
row = cur.fetchone()
if row and row[0]:
result['rfc'] = row[0] or result['rfc']
result['razon_social'] = row[1] or result['razon_social']
result['regimen_fiscal'] = row[2] or result['regimen_fiscal']
result['cp'] = row[3] or result['cp']
result['serie'] = row[4] or result['serie']
return result
def _get_sale_with_items(cur, sale_id):
"""Load a sale with its items for CFDI generation."""
@@ -134,14 +153,14 @@ def generate_invoice():
cur = conn.cursor()
try:
tenant_config = _get_tenant_config(cur)
if not tenant_config['rfc']:
return jsonify({'error': 'Tenant RFC not configured. Set tenant_rfc in config.'}), 400
sale = _get_sale_with_items(cur, sale_id)
if not sale:
return jsonify({'error': 'Sale not found'}), 404
tenant_config = _get_issuer_config(cur, sale.get('branch_id'))
if not tenant_config['rfc']:
return jsonify({'error': 'Tenant RFC not configured. Set tenant_rfc in config.'}), 400
if sale['status'] == 'cancelled':
return jsonify({'error': 'Cannot invoice a cancelled sale'}), 400
@@ -261,7 +280,7 @@ def trigger_process_queue():
cur = conn.cursor()
try:
tenant_config = _get_tenant_config(cur)
tenant_config = _get_issuer_config(cur)
horux_url = tenant_config.get('horux_api_url')
horux_key = tenant_config.get('horux_api_key')
@@ -316,7 +335,7 @@ def cancel_invoice(cfdi_id):
cur = conn.cursor()
try:
tenant_config = _get_tenant_config(cur)
tenant_config = _get_issuer_config(cur)
result = cancel_cfdi(
conn, cfdi_id, motive, replacement_uuid,
tenant_config.get('horux_api_url'),
@@ -362,7 +381,7 @@ def get_sale_pdf(sale_id):
cur.close(); conn.close()
return jsonify({'error': 'Sale not found'}), 404
tenant_config = _get_tenant_config(cur)
tenant_config = _get_issuer_config(cur, sale.get('branch_id'))
customer = _get_customer(cur, sale.get('customer_id'))
# Check if there's a stamped CFDI
@@ -424,3 +443,102 @@ def api_invoicing_stats():
'complementos': row[2] or 0,
'cancelaciones': row[3] or 0,
})
@invoicing_bp.route('/global-invoice', methods=['POST'])
@require_auth('invoicing.create')
def generate_global_invoice():
"""Generate a monthly global invoice for cash sales.
Body: {
year: int (default current year),
month: int (default current month),
branch_id: int (optional)
}
"""
data = request.get_json() or {}
now = datetime.now()
year = data.get('year', now.year)
month = data.get('month', now.month)
branch_id = data.get('branch_id')
try:
year = int(year)
month = int(month)
if month < 1 or month > 12:
return jsonify({'error': 'month must be 1-12'}), 400
except (ValueError, TypeError):
return jsonify({'error': 'year and month must be integers'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
tenant_config = _get_issuer_config(cur, branch_id)
if not tenant_config['rfc']:
cur.close(); conn.close()
return jsonify({'error': 'Tenant RFC not configured'}), 400
from services.global_invoice import generate_global_invoice
result = generate_global_invoice(
conn, tenant_config, year, month,
branch_id=branch_id,
employee_id=getattr(g, 'employee_id', None)
)
if 'error' in result:
cur.close(); conn.close()
return jsonify(result), 400
log_action(conn, 'GLOBAL_INVOICE_CREATE', 'cfdi_queue', result['id'],
new_value={'year': year, 'month': month, 'sales_count': result['sales_count']})
conn.commit()
cur.close()
conn.close()
return jsonify(result), 201
@invoicing_bp.route('/global-invoice/<int:cfdi_id>', methods=['GET'])
@require_auth('invoicing.view')
def get_global_invoice(cfdi_id):
"""Get status and linked sales of a global invoice."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
from services.global_invoice import get_global_invoice_status
result = get_global_invoice_status(conn, cfdi_id)
cur.close()
conn.close()
if not result:
return jsonify({'error': 'Global invoice not found'}), 404
return jsonify(result)
@invoicing_bp.route('/global-invoice/eligible-sales', methods=['GET'])
@require_auth('invoicing.view')
def get_eligible_sales_for_global():
"""Preview sales that would be included in a global invoice.
Query params: year, month, branch_id
"""
now = datetime.now()
year = request.args.get('year', now.year, type=int)
month = request.args.get('month', now.month, type=int)
branch_id = request.args.get('branch_id', type=int)
conn = get_tenant_conn(g.tenant_id)
from services.global_invoice import get_eligible_sales
sales = get_eligible_sales(conn, year, month, branch_id)
conn.close()
return jsonify({
'year': year, 'month': month,
'count': len(sales),
'total': sum(s['total'] for s in sales),
'sales': [{'id': s['id'], 'total': s['total'], 'created_at': s['created_at']} for s in sales],
})

View File

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

View File

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

View File

@@ -34,6 +34,9 @@ MIGRATIONS = {
'v3.1': 'v3.1_inventory_vehicle_compat.sql',
'v3.2': 'v3.2_db_performance.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',
}

View 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();

View 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();

View 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;

View File

@@ -95,8 +95,8 @@ def _clean_model_name(name):
s = re.sub(r'\s*\([^)]*\)\s*', '', s)
# 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)
# Remove body type suffixes
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)
# Remove body type suffixes (keep Saloon/Hatchback/Sedan/Wagon as they distinguish variants)
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.
s = re.sub(r'\s+(?:CREW|EXTENDED|STANDARD|CUTAWAY|PASSENGER|CARGO)\b', '', s, flags=re.IGNORECASE)
# 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
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
seen = {} # display_name → first row
# Group by (display_name, raw name) so distinct body-style variants
# (e.g. AVEO vs AVEO SALOON) remain selectable.
seen = set()
results = []
for r in filtered:
display = _clean_model_name(r[1])
if display not in seen:
seen[display] = True
key = (display, r[1])
if key not in seen:
seen.add(key)
results.append({
'id_model': r[0],
'name_model': r[1],
@@ -508,7 +510,7 @@ _SPANISH_KEYWORDS = [
(("Steering & Suspension Parts", "Sway Bars, Stabilizer Bars, Strut Rods & Parts", "Suspension Stabilizer Bar Link"),
["bieleta", "estabilizador"]),
(("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"),
["rotula"]),
(("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:
return None
name_lower = name.lower().replace('_', ' ')
name_lower = name.lower().replace('_', ' ').replace('\n', ' ').replace('\r', '')
# 1. Keyword match (most specific first)
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,
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.
Steps:
@@ -1242,9 +1244,14 @@ def get_parts_for_nexpart_triple(master_conn, mye_id, group_slug, subgroup_slug,
WHERE id = ANY(%s)
ORDER BY name
""", (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
result['data'].append({
item = {
'id_part': f'sc:{sc_id}',
'id_aftermarket': None,
'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,
'price_usd': None,
'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
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:
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 = [
pid
for pids in subgroup_data.values()
for pid in pids
if isinstance(pid, int)
]
image_map = {}
if all_part_ids:
@@ -1902,7 +1915,7 @@ def _search_meili_fallback(master_conn, q, limit):
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.
Strategy:
@@ -1945,8 +1958,8 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None):
break
# ── Inject supplier catalog items ───────────────────────────────────────
if tenant_conn:
supplier_items = _search_supplier_catalog(tenant_conn, q, mye_id, limit)
if master_conn:
supplier_items = _search_supplier_catalog(master_conn, q, mye_id, limit, tenant_id=tenant_id)
for si in supplier_items:
if f"sc:{si['id']}" in seen_local_ids:
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'],
'local_stock': None,
'local_price': None,
'supplier_price': si.get('supplier_price'),
'supplier_currency': si.get('supplier_currency'),
'vehicle_info': si['category'] or '',
'source': 'supplier_catalog',
})
@@ -1967,14 +1982,36 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None):
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.
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 []
cur = tenant_conn.cursor()
cur = master_conn.cursor()
clean_q = q.replace(' ', '').upper()
_SQL_UNACCENT = """
@@ -2016,10 +2053,19 @@ def _search_supplier_catalog(tenant_conn, q, mye_id, limit):
rows = cur.fetchall()
cur.close()
return [
{'id': r[0], 'sku': r[1], 'name': r[2], 'image_url': r[3], 'category': r[4]}
for r in rows
]
catalog_ids = [r[0] 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):

View File

@@ -464,3 +464,130 @@ def build_pago_xml(payment, tenant_config, customer, original_uuid):
return etree.tostring(root, xml_declaration=True, encoding='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')

View 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

View File

@@ -25,22 +25,23 @@ def _safe_g(attr, default=None):
def get_stock(conn, inventory_id, branch_id=None):
"""Get current stock for an inventory item. Optionally filter by branch.
Uses Redis cache first, then inventory_stock_summary, falls back to
PostgreSQL SUM query.
Uses Redis cache first, then inventory_stock (per-branch) or
inventory_stock_summary (total), falls back to PostgreSQL SUM query.
"""
# Try Redis first
cached = get_cached_stock(inventory_id, branch_id)
if cached is not None:
return cached
# Use inventory_stock_summary (O(1) lookup)
cur = conn.cursor()
if branch_id:
# Per-branch stock from inventory_stock
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)
)
else:
# Total stock from inventory_stock_summary
cur.execute(
"SELECT stock FROM inventory_stock_summary WHERE inventory_id = %s",
(inventory_id,)
@@ -73,13 +74,14 @@ def get_stock(conn, inventory_id, branch_id=None):
def get_stock_bulk(conn, branch_id=None):
"""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()
if branch_id:
cur.execute("""
SELECT inventory_id, stock
FROM inventory_stock_summary WHERE branch_id = %s
FROM inventory_stock WHERE branch_id = %s
""", (branch_id,))
else:
cur.execute("""

View File

@@ -46,6 +46,11 @@
var checkoutBtn = document.getElementById('checkoutBtn');
var cartFab = document.getElementById('cartFab');
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 ───
var nav = {
@@ -1053,6 +1058,7 @@
'</div>' +
'<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.supplier_price ? '<span class="part-card__price" style="color:#2d7d46;font-size:0.85em;">Prov: $' + fmt(p.supplier_price) + '</span>' : '') +
stockBadge +
'</div>' +
'</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 = {
toggleCart: toggleCart,
goToCheckout: goToCheckout,
@@ -2124,6 +2177,9 @@
togglePlate: togglePlate,
lookupPlate: lookupPlate,
setMode: setCatalogMode,
openUploadPricesModal: openUploadPricesModal,
closeUploadPricesModal: closeUploadPricesModal,
submitUploadPrices: submitUploadPrices,
};
// ─── INIT ───

View File

@@ -161,7 +161,7 @@ const Config = (() => {
_branches.forEach(function(b, idx) {
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>';
html += '<div class="device-card">'
@@ -170,14 +170,20 @@ const Config = (() => {
+ '</div>'
+ '<div class="device-card__body">'
+ '<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.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>';
});
// "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);">'
+ '<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>'
@@ -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) {
var res = await fetch(API + '/branches', {
method: 'POST',
var branchId = document.getElementById('branch-id').value;
var url = API + '/branches' + (branchId ? '/' + branchId : '');
var res = await fetch(url, {
method: branchId ? 'PUT' : 'POST',
headers: headers(),
body: JSON.stringify(data)
});
@@ -429,14 +462,36 @@ const Config = (() => {
try {
await saveBranch({
name: name,
address: document.getElementById('branch-address').value.trim(),
phone: document.getElementById('branch-phone').value.trim()
rfc: document.getElementById('branch-rfc').value.trim() || null,
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');
// Reset form
document.getElementById('branch-id').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-phone').value = '';
document.getElementById('branch-main').checked = false;
document.getElementById('branch-cert').value = '';
document.getElementById('branch-key').value = '';
await loadBranches();
} catch (e) {
toast(e.message, 'error');
@@ -805,7 +860,7 @@ const Config = (() => {
loadBusiness, saveBusiness, saveTaxParams,
loadCurrency, saveCurrency,
loadModules, saveModules,
openModal, closeModal
openModal, closeModal, openBranchModal, editBranch
};
// Register Cmd+K items
if (typeof registerCmdKItem === "function") {

View File

@@ -478,6 +478,51 @@ const Invoicing = (() => {
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
window.switchTab = switchTab;
window.showNewInvoiceModal = showNewInvoiceModal;
@@ -489,6 +534,7 @@ const Invoicing = (() => {
switchTab, loadFacturas, loadNotas, loadComplementos, loadCancelaciones,
showDetail, showCancelModal, confirmCancel, processQueue,
showNewInvoiceModal, closeNewInvoiceModal, submitNewInvoice, notaCreditoPlaceholder,
openGlobalInvoiceModal, previewGlobalInvoice, generateGlobalInvoice,
};
// Register Cmd+K items
if (typeof registerCmdKItem === "function") {

View File

@@ -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
document.addEventListener('DOMContentLoaded', function() {
var btn = document.getElementById('btnConfirmCancel');
@@ -1363,7 +1378,7 @@ const POS = (() => {
connectThermal, thermalPrint,
showOpenRegisterModal, closeOpenRegisterModal, openRegister,
showCutZModal, closeCutZModal, loadCutX, confirmCutZ,
openCancelModal, closeCancelModal, changeQuantity, applyDiscount,
openCancelModal, closeCancelModal, changeQuantity, applyDiscount, modifyPrice,
};
// Register Cmd+K items
if (typeof registerCmdKItem === "function") {

View File

@@ -113,6 +113,9 @@
<span class="breadcrumb__current">Catalogo</span>
</nav>
<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">
<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>
@@ -275,6 +278,29 @@
<button class="banner__dismiss" onclick="document.getElementById('offlineBanner').style.display='none'" aria-label="Cerrar">&times;</button>
</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);">&#10005;</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) -->
<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;">
@@ -295,7 +321,7 @@
<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/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/chat.js" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>

View File

@@ -718,19 +718,48 @@
MODALS
===================================================================== -->
<!-- Modal: Nueva Sucursal -->
<!-- Modal: Nueva / Editar Sucursal -->
<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">
<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')">&times;</button>
</div>
<div class="cfg-modal__body">
<input type="hidden" id="branch-id" value="" />
<div class="form-grid">
<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" />
</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">
<label class="form-label">Direccion</label>
<input class="form-input" id="branch-address" type="text" placeholder="Calle, Colonia, Ciudad" />
@@ -739,6 +768,20 @@
<label class="form-label">Telefono</label>
<input class="form-input" id="branch-phone" type="tel" placeholder="(55) 1234-5678" />
</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 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/sidebar.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>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
<script src="/pos/static/js/pwa-install.js" defer></script>

View File

@@ -356,6 +356,16 @@
<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">
<svg viewBox="0 0 24 24">
<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/pos-utils.js?v=2" 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>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
<script src="/pos/static/js/pwa-install.js" defer></script>
@@ -1091,5 +1101,40 @@
window.loadInvoicingStats = loadInvoicingStats;
loadInvoicingStats();
</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>
</html>

View File

@@ -206,6 +206,7 @@
<!-- Secondary Actions -->
<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.showLastSale()" title="Ultima venta (F5)">Ult.Venta</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/push.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>
// Cancel sale button wiring