Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
- F0 blindaje: Redis/Celery, pg_hba sin trust LAN, secretos a env 600, backups, webhook auth, jobs muertos - F1 caja: bugs POS, reembolsos al corte, apartados, CFDI, topes de descuento en cotizacion/apartado - F2/F7 IA de catalogo: autocategorizacion, dedup de SKUs con embeddings, OCR de facturas, descripciones, claves SAT, limpieza de fitment - F3 IA en el flujo: vision Yonke, preguntas MeLi, venta cruzada, anomalias, sinonimos, busqueda semantica (rerank/embeddings) - F4/F8 escala y cartera: /metrics+Prometheus+Grafana, pool, score de credito, churn, metricas multi-tenant - Nexus Yonke (M1-M6): modelo de datos 1-de-1, alta por donador, vitrina publica, MeLi usado, consignacion, garantia por dias, reportes, etiquetas - Marketplace B2B entre yonkes (catalogo + pedidos cross-tenant) - Seguridad: auth en /api/admin/* (35 endpoints), alertas por tenant - Migraciones v4.27/v4.28; timers expire-demos y rollup-metrics
This commit is contained in:
@@ -6,9 +6,13 @@ import json
|
||||
import os
|
||||
import csv
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from flask import Blueprint, request, jsonify, g, Response
|
||||
from middleware import require_auth, has_permission
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.reorder_engine import (
|
||||
generate_alerts, list_alerts, acknowledge_alert, resolve_alert,
|
||||
suggest_po_from_alerts,
|
||||
)
|
||||
from services.inventory_engine import (
|
||||
get_stock, get_stock_bulk, record_purchase, record_return,
|
||||
record_adjustment, record_transfer, record_initial,
|
||||
@@ -65,14 +69,275 @@ def _to_int(val, default=0):
|
||||
return default
|
||||
|
||||
|
||||
# ─── Vehicle resolution (tablas de vehículos viven en la MAESTRA) ──────────
|
||||
_MYE_LOOKUP_CACHE = {}
|
||||
_MYE_TABLES_MISSING = {'flag': False}
|
||||
|
||||
|
||||
def _resolve_mye_master(make, model, year):
|
||||
"""Resuelve model_year_engine_id contra la BD maestra; None si no existe.
|
||||
|
||||
`brands`/`models`/`years`/`model_year_engine` sólo existen en la maestra:
|
||||
consultarlas en la conexión del tenant lanzaba UndefinedTable y devolvía
|
||||
500 en todo el bulk-import cuando el CSV traía make/model/year.
|
||||
"""
|
||||
key = (make.strip().lower(), model.strip().lower(), int(year))
|
||||
if key in _MYE_LOOKUP_CACHE:
|
||||
return _MYE_LOOKUP_CACHE[key]
|
||||
if _MYE_TABLES_MISSING['flag']:
|
||||
return None
|
||||
|
||||
result = None
|
||||
try:
|
||||
mconn = get_master_conn()
|
||||
try:
|
||||
mcur = mconn.cursor()
|
||||
try:
|
||||
mcur.execute("""
|
||||
SELECT mye.id_mye FROM model_year_engine mye
|
||||
JOIN models m ON m.id_model = mye.model_id
|
||||
JOIN brands b ON b.id_brand = m.brand_id
|
||||
JOIN years y ON y.id_year = mye.year_id
|
||||
WHERE LOWER(b.name_brand) = LOWER(%s)
|
||||
AND LOWER(m.name_model) = LOWER(%s)
|
||||
AND y.year_car = %s
|
||||
LIMIT 1
|
||||
""", (make, model, year))
|
||||
row = mcur.fetchone()
|
||||
result = row[0] if row else None
|
||||
finally:
|
||||
mcur.close()
|
||||
finally:
|
||||
mconn.close()
|
||||
except Exception as e:
|
||||
try:
|
||||
from psycopg2.errors import UndefinedTable, UndefinedColumn
|
||||
missing = isinstance(e, (UndefinedTable, UndefinedColumn))
|
||||
except Exception:
|
||||
missing = 'does not exist' in str(e)
|
||||
if missing:
|
||||
# Esquema ausente: no se reintenta; la compat se guarda como texto.
|
||||
_MYE_TABLES_MISSING['flag'] = True
|
||||
return None
|
||||
|
||||
_MYE_LOOKUP_CACHE[key] = result
|
||||
return result
|
||||
|
||||
|
||||
# ─── AI Classification ───────────────────────────
|
||||
|
||||
@inventory_bp.route('/classify/<part_number>', methods=['GET'])
|
||||
@inventory_bp.route('/classify/<part_number>', methods=['GET', 'POST'])
|
||||
@require_auth('inventory.create')
|
||||
def classify_part_endpoint(part_number):
|
||||
"""Ask AI to identify a part by its OEM number."""
|
||||
"""Ask AI to identify a part by its OEM number and persist its category.
|
||||
|
||||
In addition to the raw LLM result ({name, brand, vehicle, category}) this
|
||||
now resolves the returned category to a tenant ``categories.id`` and stores
|
||||
it in ``inventory.category_id`` when the part exists in the tenant catalog.
|
||||
"""
|
||||
from services.ai_chat import classify_part
|
||||
from services.product_categorizer import CategoryResolver, map_llm_category
|
||||
|
||||
result = classify_part(part_number)
|
||||
|
||||
category_id = None
|
||||
category_name = None
|
||||
persisted = 0
|
||||
raw_cat = (result or {}).get('category') if isinstance(result, dict) else None
|
||||
if raw_cat:
|
||||
canonical = map_llm_category(raw_cat)
|
||||
if canonical:
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
resolver = CategoryResolver(conn, create=True)
|
||||
category_id, category_name = resolver.resolve(canonical)
|
||||
if category_id is not None:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"UPDATE inventory SET category_id = %s "
|
||||
"WHERE part_number = %s AND is_active = true "
|
||||
"AND (category_id IS NULL OR category_id <> %s)",
|
||||
(category_id, part_number, category_id),
|
||||
)
|
||||
persisted = cur.rowcount
|
||||
conn.commit()
|
||||
cur.close()
|
||||
except Exception:
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if isinstance(result, dict):
|
||||
result['category_id'] = category_id
|
||||
result['category_name'] = category_name
|
||||
result['persisted'] = persisted
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@inventory_bp.route('/categorize', methods=['POST'])
|
||||
@require_auth('inventory.edit')
|
||||
def categorize_inventory_endpoint():
|
||||
"""Auto-categorize inventory items and persist ``category_id``.
|
||||
|
||||
Body options:
|
||||
{"items": [{"part_number", "name", "brand"}, ...]} # explicit list
|
||||
{"all": true, "limit": N, "only_uncategorized": true} # batch over catalog
|
||||
Optional: use_llm (LLM fallback per item), create_categories (default true).
|
||||
"""
|
||||
from services.product_categorizer import categorize_items, categorize_inventory
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
use_llm = bool(data.get('use_llm'))
|
||||
create_categories = bool(data.get('create_categories', True))
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
if data.get('all'):
|
||||
limit = min(int(data.get('limit', 200) or 200), 5000)
|
||||
summary = categorize_inventory(
|
||||
conn, limit=limit,
|
||||
only_uncategorized=bool(data.get('only_uncategorized', True)),
|
||||
use_llm=use_llm, persist=True,
|
||||
create_categories=create_categories,
|
||||
offset=int(data.get('offset', 0) or 0),
|
||||
after_id=data.get('after_id'),
|
||||
)
|
||||
return jsonify({'mode': 'all', **summary})
|
||||
|
||||
items = data.get('items')
|
||||
if not items or not isinstance(items, list):
|
||||
return jsonify({'error': 'items[] o all:true requerido'}), 400
|
||||
summary = categorize_items(
|
||||
conn, items[:2000], use_llm=use_llm, persist=True,
|
||||
create_categories=create_categories,
|
||||
)
|
||||
return jsonify({'mode': 'items', **summary})
|
||||
except Exception as e:
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@inventory_bp.route('/match-catalog', methods=['POST'])
|
||||
@require_auth('inventory.edit')
|
||||
def match_catalog_endpoint():
|
||||
"""Match inventory SKUs against the supplier catalog.
|
||||
|
||||
Populates ``inventory.catalog_part_id`` and registers matched supplier SKUs
|
||||
as ``inventory_sku_aliases``. Body options: limit, offset, only_unmatched,
|
||||
create_aliases, use_names, name_threshold, apply(false=dry run), after_id.
|
||||
"""
|
||||
from services.sku_matcher import match_inventory, count_links
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
limit = data.get('limit')
|
||||
if limit is not None:
|
||||
limit = min(int(limit), 50000)
|
||||
|
||||
tenant_conn = get_tenant_conn(g.tenant_id)
|
||||
master_conn = get_master_conn()
|
||||
try:
|
||||
before = count_links(tenant_conn)
|
||||
summary = match_inventory(
|
||||
tenant_conn, master_conn, limit=limit,
|
||||
offset=int(data.get('offset', 0) or 0),
|
||||
only_unmatched=bool(data.get('only_unmatched', True)),
|
||||
create_aliases=bool(data.get('create_aliases', True)),
|
||||
use_names=bool(data.get('use_names', True)),
|
||||
name_threshold=float(data.get('name_threshold', 0.82)),
|
||||
apply=bool(data.get('apply', True)),
|
||||
supplier=data.get('supplier'),
|
||||
after_id=data.get('after_id'),
|
||||
)
|
||||
after = count_links(tenant_conn)
|
||||
summary['before'] = {'total': before[0], 'linked': before[1]}
|
||||
summary['after'] = {'total': after[0], 'linked': after[1]}
|
||||
return jsonify(summary)
|
||||
except Exception as e:
|
||||
try:
|
||||
tenant_conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
tenant_conn.close()
|
||||
master_conn.close()
|
||||
|
||||
|
||||
@inventory_bp.route('/supplier-invoice/parse', methods=['POST'])
|
||||
@require_auth('inventory.create')
|
||||
def supplier_invoice_parse_endpoint():
|
||||
"""OCR/parse a supplier invoice (image or PDF) into structured lines.
|
||||
|
||||
Accepts multipart ``file`` or JSON ``{file_base64, filename}``. Returns the
|
||||
detected lines plus an inventory/catalog match for each part number. Does
|
||||
NOT create stock — the front-end confirms before a purchase is recorded.
|
||||
"""
|
||||
import base64 as _b64
|
||||
from services.invoice_ocr import parse_invoice
|
||||
|
||||
data = None
|
||||
filename = None
|
||||
content_type = None
|
||||
f = request.files.get('file')
|
||||
if f:
|
||||
data = f.read()
|
||||
filename = f.filename
|
||||
content_type = f.content_type
|
||||
else:
|
||||
body = request.get_json(silent=True) or {}
|
||||
raw = body.get('file_base64')
|
||||
if raw:
|
||||
if raw.startswith('data:'):
|
||||
raw = raw.split(',', 1)[1]
|
||||
try:
|
||||
data = _b64.b64decode(raw)
|
||||
except Exception:
|
||||
return jsonify({'error': 'file_base64 inválido'}), 400
|
||||
filename = body.get('filename')
|
||||
if not data:
|
||||
return jsonify({'error': 'file (multipart) o file_base64 requerido'}), 400
|
||||
|
||||
use_vision = request.args.get('vision', '1').lower() not in ('0', 'false', 'no')
|
||||
result = parse_invoice(data, filename=filename, content_type=content_type,
|
||||
use_vision=use_vision)
|
||||
|
||||
# Enrich each line with the tenant inventory item it points at.
|
||||
norm = {}
|
||||
for ln in result.get('lines', []):
|
||||
pn = (ln.get('part_number') or '').upper().replace(' ', '')
|
||||
if pn:
|
||||
norm[pn] = None
|
||||
if norm:
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT id, UPPER(REPLACE(part_number, ' ', '')) AS npn, name, cost "
|
||||
"FROM inventory WHERE is_active = true AND "
|
||||
"UPPER(REPLACE(part_number, ' ', '')) = ANY(%s)",
|
||||
(list(norm.keys()),),
|
||||
)
|
||||
for inv_id, npn, name, cost in cur.fetchall():
|
||||
norm[npn] = {'inventory_id': inv_id, 'name': name,
|
||||
'cost': float(cost) if cost is not None else None}
|
||||
cur.close()
|
||||
finally:
|
||||
conn.close()
|
||||
for ln in result.get('lines', []):
|
||||
pn = (ln.get('part_number') or '').upper().replace(' ', '')
|
||||
hit = norm.get(pn)
|
||||
ln['matched_inventory_id'] = hit['inventory_id'] if hit else None
|
||||
if hit and not ln.get('name'):
|
||||
ln['name'] = hit['name']
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@@ -414,6 +679,119 @@ def create_item():
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@inventory_bp.route('/export', methods=['GET'])
|
||||
@require_auth('inventory.view')
|
||||
def export_inventory():
|
||||
"""Export the inventory as CSV, compatible with the bulk importer.
|
||||
|
||||
Query params:
|
||||
branch_id: optional. When given, the stock (and location) columns reflect
|
||||
only that branch, and a branch_id column is appended so the
|
||||
file can be re-imported into the same branch.
|
||||
|
||||
Columns: part_number, sku_secondary, name, brand, category, cost, price,
|
||||
price_2, price_3, stock, location, description, barcode, unit[, branch_id]
|
||||
- without branch_id, stock is the total across all branches.
|
||||
- cost is blank unless the user has the inventory.view_cost permission.
|
||||
Streamed in chunks to support large catalogs.
|
||||
"""
|
||||
include_cost = has_permission('inventory.view_cost')
|
||||
branch_id = request.args.get('branch_id', type=int)
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
branch_name = None
|
||||
if branch_id:
|
||||
cur.execute("SELECT name FROM branches WHERE id = %s", (branch_id,))
|
||||
brow = cur.fetchone()
|
||||
if not brow:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': f'Branch {branch_id} not found'}), 400
|
||||
branch_name = brow[0]
|
||||
cur.execute("""
|
||||
SELECT i.part_number,
|
||||
(SELECT a.sku FROM inventory_sku_aliases a
|
||||
WHERE a.inventory_id = i.id ORDER BY a.id LIMIT 1) AS sku_secondary,
|
||||
i.name, i.brand, c.name AS category, i.cost,
|
||||
i.price_1, i.price_2, i.price_3,
|
||||
COALESCE(ist.stock, 0) AS stock,
|
||||
COALESCE(ist.location, i.location) AS location,
|
||||
i.description, i.barcode, i.unit
|
||||
FROM inventory i
|
||||
LEFT JOIN categories c ON c.id = i.category_id
|
||||
LEFT JOIN inventory_stock ist
|
||||
ON ist.inventory_id = i.id AND ist.branch_id = %s
|
||||
WHERE i.is_active = true
|
||||
ORDER BY i.part_number
|
||||
""", (branch_id,))
|
||||
else:
|
||||
cur.execute("""
|
||||
SELECT i.part_number,
|
||||
(SELECT a.sku FROM inventory_sku_aliases a
|
||||
WHERE a.inventory_id = i.id ORDER BY a.id LIMIT 1) AS sku_secondary,
|
||||
i.name, i.brand, c.name AS category, i.cost,
|
||||
i.price_1, i.price_2, i.price_3,
|
||||
COALESCE(st.total_stock, 0) AS stock,
|
||||
i.location, i.description, i.barcode, i.unit
|
||||
FROM inventory i
|
||||
LEFT JOIN categories c ON c.id = i.category_id
|
||||
LEFT JOIN (
|
||||
SELECT inventory_id, SUM(stock) AS total_stock
|
||||
FROM inventory_stock GROUP BY inventory_id
|
||||
) st ON st.inventory_id = i.id
|
||||
WHERE i.is_active = true
|
||||
ORDER BY i.part_number
|
||||
""")
|
||||
|
||||
def generate():
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
try:
|
||||
header = ['part_number', 'sku_secondary', 'name', 'brand',
|
||||
'category', 'cost', 'price', 'price_2', 'price_3',
|
||||
'stock', 'location', 'description', 'barcode', 'unit']
|
||||
if branch_id:
|
||||
header.append('branch_id')
|
||||
writer.writerow(header)
|
||||
# BOM so Excel opens UTF-8 correctly
|
||||
yield '\ufeff' + output.getvalue()
|
||||
output.seek(0); output.truncate(0)
|
||||
while True:
|
||||
rows = cur.fetchmany(1000)
|
||||
if not rows:
|
||||
break
|
||||
for r in rows:
|
||||
row = [
|
||||
r[0] or '', r[1] or '', r[2] or '', r[3] or '', r[4] or '',
|
||||
f'{float(r[5]):.2f}' if include_cost and r[5] is not None else '',
|
||||
f'{float(r[6]):.2f}' if r[6] is not None else '0.00',
|
||||
f'{float(r[7]):.2f}' if r[7] is not None else '',
|
||||
f'{float(r[8]):.2f}' if r[8] is not None else '',
|
||||
int(r[9]) if r[9] is not None else 0,
|
||||
r[10] or '', r[11] or '', r[12] or '', r[13] or '',
|
||||
]
|
||||
if branch_id:
|
||||
row.append(branch_id)
|
||||
writer.writerow(row)
|
||||
yield output.getvalue()
|
||||
output.seek(0); output.truncate(0)
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if branch_id:
|
||||
safe_branch = ''.join(ch if ch.isalnum() else '_' for ch in (branch_name or str(branch_id)))
|
||||
filename = f"inventario_{safe_branch}_{datetime.now().strftime('%Y%m%d_%H%M')}.csv"
|
||||
else:
|
||||
filename = f"inventario_completo_{datetime.now().strftime('%Y%m%d_%H%M')}.csv"
|
||||
return Response(
|
||||
generate(),
|
||||
mimetype='text/csv; charset=utf-8',
|
||||
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@inventory_bp.route('/items/bulk-import', methods=['POST'])
|
||||
@require_auth('inventory.edit')
|
||||
def bulk_import_items():
|
||||
@@ -422,8 +800,12 @@ def bulk_import_items():
|
||||
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.
|
||||
X-Import-Strategy: 'skip' (default) ignores missing compat and never
|
||||
calls QWEN; 'qwen' auto-generates missing compat via
|
||||
QWEN (opt-in: 20.7M noisy fitment rows so far);
|
||||
'reject' requires all compat.
|
||||
X-Import-Stock: 'add' (default) sums stock to existing items;
|
||||
'skip' only updates data/prices of existing items without touching stock.
|
||||
Expected CSV columns (case-insensitive):
|
||||
sku/part_number, name, brand, price, stock, cost,
|
||||
sku_secondary, description, category, make, model, year, engine, engine_code
|
||||
@@ -434,7 +816,8 @@ def bulk_import_items():
|
||||
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()
|
||||
strategy = request.headers.get('X-Import-Strategy', 'skip').lower()
|
||||
stock_mode = request.headers.get('X-Import-Stock', 'add').lower()
|
||||
errors = []
|
||||
warnings = []
|
||||
created_ids = []
|
||||
@@ -594,8 +977,8 @@ def bulk_import_items():
|
||||
(name, brand, cost, cost, price_1, price_1, location, description, category_id, item_id)
|
||||
)
|
||||
was_inserted = False
|
||||
# Record stock adjustment for existing item if stock > 0
|
||||
if stock > 0:
|
||||
# Record stock adjustment for existing item if stock > 0 (unless skipped)
|
||||
if stock > 0 and stock_mode != 'skip':
|
||||
record_initial(conn, item_id, branch_id, stock, cost if cost > 0 else None)
|
||||
else:
|
||||
# Generate barcode for new item
|
||||
@@ -648,24 +1031,9 @@ def bulk_import_items():
|
||||
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:
|
||||
# Resolver contra la BD maestra (no la del tenant).
|
||||
mye_id = _resolve_mye_master(make, model, year)
|
||||
if mye_id is None:
|
||||
warnings.append(
|
||||
f'Row {row_num}: vehicle "{make} {model} {year}" not found in catalog; '
|
||||
'saving as text-only compatibility.'
|
||||
@@ -1078,6 +1446,251 @@ def api_adjustment():
|
||||
return jsonify({'error': str(e)}), 400
|
||||
|
||||
|
||||
|
||||
|
||||
@inventory_bp.route('/items/lookup', methods=['GET'])
|
||||
@require_auth('inventory.view')
|
||||
def lookup_item_by_code():
|
||||
"""Exact lookup by barcode / part_number / SKU alias (for scanner flows)."""
|
||||
code = (request.args.get('code') or '').strip()
|
||||
if not code:
|
||||
return jsonify({'error': 'code requerido'}), 400
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
norm = code.upper().replace(' ', '')
|
||||
cur.execute("""
|
||||
SELECT i.id, i.part_number, i.name, i.barcode,
|
||||
COALESCE(s.stock, 0) AS stock, i.price_1
|
||||
FROM inventory i
|
||||
LEFT JOIN (
|
||||
SELECT inventory_id, SUM(stock) AS stock FROM inventory_stock
|
||||
GROUP BY inventory_id
|
||||
) s ON s.inventory_id = i.id
|
||||
WHERE i.is_active = true AND (
|
||||
UPPER(i.barcode) = %s
|
||||
OR UPPER(REPLACE(i.part_number, ' ', '')) = %s
|
||||
OR i.id IN (
|
||||
SELECT a.inventory_id FROM inventory_sku_aliases a
|
||||
WHERE UPPER(REPLACE(a.sku, ' ', '')) = %s))
|
||||
LIMIT 1
|
||||
""", (code, norm, norm))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
conn.close()
|
||||
if not row:
|
||||
return jsonify({'found': False}), 404
|
||||
return jsonify({'found': True, 'item': {
|
||||
'id': row[0], 'part_number': row[1], 'name': row[2],
|
||||
'barcode': row[3], 'stock': int(row[4] or 0),
|
||||
'price_1': float(row[5] or 0),
|
||||
}})
|
||||
|
||||
|
||||
|
||||
|
||||
# ─── Transfer documents (in-transit tracking) ──────────────────────────────
|
||||
|
||||
@inventory_bp.route('/transfer-docs', methods=['GET'])
|
||||
@require_auth('inventory.view')
|
||||
def list_transfer_docs():
|
||||
"""List transfer documents. Query: status, page, per_page."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
status = request.args.get('status')
|
||||
where = "WHERE t.status = %s" if status else ""
|
||||
params = [status] if status else []
|
||||
cur.execute(f"""
|
||||
SELECT t.id, t.from_branch_id, b1.name, t.to_branch_id, b2.name,
|
||||
t.status, t.notes, t.created_at, t.sent_at, t.received_at,
|
||||
COALESCE(SUM(l.quantity), 0) AS total_qty
|
||||
FROM inventory_transfers t
|
||||
JOIN branches b1 ON b1.id = t.from_branch_id
|
||||
JOIN branches b2 ON b2.id = t.to_branch_id
|
||||
LEFT JOIN inventory_transfer_lines l ON l.transfer_id = t.id
|
||||
{where}
|
||||
GROUP BY t.id, b1.name, b2.name
|
||||
ORDER BY t.id DESC
|
||||
LIMIT 100
|
||||
""", params)
|
||||
docs = [{
|
||||
'id': r[0], 'from_branch_id': r[1], 'from_name': r[2],
|
||||
'to_branch_id': r[3], 'to_name': r[4], 'status': r[5],
|
||||
'notes': r[6], 'created_at': str(r[7]), 'sent_at': str(r[8]),
|
||||
'received_at': str(r[9]), 'total_qty': int(r[10] or 0),
|
||||
} for r in cur.fetchall()]
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'data': docs})
|
||||
|
||||
|
||||
@inventory_bp.route('/transfer-docs', methods=['POST'])
|
||||
@require_auth('inventory.transfer')
|
||||
def create_transfer_doc():
|
||||
"""Create a draft transfer document.
|
||||
|
||||
Body: { to_branch_id, items: [{inventory_id, quantity}], notes }
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
to_branch = data.get('to_branch_id')
|
||||
items = data.get('items', [])
|
||||
if not to_branch or not items:
|
||||
return jsonify({'error': 'to_branch_id e items son requeridos'}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
from_branch = data.get('from_branch_id') or g.branch_id
|
||||
if not from_branch:
|
||||
cur.execute("SELECT id FROM branches ORDER BY id LIMIT 1")
|
||||
from_branch = cur.fetchone()[0]
|
||||
if int(to_branch) == int(from_branch):
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Origen y destino no pueden ser la misma sucursal'}), 400
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO inventory_transfers (from_branch_id, to_branch_id, status, notes, employee_id)
|
||||
VALUES (%s, %s, 'draft', %s, %s) RETURNING id
|
||||
""", (from_branch, to_branch, data.get('notes'), getattr(g, 'employee_id', None)))
|
||||
doc_id = cur.fetchone()[0]
|
||||
for it in items:
|
||||
qty = int(it.get('quantity', 0) or 0)
|
||||
if qty <= 0 or not it.get('inventory_id'):
|
||||
continue
|
||||
cur.execute("""
|
||||
INSERT INTO inventory_transfer_lines (transfer_id, inventory_id, quantity)
|
||||
VALUES (%s, %s, %s)
|
||||
""", (doc_id, it['inventory_id'], qty))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'id': doc_id, 'status': 'draft'}), 201
|
||||
|
||||
|
||||
@inventory_bp.route('/transfer-docs/<int:doc_id>', methods=['GET'])
|
||||
@require_auth('inventory.view')
|
||||
def get_transfer_doc(doc_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT t.id, t.from_branch_id, b1.name, t.to_branch_id, b2.name, t.status,
|
||||
t.notes, t.created_at, t.sent_at, t.received_at
|
||||
FROM inventory_transfers t
|
||||
JOIN branches b1 ON b1.id = t.from_branch_id
|
||||
JOIN branches b2 ON b2.id = t.to_branch_id
|
||||
WHERE t.id = %s
|
||||
""", (doc_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'not found'}), 404
|
||||
doc = {
|
||||
'id': row[0], 'from_branch_id': row[1], 'from_name': row[2],
|
||||
'to_branch_id': row[3], 'to_name': row[4], 'status': row[5],
|
||||
'notes': row[6], 'created_at': str(row[7]), 'sent_at': str(row[8]),
|
||||
'received_at': str(row[9]), 'lines': [],
|
||||
}
|
||||
cur.execute("""
|
||||
SELECT l.id, l.inventory_id, i.part_number, i.name, l.quantity,
|
||||
COALESCE(l.qty_received, 0), COALESCE(s.stock, 0)
|
||||
FROM inventory_transfer_lines l
|
||||
JOIN inventory i ON i.id = l.inventory_id
|
||||
LEFT JOIN (
|
||||
SELECT inventory_id, SUM(stock) AS stock FROM inventory_stock
|
||||
WHERE branch_id = %s GROUP BY inventory_id
|
||||
) s ON s.inventory_id = l.inventory_id
|
||||
WHERE l.transfer_id = %s ORDER BY l.id
|
||||
""", (doc['to_branch_id'], doc_id))
|
||||
doc['lines'] = [{
|
||||
'id': r[0], 'inventory_id': r[1], 'part_number': r[2], 'name': r[3],
|
||||
'quantity': int(r[4]), 'qty_received': int(r[5] or 0), 'dest_stock': int(r[6] or 0),
|
||||
} for r in cur.fetchall()]
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify(doc)
|
||||
|
||||
|
||||
@inventory_bp.route('/transfer-docs/<int:doc_id>/send', methods=['POST'])
|
||||
@require_auth('inventory.transfer')
|
||||
def send_transfer_doc(doc_id):
|
||||
"""Mark draft as sent (goods leave origin: stock -qty in origin branch)."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT from_branch_id, to_branch_id, status FROM inventory_transfers WHERE id = %s", (doc_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'not found'}), 404
|
||||
if row[2] != 'draft':
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': f'Solo borradores pueden enviarse (estado: {row[2]})'}), 400
|
||||
cur.execute("SELECT inventory_id, quantity FROM inventory_transfer_lines WHERE transfer_id = %s", (doc_id,))
|
||||
lines = cur.fetchall()
|
||||
for inv_id, qty in lines:
|
||||
record_transfer(conn, inv_id, row[0], row[1], qty,
|
||||
notes=f'Transferencia #{doc_id} enviada')
|
||||
cur.execute("""
|
||||
UPDATE inventory_transfers SET status = 'sent', sent_at = NOW() WHERE id = %s
|
||||
""", (doc_id,))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'ok': True, 'status': 'sent'})
|
||||
|
||||
|
||||
@inventory_bp.route('/transfer-docs/<int:doc_id>/receive', methods=['POST'])
|
||||
@require_auth('inventory.transfer')
|
||||
def receive_transfer_doc(doc_id):
|
||||
"""Confirm receipt at destination. Adjusts destination stock to the
|
||||
received quantities (discrepancies become adjustments)."""
|
||||
data = request.get_json() or {}
|
||||
received = {int(k): int(v) for k, v in (data.get('received') or {}).items()}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT to_branch_id, status FROM inventory_transfers WHERE id = %s", (doc_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'not found'}), 404
|
||||
if row[1] != 'sent':
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': f'Solo transferencias enviadas pueden recibirse (estado: {row[1]})'}), 400
|
||||
to_branch = row[0]
|
||||
|
||||
cur.execute("""
|
||||
SELECT l.id, l.inventory_id, l.quantity FROM inventory_transfer_lines l
|
||||
WHERE l.transfer_id = %s
|
||||
""", (doc_id,))
|
||||
lines = cur.fetchall()
|
||||
differences = 0
|
||||
for line_id, inv_id, qty in lines:
|
||||
rec = received.get(line_id)
|
||||
qty_rec = rec if rec is not None else qty
|
||||
cur.execute("UPDATE inventory_transfer_lines SET qty_received = %s WHERE id = %s",
|
||||
(qty_rec, line_id))
|
||||
if qty_rec != qty:
|
||||
differences += 1
|
||||
record_adjustment(conn, inv_id, to_branch, qty_rec - qty,
|
||||
reason=f'Diferencia en recepción de transferencia #{doc_id}')
|
||||
cur.execute("""
|
||||
UPDATE inventory_transfers SET status = 'received', received_at = NOW() WHERE id = %s
|
||||
""", (doc_id,))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'ok': True, 'status': 'received', 'differences': differences})
|
||||
|
||||
|
||||
@inventory_bp.route('/transfer-docs/<int:doc_id>/cancel', methods=['POST'])
|
||||
@require_auth('inventory.transfer')
|
||||
def cancel_transfer_doc(doc_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("UPDATE inventory_transfers SET status = 'cancelled' WHERE id = %s AND status = 'draft'",
|
||||
(doc_id,))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
@inventory_bp.route('/transfer', methods=['POST'])
|
||||
@require_auth('inventory.transfer')
|
||||
def api_transfer():
|
||||
|
||||
Reference in New Issue
Block a user