diff --git a/pos/blueprints/catalog_bp.py b/pos/blueprints/catalog_bp.py index 8c6afeb..c754b78 100644 --- a/pos/blueprints/catalog_bp.py +++ b/pos/blueprints/catalog_bp.py @@ -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 []}) diff --git a/pos/blueprints/config_bp.py b/pos/blueprints/config_bp.py index 219d3fc..e31508e 100644 --- a/pos/blueprints/config_bp.py +++ b/pos/blueprints/config_bp.py @@ -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/', 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/', 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(): diff --git a/pos/blueprints/inventory_bp.py b/pos/blueprints/inventory_bp.py index 1471b7e..d741e9a 100644 --- a/pos/blueprints/inventory_bp.py +++ b/pos/blueprints/inventory_bp.py @@ -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 = [] diff --git a/pos/blueprints/invoicing_bp.py b/pos/blueprints/invoicing_bp.py index 4e3a950..e4cdf15 100644 --- a/pos/blueprints/invoicing_bp.py +++ b/pos/blueprints/invoicing_bp.py @@ -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/', 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], + }) diff --git a/pos/blueprints/pos_bp.py b/pos/blueprints/pos_bp.py index 462d7f0..4b43c0f 100644 --- a/pos/blueprints/pos_bp.py +++ b/pos/blueprints/pos_bp.py @@ -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() diff --git a/pos/blueprints/supplier_catalog_bp.py b/pos/blueprints/supplier_catalog_bp.py index 53eb13c..0fb51d7 100644 --- a/pos/blueprints/supplier_catalog_bp.py +++ b/pos/blueprints/supplier_catalog_bp.py @@ -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, + }) diff --git a/pos/migrations/runner.py b/pos/migrations/runner.py index 8ce56d8..45fe0ca 100755 --- a/pos/migrations/runner.py +++ b/pos/migrations/runner.py @@ -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', } diff --git a/pos/migrations/v3.9_supplier_catalog_prices.sql b/pos/migrations/v3.9_supplier_catalog_prices.sql new file mode 100644 index 0000000..72b67e3 --- /dev/null +++ b/pos/migrations/v3.9_supplier_catalog_prices.sql @@ -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(); diff --git a/pos/migrations/v4.0_multi_branch.sql b/pos/migrations/v4.0_multi_branch.sql new file mode 100644 index 0000000..fb83f4a --- /dev/null +++ b/pos/migrations/v4.0_multi_branch.sql @@ -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(); diff --git a/pos/migrations/v4.1_global_invoice.sql b/pos/migrations/v4.1_global_invoice.sql new file mode 100644 index 0000000..d624cd0 --- /dev/null +++ b/pos/migrations/v4.1_global_invoice.sql @@ -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; diff --git a/pos/services/catalog_service.py b/pos/services/catalog_service.py index b16574c..7332a63 100644 --- a/pos/services/catalog_service.py +++ b/pos/services/catalog_service.py @@ -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): diff --git a/pos/services/cfdi_builder.py b/pos/services/cfdi_builder.py index 55afa63..ac92b9f 100644 --- a/pos/services/cfdi_builder.py +++ b/pos/services/cfdi_builder.py @@ -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') diff --git a/pos/services/global_invoice.py b/pos/services/global_invoice.py new file mode 100644 index 0000000..41559d6 --- /dev/null +++ b/pos/services/global_invoice.py @@ -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 diff --git a/pos/services/inventory_engine.py b/pos/services/inventory_engine.py index 7412483..92c2aed 100644 --- a/pos/services/inventory_engine.py +++ b/pos/services/inventory_engine.py @@ -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(""" diff --git a/pos/static/js/catalog.js b/pos/static/js/catalog.js index 05179b0..6fbf960 100644 --- a/pos/static/js/catalog.js +++ b/pos/static/js/catalog.js @@ -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 @@ '' + '' + ''; @@ -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 = 'Selecciona un archivo primero.'; + 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 = '✓ Precios actualizados: ' + data.processed + ' (insertados: ' + data.inserted + ', actualizados: ' + data.updated + ')'; + uploadPricesFile.value = ''; + } else { + var msg = data.error || 'Error al subir precios'; + var details = (data.details || []).join('
'); + if (uploadPricesStatus) uploadPricesStatus.innerHTML = '' + esc(msg) + '' + (details ? '
' + details + '
' : ''); + } + } catch (e) { + if (uploadPricesStatus) uploadPricesStatus.innerHTML = 'Error de red: ' + esc(e.message) + ''; + } + } + + 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 ─── diff --git a/pos/static/js/config.js b/pos/static/js/config.js index 8e3b082..15f6225 100644 --- a/pos/static/js/config.js +++ b/pos/static/js/config.js @@ -161,7 +161,7 @@ const Config = (() => { _branches.forEach(function(b, idx) { var statusBadge = b.is_active - ? '' + (idx === 0 ? 'Principal' : 'Activa') + '' + ? '' + (b.is_main ? 'Principal' : 'Activa') + '' : 'Inactiva'; html += '
' @@ -170,14 +170,20 @@ const Config = (() => { + '
' + '
' + '
' + escHtml(b.name) + '
' - + '
' + statusBadge + '
' + + '
' + statusBadge + + (b.rfc ? ' · RFC: ' + escHtml(b.rfc) : '') + + (b.codigo_postal ? ' · CP: ' + escHtml(b.codigo_postal) : '') + + '
' + (b.address ? '
' + escHtml(b.address) + '
' : '') + (b.phone ? '
' + escHtml(b.phone) + '
' : '') + + '
' + + '
' + + '' + '
'; }); // "Agregar Sucursal" card - html += '
' + html += '
' + '
' + '' + '
' @@ -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") { diff --git a/pos/static/js/invoicing.js b/pos/static/js/invoicing.js index d8b7a67..4ff65b3 100644 --- a/pos/static/js/invoicing.js +++ b/pos/static/js/invoicing.js @@ -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 = `${res.count} ventas elegibles — Total: $${fmt(res.total)}
${res.sales.map(s => '#' + s.id).join(', ')}`; + } catch (e) { + preview.innerHTML = 'Error: ' + e.message + ''; + } + } + + 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") { diff --git a/pos/static/js/pos.js b/pos/static/js/pos.js index f5528a3..ab1a133 100644 --- a/pos/static/js/pos.js +++ b/pos/static/js/pos.js @@ -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") { diff --git a/pos/templates/catalog.html b/pos/templates/catalog.html index a73d465..a426d8d 100644 --- a/pos/templates/catalog.html +++ b/pos/templates/catalog.html @@ -113,6 +113,9 @@ Catalogo
+
@@ -275,6 +278,29 @@
+ + +