Compare commits
7 Commits
af404a6474
...
3a271dd4ed
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a271dd4ed | |||
| ab655e2ff1 | |||
| 070e2df723 | |||
| 659832ab16 | |||
| 7d7ead4d52 | |||
| 4e7942d1d1 | |||
| db205d6228 |
14
pos/app.py
14
pos/app.py
@@ -10,6 +10,12 @@ def create_app():
|
|||||||
from blueprints.config_bp import config_bp
|
from blueprints.config_bp import config_bp
|
||||||
app.register_blueprint(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
|
# Health check
|
||||||
@app.route('/pos/health')
|
@app.route('/pos/health')
|
||||||
def health():
|
def health():
|
||||||
@@ -21,6 +27,14 @@ def create_app():
|
|||||||
def pos_login():
|
def pos_login():
|
||||||
return render_template('login.html')
|
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>')
|
@app.route('/pos/static/<path:filename>')
|
||||||
def pos_static(filename):
|
def pos_static(filename):
|
||||||
return send_from_directory('static', filename)
|
return send_from_directory('static', filename)
|
||||||
|
|||||||
265
pos/blueprints/catalog_bp.py
Normal file
265
pos/blueprints/catalog_bp.py
Normal 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
|
||||||
840
pos/blueprints/inventory_bp.py
Normal file
840
pos/blueprints/inventory_bp.py
Normal 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})
|
||||||
@@ -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 INDEX idx_audit_log_created ON audit_log(created_at);
|
||||||
CREATE UNIQUE INDEX idx_inventory_branch_part ON inventory(branch_id, part_number);
|
CREATE UNIQUE INDEX idx_inventory_branch_part ON inventory(branch_id, part_number);
|
||||||
CREATE INDEX idx_employee_sessions_token ON employee_sessions(token);
|
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;
|
||||||
|
|||||||
61
pos/services/barcode_generator.py
Normal file
61
pos/services/barcode_generator.py
Normal 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]
|
||||||
231
pos/services/inventory_engine.py
Normal file
231
pos/services/inventory_engine.py
Normal 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
|
||||||
@@ -55,3 +55,31 @@ body {
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 24px;
|
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
293
pos/static/js/catalog.js
Normal 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 ? ' · ' + 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) + ')"') + '>« 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 »</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 + ' ×</span>');
|
||||||
|
if (currentFilters.brand) chips.push('<span class="chip active" onclick="window._removeFilter(\'brand\')">' + escHtml(currentFilters.brand) + ' ×</span>');
|
||||||
|
if (currentFilters.vehicle_brand) chips.push('<span class="chip active" onclick="window._removeFilter(\'vehicle_brand\')">Vehiculo: ' + escHtml(currentFilters.vehicle_brand) + ' ×</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
298
pos/static/js/inventory.js
Normal 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) + ')"') + '>«</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) + ')"') + '>»</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);
|
||||||
|
})();
|
||||||
91
pos/templates/catalog.html
Normal file
91
pos/templates/catalog.html
Normal 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;">×</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>
|
||||||
191
pos/templates/inventory.html
Normal file
191
pos/templates/inventory.html
Normal 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;">×</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;">×</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>
|
||||||
Reference in New Issue
Block a user