Compare commits

...

7 Commits

11 changed files with 2338 additions and 0 deletions

View File

@@ -10,6 +10,12 @@ def create_app():
from blueprints.config_bp import config_bp
app.register_blueprint(config_bp)
from blueprints.inventory_bp import inventory_bp
app.register_blueprint(inventory_bp)
from blueprints.catalog_bp import catalog_bp
app.register_blueprint(catalog_bp)
# Health check
@app.route('/pos/health')
def health():
@@ -21,6 +27,14 @@ def create_app():
def pos_login():
return render_template('login.html')
@app.route('/pos/catalog')
def pos_catalog():
return render_template('catalog.html')
@app.route('/pos/inventory')
def pos_inventory():
return render_template('inventory.html')
@app.route('/pos/static/<path:filename>')
def pos_static(filename):
return send_from_directory('static', filename)

View File

@@ -0,0 +1,265 @@
# /home/Autopartes/pos/blueprints/catalog_bp.py
"""Catalog blueprint: browsable inventory with cart, external availability lookup,
and cross-reference queries."""
from flask import Blueprint, request, jsonify, g
from middleware import require_auth
from tenant_db import get_tenant_conn
from services.inventory_engine import get_stock_bulk
catalog_bp = Blueprint('catalog', __name__, url_prefix='/pos/api/catalog')
@catalog_bp.route('/search', methods=['GET'])
@require_auth('catalog.view')
def search_catalog():
"""Search the tenant's inventory as a catalog. Returns items with stock and pricing.
Query params: q (search), category, brand, vehicle_brand, page, per_page
NOTE on filtering:
- `brand` filters by part manufacturer (Bosch, NGK, etc.) — the `brand` column.
- `vehicle_brand` filters by vehicle compatibility (Toyota, Nissan, etc.) — searches
inside the `vehicle_compatibility` JSON field via ILIKE on the cast text.
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
q = request.args.get('q', '')
category = request.args.get('category', '')
brand = request.args.get('brand', '')
vehicle_brand = request.args.get('vehicle_brand', '')
branch_id = request.args.get('branch_id', g.branch_id)
in_stock_only = request.args.get('in_stock', '') == 'true'
page = int(request.args.get('page', 1))
per_page = min(int(request.args.get('per_page', 30)), 100)
where = ["i.is_active = true"]
params = []
if branch_id:
where.append("i.branch_id = %s")
params.append(branch_id)
if q:
where.append("(i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode = %s)")
params.extend([f'%{q}%', f'%{q}%', q])
if category:
where.append("i.category_id = %s")
params.append(int(category))
if brand:
where.append("i.brand ILIKE %s")
params.append(f'%{brand}%')
if vehicle_brand:
where.append("i.vehicle_compatibility::text ILIKE %s")
params.append(f'%{vehicle_brand}%')
where_sql = " AND ".join(where)
cur.execute(f"SELECT count(*) FROM inventory i WHERE {where_sql}", params)
total = cur.fetchone()[0]
cur.execute(f"""
SELECT i.id, i.part_number, i.barcode, i.name, i.brand, i.unit,
i.price_1, i.price_2, i.price_3, i.tax_rate,
i.image_url, i.category_id, i.location, i.min_stock
FROM inventory i
WHERE {where_sql}
ORDER BY i.name
LIMIT %s OFFSET %s
""", params + [per_page, (page - 1) * per_page])
rows = cur.fetchall()
inv_ids = [r[0] for r in rows]
# Bulk stock lookup
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 rows:
stock = stock_map.get(r[0], 0)
if in_stock_only and stock <= 0:
continue
items.append({
'id': r[0], 'part_number': r[1], 'barcode': r[2], 'name': r[3],
'brand': r[4], 'unit': r[5],
'price_1': float(r[6]) if r[6] else 0,
'price_2': float(r[7]) if r[7] else 0,
'price_3': float(r[8]) if r[8] else 0,
'tax_rate': float(r[9]) if r[9] else 0.16,
'image_url': r[10], 'category_id': r[11], 'location': r[12],
'stock': stock,
'low_stock': r[13] and stock < r[13] if r[13] else False
})
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}
})
@catalog_bp.route('/categories', methods=['GET'])
@require_auth('catalog.view')
def catalog_categories():
"""Get categories with item counts for catalog navigation."""
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.category_id, COUNT(*) as item_count
FROM inventory i
WHERE {where} AND i.category_id IS NOT NULL
GROUP BY i.category_id
ORDER BY item_count DESC
""", params)
categories = [{'id': r[0], 'count': r[1]} for r in cur.fetchall()]
cur.close(); conn.close()
return jsonify({'data': categories})
@catalog_bp.route('/brands', methods=['GET'])
@require_auth('catalog.view')
def catalog_brands():
"""Get part manufacturer brands with item counts for catalog navigation."""
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.brand, COUNT(*) as item_count
FROM inventory i
WHERE {where} AND i.brand IS NOT NULL AND i.brand != ''
GROUP BY i.brand
ORDER BY item_count DESC
""", params)
brands = [{'name': r[0], 'count': r[1]} for r in cur.fetchall()]
cur.close(); conn.close()
return jsonify({'data': brands})
@catalog_bp.route('/barcode/<barcode>', methods=['GET'])
@require_auth('catalog.view')
def lookup_barcode(barcode):
"""Lookup a part by barcode (for scanner). Returns item with stock."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT i.id, i.part_number, i.barcode, i.name, i.brand, i.unit,
i.price_1, i.price_2, i.price_3, i.tax_rate, i.cost,
i.image_url, i.branch_id
FROM inventory i
WHERE (i.barcode = %s OR i.part_number = %s) AND i.is_active = true
LIMIT 1
""", (barcode, barcode))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Part not found'}), 404
from services.inventory_engine import get_stock
item = {
'id': row[0], 'part_number': row[1], 'barcode': row[2], 'name': row[3],
'brand': row[4], 'unit': row[5],
'price_1': float(row[6]) if row[6] else 0,
'price_2': float(row[7]) if row[7] else 0,
'price_3': float(row[8]) if row[8] else 0,
'tax_rate': float(row[9]) if row[9] else 0.16,
'cost': float(row[10]) if row[10] else 0,
'image_url': row[11],
'stock': get_stock(conn, row[0], row[12])
}
cur.close(); conn.close()
return jsonify(item)
@catalog_bp.route('/external-availability/<part_number>', methods=['GET'])
@require_auth('catalog.view')
def external_availability(part_number):
"""Check part availability in external bodegas (Nexus marketplace).
Requires internet. Calls the main Nexus API.
"""
import requests
try:
# Query the Nexus master API for warehouse inventory
# This calls the existing /api/search endpoint on the main Nexus server
resp = requests.get(
'http://localhost:5000/api/search',
params={'q': part_number},
timeout=5
)
if resp.status_code != 200:
return jsonify({'data': [], 'source': 'nexus', 'error': 'Catalog unavailable'}), 200
results = resp.json()
return jsonify({'data': results.get('results', []), 'source': 'nexus'})
except requests.RequestException:
return jsonify({'data': [], 'source': 'nexus', 'error': 'No internet connection'}), 200
@catalog_bp.route('/cross-references/<part_number>', methods=['GET'])
@require_auth('catalog.view')
def cross_references(part_number):
"""Get OEM <-> aftermarket cross-references for a part number.
Calls the Nexus master API which has the full cross-reference database
(part_cross_references table). Returns OEM equivalents and aftermarket
alternatives.
This follows the same pattern as external-availability: the tenant POS
calls the central Nexus server which holds the master catalog data.
"""
import requests
try:
resp = requests.get(
'http://localhost:5000/api/cross-references',
params={'part_number': part_number},
timeout=5
)
if resp.status_code != 200:
return jsonify({'data': [], 'source': 'nexus', 'error': 'Cross-reference service unavailable'}), 200
results = resp.json()
return jsonify({
'part_number': part_number,
'cross_references': results.get('cross_references', []),
'source': 'nexus'
})
except requests.RequestException:
return jsonify({
'part_number': part_number,
'cross_references': [],
'source': 'nexus',
'error': 'No internet connection'
}), 200

View File

@@ -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/<int:item_id>', 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/<int:item_id>', 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/<int:item_id>/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})

View File

@@ -351,3 +351,29 @@ CREATE INDEX idx_audit_log_employee ON audit_log(employee_id);
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;

View File

@@ -0,0 +1,61 @@
# /home/Autopartes/pos/services/barcode_generator.py
"""Generate internal barcodes for parts that don't have manufacturer barcodes.
Format: NX-{tenant_short}-{sequential_number}
Uses a PostgreSQL sequence (barcode_seq) per tenant schema to guarantee uniqueness
under concurrent requests. No MAX+1 race conditions.
"""
def _ensure_sequence(conn):
"""Create the barcode sequence if it doesn't exist yet."""
cur = conn.cursor()
cur.execute("""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_class WHERE relkind = 'S' AND relname = 'barcode_seq'
) THEN
CREATE SEQUENCE barcode_seq START WITH 1 INCREMENT BY 1 NO MAXVALUE;
END IF;
END $$;
""")
cur.close()
def generate_barcode(conn, tenant_db_name):
"""Generate the next barcode for this tenant.
Format: NX-{tenant_short}-{NNNNNNN}
Example: NX-REFLOPEZ-0000001
Uses a PostgreSQL sequence to guarantee unique sequential numbers even
under concurrent requests (no race conditions).
"""
short = tenant_db_name.replace('tenant_', '').replace('_', '').upper()[:10]
prefix = f"NX-{short}-"
_ensure_sequence(conn)
cur = conn.cursor()
cur.execute("SELECT nextval('barcode_seq')")
next_num = cur.fetchone()[0]
cur.close()
return f"{prefix}{next_num:07d}"
def generate_barcodes_batch(conn, tenant_db_name, count):
"""Generate multiple sequential barcodes atomically."""
short = tenant_db_name.replace('tenant_', '').replace('_', '').upper()[:10]
prefix = f"NX-{short}-"
_ensure_sequence(conn)
cur = conn.cursor()
# setval + generate range atomically via a single call
cur.execute("SELECT nextval('barcode_seq') FROM generate_series(1, %s)", (count,))
nums = [r[0] for r in cur.fetchall()]
cur.close()
return [f"{prefix}{n:07d}" for n in nums]

View File

@@ -0,0 +1,231 @@
# /home/Autopartes/pos/services/inventory_engine.py
"""Inventory operations engine. All stock mutations go through here.
Stock is NEVER stored as a field — it is always computed as:
SUM(inventory_operations.quantity) WHERE inventory_id = X AND branch_id = Y
Operations are append-only. No UPDATE, no DELETE on inventory_operations.
"""
from flask import g
from services.audit import log_action
def get_stock(conn, inventory_id, branch_id=None):
"""Get current stock for an inventory item. Optionally filter by branch."""
cur = conn.cursor()
if branch_id:
cur.execute(
"SELECT COALESCE(SUM(quantity), 0) FROM inventory_operations WHERE inventory_id = %s AND branch_id = %s",
(inventory_id, branch_id)
)
else:
cur.execute(
"SELECT COALESCE(SUM(quantity), 0) FROM inventory_operations WHERE inventory_id = %s",
(inventory_id,)
)
stock = cur.fetchone()[0]
cur.close()
return stock
def get_stock_bulk(conn, branch_id=None):
"""Get stock for all items. Returns dict {inventory_id: stock_quantity}."""
cur = conn.cursor()
if branch_id:
cur.execute("""
SELECT inventory_id, COALESCE(SUM(quantity), 0)
FROM inventory_operations WHERE branch_id = %s
GROUP BY inventory_id
""", (branch_id,))
else:
cur.execute("""
SELECT inventory_id, COALESCE(SUM(quantity), 0)
FROM inventory_operations
GROUP BY inventory_id
""")
stock_map = {r[0]: r[1] for r in cur.fetchall()}
cur.close()
return stock_map
def record_operation(conn, inventory_id, branch_id, operation_type, quantity,
reference_id=None, reference_type=None, cost_at_time=None, notes=None):
"""Record a single inventory operation. Does NOT commit — caller controls transaction.
Args:
quantity: positive for entries (PURCHASE, RETURN, INITIAL), negative for exits (SALE)
operation_type: SALE, PURCHASE, RETURN, ADJUST, TRANSFER, INITIAL
"""
cur = conn.cursor()
cur.execute("""
INSERT INTO inventory_operations
(inventory_id, branch_id, operation_type, quantity, reference_id,
reference_type, cost_at_time, employee_id, device_id, notes)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""", (
inventory_id, branch_id, operation_type, quantity,
reference_id, reference_type, cost_at_time,
getattr(g, 'employee_id', None),
getattr(g, 'device_id', None),
notes
))
op_id = cur.fetchone()[0]
cur.close()
return op_id
def record_purchase(conn, inventory_id, branch_id, quantity, unit_cost,
supplier_invoice=None, notes=None):
"""Record a purchase entry. Updates weighted average cost on the inventory item.
IMPORTANT: Cost is stored globally on the inventory item (not per-branch), so we
must use TOTAL stock across ALL branches when computing the weighted average.
Using branch-scoped stock would produce incorrect averages when the same item
exists in multiple branches.
"""
cur = conn.cursor()
cur.execute("SELECT cost FROM inventory WHERE id = %s", (inventory_id,))
current_cost = float(cur.fetchone()[0] or 0)
# Use GLOBAL stock (all branches) because cost is a global field on the inventory item
current_stock = get_stock(conn, inventory_id, branch_id=None)
# Weighted average cost
if current_stock + quantity > 0:
new_cost = ((current_cost * max(current_stock, 0)) + (unit_cost * quantity)) / (max(current_stock, 0) + quantity)
else:
new_cost = unit_cost
# Update cost on inventory item
cur.execute("UPDATE inventory SET cost = %s WHERE id = %s", (round(new_cost, 2), inventory_id))
cur.close()
ref_note = f"Compra: {quantity} uds @ ${unit_cost:.2f}"
if supplier_invoice:
ref_note += f" | Factura: {supplier_invoice}"
if notes:
ref_note += f" | {notes}"
return record_operation(
conn, inventory_id, branch_id, 'PURCHASE', quantity,
cost_at_time=unit_cost, notes=ref_note
)
def record_sale(conn, inventory_id, branch_id, quantity, sale_id=None, cost_at_time=None):
"""Record a sale (negative quantity).
NOT exposed via HTTP endpoint — called directly by the POS blueprint (Plan 3)
which imports inventory_engine as part of the full sale transaction.
"""
return record_operation(
conn, inventory_id, branch_id, 'SALE', -abs(quantity),
reference_id=sale_id, reference_type='sale', cost_at_time=cost_at_time
)
def record_return(conn, inventory_id, branch_id, quantity, sale_id=None, notes=None):
"""Record a customer return (positive quantity)."""
return record_operation(
conn, inventory_id, branch_id, 'RETURN', abs(quantity),
reference_id=sale_id, reference_type='return', notes=notes
)
def record_adjustment(conn, inventory_id, branch_id, quantity, reason):
"""Record a manual stock adjustment. Reason is mandatory."""
if not reason or len(reason.strip()) < 3:
raise ValueError("Adjustment reason is mandatory (min 3 characters)")
log_action(conn, 'STOCK_ADJUST', 'inventory', inventory_id,
old_value={'stock': get_stock(conn, inventory_id, branch_id)},
new_value={'adjustment': quantity, 'reason': reason})
return record_operation(
conn, inventory_id, branch_id, 'ADJUST', quantity,
notes=f"Ajuste: {reason}"
)
def record_transfer(conn, inventory_id, from_branch_id, to_branch_id, quantity, notes=None):
"""Transfer stock between branches. Creates two operations (out + in)."""
out_id = record_operation(
conn, inventory_id, from_branch_id, 'TRANSFER', -abs(quantity),
notes=f"Transferencia a sucursal {to_branch_id}" + (f" | {notes}" if notes else "")
)
in_id = record_operation(
conn, inventory_id, to_branch_id, 'TRANSFER', abs(quantity),
notes=f"Transferencia desde sucursal {from_branch_id}" + (f" | {notes}" if notes else "")
)
return out_id, in_id
def record_initial(conn, inventory_id, branch_id, quantity, cost=None):
"""Record initial stock load."""
return record_operation(
conn, inventory_id, branch_id, 'INITIAL', quantity,
cost_at_time=cost, notes="Carga inicial de inventario"
)
def get_alerts(conn, branch_id=None):
"""Get stock alerts: zero stock, below minimum, above maximum."""
stock_map = get_stock_bulk(conn, branch_id)
cur = conn.cursor()
where = "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.min_stock, i.max_stock, i.branch_id
FROM inventory i {where}
""", params)
alerts = []
for row in cur.fetchall():
inv_id, part_num, name, min_s, max_s, br_id = row
stock = stock_map.get(inv_id, 0)
if stock <= 0:
alerts.append({'type': 'zero', 'severity': 'critical', 'inventory_id': inv_id,
'part_number': part_num, 'name': name, 'stock': stock, 'branch_id': br_id})
elif min_s and stock < min_s:
alerts.append({'type': 'low', 'severity': 'warning', 'inventory_id': inv_id,
'part_number': part_num, 'name': name, 'stock': stock,
'min_stock': min_s, 'branch_id': br_id})
elif max_s and stock > max_s:
alerts.append({'type': 'over', 'severity': 'info', 'inventory_id': inv_id,
'part_number': part_num, 'name': name, 'stock': stock,
'max_stock': max_s, 'branch_id': br_id})
cur.close()
return alerts
def get_movement_history(conn, inventory_id, limit=50):
"""Get operation history for a specific item."""
cur = conn.cursor()
cur.execute("""
SELECT io.id, io.operation_type, io.quantity, io.cost_at_time,
io.notes, io.created_at, e.name as employee_name, io.branch_id
FROM inventory_operations io
LEFT JOIN employees e ON io.employee_id = e.id
WHERE io.inventory_id = %s
ORDER BY io.created_at DESC
LIMIT %s
""", (inventory_id, limit))
history = []
for r in cur.fetchall():
history.append({
'id': r[0], 'type': r[1], 'quantity': r[2],
'cost': float(r[3]) if r[3] else None,
'notes': r[4], 'date': str(r[5]),
'employee': r[6], 'branch_id': r[7]
})
cur.close()
return history

View File

@@ -55,3 +55,31 @@ body {
border-radius: var(--radius);
padding: 24px;
}
/* Catalog grid */
.catalog-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; }
.catalog-card { cursor: pointer; transition: all 0.2s; }
.catalog-card:hover { border-color: var(--color-primary); transform: translateY(-2px); box-shadow: var(--shadow); }
.stock-badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.75rem; font-weight: 600; }
.stock-badge--ok { background: #dcfce7; color: #166534; }
.stock-badge--low { background: #fef9c3; color: #854d0e; }
.stock-badge--zero { background: #fecaca; color: #991b1b; }
/* Cart sidebar */
.cart-sidebar { position: fixed; right: 0; top: 0; bottom: 0; width: 360px; background: var(--color-surface); border-left: 1px solid var(--color-border); padding: 20px; overflow-y: auto; transform: translateX(100%); transition: transform 0.3s; z-index: 50; }
.cart-sidebar.open { transform: translateX(0); }
.cart-item { display: flex; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--color-border); }
.cart-total { font-family: var(--font-mono); font-size: 1.3rem; font-weight: 700; }
/* Search bar */
.search-bar { display: flex; gap: 8px; margin-bottom: 20px; }
.search-bar input { flex: 1; padding: 10px 16px; border: 1px solid var(--color-border); border-radius: var(--radius); font-size: 1rem; }
.search-bar input:focus { outline: none; border-color: var(--color-primary); }
/* Filter chips */
.filter-chips { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
.chip { padding: 4px 12px; border-radius: 20px; border: 1px solid var(--color-border); font-size: 0.8rem; cursor: pointer; background: transparent; }
.chip.active { background: var(--color-primary); color: white; border-color: var(--color-primary); }
/* External availability */
.external-results { background: #eff6ff; border: 1px solid #bfdbfe; border-radius: var(--radius); padding: 16px; margin-top: 12px; }

293
pos/static/js/catalog.js Normal file
View File

@@ -0,0 +1,293 @@
// /home/Autopartes/pos/static/js/catalog.js
// Catalog UI: browsable inventory with cart, barcode scanner, external lookup
(function () {
'use strict';
const API = '/pos/api';
const token = localStorage.getItem('pos_token');
if (!token) { window.location.href = '/pos/login'; return; }
const headers = { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
// ─── State ───
let currentPage = 1;
let currentFilters = {};
let cartItems = JSON.parse(localStorage.getItem('pos_cart') || '[]');
let barcodeBuffer = '';
let barcodeTimeout = null;
// ─── API helpers ───
async function apiFetch(url, opts) {
const resp = await fetch(url, Object.assign({ headers: headers }, opts || {}));
if (resp.status === 401) { localStorage.removeItem('pos_token'); window.location.href = '/pos/login'; return null; }
return resp.json();
}
// ─── Catalog loading ───
async function loadCatalog(page, filters) {
currentPage = page || 1;
currentFilters = filters || currentFilters;
const params = new URLSearchParams({ page: currentPage, per_page: 30 });
if (currentFilters.q) params.set('q', currentFilters.q);
if (currentFilters.category) params.set('category', currentFilters.category);
if (currentFilters.brand) params.set('brand', currentFilters.brand);
if (currentFilters.vehicle_brand) params.set('vehicle_brand', currentFilters.vehicle_brand);
const data = await apiFetch(API + '/catalog/search?' + params.toString());
if (!data) return;
renderGrid(data.data || []);
renderPagination(data.pagination || {});
renderActiveFilters();
}
function renderGrid(items) {
const grid = document.getElementById('catalogGrid');
if (!items.length) {
grid.innerHTML = '<div class="empty-state"><p>No se encontraron productos</p><button onclick="checkExternalAvailability()" style="margin-top:12px; padding:8px 16px; cursor:pointer;">Buscar en bodegas Nexus</button></div>';
return;
}
grid.innerHTML = items.map(function (it) {
var stockClass = it.stock <= 0 ? 'stock-badge--zero' : (it.low_stock ? 'stock-badge--low' : 'stock-badge--ok');
var stockLabel = it.stock <= 0 ? 'Agotado' : it.stock + ' ' + (it.unit || 'PZA');
return '<div class="card catalog-card" onclick="window._addToCart(' + it.id + ')" data-id="' + it.id + '">' +
(it.image_url ? '<img src="' + it.image_url + '" alt="" style="width:100%;height:120px;object-fit:contain;margin-bottom:8px;">' : '<div style="height:120px;background:#f0f0f0;display:flex;align-items:center;justify-content:center;margin-bottom:8px;border-radius:4px;color:#aaa;">Sin imagen</div>') +
'<div style="font-weight:600;font-size:0.9rem;margin-bottom:4px;">' + escHtml(it.name) + '</div>' +
'<div style="font-size:0.8rem;color:#666;margin-bottom:4px;">' + escHtml(it.part_number) + (it.brand ? ' &middot; ' + escHtml(it.brand) : '') + '</div>' +
'<div style="display:flex;justify-content:space-between;align-items:center;">' +
'<span style="font-weight:700;font-size:1rem;">$' + fmt(it.price_1) + '</span>' +
'<span class="stock-badge ' + stockClass + '">' + stockLabel + '</span>' +
'</div></div>';
}).join('');
// Store items for cart lookup
window._catalogItems = {};
items.forEach(function (it) { window._catalogItems[it.id] = it; });
}
function renderPagination(pg) {
var el = document.getElementById('pagination');
if (!pg || pg.total_pages <= 1) { el.innerHTML = ''; return; }
var html = '<button ' + (pg.page <= 1 ? 'disabled' : 'onclick="window._loadPage(' + (pg.page - 1) + ')"') + '>&laquo; Anterior</button>';
html += '<span style="padding:6px 12px; font-size:0.85rem;">' + pg.page + ' / ' + pg.total_pages + '</span>';
html += '<button ' + (pg.page >= pg.total_pages ? 'disabled' : 'onclick="window._loadPage(' + (pg.page + 1) + ')"') + '>Siguiente &raquo;</button>';
el.innerHTML = html;
}
function renderActiveFilters() {
var el = document.getElementById('activeFilters');
var chips = [];
if (currentFilters.category) chips.push('<span class="chip active" onclick="window._removeFilter(\'category\')">Cat: ' + currentFilters.category + ' &times;</span>');
if (currentFilters.brand) chips.push('<span class="chip active" onclick="window._removeFilter(\'brand\')">' + escHtml(currentFilters.brand) + ' &times;</span>');
if (currentFilters.vehicle_brand) chips.push('<span class="chip active" onclick="window._removeFilter(\'vehicle_brand\')">Vehiculo: ' + escHtml(currentFilters.vehicle_brand) + ' &times;</span>');
el.innerHTML = chips.join('');
}
// ─── Sidebar filters ───
async function loadCategories() {
var data = await apiFetch(API + '/catalog/categories');
if (!data) return;
var ul = document.getElementById('categoryList');
var cats = data.data || [];
if (!cats.length) { ul.innerHTML = '<li style="color:#999;">Sin categorias</li>'; return; }
ul.innerHTML = '<li onclick="window._filterCat(null)" class="' + (!currentFilters.category ? 'active' : '') + '">Todas</li>' +
cats.map(function (c) { return '<li onclick="window._filterCat(' + c.id + ')" class="' + (currentFilters.category == c.id ? 'active' : '') + '">Cat #' + c.id + ' <small>(' + c.count + ')</small></li>'; }).join('');
}
async function loadBrands() {
var data = await apiFetch(API + '/catalog/brands');
if (!data) return;
var ul = document.getElementById('brandList');
var brands = data.data || [];
if (!brands.length) { ul.innerHTML = '<li style="color:#999;">Sin marcas</li>'; return; }
ul.innerHTML = '<li onclick="window._filterBrand(null)" class="' + (!currentFilters.brand ? 'active' : '') + '">Todas</li>' +
brands.map(function (b) { return '<li onclick="window._filterBrand(\'' + escHtml(b.name) + '\')" class="' + (currentFilters.brand === b.name ? 'active' : '') + '">' + escHtml(b.name) + ' <small>(' + b.count + ')</small></li>'; }).join('');
}
// ─── Barcode scanner ───
async function lookupBarcode(code) {
var data = await apiFetch(API + '/catalog/barcode/' + encodeURIComponent(code));
if (!data || data.error) { alert('Parte no encontrada: ' + code); return; }
addToCart(data);
}
// Listen for rapid keypress (barcode scanners type fast, then Enter)
document.addEventListener('keydown', function (e) {
if (e.key === 'F1') { e.preventDefault(); document.getElementById('searchInput').focus(); return; }
if (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA') return;
if (e.key === 'Enter' && barcodeBuffer.length >= 4) {
lookupBarcode(barcodeBuffer.trim());
barcodeBuffer = '';
return;
}
if (e.key.length === 1) {
barcodeBuffer += e.key;
clearTimeout(barcodeTimeout);
barcodeTimeout = setTimeout(function () { barcodeBuffer = ''; }, 200);
}
});
// ─── Cart ───
function addToCart(item) {
var existing = cartItems.find(function (c) { return c.id === item.id; });
if (existing) {
existing.quantity += 1;
} else {
cartItems.push({
id: item.id, part_number: item.part_number, name: item.name,
brand: item.brand, price: item.price_1, tax_rate: item.tax_rate || 0.16,
unit: item.unit || 'PZA', stock: item.stock, quantity: 1
});
}
saveCart();
renderCart();
}
function removeFromCart(index) {
cartItems.splice(index, 1);
saveCart();
renderCart();
}
function updateQuantity(index, qty) {
qty = parseInt(qty);
if (qty <= 0) { removeFromCart(index); return; }
cartItems[index].quantity = qty;
saveCart();
renderCart();
}
function clearCartFn() {
cartItems = [];
saveCart();
renderCart();
}
function saveCart() {
localStorage.setItem('pos_cart', JSON.stringify(cartItems));
}
function renderCart() {
var badge = document.getElementById('cartBadge');
var total = cartItems.reduce(function (s, c) { return s + c.quantity; }, 0);
badge.textContent = total;
badge.style.display = total > 0 ? 'flex' : 'none';
var container = document.getElementById('cartItems');
var empty = document.getElementById('cartEmpty');
var checkoutBtn = document.getElementById('checkoutBtn');
if (!cartItems.length) {
container.innerHTML = '';
empty.style.display = 'block';
checkoutBtn.disabled = true;
document.getElementById('cartSubtotal').textContent = '$0.00';
document.getElementById('cartTax').textContent = '$0.00';
document.getElementById('cartTotal').textContent = '$0.00';
return;
}
empty.style.display = 'none';
checkoutBtn.disabled = false;
var subtotal = 0;
var tax = 0;
container.innerHTML = cartItems.map(function (c, i) {
var lineTotal = c.price * c.quantity;
var lineTax = lineTotal * c.tax_rate;
subtotal += lineTotal;
tax += lineTax;
return '<div class="cart-item">' +
'<div style="flex:1;">' +
'<div style="font-weight:600;font-size:0.85rem;">' + escHtml(c.name) + '</div>' +
'<div style="font-size:0.75rem;color:#666;">' + escHtml(c.part_number) + '</div>' +
'<div style="margin-top:4px;display:flex;align-items:center;gap:6px;">' +
'<button onclick="window._updateQty(' + i + ',' + (c.quantity - 1) + ')" style="width:24px;height:24px;border:1px solid #ddd;background:#fff;border-radius:4px;cursor:pointer;">-</button>' +
'<span style="font-weight:600;">' + c.quantity + '</span>' +
'<button onclick="window._updateQty(' + i + ',' + (c.quantity + 1) + ')" style="width:24px;height:24px;border:1px solid #ddd;background:#fff;border-radius:4px;cursor:pointer;">+</button>' +
'</div></div>' +
'<div style="text-align:right;">' +
'<div style="font-weight:600;">$' + fmt(lineTotal) + '</div>' +
'<button onclick="window._removeFromCart(' + i + ')" style="font-size:0.75rem;color:#ef4444;background:none;border:none;cursor:pointer;margin-top:4px;">Quitar</button>' +
'</div></div>';
}).join('');
document.getElementById('cartSubtotal').textContent = '$' + fmt(subtotal);
document.getElementById('cartTax').textContent = '$' + fmt(tax);
document.getElementById('cartTotal').textContent = '$' + fmt(subtotal + tax);
}
function toggleCart() {
document.getElementById('cartSidebar').classList.toggle('open');
}
function goToCheckout() {
localStorage.setItem('pos_cart', JSON.stringify(cartItems));
window.location.href = '/pos/sale';
}
// ─── External availability ───
async function checkExternalAvailability(partNumber) {
var pn = partNumber || currentFilters.q || '';
if (!pn) return;
var section = document.getElementById('externalSection');
var results = document.getElementById('externalResults');
section.style.display = 'block';
results.innerHTML = '<p>Buscando en bodegas...</p>';
var data = await apiFetch(API + '/catalog/external-availability/' + encodeURIComponent(pn));
if (!data || !data.data || !data.data.length) {
results.innerHTML = '<p>No se encontraron resultados externos para "' + escHtml(pn) + '"</p>';
return;
}
results.innerHTML = '<ul>' + data.data.map(function (r) {
return '<li><strong>' + escHtml(r.name || r.part_number || pn) + '</strong> — Stock: ' + (r.stock || 'N/A') + '</li>';
}).join('') + '</ul>';
}
// ─── Helpers ───
function fmt(n) { return (parseFloat(n) || 0).toFixed(2); }
function escHtml(s) { if (!s) return ''; var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
// ─── Search input ───
var searchInput = document.getElementById('searchInput');
var searchTimeout = null;
searchInput.addEventListener('input', function () {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(function () {
currentFilters.q = searchInput.value.trim();
loadCatalog(1, currentFilters);
}, 350);
});
searchInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
clearTimeout(searchTimeout);
currentFilters.q = searchInput.value.trim();
loadCatalog(1, currentFilters);
}
});
// ─── Expose globals for inline handlers ───
window._addToCart = function (id) {
var it = window._catalogItems && window._catalogItems[id];
if (it) addToCart(it);
};
window._loadPage = function (p) { loadCatalog(p); };
window._removeFilter = function (key) { delete currentFilters[key]; loadCatalog(1); loadCategories(); loadBrands(); };
window._filterCat = function (id) { if (id) currentFilters.category = id; else delete currentFilters.category; loadCatalog(1); loadCategories(); };
window._filterBrand = function (name) { if (name) currentFilters.brand = name; else delete currentFilters.brand; loadCatalog(1); loadBrands(); };
window._removeFromCart = removeFromCart;
window._updateQty = updateQuantity;
window.toggleCart = toggleCart;
window.goToCheckout = goToCheckout;
window.clearCart = clearCartFn;
window.checkExternalAvailability = checkExternalAvailability;
// ─── Init ───
renderCart();
loadCatalog(1, {});
loadCategories();
loadBrands();
})();

298
pos/static/js/inventory.js Normal file
View File

@@ -0,0 +1,298 @@
// /home/Autopartes/pos/static/js/inventory.js
// Inventory management UI: CRUD, purchases, adjustments, transfers, physical count, alerts
(function () {
'use strict';
const API = '/pos/api/inventory';
const token = localStorage.getItem('pos_token');
if (!token) { window.location.href = '/pos/login'; return; }
const headers = { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
let currentPage = 1;
let currentSearch = '';
let draftCountId = null;
// --- API helper ---
async function apiFetch(url, opts) {
const resp = await fetch(url, Object.assign({ headers: headers }, opts || {}));
if (resp.status === 401) { localStorage.removeItem('pos_token'); window.location.href = '/pos/login'; return null; }
return resp.json();
}
// --- Tab switching ---
document.querySelectorAll('.tab').forEach(function (tab) {
tab.addEventListener('click', function () {
document.querySelectorAll('.tab').forEach(function (t) { t.classList.remove('active'); });
document.querySelectorAll('.tab-content').forEach(function (c) { c.classList.remove('active'); });
tab.classList.add('active');
document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
if (tab.dataset.tab === 'alerts') loadAlerts();
});
});
// --- Products ---
async function loadItems(page, search) {
currentPage = page || 1;
currentSearch = search !== undefined ? search : currentSearch;
var params = new URLSearchParams({ page: currentPage, per_page: 50 });
if (currentSearch) params.set('q', currentSearch);
var data = await apiFetch(API + '/items?' + params.toString());
if (!data) return;
var tbody = document.getElementById('productTableBody');
var items = data.data || [];
if (!items.length) { tbody.innerHTML = '<tr><td colspan="11" style="text-align:center;padding:30px;color:#999;">Sin productos</td></tr>'; return; }
tbody.innerHTML = items.map(function (it) {
return '<tr>' +
'<td style="font-family:monospace;font-size:0.8rem;">' + esc(it.barcode) + '</td>' +
'<td>' + esc(it.part_number) + '</td>' +
'<td><strong>' + esc(it.name) + '</strong></td>' +
'<td>' + esc(it.brand) + '</td>' +
'<td style="font-weight:600;">' + it.stock + '</td>' +
'<td>$' + fmt(it.cost) + '</td>' +
'<td>$' + fmt(it.price_1) + '</td>' +
'<td>$' + fmt(it.price_2) + '</td>' +
'<td>$' + fmt(it.price_3) + '</td>' +
'<td>' + esc(it.location) + '</td>' +
'<td><button class="btn btn-secondary" onclick="viewHistory(' + it.id + ')" style="padding:4px 8px;font-size:0.75rem;">Historial</button> ' +
'<button class="btn btn-secondary" onclick="printBarcode(\'' + esc(it.barcode) + '\',\'' + esc(it.part_number) + '\',\'' + esc(it.name) + '\')" style="padding:4px 8px;font-size:0.75rem;">Etiqueta</button></td>' +
'</tr>';
}).join('');
// Pagination
var pg = data.pagination || {};
var pgEl = document.getElementById('productPagination');
if (pg.total_pages > 1) {
pgEl.innerHTML = '<button class="btn btn-secondary" ' + (pg.page <= 1 ? 'disabled' : 'onclick="window._loadItems(' + (pg.page - 1) + ')"') + '>&laquo;</button>' +
'<span style="padding:6px 12px;font-size:0.85rem;">' + pg.page + ' / ' + pg.total_pages + ' (' + pg.total + ' items)</span>' +
'<button class="btn btn-secondary" ' + (pg.page >= pg.total_pages ? 'disabled' : 'onclick="window._loadItems(' + (pg.page + 1) + ')"') + '>&raquo;</button>';
} else {
pgEl.innerHTML = '<span style="font-size:0.85rem;color:#999;">' + (pg.total || 0) + ' productos</span>';
}
}
// Search
var searchInput = document.getElementById('productSearch');
var searchTimeout;
searchInput.addEventListener('input', function () {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(function () { loadItems(1, searchInput.value.trim()); }, 350);
});
// --- Create item ---
async function createItem() {
var data = {
part_number: document.getElementById('newPartNumber').value.trim(),
name: document.getElementById('newName').value.trim(),
brand: document.getElementById('newBrand').value.trim(),
barcode: document.getElementById('newBarcode').value.trim() || undefined,
cost: parseFloat(document.getElementById('newCost').value) || 0,
price_1: parseFloat(document.getElementById('newPrice1').value) || 0,
price_2: parseFloat(document.getElementById('newPrice2').value) || 0,
price_3: parseFloat(document.getElementById('newPrice3').value) || 0,
min_stock: parseInt(document.getElementById('newMinStock').value) || 0,
initial_stock: parseInt(document.getElementById('newInitialStock').value) || 0,
location: document.getElementById('newLocation').value.trim()
};
if (!data.part_number || !data.name) { document.getElementById('createResult').innerHTML = '<span style="color:red;">Numero de parte y nombre son obligatorios</span>'; return; }
var result = await apiFetch(API + '/items', { method: 'POST', body: JSON.stringify(data) });
if (result && result.id) {
document.getElementById('createResult').innerHTML = '<span style="color:green;">Creado ID ' + result.id + ' | Barcode: ' + result.barcode + '</span>';
loadItems(currentPage);
} else {
document.getElementById('createResult').innerHTML = '<span style="color:red;">' + (result ? result.error || 'Error' : 'Error de red') + '</span>';
}
}
// --- Purchase ---
async function recordPurchase() {
var data = {
inventory_id: parseInt(document.getElementById('purchaseItemId').value),
quantity: parseInt(document.getElementById('purchaseQty').value),
unit_cost: parseFloat(document.getElementById('purchaseCost').value),
supplier_invoice: document.getElementById('purchaseInvoice').value.trim(),
notes: document.getElementById('purchaseNotes').value.trim()
};
if (!data.inventory_id || !data.quantity || !data.unit_cost) {
document.getElementById('purchaseResult').innerHTML = '<span style="color:red;">Complete todos los campos</span>'; return;
}
var result = await apiFetch(API + '/purchase', { method: 'POST', body: JSON.stringify(data) });
document.getElementById('purchaseResult').innerHTML = result && result.operation_id
? '<span style="color:green;">Compra registrada (op #' + result.operation_id + ')</span>'
: '<span style="color:red;">' + (result ? result.error || 'Error' : 'Error de red') + '</span>';
}
// --- Adjustment ---
async function recordAdjustment() {
var data = {
inventory_id: parseInt(document.getElementById('adjustItemId').value),
quantity: parseInt(document.getElementById('adjustQty').value),
reason: document.getElementById('adjustReason').value.trim()
};
if (!data.inventory_id || data.quantity === undefined || !data.reason) {
document.getElementById('adjustResult').innerHTML = '<span style="color:red;">Complete todos los campos (razon obligatoria)</span>'; return;
}
var result = await apiFetch(API + '/adjustment', { method: 'POST', body: JSON.stringify(data) });
document.getElementById('adjustResult').innerHTML = result && result.operation_id
? '<span style="color:green;">Ajuste registrado (op #' + result.operation_id + ')</span>'
: '<span style="color:red;">' + (result ? result.error || 'Error' : 'Error de red') + '</span>';
}
// --- Transfer ---
async function recordTransfer() {
var data = {
inventory_id: parseInt(document.getElementById('transferItemId').value),
from_branch_id: parseInt(document.getElementById('transferFrom').value),
to_branch_id: parseInt(document.getElementById('transferTo').value),
quantity: parseInt(document.getElementById('transferQty').value),
notes: document.getElementById('transferNotes').value.trim()
};
if (!data.inventory_id || !data.from_branch_id || !data.to_branch_id || !data.quantity) {
document.getElementById('transferResult').innerHTML = '<span style="color:red;">Complete todos los campos</span>'; return;
}
var result = await apiFetch(API + '/transfer', { method: 'POST', body: JSON.stringify(data) });
document.getElementById('transferResult').innerHTML = result && result.out_operation_id
? '<span style="color:green;">Transferencia registrada</span>'
: '<span style="color:red;">' + (result ? result.error || 'Error' : 'Error de red') + '</span>';
}
// --- Physical Count (two-phase) ---
function addCountLine() {
var container = document.getElementById('countLines');
var row = document.createElement('div');
row.className = 'count-row';
row.innerHTML = '<input type="number" placeholder="ID producto" class="count-inv-id" style="width:120px;">' +
'<input type="number" placeholder="Cantidad contada" class="count-qty" style="width:140px;">' +
'<button class="btn btn-secondary" onclick="this.parentElement.remove()">Quitar</button>';
container.appendChild(row);
}
async function startPhysicalCount() {
var rows = document.querySelectorAll('.count-row');
var items = [];
rows.forEach(function (row) {
var invId = parseInt(row.querySelector('.count-inv-id').value);
var qty = parseInt(row.querySelector('.count-qty').value);
if (invId && !isNaN(qty)) items.push({ inventory_id: invId, counted_quantity: qty });
});
if (!items.length) { alert('Agregue al menos una linea'); return; }
var result = await apiFetch(API + '/physical-count/start', { method: 'POST', body: JSON.stringify({ items: items }) });
if (!result || !result.count_id) {
document.getElementById('countResults').innerHTML = '<span style="color:red;">' + (result ? result.error || 'Error' : 'Error de red') + '</span>';
return;
}
draftCountId = result.count_id;
var html = '<h4>Borrador #' + result.count_id + ' — ' + result.message + '</h4>';
html += '<table class="inv-table"><thead><tr><th>ID</th><th>Esperado</th><th>Contado</th><th>Diferencia</th></tr></thead><tbody>';
(result.results || []).forEach(function (r) {
var color = r.difference === 0 ? '#16a34a' : (r.difference < 0 ? '#dc2626' : '#ca8a04');
html += '<tr><td>' + r.inventory_id + '</td><td>' + r.expected + '</td><td>' + r.counted + '</td><td style="color:' + color + ';font-weight:600;">' + (r.difference > 0 ? '+' : '') + r.difference + '</td></tr>';
});
html += '</tbody></table>';
html += '<button class="btn btn-primary" onclick="approvePhysicalCount()" style="margin-top:12px;">Aprobar y aplicar ajustes</button>';
html += ' <button class="btn btn-secondary" onclick="cancelDraft()" style="margin-top:12px;">Cancelar</button>';
document.getElementById('countResults').innerHTML = html;
}
async function approvePhysicalCount() {
if (!draftCountId) { alert('No hay borrador activo'); return; }
var result = await apiFetch(API + '/physical-count/approve', { method: 'POST', body: JSON.stringify({ count_id: draftCountId }) });
if (result && result.status === 'approved') {
document.getElementById('countResults').innerHTML = '<span style="color:green;">' + result.message + '</span>';
draftCountId = null;
} else {
document.getElementById('countResults').innerHTML += '<br><span style="color:red;">' + (result ? result.error || 'Error' : 'Error de red') + '</span>';
}
}
function cancelDraft() {
draftCountId = null;
document.getElementById('countResults').innerHTML = '<span style="color:#999;">Borrador cancelado</span>';
}
// --- Alerts ---
async function loadAlerts() {
var data = await apiFetch(API + '/alerts');
if (!data) return;
var el = document.getElementById('alertsList');
var alerts = data.data || [];
if (!alerts.length) { el.innerHTML = '<p style="color:#999;">Sin alertas activas</p>'; return; }
el.innerHTML = alerts.map(function (a) {
var cls = a.severity === 'critical' ? 'alert-critical' : (a.severity === 'warning' ? 'alert-warning' : 'alert-info');
var icon = a.type === 'zero' ? 'AGOTADO' : (a.type === 'low' ? 'BAJO' : 'EXCESO');
return '<div class="alert-card ' + cls + '">' +
'<div><strong>[' + icon + ']</strong> ' + esc(a.part_number) + ' — ' + esc(a.name) + ' | Stock: ' + a.stock +
(a.min_stock ? ' (min: ' + a.min_stock + ')' : '') + (a.max_stock ? ' (max: ' + a.max_stock + ')' : '') + '</div>' +
'<span style="font-size:0.8rem;color:#888;">Sucursal ' + a.branch_id + '</span></div>';
}).join('');
}
// --- History modal ---
async function viewHistory(itemId) {
var data = await apiFetch(API + '/items/' + itemId + '/history');
if (!data) return;
var history = data.data || [];
var html = '';
if (!history.length) { html = '<p style="color:#999;">Sin movimientos</p>'; }
else {
html = '<table class="inv-table"><thead><tr><th>Fecha</th><th>Tipo</th><th>Cantidad</th><th>Costo</th><th>Empleado</th><th>Notas</th></tr></thead><tbody>';
history.forEach(function (h) {
var qtyColor = h.quantity > 0 ? '#16a34a' : '#dc2626';
html += '<tr><td style="font-size:0.8rem;">' + h.date + '</td><td>' + h.type + '</td><td style="color:' + qtyColor + ';font-weight:600;">' + (h.quantity > 0 ? '+' : '') + h.quantity + '</td><td>' + (h.cost ? '$' + fmt(h.cost) : '-') + '</td><td>' + esc(h.employee) + '</td><td style="font-size:0.8rem;">' + esc(h.notes) + '</td></tr>';
});
html += '</tbody></table>';
}
document.getElementById('historyContent').innerHTML = html;
document.getElementById('historyModal').classList.add('show');
}
function closeHistoryModal() { document.getElementById('historyModal').classList.remove('show'); }
// --- Create modal ---
function showCreateModal() { document.getElementById('createModal').classList.add('show'); }
function closeCreateModal() { document.getElementById('createModal').classList.remove('show'); }
// --- Barcode label ---
function printBarcode(barcode, partNumber, name) {
var w = window.open('', '_blank', 'width=400,height=250');
w.document.write('<html><head><title>Etiqueta</title><style>body{font-family:monospace;text-align:center;padding:20px;}h1{font-size:1.5rem;margin:8px 0;}p{margin:4px 0;}</style></head><body>');
w.document.write('<h1>' + barcode + '</h1>');
w.document.write('<p>' + partNumber + '</p>');
w.document.write('<p style="font-size:0.85rem;">' + name + '</p>');
w.document.write('</body></html>');
w.document.close();
w.print();
}
// --- Helpers ---
function fmt(n) { return (parseFloat(n) || 0).toFixed(2); }
function esc(s) { if (!s) return ''; var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
// --- Expose globals ---
window._loadItems = function (p) { loadItems(p); };
window.viewHistory = viewHistory;
window.closeHistoryModal = closeHistoryModal;
window.showCreateModal = showCreateModal;
window.closeCreateModal = closeCreateModal;
window.createItem = createItem;
window.recordPurchase = recordPurchase;
window.recordAdjustment = recordAdjustment;
window.recordTransfer = recordTransfer;
window.addCountLine = addCountLine;
window.startPhysicalCount = startPhysicalCount;
window.approvePhysicalCount = approvePhysicalCount;
window.cancelDraft = cancelDraft;
window.loadAlerts = loadAlerts;
window.printBarcode = printBarcode;
// --- Init ---
loadItems(1);
})();

View File

@@ -0,0 +1,91 @@
<!-- /home/Autopartes/pos/templates/catalog.html -->
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Catalogo - Nexus POS</title>
<link rel="stylesheet" href="/pos/static/css/common.css">
<style>
body { margin: 0; font-family: var(--font-sans, 'Inter', system-ui, sans-serif); background: var(--color-bg, #f5f5f5); }
.top-bar { display: flex; align-items: center; gap: 12px; padding: 12px 20px; background: var(--color-surface, #fff); border-bottom: 1px solid var(--color-border, #e5e5e5); position: sticky; top: 0; z-index: 40; }
.top-bar h1 { font-size: 1.1rem; margin: 0; white-space: nowrap; }
.cart-btn { position: relative; background: var(--color-primary, #2563eb); color: #fff; border: none; padding: 8px 16px; border-radius: var(--radius, 6px); cursor: pointer; font-size: 0.9rem; }
.cart-btn .badge { position: absolute; top: -6px; right: -6px; background: #ef4444; color: #fff; border-radius: 50%; width: 20px; height: 20px; font-size: 0.7rem; display: flex; align-items: center; justify-content: center; }
.main-layout { display: flex; gap: 0; min-height: calc(100vh - 60px); }
.sidebar-filters { width: 220px; background: var(--color-surface, #fff); border-right: 1px solid var(--color-border, #e5e5e5); padding: 16px; overflow-y: auto; flex-shrink: 0; }
.sidebar-filters h3 { font-size: 0.85rem; text-transform: uppercase; color: #666; margin: 16px 0 8px; }
.sidebar-filters ul { list-style: none; padding: 0; margin: 0; }
.sidebar-filters li { padding: 6px 8px; cursor: pointer; border-radius: 4px; font-size: 0.85rem; }
.sidebar-filters li:hover, .sidebar-filters li.active { background: var(--color-primary-light, #eff6ff); color: var(--color-primary, #2563eb); }
.content { flex: 1; padding: 20px; overflow-y: auto; }
.pagination { display: flex; justify-content: center; gap: 8px; margin-top: 20px; }
.pagination button { padding: 6px 14px; border: 1px solid var(--color-border, #e5e5e5); border-radius: var(--radius, 6px); background: #fff; cursor: pointer; }
.pagination button.active { background: var(--color-primary, #2563eb); color: #fff; border-color: var(--color-primary, #2563eb); }
.pagination button:disabled { opacity: 0.4; cursor: default; }
.external-section { margin-top: 20px; }
.empty-state { text-align: center; padding: 60px 20px; color: #888; }
.kbd { display: inline-block; padding: 2px 6px; background: #e5e5e5; border-radius: 3px; font-size: 0.75rem; font-family: monospace; }
</style>
</head>
<body>
<div class="top-bar">
<h1>Catalogo</h1>
<div class="search-bar" style="flex:1">
<input type="text" id="searchInput" placeholder="Buscar por nombre, numero de parte o codigo de barras... (F1)" autocomplete="off">
</div>
<button class="cart-btn" id="cartToggle" onclick="toggleCart()">
Carrito <span class="badge" id="cartBadge" style="display:none">0</span>
</button>
</div>
<div class="main-layout">
<aside class="sidebar-filters" id="sidebarFilters">
<h3>Categorias</h3>
<ul id="categoryList"><li>Cargando...</li></ul>
<h3>Marcas (fabricante)</h3>
<ul id="brandList"><li>Cargando...</li></ul>
</aside>
<main class="content">
<div class="filter-chips" id="activeFilters"></div>
<div class="catalog-grid" id="catalogGrid"></div>
<div class="pagination" id="pagination"></div>
<div class="external-section" id="externalSection" style="display:none">
<h3>Disponibilidad en Bodegas Nexus</h3>
<div class="external-results" id="externalResults"></div>
</div>
</main>
</div>
<!-- Cart sidebar -->
<div class="cart-sidebar" id="cartSidebar">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
<h2 style="margin:0; font-size:1.1rem;">Carrito</h2>
<button onclick="toggleCart()" style="background:none; border:none; font-size:1.3rem; cursor:pointer;">&times;</button>
</div>
<div id="cartItems"></div>
<div id="cartEmpty" class="empty-state" style="padding:30px 0;">Carrito vacio</div>
<hr style="margin:16px 0;">
<div style="display:flex; justify-content:space-between; margin-bottom:4px;">
<span>Subtotal</span><span id="cartSubtotal">$0.00</span>
</div>
<div style="display:flex; justify-content:space-between; margin-bottom:4px;">
<span>IVA</span><span id="cartTax">$0.00</span>
</div>
<div style="display:flex; justify-content:space-between; font-weight:700; font-size:1.2rem; margin-bottom:16px;">
<span>Total</span><span class="cart-total" id="cartTotal">$0.00</span>
</div>
<button onclick="goToCheckout()" id="checkoutBtn" disabled
style="width:100%; padding:12px; background:var(--color-primary, #2563eb); color:#fff; border:none; border-radius:var(--radius, 6px); font-size:1rem; cursor:pointer;">
Ir a cobrar
</button>
<button onclick="clearCart()" style="width:100%; padding:8px; background:none; border:1px solid #ddd; border-radius:var(--radius, 6px); margin-top:8px; cursor:pointer; font-size:0.85rem;">
Vaciar carrito
</button>
</div>
<script src="/pos/static/js/catalog.js"></script>
</body>
</html>

View File

@@ -0,0 +1,191 @@
<!-- /home/Autopartes/pos/templates/inventory.html -->
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Inventario - Nexus POS</title>
<link rel="stylesheet" href="/pos/static/css/common.css">
<style>
body { margin: 0; font-family: var(--font-sans, 'Inter', system-ui, sans-serif); background: var(--color-bg, #f5f5f5); }
.header { padding: 12px 20px; background: var(--color-surface, #fff); border-bottom: 1px solid var(--color-border, #e5e5e5); }
.header h1 { margin: 0; font-size: 1.2rem; }
.tabs { display: flex; gap: 0; border-bottom: 2px solid var(--color-border, #e5e5e5); background: var(--color-surface, #fff); padding: 0 20px; overflow-x: auto; }
.tab { padding: 10px 18px; cursor: pointer; font-size: 0.85rem; border-bottom: 2px solid transparent; margin-bottom: -2px; white-space: nowrap; color: #666; }
.tab:hover { color: #333; }
.tab.active { color: var(--color-primary, #2563eb); border-bottom-color: var(--color-primary, #2563eb); font-weight: 600; }
.tab-content { display: none; padding: 20px; }
.tab-content.active { display: block; }
.inv-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.inv-table th { text-align: left; padding: 8px 10px; background: #f9f9f9; border-bottom: 2px solid #e5e5e5; font-weight: 600; font-size: 0.8rem; text-transform: uppercase; color: #666; }
.inv-table td { padding: 8px 10px; border-bottom: 1px solid #eee; }
.inv-table tr:hover { background: #f9fafb; }
.form-group { margin-bottom: 14px; }
.form-group label { display: block; font-size: 0.8rem; font-weight: 600; color: #555; margin-bottom: 4px; }
.form-group input, .form-group select, .form-group textarea { width: 100%; padding: 8px 10px; border: 1px solid var(--color-border, #ddd); border-radius: var(--radius, 6px); font-size: 0.9rem; box-sizing: border-box; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.btn { padding: 8px 18px; border: none; border-radius: var(--radius, 6px); cursor: pointer; font-size: 0.85rem; }
.btn-primary { background: var(--color-primary, #2563eb); color: #fff; }
.btn-secondary { background: #f3f4f6; color: #333; border: 1px solid #ddd; }
.btn-danger { background: #ef4444; color: #fff; }
.alert-card { padding: 12px 16px; border-radius: var(--radius, 6px); margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center; }
.alert-critical { background: #fef2f2; border: 1px solid #fecaca; }
.alert-warning { background: #fefce8; border: 1px solid #fef08a; }
.alert-info { background: #eff6ff; border: 1px solid #bfdbfe; }
.count-results { margin-top: 16px; }
.count-row { display: flex; gap: 12px; align-items: center; margin-bottom: 8px; padding: 8px; background: #fff; border: 1px solid #eee; border-radius: 4px; }
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 100; }
.modal-overlay.show { display: flex; align-items: center; justify-content: center; }
.modal { background: #fff; border-radius: 8px; padding: 24px; width: 600px; max-width: 90vw; max-height: 80vh; overflow-y: auto; }
.search-row { display: flex; gap: 8px; margin-bottom: 16px; }
.search-row input { flex: 1; padding: 8px 12px; border: 1px solid #ddd; border-radius: var(--radius, 6px); }
</style>
</head>
<body>
<div class="header">
<h1>Gestion de Inventario</h1>
</div>
<div class="tabs">
<div class="tab active" data-tab="products">Productos</div>
<div class="tab" data-tab="purchases">Entradas</div>
<div class="tab" data-tab="adjustments">Ajustes</div>
<div class="tab" data-tab="transfers">Transferencias</div>
<div class="tab" data-tab="count">Toma Fisica</div>
<div class="tab" data-tab="alerts">Alertas</div>
</div>
<!-- Products tab -->
<div class="tab-content active" id="tab-products">
<div class="search-row">
<input type="text" id="productSearch" placeholder="Buscar productos...">
<button class="btn btn-primary" onclick="showCreateModal()">+ Nuevo producto</button>
</div>
<div style="overflow-x:auto;">
<table class="inv-table">
<thead>
<tr><th>Cod. Barras</th><th>No. Parte</th><th>Nombre</th><th>Marca</th><th>Stock</th><th>Costo</th><th>P1</th><th>P2</th><th>P3</th><th>Ubicacion</th><th>Acciones</th></tr>
</thead>
<tbody id="productTableBody"></tbody>
</table>
</div>
<div id="productPagination" class="pagination" style="margin-top:12px;"></div>
</div>
<!-- Purchases tab -->
<div class="tab-content" id="tab-purchases">
<h3>Registrar Entrada de Compra</h3>
<div style="max-width:500px;">
<div class="form-group"><label>Producto (ID)</label><input type="number" id="purchaseItemId" placeholder="ID del producto"></div>
<div class="form-row">
<div class="form-group"><label>Cantidad</label><input type="number" id="purchaseQty" min="1" value="1"></div>
<div class="form-group"><label>Costo unitario</label><input type="number" id="purchaseCost" step="0.01" placeholder="0.00"></div>
</div>
<div class="form-group"><label>Factura proveedor</label><input type="text" id="purchaseInvoice" placeholder="FAC-001"></div>
<div class="form-group"><label>Notas</label><textarea id="purchaseNotes" rows="2"></textarea></div>
<button class="btn btn-primary" onclick="recordPurchase()">Registrar compra</button>
<div id="purchaseResult" style="margin-top:12px;"></div>
</div>
</div>
<!-- Adjustments tab -->
<div class="tab-content" id="tab-adjustments">
<h3>Ajuste Manual de Stock</h3>
<div style="max-width:500px;">
<div class="form-group"><label>Producto (ID)</label><input type="number" id="adjustItemId" placeholder="ID del producto"></div>
<div class="form-group"><label>Cantidad (+/-)</label><input type="number" id="adjustQty" placeholder="-2 o +5"></div>
<div class="form-group"><label>Razon (obligatoria)</label><textarea id="adjustReason" rows="2" placeholder="Merma, error de conteo, etc."></textarea></div>
<button class="btn btn-primary" onclick="recordAdjustment()">Registrar ajuste</button>
<div id="adjustResult" style="margin-top:12px;"></div>
</div>
</div>
<!-- Transfers tab -->
<div class="tab-content" id="tab-transfers">
<h3>Transferencia entre Sucursales</h3>
<div style="max-width:500px;">
<div class="form-group"><label>Producto (ID)</label><input type="number" id="transferItemId" placeholder="ID del producto"></div>
<div class="form-row">
<div class="form-group"><label>Sucursal origen (ID)</label><input type="number" id="transferFrom"></div>
<div class="form-group"><label>Sucursal destino (ID)</label><input type="number" id="transferTo"></div>
</div>
<div class="form-group"><label>Cantidad</label><input type="number" id="transferQty" min="1" value="1"></div>
<div class="form-group"><label>Notas</label><textarea id="transferNotes" rows="2"></textarea></div>
<button class="btn btn-primary" onclick="recordTransfer()">Transferir</button>
<div id="transferResult" style="margin-top:12px;"></div>
</div>
</div>
<!-- Physical Count tab -->
<div class="tab-content" id="tab-count">
<h3>Toma Fisica de Inventario</h3>
<p style="font-size:0.85rem;color:#666;">Fase 1: Ingrese los conteos. Se generara un borrador con comparacion esperado vs contado. Fase 2: Apruebe para aplicar ajustes.</p>
<div id="countForm">
<div id="countLines">
<div class="count-row">
<input type="number" placeholder="ID producto" class="count-inv-id" style="width:120px;">
<input type="number" placeholder="Cantidad contada" class="count-qty" style="width:140px;">
<button class="btn btn-secondary" onclick="this.parentElement.remove()">Quitar</button>
</div>
</div>
<button class="btn btn-secondary" onclick="addCountLine()" style="margin:8px 0;">+ Agregar linea</button>
<br>
<button class="btn btn-primary" onclick="startPhysicalCount()">Crear borrador</button>
</div>
<div id="countResults" class="count-results"></div>
</div>
<!-- Alerts tab -->
<div class="tab-content" id="tab-alerts">
<h3>Alertas de Stock</h3>
<button class="btn btn-secondary" onclick="loadAlerts()" style="margin-bottom:12px;">Actualizar</button>
<div id="alertsList"></div>
</div>
<!-- History modal -->
<div class="modal-overlay" id="historyModal">
<div class="modal">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<h3 style="margin:0;">Historial de movimientos</h3>
<button onclick="closeHistoryModal()" style="background:none;border:none;font-size:1.3rem;cursor:pointer;">&times;</button>
</div>
<div id="historyContent"></div>
</div>
</div>
<!-- Create/Edit modal -->
<div class="modal-overlay" id="createModal">
<div class="modal">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<h3 style="margin:0;" id="createModalTitle">Nuevo Producto</h3>
<button onclick="closeCreateModal()" style="background:none;border:none;font-size:1.3rem;cursor:pointer;">&times;</button>
</div>
<div class="form-row">
<div class="form-group"><label>No. Parte *</label><input type="text" id="newPartNumber"></div>
<div class="form-group"><label>Nombre *</label><input type="text" id="newName"></div>
</div>
<div class="form-row">
<div class="form-group"><label>Marca (fabricante)</label><input type="text" id="newBrand" placeholder="Bosch, NGK..."></div>
<div class="form-group"><label>Codigo de barras</label><input type="text" id="newBarcode" placeholder="Auto-generado si vacio"></div>
</div>
<div class="form-row">
<div class="form-group"><label>Costo</label><input type="number" id="newCost" step="0.01" value="0"></div>
<div class="form-group"><label>Precio 1</label><input type="number" id="newPrice1" step="0.01" value="0"></div>
</div>
<div class="form-row">
<div class="form-group"><label>Precio 2</label><input type="number" id="newPrice2" step="0.01" value="0"></div>
<div class="form-group"><label>Precio 3</label><input type="number" id="newPrice3" step="0.01" value="0"></div>
</div>
<div class="form-row">
<div class="form-group"><label>Stock minimo</label><input type="number" id="newMinStock" value="0"></div>
<div class="form-group"><label>Stock inicial</label><input type="number" id="newInitialStock" value="0"></div>
</div>
<div class="form-group"><label>Ubicacion</label><input type="text" id="newLocation" placeholder="Pasillo A, Estante 3"></div>
<button class="btn btn-primary" onclick="createItem()">Guardar</button>
<div id="createResult" style="margin-top:8px;"></div>
</div>
</div>
<script src="/pos/static/js/inventory.js"></script>
</body>
</html>