- Cleaned 137+ fake engine-displacement models from supplier imports (v3/v4 scripts: Chevrolet, Ford, Chrysler, Dodge, Jeep, Nissan, etc.) - Removed 1,251+ corrupted models (INT. prefixes, year-suffix, torque specs, empty names, trailing-year variants) - Migrated supplier tables to master DB (supplier_catalog, supplier_catalog_compat, supplier_catalog_interchange) - Fixed _get_mye_ids_with_parts() to query supplier_catalog_compat from master DB so supplier-only vehicles appear for all tenants - Added fuzzy model matcher with parenthesis stripping, noise suffix removal, compact matching, prefix/substring fallback, model aliases, and ±3 year proximity - Matched compat rows: KEEP GREEN +14,152, KNADIAN +3,021, VAZLO +127,500, LUK +477, RAYBESTOS +1,743 - Added KNADIAN catalog importer with year-range expansion and future-year filtering - Added VAZLO catalog importer with position parsing and SKU-in-model cleanup - Added Keep Green, LUK, Yokomitsu, Raybestos catalog importers - Cache clearing after cleanups (_classify_cache_*, nexus:mye_ids:*, nexus:brand_mye_counts:*) Final match rates: - KEEP GREEN: 90.3% - VAZLO: 93.6% - YOKOMITSU: 100.0% - KNADIAN: 57.4% - LUK: 51.0% - RAYBESTOS: 55.9%
2346 lines
84 KiB
Python
2346 lines
84 KiB
Python
# /home/Autopartes/pos/blueprints/inventory_bp.py
|
|
"""Inventory blueprint: CRUD for inventory items + stock operations + reports."""
|
|
|
|
import io
|
|
import json
|
|
import os
|
|
import csv
|
|
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
|
|
from tenant_db import get_master_conn
|
|
from services.inventory_vehicle_compat import (
|
|
auto_match_vehicle_compatibility, add_compatibility, remove_compatibility,
|
|
remove_compatibility_by_id, remove_all_compatibility, get_compatibility,
|
|
search_mye, get_compat_source,
|
|
)
|
|
from tasks import sync_vehicle_compatibility_task
|
|
|
|
inventory_bp = Blueprint('inventory', __name__, url_prefix='/pos/api/inventory')
|
|
|
|
|
|
def _get_tier_discounts(conn):
|
|
"""Read global tier discounts from DB. Returns dict {tier_id: discount_pct}."""
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT tier_id, discount_pct FROM tier_discounts")
|
|
rows = cur.fetchall()
|
|
cur.close()
|
|
return {r[0]: float(r[1]) for r in rows}
|
|
|
|
|
|
def _apply_tier_discounts(price_1, discounts):
|
|
"""Given a base price and discount dict, return (price_2, price_3)."""
|
|
if not price_1:
|
|
return 0, 0
|
|
disc2 = discounts.get(2, 0)
|
|
disc3 = discounts.get(3, 0)
|
|
p2 = round(float(price_1) * (1 - disc2 / 100), 2)
|
|
p3 = round(float(price_1) * (1 - disc3 / 100), 2)
|
|
return p2, p3
|
|
|
|
|
|
def _to_decimal(val, default=0):
|
|
if val is None or val == '':
|
|
return default
|
|
try:
|
|
return float(str(val).replace(',', ''))
|
|
except (ValueError, TypeError):
|
|
return default
|
|
|
|
|
|
def _to_int(val, default=0):
|
|
if val is None or val == '':
|
|
return default
|
|
try:
|
|
return int(float(str(val).replace(',', '')))
|
|
except (ValueError, TypeError):
|
|
return default
|
|
|
|
|
|
# ─── AI Classification ───────────────────────────
|
|
|
|
@inventory_bp.route('/classify/<part_number>', methods=['GET'])
|
|
@require_auth('inventory.create')
|
|
def classify_part_endpoint(part_number):
|
|
"""Ask AI to identify a part by its OEM number."""
|
|
from services.ai_chat import classify_part
|
|
result = classify_part(part_number)
|
|
return jsonify(result)
|
|
|
|
|
|
# ─── 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, c.name as category_name
|
|
FROM inventory i
|
|
LEFT JOIN branches b ON i.branch_id = b.id
|
|
LEFT JOIN categories c ON i.category_id = c.id
|
|
WHERE i.id = %s
|
|
""", (item_id,))
|
|
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
|
|
|
|
# Plan limit check
|
|
from services.billing import check_limit, next_plan, PLANS, get_plan
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
cur_count = conn.cursor()
|
|
cur_count.execute("SELECT count(*) FROM inventory WHERE is_active = true")
|
|
current_products = cur_count.fetchone()[0]
|
|
cur_count.close()
|
|
|
|
allowed, limit, current = check_limit(g.tenant_id, 'max_products', current_products)
|
|
if not allowed:
|
|
conn.close()
|
|
plan_key = get_plan(g.tenant_id)
|
|
nxt = next_plan(plan_key)
|
|
nxt_name = PLANS[nxt]['name'] if nxt else 'Enterprise'
|
|
return jsonify({'error': f'Plan limit reached ({limit} products). Upgrade to {nxt_name}.'}), 403
|
|
|
|
conn.close()
|
|
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)
|
|
|
|
# Auto-calculate tier prices from global discounts
|
|
discounts = _get_tier_discounts(conn)
|
|
price_1 = data.get('price_1', 0)
|
|
price_2, price_3 = _apply_tier_discounts(price_1, discounts)
|
|
# Allow override if explicitly sent (backward compat)
|
|
if 'price_2' in data:
|
|
price_2 = data['price_2']
|
|
if 'price_3' in data:
|
|
price_3 = data['price_3']
|
|
|
|
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),
|
|
price_1, price_2, price_3,
|
|
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'))
|
|
|
|
# Insert SKU aliases if provided
|
|
sku_aliases = data.get('sku_aliases', [])
|
|
if sku_aliases:
|
|
for alias in sku_aliases:
|
|
sku = (alias.get('sku') or '').strip()
|
|
label = (alias.get('label') or '').strip()
|
|
if sku:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO inventory_sku_aliases (inventory_id, sku, label)
|
|
VALUES (%s, %s, %s)
|
|
ON CONFLICT (inventory_id, sku) DO UPDATE SET
|
|
is_active = true, label = EXCLUDED.label
|
|
""",
|
|
(item_id, sku, label or None),
|
|
)
|
|
|
|
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()
|
|
|
|
# ── Vehicle compatibility (respects tenant config) ────────────────
|
|
compat_source = get_compat_source(g.tenant_id)
|
|
qwen_added = 0
|
|
|
|
# Offload to Celery background task if possible (QWEN can take 90s+)
|
|
try:
|
|
sync_vehicle_compatibility_task.delay(
|
|
g.tenant_id, item_id, data['part_number'],
|
|
data['name'], data.get('brand', ''), compat_source
|
|
)
|
|
compat_background = True
|
|
except Exception as celery_err:
|
|
print(f"[celery] Failed to queue compatibility task for item {item_id}: {celery_err}")
|
|
compat_background = False
|
|
|
|
if not compat_background:
|
|
# Fallback: synchronous processing
|
|
if compat_source in ('tecdoc', 'both'):
|
|
master = None
|
|
try:
|
|
master = get_master_conn()
|
|
auto_match_vehicle_compatibility(master, conn, item_id, data['part_number'],
|
|
brand=data.get('brand'), name=data.get('name'))
|
|
except Exception as am_err:
|
|
print(f"[auto_match] Error for item {item_id}: {am_err}")
|
|
finally:
|
|
if master:
|
|
try:
|
|
master.close()
|
|
except Exception:
|
|
pass
|
|
|
|
if compat_source in ('qwen', 'both'):
|
|
try:
|
|
from services.qwen_fitment import get_vehicle_fitment
|
|
from services.inventory_vehicle_compat import save_qwen_fitment
|
|
fitment = get_vehicle_fitment(
|
|
data['part_number'],
|
|
data['name'],
|
|
data.get('brand', '')
|
|
)
|
|
qwen_added = save_qwen_fitment(conn, item_id, fitment)
|
|
except Exception as qwen_err:
|
|
print(f"[qwen_fitment] Error for item {item_id}: {qwen_err}")
|
|
|
|
conn.close()
|
|
return jsonify({
|
|
'id': item_id,
|
|
'barcode': barcode,
|
|
'message': 'Item created',
|
|
'vehicle_compatibilities_added': qwen_added,
|
|
'vehicle_compat_queued': compat_background,
|
|
}), 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/bulk-import', methods=['POST'])
|
|
@require_auth('inventory.edit')
|
|
def bulk_import_items():
|
|
"""
|
|
Bulk import inventory items with optional vehicle compatibility.
|
|
Expects multipart/form-data with a 'file' (CSV/Excel) or JSON body.
|
|
Headers:
|
|
X-Import-Mode: 'strict' (default) aborts on first error; 'lenient' skips bad rows.
|
|
X-Import-Strategy: 'qwen' (default) auto-generates missing compat via QWEN;
|
|
'skip' ignores missing compat; 'reject' requires all compat.
|
|
Expected CSV columns (case-insensitive):
|
|
sku/part_number, name, brand, price, stock, cost,
|
|
location, description, category, make, model, year, engine, engine_code
|
|
Optional compat columns: make, model, year, engine, engine_code
|
|
"""
|
|
from services.qwen_fitment import get_vehicle_fitment
|
|
from services.inventory_vehicle_compat import save_qwen_fitment
|
|
import services.inventory_vehicle_compat as ivc_service
|
|
|
|
mode = request.headers.get('X-Import-Mode', 'strict').lower()
|
|
strategy = request.headers.get('X-Import-Strategy', 'qwen').lower()
|
|
errors = []
|
|
warnings = []
|
|
created_ids = []
|
|
skipped = 0
|
|
created = 0
|
|
|
|
# ---------- 1. Parse input ----------
|
|
rows = []
|
|
if request.content_type and 'multipart/form-data' in request.content_type:
|
|
file = request.files.get('file')
|
|
if not file:
|
|
return jsonify({'error': 'No file uploaded'}), 400
|
|
try:
|
|
ext = os.path.splitext(file.filename)[1].lower()
|
|
if ext == '.csv':
|
|
stream = io.TextIOWrapper(file.stream, encoding='utf-8-sig')
|
|
reader = csv.DictReader(stream)
|
|
rows = list(reader)
|
|
elif ext in ('.xls', '.xlsx', '.xlsm'):
|
|
try:
|
|
import openpyxl
|
|
except ImportError:
|
|
return jsonify({'error': 'Excel support requires openpyxl. Please convert to CSV or install openpyxl.'}), 400
|
|
wb = openpyxl.load_workbook(file.stream, data_only=True)
|
|
ws = wb.active
|
|
headers = [str(c).strip().lower().replace(' ', '_') if c else '' for c in next(ws.iter_rows(values_only=True))]
|
|
for raw in ws.iter_rows(min_row=2, values_only=True):
|
|
rows.append({headers[i]: (str(v) if v is not None else '') for i, v in enumerate(raw) if i < len(headers)})
|
|
else:
|
|
return jsonify({'error': 'Unsupported file type. Use CSV or Excel.'}), 400
|
|
except Exception as e:
|
|
return jsonify({'error': f'Failed to parse file: {e}'}), 400
|
|
else:
|
|
body = request.get_json() or {}
|
|
rows = body.get('items')
|
|
if not rows or not isinstance(rows, list):
|
|
return jsonify({'error': 'Expected JSON body with an "items" array'}), 400
|
|
|
|
if not rows:
|
|
return jsonify({'error': 'No data rows found'}), 400
|
|
|
|
# Normalise column names on first row
|
|
if rows:
|
|
first = rows[0]
|
|
normalised_keys = {}
|
|
for k in list(first.keys()):
|
|
nk = str(k).strip().lower().replace(' ', '_')
|
|
normalised_keys[k] = nk
|
|
for r in rows:
|
|
for old_k, new_k in normalised_keys.items():
|
|
if old_k in r:
|
|
r[new_k] = r.pop(old_k)
|
|
|
|
# Map common synonyms
|
|
col_map = {
|
|
'sku': 'part_number', 'numero_de_parte': 'part_number', 'parte': 'part_number',
|
|
'nombre': 'name', 'producto': 'name', 'descripcion': 'name',
|
|
'marca': 'brand', 'precio': 'price', 'costo': 'cost',
|
|
'cantidad': 'stock', 'existencia': 'stock', 'inventario': 'stock',
|
|
'ubicacion': 'location', 'categoria': 'category',
|
|
'fabricante': 'make', 'vehiculo': 'make', 'auto': 'make',
|
|
'modelo': 'model', 'anio': 'year', 'ano': 'year',
|
|
'motor': 'engine', 'codigo_motor': 'engine_code',
|
|
}
|
|
for r in rows:
|
|
for old_k, new_k in col_map.items():
|
|
if old_k in r and new_k not in r:
|
|
r[new_k] = r.pop(old_k)
|
|
|
|
required = ['part_number', 'name']
|
|
first_keys = set(rows[0].keys()) if rows else set()
|
|
missing_required = [c for c in required if c not in first_keys]
|
|
if missing_required:
|
|
return jsonify({'error': f'Missing required columns: {missing_required}'}), 400
|
|
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
try:
|
|
cur = conn.cursor()
|
|
from services.barcode_generator import generate_barcode
|
|
from tenant_db import get_master_conn
|
|
from services.inventory_engine import record_initial
|
|
|
|
# Pre-fetch tenant db_name for barcode generation
|
|
mconn = get_master_conn()
|
|
mcur = mconn.cursor()
|
|
mcur.execute("SELECT db_name FROM tenants WHERE id = %s", (g.tenant_id,))
|
|
db_name_row = mcur.fetchone()
|
|
db_name = db_name_row[0] if db_name_row else None
|
|
mcur.close(); mconn.close()
|
|
|
|
for row_num, row in enumerate(rows, start=1):
|
|
part_number = str(row.get('part_number', '')).strip()
|
|
name = str(row.get('name', '')).strip()
|
|
if not part_number or not name:
|
|
msg = f'Row {row_num}: part_number and name are required'
|
|
if mode == 'strict':
|
|
conn.rollback(); cur.close(); conn.close()
|
|
return jsonify({'error': msg}), 400
|
|
warnings.append(msg)
|
|
skipped += 1
|
|
continue
|
|
|
|
branch_id = _to_int(row.get('branch_id'), g.branch_id)
|
|
if not branch_id:
|
|
msg = f'Row {row_num}: branch_id required (not set in row or session)'
|
|
if mode == 'strict':
|
|
conn.rollback(); cur.close(); conn.close()
|
|
return jsonify({'error': msg}), 400
|
|
warnings.append(msg)
|
|
skipped += 1
|
|
continue
|
|
|
|
brand = str(row.get('brand', '')).strip()
|
|
price_1 = _to_decimal(row.get('price'), 0)
|
|
stock = _to_int(row.get('stock'), 0)
|
|
cost = _to_decimal(row.get('cost'), 0)
|
|
location = str(row.get('location', '')).strip()
|
|
description = str(row.get('description', '')).strip()
|
|
category = str(row.get('category', '')).strip()
|
|
|
|
# Check if item already exists for this branch
|
|
cur.execute("SELECT id FROM inventory WHERE branch_id = %s AND part_number = %s", (branch_id, part_number))
|
|
existing = cur.fetchone()
|
|
|
|
if existing:
|
|
item_id = existing[0]
|
|
# Update existing item — add stock if provided
|
|
cur.execute(
|
|
"""
|
|
UPDATE inventory SET
|
|
name = %s,
|
|
brand = COALESCE(NULLIF(%s,''), brand),
|
|
cost = CASE WHEN %s > 0 THEN %s ELSE cost END,
|
|
price_1 = CASE WHEN %s > 0 THEN %s ELSE price_1 END,
|
|
stock = stock + %s,
|
|
location = COALESCE(NULLIF(%s,''), location),
|
|
description = COALESCE(NULLIF(%s,''), description),
|
|
category = COALESCE(NULLIF(%s,''), category)
|
|
WHERE id = %s
|
|
""",
|
|
(name, brand, cost, cost, price_1, price_1, stock, location, description, category, item_id)
|
|
)
|
|
was_inserted = False
|
|
# Record stock adjustment for existing item if stock > 0
|
|
if stock > 0:
|
|
record_initial(conn, item_id, branch_id, stock, cost if cost > 0 else None)
|
|
else:
|
|
# Generate barcode for new item
|
|
barcode = generate_barcode(conn, db_name)
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO inventory
|
|
(branch_id, part_number, barcode, name, brand, cost, price_1, location, description, category, unit)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
RETURNING id
|
|
""",
|
|
(branch_id, part_number, barcode, name, brand, cost, price_1, location, description, category, 'PZA')
|
|
)
|
|
item_id = cur.fetchone()[0]
|
|
was_inserted = True
|
|
|
|
# Record initial stock if provided and new item
|
|
if was_inserted and stock > 0:
|
|
record_initial(conn, item_id, branch_id, stock, cost if cost > 0 else None)
|
|
|
|
conn.commit()
|
|
created_ids.append(item_id)
|
|
created += 1
|
|
|
|
# ---------- 2. Vehicle compatibility ----------
|
|
make = str(row.get('make', '')).strip()
|
|
model = str(row.get('model', '')).strip()
|
|
year_str = str(row.get('year', '')).strip()
|
|
engine = str(row.get('engine', '')).strip()
|
|
engine_code = str(row.get('engine_code', '')).strip()
|
|
|
|
has_compat = any([make, model, year_str, engine, engine_code])
|
|
|
|
if has_compat:
|
|
# Validate / resolve against vehicle tables
|
|
year = _to_int(year_str, None)
|
|
mye_id = None
|
|
if make and model and year:
|
|
# Try exact match against model_year_engine
|
|
cur.execute(
|
|
"""
|
|
SELECT mye.id FROM model_year_engine mye
|
|
JOIN models m ON m.id = mye.model_id
|
|
JOIN brands b ON b.id = m.brand_id
|
|
JOIN years y ON y.id = mye.year_id
|
|
WHERE LOWER(b.name) = LOWER(%s)
|
|
AND LOWER(m.name) = LOWER(%s)
|
|
AND y.year = %s
|
|
LIMIT 1
|
|
""",
|
|
(make, model, year)
|
|
)
|
|
r = cur.fetchone()
|
|
if r:
|
|
mye_id = r[0]
|
|
else:
|
|
warnings.append(
|
|
f'Row {row_num}: vehicle "{make} {model} {year}" not found in catalog; '
|
|
'saving as text-only compatibility.'
|
|
)
|
|
|
|
if mye_id:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO inventory_vehicle_compat
|
|
(inventory_id, model_year_engine_id, make, model, year, engine, engine_code)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
ON CONFLICT DO NOTHING
|
|
""",
|
|
(item_id, mye_id, make, model, year_str, engine, engine_code)
|
|
)
|
|
else:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO inventory_vehicle_compat
|
|
(inventory_id, make, model, year, engine, engine_code)
|
|
VALUES (%s, %s, %s, %s, %s, %s)
|
|
ON CONFLICT DO NOTHING
|
|
""",
|
|
(item_id, make, model, year_str, engine, engine_code)
|
|
)
|
|
conn.commit()
|
|
|
|
else:
|
|
# No compatibility provided
|
|
if strategy == 'reject':
|
|
msg = f'Row {row_num}: missing vehicle compatibility (strategy=reject)'
|
|
if mode == 'strict':
|
|
conn.rollback(); cur.close(); conn.close()
|
|
return jsonify({'error': msg}), 400
|
|
warnings.append(msg)
|
|
elif strategy == 'qwen':
|
|
try:
|
|
fitment = get_vehicle_fitment(part_number, name, brand)
|
|
save_qwen_fitment(conn, item_id, fitment)
|
|
conn.commit()
|
|
except Exception as qe:
|
|
warnings.append(f'Row {row_num}: QWEN fitment failed: {qe}')
|
|
# strategy == 'skip' → do nothing
|
|
|
|
cur.close()
|
|
conn.close()
|
|
return jsonify({
|
|
'created': created,
|
|
'skipped': skipped,
|
|
'item_ids': created_ids,
|
|
'warnings': warnings,
|
|
'errors': errors,
|
|
}), 200
|
|
|
|
except Exception as e:
|
|
conn.rollback()
|
|
try: cur.close()
|
|
except Exception: pass
|
|
conn.close()
|
|
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
|
|
|
|
# Auto-calculate tier prices if price_1 changes and no explicit override
|
|
discounts = _get_tier_discounts(conn)
|
|
if 'price_1' in data and ('price_2' not in data or 'price_3' not in data):
|
|
p2, p3 = _apply_tier_discounts(data['price_1'], discounts)
|
|
if 'price_2' not in data:
|
|
data['price_2'] = p2
|
|
if 'price_3' not in data:
|
|
data['price_3'] = p3
|
|
|
|
# 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()
|
|
|
|
# Re-run auto-match if part_number changed
|
|
if 'part_number' in data and data['part_number'] != old_dict.get('part_number'):
|
|
try:
|
|
master = get_master_conn()
|
|
# Clear old compatibilities and re-match
|
|
remove_all_compatibility(conn, item_id)
|
|
auto_match_vehicle_compatibility(master, conn, item_id, data['part_number'],
|
|
brand=data.get('brand'), name=data.get('name'))
|
|
master.close()
|
|
except Exception as am_err:
|
|
print(f"[auto_match] Re-match error for item {item_id}: {am_err}")
|
|
|
|
conn.close()
|
|
return jsonify({'message': 'Item updated'})
|
|
|
|
|
|
# ─── Image Upload / Delete ─────────────────────
|
|
|
|
IMAGES_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static', 'images', 'parts')
|
|
ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'webp'}
|
|
MAX_IMAGE_BYTES = 5 * 1024 * 1024 # 5 MB
|
|
|
|
|
|
def _process_image(file_data, max_size=800):
|
|
"""Resize image to max_size and convert to JPEG."""
|
|
from PIL import Image
|
|
img = Image.open(io.BytesIO(file_data))
|
|
img.thumbnail((max_size, max_size), Image.LANCZOS)
|
|
if img.mode not in ('RGB', 'L'):
|
|
img = img.convert('RGB')
|
|
output = io.BytesIO()
|
|
img.save(output, format='JPEG', quality=85)
|
|
return output.getvalue()
|
|
|
|
|
|
def _process_thumbnail(file_data, size=300):
|
|
"""Generate a smaller thumbnail."""
|
|
return _process_image(file_data, max_size=size)
|
|
|
|
|
|
def _delete_image_files(tenant_id, item_id):
|
|
"""Remove image and thumbnail for the given item from disk."""
|
|
for suffix in ('', '_thumb'):
|
|
path = os.path.join(IMAGES_DIR, f'{tenant_id}_{item_id}{suffix}.jpg')
|
|
if os.path.exists(path):
|
|
os.remove(path)
|
|
|
|
|
|
@inventory_bp.route('/items/<int:item_id>/image', methods=['POST'])
|
|
@require_auth('inventory.edit')
|
|
def upload_image(item_id):
|
|
"""Upload an image for an inventory item. Accepts multipart file upload.
|
|
Validates file type (jpg, png, webp) and size (max 5 MB).
|
|
Saves resized image + thumbnail, updates inventory.image_url.
|
|
"""
|
|
if 'file' not in request.files:
|
|
return jsonify({'error': 'No file provided'}), 400
|
|
|
|
f = request.files['file']
|
|
if not f.filename:
|
|
return jsonify({'error': 'Empty filename'}), 400
|
|
|
|
ext = f.filename.rsplit('.', 1)[-1].lower() if '.' in f.filename else ''
|
|
if ext not in ALLOWED_EXTENSIONS:
|
|
return jsonify({'error': f'File type not allowed. Use: {", ".join(ALLOWED_EXTENSIONS)}'}), 400
|
|
|
|
raw = f.read()
|
|
if len(raw) > MAX_IMAGE_BYTES:
|
|
return jsonify({'error': 'File too large (max 5 MB)'}), 400
|
|
|
|
# Verify item exists
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT id FROM inventory WHERE id = %s", (item_id,))
|
|
if not cur.fetchone():
|
|
cur.close(); conn.close()
|
|
return jsonify({'error': 'Item not found'}), 404
|
|
|
|
try:
|
|
# Process and save main image
|
|
os.makedirs(IMAGES_DIR, exist_ok=True)
|
|
main_data = _process_image(raw)
|
|
main_filename = f'{g.tenant_id}_{item_id}.jpg'
|
|
main_path = os.path.join(IMAGES_DIR, main_filename)
|
|
with open(main_path, 'wb') as out:
|
|
out.write(main_data)
|
|
|
|
# Process and save thumbnail
|
|
thumb_data = _process_thumbnail(raw)
|
|
thumb_filename = f'{g.tenant_id}_{item_id}_thumb.jpg'
|
|
thumb_path = os.path.join(IMAGES_DIR, thumb_filename)
|
|
with open(thumb_path, 'wb') as out:
|
|
out.write(thumb_data)
|
|
|
|
# Update DB
|
|
image_url = f'/pos/static/images/parts/{main_filename}'
|
|
cur.execute("UPDATE inventory SET image_url = %s WHERE id = %s", (image_url, item_id))
|
|
conn.commit()
|
|
|
|
log_action(conn, 'IMAGE_UPLOAD', 'inventory', item_id,
|
|
new_value={'image_url': image_url})
|
|
|
|
cur.close(); conn.close()
|
|
return jsonify({
|
|
'image_url': image_url,
|
|
'thumbnail_url': f'/pos/static/images/parts/{thumb_filename}',
|
|
'message': 'Image uploaded'
|
|
})
|
|
|
|
except Exception as e:
|
|
conn.rollback()
|
|
cur.close(); conn.close()
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
@inventory_bp.route('/items/<int:item_id>/image', methods=['DELETE'])
|
|
@require_auth('inventory.edit')
|
|
def delete_image(item_id):
|
|
"""Delete the image for an inventory item. Removes files from disk and sets image_url = NULL."""
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
cur = conn.cursor()
|
|
|
|
cur.execute("SELECT image_url FROM inventory WHERE id = %s", (item_id,))
|
|
row = cur.fetchone()
|
|
if not row:
|
|
cur.close(); conn.close()
|
|
return jsonify({'error': 'Item not found'}), 404
|
|
|
|
# Remove files from disk
|
|
_delete_image_files(g.tenant_id, item_id)
|
|
|
|
# Clear DB
|
|
cur.execute("UPDATE inventory SET image_url = NULL WHERE id = %s", (item_id,))
|
|
conn.commit()
|
|
|
|
log_action(conn, 'IMAGE_DELETE', 'inventory', item_id,
|
|
old_value={'image_url': row[0]})
|
|
|
|
cur.close(); conn.close()
|
|
return jsonify({'message': 'Image deleted'})
|
|
|
|
|
|
@inventory_bp.route('/items/<int:item_id>', methods=['DELETE'])
|
|
@require_auth('inventory.edit')
|
|
def delete_item(item_id):
|
|
"""Soft-delete an inventory item (mark is_active = false).
|
|
|
|
Keeps historical data (sales, movements) intact while removing
|
|
the item from the active catalog and stock views.
|
|
"""
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
cur = conn.cursor()
|
|
|
|
cur.execute("SELECT id, part_number, name FROM inventory WHERE id = %s", (item_id,))
|
|
row = cur.fetchone()
|
|
if not row:
|
|
cur.close(); conn.close()
|
|
return jsonify({'error': 'Item not found'}), 404
|
|
|
|
cur.execute("UPDATE inventory SET is_active = false WHERE id = %s", (item_id,))
|
|
conn.commit()
|
|
|
|
log_action(conn, 'INVENTORY_DELETE', 'inventory', item_id,
|
|
old_value={'part_number': row[1], 'name': row[2]})
|
|
|
|
cur.close(); conn.close()
|
|
return jsonify({'message': 'Item deleted', 'id': item_id})
|
|
|
|
|
|
# ─── Bulk Image Import ─────────────────────────
|
|
|
|
@inventory_bp.route('/bulk-images', methods=['POST'])
|
|
@require_auth('inventory.edit')
|
|
def bulk_upload_images():
|
|
"""Bulk import images from URLs for multiple inventory items.
|
|
|
|
Accepts JSON: {items: [{part_number, image_url}, ...]}
|
|
Downloads each image, resizes/optimizes, saves to disk, updates DB.
|
|
Returns {imported: N, errors: [...]}
|
|
"""
|
|
data = request.get_json() or {}
|
|
items_list = data.get('items', [])
|
|
|
|
if not items_list:
|
|
return jsonify({'error': 'items array required'}), 400
|
|
|
|
if len(items_list) > 500:
|
|
return jsonify({'error': 'Maximum 500 items per request'}), 400
|
|
|
|
from services.image_scraper import bulk_import
|
|
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
try:
|
|
result = bulk_import(conn, g.tenant_id, items_list)
|
|
log_action(conn, 'BULK_IMAGE_IMPORT', 'inventory', None,
|
|
new_value={'imported': result['imported'], 'error_count': len(result['errors'])})
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 500
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@inventory_bp.route('/items/<int:item_id>/auto-image', methods=['POST'])
|
|
@require_auth('inventory.edit')
|
|
def auto_image(item_id):
|
|
"""Generate a placeholder image for an inventory item.
|
|
|
|
Creates a branded placeholder with the part number text.
|
|
Useful when no real product image is available.
|
|
"""
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
cur = conn.cursor()
|
|
|
|
cur.execute("SELECT part_number, name FROM inventory WHERE id = %s", (item_id,))
|
|
row = cur.fetchone()
|
|
if not row:
|
|
cur.close(); conn.close()
|
|
return jsonify({'error': 'Item not found'}), 404
|
|
|
|
part_number, name = row
|
|
|
|
try:
|
|
from services.image_scraper import generate_placeholder
|
|
rel_url = generate_placeholder(g.tenant_id, item_id, part_number, name or '')
|
|
|
|
cur.execute("UPDATE inventory SET image_url = %s WHERE id = %s", (rel_url, item_id))
|
|
conn.commit()
|
|
|
|
log_action(conn, 'AUTO_IMAGE_GENERATED', 'inventory', item_id,
|
|
new_value={'image_url': rel_url})
|
|
|
|
cur.close(); conn.close()
|
|
return jsonify({
|
|
'image_url': rel_url,
|
|
'message': 'Placeholder image generated'
|
|
})
|
|
except Exception as e:
|
|
conn.rollback()
|
|
cur.close(); conn.close()
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
# ─── 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'})
|
|
|
|
|
|
@inventory_bp.route('/operations', methods=['GET'])
|
|
@require_auth('inventory.view')
|
|
def api_operations():
|
|
"""List inventory operations (purchases, sales, transfers, adjustments).
|
|
Supports filtering by operation_type, pagination, and date range.
|
|
"""
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
cur = conn.cursor()
|
|
|
|
op_type = request.args.get('type')
|
|
page = int(request.args.get('page', 1))
|
|
per_page = int(request.args.get('per_page', 50))
|
|
branch_id = request.args.get('branch_id', g.branch_id)
|
|
date_from = request.args.get('date_from')
|
|
date_to = request.args.get('date_to')
|
|
offset = (page - 1) * per_page
|
|
|
|
# Build query dynamically
|
|
where_clauses = ['1=1']
|
|
params = []
|
|
|
|
if op_type:
|
|
where_clauses.append('io.operation_type = %s')
|
|
params.append(op_type)
|
|
if branch_id:
|
|
where_clauses.append('(io.branch_id = %s OR io.branch_id IS NULL)')
|
|
params.append(branch_id)
|
|
if date_from:
|
|
where_clauses.append('io.created_at >= %s')
|
|
params.append(date_from)
|
|
if date_to:
|
|
where_clauses.append('io.created_at <= %s')
|
|
params.append(date_to + ' 23:59:59')
|
|
|
|
where_sql = ' AND '.join(where_clauses)
|
|
|
|
# Get total count
|
|
cur.execute(f"""
|
|
SELECT COUNT(*) FROM inventory_operations io
|
|
WHERE {where_sql}
|
|
""", params)
|
|
total = cur.fetchone()[0]
|
|
|
|
# Get operations with product and employee info
|
|
cur.execute(f"""
|
|
SELECT
|
|
io.id,
|
|
io.operation_type,
|
|
io.quantity,
|
|
io.cost_at_time,
|
|
io.notes,
|
|
io.created_at,
|
|
io.employee_id,
|
|
e.name as employee_name,
|
|
i.id as inventory_id,
|
|
i.part_number,
|
|
i.name as product_name,
|
|
i.barcode,
|
|
io.branch_id,
|
|
b.name as branch_name
|
|
FROM inventory_operations io
|
|
LEFT JOIN inventory i ON io.inventory_id = i.id
|
|
LEFT JOIN employees e ON io.employee_id = e.id
|
|
LEFT JOIN branches b ON io.branch_id = b.id
|
|
WHERE {where_sql}
|
|
ORDER BY io.created_at DESC
|
|
LIMIT %s OFFSET %s
|
|
""", params + [per_page, offset])
|
|
|
|
rows = cur.fetchall()
|
|
operations = []
|
|
for row in rows:
|
|
operations.append({
|
|
'id': row[0],
|
|
'operation_type': row[1],
|
|
'quantity': row[2],
|
|
'cost_at_time': float(row[3]) if row[3] else None,
|
|
'notes': row[4],
|
|
'created_at': row[5].isoformat() if row[5] else None,
|
|
'employee_id': row[6],
|
|
'employee_name': row[7],
|
|
'inventory_id': row[8],
|
|
'part_number': row[9],
|
|
'product_name': row[10],
|
|
'barcode': row[11],
|
|
'branch_id': row[12],
|
|
'branch_name': row[13],
|
|
'total': float(row[3] * row[2]) if row[3] and row[2] else None
|
|
})
|
|
|
|
cur.close()
|
|
conn.close()
|
|
|
|
return jsonify({
|
|
'data': operations,
|
|
'pagination': {
|
|
'page': page,
|
|
'per_page': per_page,
|
|
'total': total,
|
|
'total_pages': (total + per_page - 1) // per_page
|
|
}
|
|
})
|
|
|
|
|
|
# ─── 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
|
|
})
|
|
|
|
|
|
# ─── Stats Summary ─────────────────────────────
|
|
|
|
@inventory_bp.route('/stats', methods=['GET'])
|
|
@require_auth('inventory.view')
|
|
def api_inventory_stats():
|
|
"""Get inventory summary counts for dashboard badges."""
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
cur = conn.cursor()
|
|
branch_id = getattr(g, 'branch_id', None)
|
|
|
|
# Stock count
|
|
cur.execute("SELECT COUNT(*) FROM inventory WHERE is_active = true AND (branch_id = %s OR %s IS NULL)", (branch_id, branch_id))
|
|
stock = cur.fetchone()[0]
|
|
|
|
# Operations counts by type
|
|
cur.execute("""
|
|
SELECT operation_type, COUNT(*)
|
|
FROM inventory_operations
|
|
WHERE (branch_id = %s OR %s IS NULL)
|
|
GROUP BY operation_type
|
|
""", (branch_id, branch_id))
|
|
op_counts = {row[0]: row[1] for row in cur.fetchall()}
|
|
|
|
# Physical counts
|
|
cur.execute("SELECT COUNT(*) FROM physical_counts WHERE (branch_id = %s OR %s IS NULL)", (branch_id, branch_id))
|
|
physical = cur.fetchone()[0]
|
|
|
|
cur.close()
|
|
conn.close()
|
|
|
|
# Alerts (reuse existing function)
|
|
conn2 = get_tenant_conn(g.tenant_id)
|
|
alerts_list = get_alerts(conn2, branch_id)
|
|
conn2.close()
|
|
|
|
return jsonify({
|
|
'stock': stock,
|
|
'entradas': op_counts.get('PURCHASE', 0),
|
|
'salidas': op_counts.get('SALE', 0),
|
|
'traspasos': op_counts.get('TRANSFER', 0),
|
|
'ajustes': op_counts.get('ADJUST', 0),
|
|
'conteos': physical,
|
|
'alertas': len(alerts_list)
|
|
})
|
|
|
|
|
|
@inventory_bp.route('/summary', methods=['GET'])
|
|
@require_auth('inventory.view')
|
|
def api_inventory_summary():
|
|
"""Get high-level summary counts for the inventory dashboard badges."""
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
cur = conn.cursor()
|
|
branch_id = getattr(g, 'branch_id', None)
|
|
|
|
where_branch = ""
|
|
params = []
|
|
if branch_id:
|
|
where_branch = "AND i.branch_id = %s"
|
|
params.append(branch_id)
|
|
|
|
# 1. Total active SKUs
|
|
cur.execute(f"""
|
|
SELECT COUNT(*) FROM inventory i
|
|
WHERE i.is_active = true {where_branch}
|
|
""", params.copy())
|
|
total_skus = cur.fetchone()[0] or 0
|
|
|
|
# 2. Total inventory value (cost * stock)
|
|
cur.execute(f"""
|
|
SELECT COALESCE(SUM(i.cost * COALESCE(s.stock, 0)), 0)
|
|
FROM inventory i
|
|
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
|
|
WHERE i.is_active = true {where_branch}
|
|
""", params.copy())
|
|
total_value = float(cur.fetchone()[0] or 0)
|
|
|
|
# 3. Low stock count (below min_stock)
|
|
cur.execute(f"""
|
|
SELECT COUNT(*)
|
|
FROM inventory i
|
|
LEFT JOIN inventory_stock_summary 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
|
|
""", params.copy())
|
|
low_stock = cur.fetchone()[0] or 0
|
|
|
|
# 4. No movement in last 60 days
|
|
cutoff = datetime.utcnow() - timedelta(days=60)
|
|
cur.execute(f"""
|
|
SELECT COUNT(*)
|
|
FROM inventory i
|
|
WHERE i.is_active = true {where_branch}
|
|
AND i.id NOT IN (
|
|
SELECT inventory_id FROM inventory_operations
|
|
WHERE created_at > %s
|
|
)
|
|
""", params + [cutoff])
|
|
no_movement = cur.fetchone()[0] or 0
|
|
|
|
cur.close(); conn.close()
|
|
|
|
return jsonify({
|
|
'total_skus': total_skus,
|
|
'total_value': round(total_value, 2),
|
|
'low_stock': low_stock,
|
|
'no_movement': no_movement,
|
|
})
|
|
|
|
|
|
# ─── 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)
|
|
limit = min(int(request.args.get('limit', 500)), 2000)
|
|
alerts = get_alerts(conn, branch_id, limit_per_type=limit)
|
|
conn.close()
|
|
|
|
# Count totals by severity for UI summary
|
|
counts = {'critical': 0, 'warning': 0, 'info': 0, 'total': 0}
|
|
for a in alerts:
|
|
counts['total'] += 1
|
|
if a['severity'] in counts:
|
|
counts[a['severity']] += 1
|
|
|
|
return jsonify({'data': alerts, 'count': len(alerts), 'counts': counts, 'limit_per_type': limit})
|
|
|
|
|
|
@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('/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})
|
|
|
|
|
|
# ─── Multi-branch sync ──────────────────────────────────────────────────────
|
|
|
|
@inventory_bp.route('/stock-by-branch', methods=['GET'])
|
|
@require_auth('inventory.view')
|
|
def api_stock_by_branch():
|
|
"""Get stock for a specific inventory item across all branches."""
|
|
inventory_id = request.args.get('inventory_id', type=int)
|
|
if not inventory_id:
|
|
return jsonify({'error': 'inventory_id is required'}), 400
|
|
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT b.id, b.name, b.address,
|
|
COALESCE(SUM(io.quantity), 0) as stock
|
|
FROM branches b
|
|
LEFT JOIN inventory_operations io
|
|
ON io.branch_id = b.id AND io.inventory_id = %s
|
|
WHERE b.is_active = true
|
|
GROUP BY b.id, b.name, b.address
|
|
ORDER BY b.name
|
|
""", (inventory_id,))
|
|
data = []
|
|
for r in cur.fetchall():
|
|
data.append({
|
|
'branch_id': r[0], 'branch_name': r[1], 'address': r[2],
|
|
'stock': r[3],
|
|
})
|
|
cur.close(); conn.close()
|
|
return jsonify({'data': data})
|
|
|
|
|
|
@inventory_bp.route('/transfers', methods=['GET'])
|
|
@require_auth('inventory.view')
|
|
def api_transfers():
|
|
"""List stock transfer operations."""
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
cur = conn.cursor()
|
|
branch_id = request.args.get('branch_id', g.branch_id)
|
|
limit = request.args.get('limit', 50, type=int)
|
|
offset = request.args.get('offset', 0, type=int)
|
|
|
|
cur.execute("""
|
|
SELECT io.id, io.inventory_id, i.part_number, i.name,
|
|
io.branch_id, io.quantity, io.notes, io.created_at,
|
|
e.name as employee_name
|
|
FROM inventory_operations io
|
|
JOIN inventory i ON io.inventory_id = i.id
|
|
LEFT JOIN employees e ON io.employee_id = e.id
|
|
WHERE io.operation_type = 'TRANSFER'
|
|
AND (%s IS NULL OR io.branch_id = %s)
|
|
ORDER BY io.created_at DESC
|
|
LIMIT %s OFFSET %s
|
|
""", (branch_id, branch_id, limit, offset))
|
|
data = []
|
|
for r in cur.fetchall():
|
|
data.append({
|
|
'id': r[0], 'inventory_id': r[1], 'part_number': r[2], 'name': r[3],
|
|
'branch_id': r[4], 'quantity': r[5], 'notes': r[6],
|
|
'created_at': str(r[7]), 'employee': r[8],
|
|
})
|
|
cur.close(); conn.close()
|
|
return jsonify({'data': data})
|
|
|
|
|
|
@inventory_bp.route('/sync-prices', methods=['POST'])
|
|
@require_auth('inventory.edit')
|
|
def api_sync_prices():
|
|
"""Sync prices from one inventory item to others with the same part_number."""
|
|
data = request.get_json() or {}
|
|
source_id = data.get('source_inventory_id')
|
|
if not source_id:
|
|
return jsonify({'error': 'source_inventory_id is required'}), 400
|
|
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT part_number, price_1, price_2, price_3, cost FROM inventory WHERE id = %s", (source_id,))
|
|
source = cur.fetchone()
|
|
if not source:
|
|
cur.close(); conn.close()
|
|
return jsonify({'error': 'Source item not found'}), 404
|
|
|
|
part_number, p1, p2, p3, cost = source
|
|
cur.execute("""
|
|
UPDATE inventory
|
|
SET price_1 = %s, price_2 = %s, price_3 = %s, cost = %s, updated_at = NOW()
|
|
WHERE part_number = %s AND id != %s
|
|
""", (p1, p2, p3, cost, part_number, source_id))
|
|
updated = cur.rowcount
|
|
conn.commit()
|
|
cur.close(); conn.close()
|
|
return jsonify({'message': f'Synced prices to {updated} items', 'updated': updated})
|
|
|
|
|
|
# ─── Reorder alerts ─────────────────────────────────────────────────────────
|
|
|
|
@inventory_bp.route('/generate-alerts', methods=['POST'])
|
|
@require_auth('inventory.view')
|
|
def api_generate_alerts():
|
|
"""Scan inventory and generate reorder alerts."""
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
try:
|
|
result = generate_alerts(conn, branch_id=g.branch_id, auto_notify=True)
|
|
conn.commit()
|
|
conn.close()
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
conn.rollback()
|
|
conn.close()
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
@inventory_bp.route('/reorder-alerts', methods=['GET'])
|
|
@require_auth('inventory.view')
|
|
def api_reorder_alerts():
|
|
"""List reorder alerts."""
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
status = request.args.get('status')
|
|
branch_id = request.args.get('branch_id', g.branch_id)
|
|
limit = request.args.get('limit', 50, type=int)
|
|
offset = request.args.get('offset', 0, type=int)
|
|
data = list_alerts(conn, status=status, branch_id=branch_id, limit=limit, offset=offset)
|
|
conn.close()
|
|
return jsonify({'data': data, 'count': len(data)})
|
|
|
|
|
|
@inventory_bp.route('/reorder-alerts/<int:alert_id>/acknowledge', methods=['PUT'])
|
|
@require_auth('inventory.edit')
|
|
def api_ack_alert(alert_id):
|
|
"""Acknowledge a reorder alert."""
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
data = request.get_json() or {}
|
|
try:
|
|
ok = acknowledge_alert(conn, alert_id, employee_id=g.employee_id, notes=data.get('notes'))
|
|
conn.commit()
|
|
conn.close()
|
|
if not ok:
|
|
return jsonify({'error': 'Alert not found or already acknowledged'}), 404
|
|
return jsonify({'message': 'Alert acknowledged'})
|
|
except Exception as e:
|
|
conn.rollback()
|
|
conn.close()
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
@inventory_bp.route('/reorder-alerts/<int:alert_id>/resolve', methods=['PUT'])
|
|
@require_auth('inventory.edit')
|
|
def api_resolve_alert(alert_id):
|
|
"""Resolve a reorder alert."""
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
data = request.get_json() or {}
|
|
try:
|
|
ok = resolve_alert(conn, alert_id, po_id=data.get('po_id'), notes=data.get('notes'))
|
|
conn.commit()
|
|
conn.close()
|
|
if not ok:
|
|
return jsonify({'error': 'Alert not found'}), 404
|
|
return jsonify({'message': 'Alert resolved'})
|
|
except Exception as e:
|
|
conn.rollback()
|
|
conn.close()
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
@inventory_bp.route('/reorder-suggest-po', methods=['GET'])
|
|
@require_auth('inventory.edit')
|
|
def api_reorder_suggest_po():
|
|
"""Suggest a purchase order based on open low/zero stock alerts."""
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
supplier_id = request.args.get('supplier_id', type=int)
|
|
branch_id = request.args.get('branch_id', g.branch_id)
|
|
suggestion = suggest_po_from_alerts(conn, supplier_id=supplier_id, branch_id=branch_id)
|
|
conn.close()
|
|
return jsonify(suggestion)
|
|
|
|
|
|
# ─── Vehicle Compatibility ───────────────────────────
|
|
|
|
@inventory_bp.route('/items/<int:item_id>/vehicles', methods=['GET'])
|
|
@require_auth('inventory.view')
|
|
def get_item_vehicles(item_id):
|
|
"""Get all vehicle compatibilities for an inventory item."""
|
|
tenant = get_tenant_conn(g.tenant_id)
|
|
master = get_master_conn()
|
|
try:
|
|
vehicles = get_compatibility(tenant, master, item_id)
|
|
return jsonify({'vehicles': vehicles})
|
|
finally:
|
|
tenant.close()
|
|
master.close()
|
|
|
|
|
|
@inventory_bp.route('/items/<int:item_id>/vehicles', methods=['POST'])
|
|
@require_auth('inventory.edit')
|
|
def add_item_vehicle(item_id):
|
|
"""Manually add a vehicle compatibility."""
|
|
data = request.get_json() or {}
|
|
mye_id = data.get('model_year_engine_id')
|
|
if not mye_id:
|
|
return jsonify({'error': 'model_year_engine_id required'}), 400
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
try:
|
|
cid = add_compatibility(conn, item_id, mye_id, source='manual')
|
|
return jsonify({'id': cid, 'message': 'Compatibility added'}), 201
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@inventory_bp.route('/items/<int:item_id>/vehicles/<int:compat_id>', methods=['DELETE'])
|
|
@require_auth('inventory.edit')
|
|
def delete_item_vehicle(item_id, compat_id):
|
|
"""Remove a vehicle compatibility by its row id.
|
|
|
|
Works for both TecDoc-linked (mye_id present) and text-only QWEN records.
|
|
"""
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
try:
|
|
deleted = remove_compatibility_by_id(conn, compat_id)
|
|
return jsonify({'message': 'Compatibility removed', 'deleted': deleted})
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@inventory_bp.route('/items/<int:item_id>/vehicles/auto-match', methods=['POST'])
|
|
@require_auth('inventory.edit')
|
|
def auto_match_item_vehicles(item_id):
|
|
"""Run auto-match for an existing inventory item."""
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT part_number, brand, name FROM inventory WHERE id = %s", (item_id,))
|
|
row = cur.fetchone()
|
|
cur.close()
|
|
if not row:
|
|
conn.close()
|
|
return jsonify({'error': 'Item not found'}), 404
|
|
|
|
part_number, brand, name = row
|
|
compat_source = get_compat_source(g.tenant_id)
|
|
|
|
tecdoc_result = None
|
|
qwen_result = None
|
|
|
|
# TecDoc auto-match
|
|
if compat_source in ('tecdoc', 'both'):
|
|
master = get_master_conn()
|
|
try:
|
|
tecdoc_result = auto_match_vehicle_compatibility(master, conn, item_id, part_number,
|
|
brand=brand, name=name)
|
|
finally:
|
|
master.close()
|
|
|
|
# QWEN AI auto-match
|
|
if compat_source in ('qwen', 'both'):
|
|
try:
|
|
from services.qwen_fitment import get_vehicle_fitment
|
|
from services.inventory_vehicle_compat import save_qwen_fitment
|
|
fitment = get_vehicle_fitment(part_number, name, brand)
|
|
inserted = save_qwen_fitment(conn, item_id, fitment)
|
|
qwen_myes = [v['mye_id'] for v in fitment.get('vehicles', []) if v.get('mye_id')]
|
|
qwen_result = {
|
|
'matched': len(qwen_myes) > 0,
|
|
'matches': [],
|
|
'myes': qwen_myes,
|
|
'inserted': inserted,
|
|
'total_qwen': len(qwen_myes),
|
|
'confidence': fitment.get('confidence', 0),
|
|
'notes': fitment.get('notes', ''),
|
|
}
|
|
except Exception as e:
|
|
qwen_result = {'error': str(e)}
|
|
|
|
conn.close()
|
|
|
|
# Return combined or single-source result
|
|
if compat_source == 'both':
|
|
return jsonify({
|
|
'tecdoc': tecdoc_result,
|
|
'qwen': qwen_result,
|
|
'matched': bool(
|
|
(tecdoc_result and tecdoc_result.get('matched'))
|
|
or (qwen_result and qwen_result.get('matched'))
|
|
),
|
|
})
|
|
if compat_source == 'tecdoc':
|
|
return jsonify(tecdoc_result)
|
|
if compat_source == 'qwen':
|
|
return jsonify(qwen_result)
|
|
|
|
return jsonify({'error': 'No compatibility source configured'}), 400
|
|
|
|
|
|
# ─── SKU Aliases (multiple part numbers per item) ───────────────────────
|
|
|
|
@inventory_bp.route('/items/<int:item_id>/skus', methods=['GET'])
|
|
@require_auth('inventory.view')
|
|
def get_item_sku_aliases(item_id):
|
|
"""Return active SKU aliases for an inventory item."""
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
try:
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"""
|
|
SELECT id, sku, label, created_at
|
|
FROM inventory_sku_aliases
|
|
WHERE inventory_id = %s AND is_active = true
|
|
ORDER BY created_at
|
|
""",
|
|
(item_id,),
|
|
)
|
|
rows = cur.fetchall()
|
|
cur.close()
|
|
return jsonify({
|
|
'aliases': [
|
|
{'id': r[0], 'sku': r[1], 'label': r[2], 'created_at': r[3]}
|
|
for r in rows
|
|
]
|
|
})
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@inventory_bp.route('/items/<int:item_id>/skus', methods=['POST'])
|
|
@require_auth('inventory.edit')
|
|
def add_item_sku_alias(item_id):
|
|
"""Add an SKU alias to an inventory item."""
|
|
data = request.get_json() or {}
|
|
sku = (data.get('sku') or '').strip()
|
|
label = (data.get('label') or '').strip()
|
|
if not sku:
|
|
return jsonify({'error': 'sku is required'}), 400
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
try:
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO inventory_sku_aliases (inventory_id, sku, label)
|
|
VALUES (%s, %s, %s)
|
|
ON CONFLICT (inventory_id, sku) DO UPDATE SET
|
|
is_active = true, label = EXCLUDED.label
|
|
RETURNING id
|
|
""",
|
|
(item_id, sku, label or None),
|
|
)
|
|
row = cur.fetchone()
|
|
conn.commit()
|
|
cur.close()
|
|
return jsonify({'id': row[0], 'message': 'SKU alias added'}), 201
|
|
except Exception as e:
|
|
conn.rollback()
|
|
return jsonify({'error': str(e)}), 500
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@inventory_bp.route('/items/<int:item_id>/skus/<int:alias_id>', methods=['DELETE'])
|
|
@require_auth('inventory.edit')
|
|
def delete_item_sku_alias(item_id, alias_id):
|
|
"""Soft-delete an SKU alias."""
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
try:
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"""
|
|
UPDATE inventory_sku_aliases SET is_active = false WHERE id = %s AND inventory_id = %s
|
|
""",
|
|
(alias_id, item_id),
|
|
)
|
|
conn.commit()
|
|
cur.close()
|
|
return jsonify({'message': 'SKU alias removed'})
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@inventory_bp.route('/mye/search', methods=['GET'])
|
|
@require_auth()
|
|
def search_mye_endpoint():
|
|
"""Search model_year_engine records for manual compatibility assignment."""
|
|
brand_id = request.args.get('brand_id', type=int)
|
|
model_id = request.args.get('model_id', type=int)
|
|
year_id = request.args.get('year_id', type=int)
|
|
engine_id = request.args.get('engine_id', type=int)
|
|
master = get_master_conn()
|
|
try:
|
|
results = search_mye(master, brand_id=brand_id, model_id=model_id,
|
|
year_id=year_id, engine_id=engine_id)
|
|
return jsonify({'data': results})
|
|
finally:
|
|
master.close()
|
|
|
|
|
|
# ─── Manual Vehicle Compatibility (text-based) ────────────────────────────
|
|
|
|
@inventory_bp.route('/items/<int:item_id>/vehicles/manual', methods=['POST'])
|
|
@require_auth('inventory.edit')
|
|
def add_manual_vehicle_compat(item_id):
|
|
"""Add a manual vehicle compatibility using free-text fields."""
|
|
data = request.get_json() or {}
|
|
make = (data.get('make') or '').strip()
|
|
model = (data.get('model') or '').strip()
|
|
year = data.get('year')
|
|
engine = (data.get('engine') or '').strip()
|
|
engine_code = (data.get('engine_code') or '').strip()
|
|
|
|
if not make or not model or not year:
|
|
return jsonify({'error': 'make, model and year are required'}), 400
|
|
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
try:
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO inventory_vehicle_compat
|
|
(inventory_id, make, model, year, engine, engine_code, source, model_year_engine_id)
|
|
VALUES (%s, %s, %s, %s, %s, %s, 'manual', NULL)
|
|
ON CONFLICT DO NOTHING
|
|
RETURNING id
|
|
""",
|
|
(item_id, make, model, year, engine or None, engine_code or None),
|
|
)
|
|
row = cur.fetchone()
|
|
conn.commit()
|
|
cur.close()
|
|
if not row:
|
|
return jsonify({'error': 'Compatibility already exists or item not found'}), 409
|
|
return jsonify({'id': row[0], 'message': 'Compatibility added'}), 201
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@inventory_bp.route('/vehicles/makes', methods=['GET'])
|
|
@require_auth()
|
|
def get_vehicle_makes():
|
|
"""Return distinct vehicle makes from master DB."""
|
|
master = get_master_conn()
|
|
try:
|
|
cur = master.cursor()
|
|
cur.execute("SELECT id_brand, name_brand FROM brands ORDER BY name_brand")
|
|
rows = cur.fetchall()
|
|
cur.close()
|
|
return jsonify({'makes': [{'id': r[0], 'name': r[1]} for r in rows]})
|
|
finally:
|
|
master.close()
|
|
|
|
|
|
@inventory_bp.route('/vehicles/models', methods=['GET'])
|
|
@require_auth()
|
|
def get_vehicle_models():
|
|
"""Return models for a given brand."""
|
|
brand_id = request.args.get('brand_id', type=int)
|
|
if not brand_id:
|
|
return jsonify({'error': 'brand_id required'}), 400
|
|
master = get_master_conn()
|
|
try:
|
|
cur = master.cursor()
|
|
cur.execute(
|
|
"SELECT id_model, name_model FROM models WHERE brand_id = %s ORDER BY name_model",
|
|
(brand_id,),
|
|
)
|
|
rows = cur.fetchall()
|
|
cur.close()
|
|
return jsonify({'models': [{'id': r[0], 'name': r[1]} for r in rows]})
|
|
finally:
|
|
master.close()
|
|
|
|
|
|
@inventory_bp.route('/vehicles/years', methods=['GET'])
|
|
@require_auth()
|
|
def get_vehicle_years():
|
|
"""Return distinct years available for a model."""
|
|
model_id = request.args.get('model_id', type=int)
|
|
if not model_id:
|
|
return jsonify({'error': 'model_id required'}), 400
|
|
master = get_master_conn()
|
|
try:
|
|
cur = master.cursor()
|
|
cur.execute(
|
|
"""
|
|
SELECT DISTINCT y.id_year, y.year_car
|
|
FROM model_year_engine mye
|
|
JOIN years y ON y.id_year = mye.year_id
|
|
WHERE mye.model_id = %s
|
|
ORDER BY y.year_car DESC
|
|
""",
|
|
(model_id,),
|
|
)
|
|
rows = cur.fetchall()
|
|
cur.close()
|
|
return jsonify({'years': [{'id': r[0], 'year': r[1]} for r in rows]})
|
|
finally:
|
|
master.close()
|
|
|
|
|
|
@inventory_bp.route('/vehicles/engines', methods=['GET'])
|
|
@require_auth()
|
|
def get_vehicle_engines():
|
|
"""Return engines available for a model+year."""
|
|
model_id = request.args.get('model_id', type=int)
|
|
year_id = request.args.get('year_id', type=int)
|
|
if not model_id or not year_id:
|
|
return jsonify({'error': 'model_id and year_id required'}), 400
|
|
master = get_master_conn()
|
|
try:
|
|
cur = master.cursor()
|
|
cur.execute(
|
|
"""
|
|
SELECT DISTINCT e.id_engine, e.name_engine, e.engine_code
|
|
FROM model_year_engine mye
|
|
JOIN engines e ON e.id_engine = mye.engine_id
|
|
WHERE mye.model_id = %s AND mye.year_id = %s
|
|
ORDER BY e.name_engine
|
|
""",
|
|
(model_id, year_id),
|
|
)
|
|
rows = cur.fetchall()
|
|
cur.close()
|
|
return jsonify({'engines': [{'id': r[0], 'name': r[1], 'code': r[2]} for r in rows]})
|
|
finally:
|
|
master.close()
|
|
|
|
|
|
# ─── Categories ──────────────────────────────────
|
|
|
|
@inventory_bp.route('/categories', methods=['GET'])
|
|
@require_auth()
|
|
def list_inventory_categories():
|
|
"""Return active categories (root only). Optional ?parent_id= for subcategories."""
|
|
parent_id = request.args.get('parent_id')
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
try:
|
|
cur = conn.cursor()
|
|
if parent_id:
|
|
cur.execute(
|
|
"SELECT id, name FROM categories WHERE parent_id = %s AND is_active = true ORDER BY name",
|
|
(parent_id,)
|
|
)
|
|
else:
|
|
cur.execute(
|
|
"SELECT id, name FROM categories WHERE parent_id IS NULL AND is_active = true ORDER BY name"
|
|
)
|
|
rows = cur.fetchall()
|
|
return jsonify({'categories': [{'id': r[0], 'name': r[1]} for r in rows]})
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@inventory_bp.route('/categories/<int:category_id>/subcategories', methods=['GET'])
|
|
@require_auth()
|
|
def list_inventory_subcategories(category_id):
|
|
"""Return subcategories for a given category."""
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
try:
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"SELECT id, name FROM categories WHERE parent_id = %s AND is_active = true ORDER BY name",
|
|
(category_id,)
|
|
)
|
|
rows = cur.fetchall()
|
|
return jsonify({'subcategories': [{'id': r[0], 'name': r[1]} for r in rows]})
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# ─── Global Tier Discounts ───────────────────────
|
|
|
|
@inventory_bp.route('/tier-discounts', methods=['GET'])
|
|
@require_auth()
|
|
def get_tier_discounts_endpoint():
|
|
"""Return global tier discount percentages."""
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
try:
|
|
discounts = _get_tier_discounts(conn)
|
|
return jsonify({
|
|
'data': [
|
|
{'tier_id': 2, 'tier_name': 'Taller', 'discount_pct': discounts.get(2, 0)},
|
|
{'tier_id': 3, 'tier_name': 'Mayoreo', 'discount_pct': discounts.get(3, 0)},
|
|
]
|
|
})
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@inventory_bp.route('/tier-discounts', methods=['PUT'])
|
|
@require_auth('config.edit_prices')
|
|
def update_tier_discounts_endpoint():
|
|
"""Update global tier discount percentages."""
|
|
data = request.get_json() or {}
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
cur = conn.cursor()
|
|
try:
|
|
for tier_id in (2, 3):
|
|
key = f'discount_pct_{tier_id}'
|
|
if key in data:
|
|
val = max(0, min(100, float(data[key])))
|
|
cur.execute("""
|
|
INSERT INTO tier_discounts (tier_id, tier_name, discount_pct)
|
|
VALUES (%s, %s, %s)
|
|
ON CONFLICT (tier_id) DO UPDATE SET discount_pct = EXCLUDED.discount_pct
|
|
""", (tier_id, 'Taller' if tier_id == 2 else 'Mayoreo', val))
|
|
conn.commit()
|
|
return jsonify({'message': 'Descuentos actualizados'})
|
|
finally:
|
|
cur.close()
|
|
conn.close()
|