diff --git a/pos/blueprints/inventory_bp.py b/pos/blueprints/inventory_bp.py new file mode 100644 index 0000000..29bcbf2 --- /dev/null +++ b/pos/blueprints/inventory_bp.py @@ -0,0 +1,840 @@ +# /home/Autopartes/pos/blueprints/inventory_bp.py +"""Inventory blueprint: CRUD for inventory items + stock operations + reports.""" + +import json +from datetime import datetime, timedelta +from flask import Blueprint, request, jsonify, g +from middleware import require_auth, has_permission +from tenant_db import get_tenant_conn +from services.inventory_engine import ( + get_stock, get_stock_bulk, record_purchase, record_return, + record_adjustment, record_transfer, record_initial, + get_alerts, get_movement_history +) +from services.barcode_generator import generate_barcode +from services.audit import log_action + +inventory_bp = Blueprint('inventory', __name__, url_prefix='/pos/api/inventory') + + +# ─── Item CRUD ────────────────────────────────── + +@inventory_bp.route('/items', methods=['GET']) +@require_auth('inventory.view') +def list_items(): + """List inventory items with current stock. Supports search, pagination, filtering. + + The low_stock filter is applied at the SQL level via a LEFT JOIN + HAVING clause, + so pagination counts remain accurate. + """ + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + page = int(request.args.get('page', 1)) + per_page = min(int(request.args.get('per_page', 50)), 200) + search = request.args.get('q', '') + category = request.args.get('category', '') + brand = request.args.get('brand', '') + branch_id = request.args.get('branch_id', g.branch_id) + low_stock = request.args.get('low_stock', '') == 'true' + + where_clauses = ["i.is_active = true"] + params = [] + + if branch_id: + where_clauses.append("i.branch_id = %s") + params.append(branch_id) + 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}%']) + if category: + where_clauses.append("i.category_id = %s") + params.append(int(category)) + if brand: + where_clauses.append("i.brand ILIKE %s") + params.append(f'%{brand}%') + + 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. + 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 + 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] + + cur.execute(f""" + SELECT i.id, i.branch_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 + 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 + WHERE {where} AND i.min_stock IS NOT NULL AND i.min_stock > 0 + AND COALESCE(s.stock, 0) < i.min_stock + ORDER BY i.name + LIMIT %s OFFSET %s + """, 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] + }) + 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] + + cur.execute(f""" + SELECT i.id, i.branch_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 + FROM inventory i + 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()} + + items = [] + for r in items_raw: + stock = stock_map.get(r[0], 0) + 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 + }) + + cur.close() + conn.close() + + total_pages = (total + per_page - 1) // per_page + return jsonify({ + 'data': items, + 'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages} + }) + + +@inventory_bp.route('/items/', methods=['GET']) +@require_auth('inventory.view') +def get_item(item_id): + """Get a single inventory item with stock and movement history.""" + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + cur.execute(""" + SELECT i.*, b.name as branch_name + FROM inventory i + LEFT JOIN branches b ON i.branch_id = b.id + WHERE i.id = %s + """, (item_id,)) + row = cur.fetchone() + if not row: + cur.close(); conn.close() + return jsonify({'error': 'Item not found'}), 404 + + cols = [desc[0] for desc in cur.description] + item = dict(zip(cols, row)) + # Convert Decimal to float + for k in ('cost', 'price_1', 'price_2', 'price_3', 'tax_rate'): + if item.get(k) is not None: + item[k] = float(item[k]) + + item['stock'] = get_stock(conn, item_id, item.get('branch_id')) + item['history'] = get_movement_history(conn, item_id, limit=20) + + cur.close() + conn.close() + return jsonify(item) + + +@inventory_bp.route('/items', methods=['POST']) +@require_auth('inventory.create') +def create_item(): + """Create a new inventory item. Optionally set initial stock.""" + data = request.get_json() or {} + required = ['part_number', 'name'] + for f in required: + if not data.get(f): + 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 + + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + # Generate barcode if not provided + barcode = data.get('barcode') + if not barcode: + # Look up tenant db_name + from tenant_db import get_master_conn + mconn = get_master_conn() + mcur = mconn.cursor() + mcur.execute("SELECT db_name FROM tenants WHERE id = %s", (g.tenant_id,)) + db_name = mcur.fetchone()[0] + mcur.close(); mconn.close() + barcode = generate_barcode(conn, db_name) + + try: + cur.execute(""" + INSERT INTO inventory + (branch_id, 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) + RETURNING id + """, ( + branch_id, 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), + data.get('price_1', 0), data.get('price_2', 0), data.get('price_3', 0), + data.get('tax_rate', 0.16), + data.get('min_stock', 0), data.get('max_stock', 0), + data.get('location'), data.get('image_url'), data.get('catalog_part_id') + )) + item_id = cur.fetchone()[0] + + # Record initial stock if provided + initial_stock = data.get('initial_stock', 0) + if initial_stock > 0: + record_initial(conn, item_id, branch_id, initial_stock, data.get('cost')) + + log_action(conn, 'INVENTORY_CREATE', 'inventory', item_id, + new_value={'part_number': data['part_number'], 'name': data['name'], 'initial_stock': initial_stock}) + + conn.commit() + cur.close(); conn.close() + return jsonify({'id': item_id, 'barcode': barcode, 'message': 'Item created'}), 201 + + 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 + return jsonify({'error': str(e)}), 500 + + +@inventory_bp.route('/items/', methods=['PUT']) +@require_auth('inventory.edit') +def update_item(item_id): + """Update inventory item fields (not stock — use operations for that).""" + data = request.get_json() or {} + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + # Get current values for audit + cur.execute("SELECT * FROM inventory WHERE id = %s", (item_id,)) + old = cur.fetchone() + if not old: + cur.close(); conn.close() + return jsonify({'error': 'Item not found'}), 404 + + old_cols = [desc[0] for desc in cur.description] + old_dict = dict(zip(old_cols, old)) + + # Price change requires special permission + price_fields = {'price_1', 'price_2', 'price_3', 'cost'} + changing_prices = price_fields & set(data.keys()) + if changing_prices and not has_permission('config.edit_prices'): + return jsonify({'error': 'Permission config.edit_prices required to change prices'}), 403 + + # Build dynamic update + allowed = ['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', 'is_active'] + sets = [] + vals = [] + for field in allowed: + if field in data: + val = data[field] + if field == 'vehicle_compatibility' and isinstance(val, (dict, list)): + val = json.dumps(val) + sets.append(f"{field} = %s") + vals.append(val) + + if not sets: + cur.close(); conn.close() + return jsonify({'error': 'No fields to update'}), 400 + + vals.append(item_id) + cur.execute(f"UPDATE inventory SET {', '.join(sets)} WHERE id = %s", vals) + + if changing_prices: + log_action(conn, 'PRICE_CHANGE', 'inventory', item_id, + old_value={k: float(old_dict[k]) if old_dict[k] else 0 for k in changing_prices}, + new_value={k: data[k] for k in changing_prices}) + + conn.commit() + cur.close(); conn.close() + return jsonify({'message': 'Item updated'}) + + +# ─── Stock Operations ────────────────────────── + +@inventory_bp.route('/purchase', methods=['POST']) +@require_auth('inventory.create') +def api_purchase(): + """Record a purchase entry (stock in).""" + data = request.get_json() or {} + required = ['inventory_id', 'quantity', 'unit_cost'] + for f in required: + if not data.get(f) and data.get(f) != 0: + return jsonify({'error': f'{f} required'}), 400 + + conn = get_tenant_conn(g.tenant_id) + branch_id = data.get('branch_id', g.branch_id) + op_id = record_purchase( + conn, data['inventory_id'], branch_id, + data['quantity'], data['unit_cost'], + supplier_invoice=data.get('supplier_invoice'), + notes=data.get('notes') + ) + conn.commit() + conn.close() + return jsonify({'operation_id': op_id, 'message': 'Purchase recorded'}) + + +@inventory_bp.route('/adjustment', methods=['POST']) +@require_auth('inventory.adjust') +def api_adjustment(): + """Record a manual stock adjustment.""" + data = request.get_json() or {} + if not data.get('inventory_id') or data.get('quantity') is None or not data.get('reason'): + return jsonify({'error': 'inventory_id, quantity, and reason required'}), 400 + + conn = get_tenant_conn(g.tenant_id) + try: + branch_id = data.get('branch_id', g.branch_id) + op_id = record_adjustment(conn, data['inventory_id'], branch_id, data['quantity'], data['reason']) + conn.commit() + conn.close() + return jsonify({'operation_id': op_id, 'message': 'Adjustment recorded'}) + except ValueError as e: + conn.close() + return jsonify({'error': str(e)}), 400 + + +@inventory_bp.route('/transfer', methods=['POST']) +@require_auth('inventory.transfer') +def api_transfer(): + """Transfer stock between branches.""" + data = request.get_json() or {} + required = ['inventory_id', 'from_branch_id', 'to_branch_id', 'quantity'] + for f in required: + if not data.get(f): + return jsonify({'error': f'{f} required'}), 400 + + conn = get_tenant_conn(g.tenant_id) + out_id, in_id = record_transfer( + conn, data['inventory_id'], data['from_branch_id'], + data['to_branch_id'], data['quantity'], data.get('notes') + ) + conn.commit() + conn.close() + return jsonify({'out_operation_id': out_id, 'in_operation_id': in_id, 'message': 'Transfer recorded'}) + + +@inventory_bp.route('/return', methods=['POST']) +@require_auth('inventory.create') +def api_return(): + """Record a customer return.""" + data = request.get_json() or {} + if not data.get('inventory_id') or not data.get('quantity'): + return jsonify({'error': 'inventory_id and quantity required'}), 400 + + conn = get_tenant_conn(g.tenant_id) + branch_id = data.get('branch_id', g.branch_id) + op_id = record_return(conn, data['inventory_id'], branch_id, + data['quantity'], data.get('sale_id'), data.get('notes')) + conn.commit() + conn.close() + return jsonify({'operation_id': op_id, 'message': 'Return recorded'}) + + +# ─── Physical Count (two-phase: start → approve) ────────── + +@inventory_bp.route('/physical-count/start', methods=['POST']) +@require_auth('inventory.view') +def physical_count_start(): + """Start a physical count. Creates a draft that compares expected vs counted + WITHOUT making any adjustments. Returns a draft ID and comparison results. + + Body: { items: [{inventory_id, counted_quantity}, ...], branch_id, notes } + """ + data = request.get_json() or {} + items = data.get('items', []) + branch_id = data.get('branch_id', g.branch_id) + notes = data.get('notes', 'Toma fisica') + + if not items: + return jsonify({'error': 'items array required'}), 400 + + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + # Create a draft physical count record + cur.execute(""" + INSERT INTO physical_counts (branch_id, status, notes, employee_id, created_at) + VALUES (%s, 'draft', %s, %s, NOW()) + RETURNING id + """, (branch_id, notes, getattr(g, 'employee_id', None))) + count_id = cur.fetchone()[0] + + results = [] + for item in items: + inv_id = item.get('inventory_id') + counted = item.get('counted_quantity', 0) + expected = get_stock(conn, inv_id, branch_id) + diff = counted - expected + + # Store each line in the draft + cur.execute(""" + INSERT INTO physical_count_lines + (physical_count_id, inventory_id, expected_quantity, counted_quantity, difference) + VALUES (%s, %s, %s, %s, %s) + """, (count_id, inv_id, expected, counted, diff)) + + results.append({ + 'inventory_id': inv_id, + 'expected': expected, + 'counted': counted, + 'difference': diff, + 'needs_adjustment': diff != 0 + }) + + conn.commit() + cur.close() + conn.close() + + adjustments_needed = sum(1 for r in results if r['needs_adjustment']) + return jsonify({ + 'count_id': count_id, + 'status': 'draft', + 'message': f'Draft created. {adjustments_needed} items need adjustment.', + 'results': results + }) + + +@inventory_bp.route('/physical-count/approve', methods=['POST']) +@require_auth('inventory.adjust') +def physical_count_approve(): + """Approve a draft physical count and create ADJUST operations for all differences. + + Body: { count_id: int } + Requires inventory.adjust permission. + """ + data = request.get_json() or {} + count_id = data.get('count_id') + if not count_id: + return jsonify({'error': 'count_id required'}), 400 + + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + # Verify draft exists and is still draft + cur.execute("SELECT branch_id, status, notes FROM physical_counts WHERE id = %s", (count_id,)) + row = cur.fetchone() + if not row: + cur.close(); conn.close() + return jsonify({'error': 'Physical count not found'}), 404 + branch_id, status, notes = row + if status != 'draft': + cur.close(); conn.close() + return jsonify({'error': f'Count already {status} — cannot approve again'}), 409 + + # Get all lines with differences + cur.execute(""" + SELECT inventory_id, expected_quantity, counted_quantity, difference + FROM physical_count_lines + WHERE physical_count_id = %s AND difference != 0 + """, (count_id,)) + lines = cur.fetchall() + + results = [] + for inv_id, expected, counted, diff in lines: + record_adjustment( + conn, inv_id, branch_id, diff, + f"{notes}: contado={counted}, esperado={expected}, diferencia={diff}" + ) + results.append({ + 'inventory_id': inv_id, + 'expected': expected, + 'counted': counted, + 'difference': diff, + 'adjusted': True + }) + + # Mark count as approved + cur.execute(""" + UPDATE physical_counts SET status = 'approved', approved_at = NOW(), + approved_by = %s WHERE id = %s + """, (getattr(g, 'employee_id', None), count_id)) + + conn.commit() + cur.close() + conn.close() + + return jsonify({ + 'count_id': count_id, + 'status': 'approved', + 'message': f'Physical count approved. {len(results)} adjustments created.', + 'results': results + }) + + +# ─── Alerts and History ──────────────────────── + +@inventory_bp.route('/alerts', methods=['GET']) +@require_auth('inventory.view') +def api_alerts(): + """Get stock alerts (zero, low, over).""" + conn = get_tenant_conn(g.tenant_id) + branch_id = request.args.get('branch_id', g.branch_id) + alerts = get_alerts(conn, branch_id) + conn.close() + return jsonify({'data': alerts, 'count': len(alerts)}) + + +@inventory_bp.route('/items//history', methods=['GET']) +@require_auth('inventory.view') +def api_history(item_id): + """Get movement history for an item.""" + conn = get_tenant_conn(g.tenant_id) + limit = min(int(request.args.get('limit', 50)), 200) + history = get_movement_history(conn, item_id, limit) + conn.close() + return jsonify({'data': history}) + + +# ─── Inventory Reports ──────────────────────── + +@inventory_bp.route('/reports/valuation', methods=['GET']) +@require_auth('inventory.view') +def report_valuation(): + """Inventory valuation report: stock x cost per item, with totals. + + Returns each active item with its current stock and cost, plus the + line-level value (stock * cost) and a grand total across all items. + """ + conn = get_tenant_conn(g.tenant_id) + 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) + + 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) + + items = [] + grand_total = 0 + for r in cur.fetchall(): + val = float(r[7]) + 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) + }) + + cur.close(); conn.close() + return jsonify({'data': items, 'grand_total': round(grand_total, 2), 'item_count': len(items)}) + + +@inventory_bp.route('/reports/abc', methods=['GET']) +@require_auth('inventory.view') +def report_abc(): + """ABC classification by sales volume (last 90 days). + + A = top 80% of sales volume, B = next 15%, C = remaining 5%. + """ + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + branch_id = request.args.get('branch_id', g.branch_id) + days = int(request.args.get('days', 90)) + + where_branch = "" + params = [datetime.utcnow() - timedelta(days=days)] + if branch_id: + where_branch = "AND io.branch_id = %s" + params.append(branch_id) + + cur.execute(f""" + SELECT i.id, i.part_number, i.name, i.brand, + COALESCE(ABS(SUM(io.quantity)), 0) AS sales_volume + FROM inventory i + LEFT JOIN inventory_operations io + ON io.inventory_id = i.id + AND io.operation_type = 'SALE' + AND io.created_at >= %s + {where_branch} + WHERE i.is_active = true + GROUP BY i.id, i.part_number, i.name, i.brand + ORDER BY sales_volume DESC + """, params) + + rows = cur.fetchall() + total_volume = sum(r[4] for r in rows) + + items = [] + cumulative = 0 + for r in rows: + vol = r[4] + cumulative += vol + pct = (cumulative / total_volume * 100) if total_volume > 0 else 0 + if pct <= 80: + cls = 'A' + elif pct <= 95: + cls = 'B' + else: + cls = 'C' + items.append({ + 'id': r[0], 'part_number': r[1], 'name': r[2], 'brand': r[3], + 'sales_volume': vol, 'cumulative_pct': round(pct, 1), 'classification': cls + }) + + cur.close(); conn.close() + a_count = sum(1 for i in items if i['classification'] == 'A') + b_count = sum(1 for i in items if i['classification'] == 'B') + c_count = sum(1 for i in items if i['classification'] == 'C') + return jsonify({ + 'data': items, + 'summary': {'A': a_count, 'B': b_count, 'C': c_count, 'total_volume': total_volume, 'days': days} + }) + + +@inventory_bp.route('/reports/no-movement', methods=['GET']) +@require_auth('inventory.view') +def report_no_movement(): + """Products with no inventory operations in the last N days (default 60).""" + 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""" + 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 ( + 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} + 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]) + + items = [] + for r in cur.fetchall(): + items.append({ + 'id': r[0], 'part_number': r[1], 'name': r[2], 'brand': r[3], + 'cost': float(r[4]) if r[4] else 0, 'stock': r[5], + 'last_movement': str(r[6]) if r[6] else None + }) + + cur.close(); conn.close() + return jsonify({'data': items, 'days_threshold': days, 'count': len(items)}) + + +@inventory_bp.route('/reports/low-stock', methods=['GET']) +@require_auth('inventory.view') +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""" + 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} + 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(): + items.append({ + 'id': r[0], 'part_number': r[1], 'name': r[2], 'brand': r[3], + 'min_stock': r[4], 'stock': r[5], 'deficit': r[6] + }) + + cur.close(); conn.close() + return jsonify({'data': items, 'count': len(items)}) + + +@inventory_bp.route('/reports/branch-comparison', methods=['GET']) +@require_auth('inventory.view') +def report_branch_comparison(): + """Stock comparison across all branches for each item.""" + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + cur.execute(""" + SELECT i.id, i.part_number, i.name, i.brand, i.branch_id, + b.name AS branch_name, + COALESCE(s.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 + WHERE i.is_active = true + ORDER BY i.part_number, b.name + """) + + # Group by part_number for comparison + by_part = {} + for r in cur.fetchall(): + 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] + }) + + cur.close(); conn.close() + items = list(by_part.values()) + return jsonify({'data': items, 'count': len(items)}) + + +# ─── Categories and Brands ───────────────────── + +@inventory_bp.route('/categories', methods=['GET']) +@require_auth('inventory.view') +def list_categories(): + """Get distinct categories from inventory.""" + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + cur.execute(""" + SELECT DISTINCT category_id FROM inventory + WHERE is_active = true AND category_id IS NOT NULL + ORDER BY category_id + """) + categories = [r[0] for r in cur.fetchall()] + cur.close(); conn.close() + return jsonify({'data': categories}) + + +@inventory_bp.route('/brands', methods=['GET']) +@require_auth('inventory.view') +def list_brands(): + """Get distinct part manufacturer brands from inventory. + + NOTE: These are PART manufacturers (Bosch, NGK, Monroe), not vehicle brands. + Vehicle compatibility is stored in the vehicle_compatibility JSON field and + searched via the vehicle_brand parameter on the catalog search endpoint. + """ + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + cur.execute(""" + SELECT DISTINCT brand FROM inventory + WHERE is_active = true AND brand IS NOT NULL AND brand != '' + ORDER BY brand + """) + brands = [r[0] for r in cur.fetchall()] + cur.close(); conn.close() + return jsonify({'data': brands}) + + +# ─── Barcode ─────────────────────────────────── + +@inventory_bp.route('/generate-barcode', methods=['POST']) +@require_auth('inventory.create') +def api_generate_barcode(): + """Generate a new internal barcode.""" + conn = get_tenant_conn(g.tenant_id) + from tenant_db import get_master_conn + mconn = get_master_conn() + mcur = mconn.cursor() + mcur.execute("SELECT db_name FROM tenants WHERE id = %s", (g.tenant_id,)) + db_name = mcur.fetchone()[0] + mcur.close(); mconn.close() + + barcode = generate_barcode(conn, db_name) + conn.close() + return jsonify({'barcode': barcode}) diff --git a/pos/migrations/v1.0_initial.sql b/pos/migrations/v1.0_initial.sql index 14f2e59..ad65641 100644 --- a/pos/migrations/v1.0_initial.sql +++ b/pos/migrations/v1.0_initial.sql @@ -352,5 +352,28 @@ CREATE INDEX idx_audit_log_created ON audit_log(created_at); CREATE UNIQUE INDEX idx_inventory_branch_part ON inventory(branch_id, part_number); CREATE INDEX idx_employee_sessions_token ON employee_sessions(token); +-- ===================== +-- PHYSICAL COUNTS (two-phase inventory count) +-- ===================== +CREATE TABLE IF NOT EXISTS physical_counts ( + id SERIAL PRIMARY KEY, + branch_id INTEGER NOT NULL REFERENCES branches(id), + status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft, approved, cancelled + notes TEXT, + employee_id INTEGER REFERENCES employees(id), + approved_by INTEGER REFERENCES employees(id), + created_at TIMESTAMP DEFAULT NOW(), + approved_at TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS physical_count_lines ( + id SERIAL PRIMARY KEY, + physical_count_id INTEGER NOT NULL REFERENCES physical_counts(id), + inventory_id INTEGER NOT NULL REFERENCES inventory(id), + expected_quantity INTEGER NOT NULL, + counted_quantity INTEGER NOT NULL, + difference INTEGER NOT NULL +); + -- Barcode sequence CREATE SEQUENCE IF NOT EXISTS barcode_seq START 1;