FASE 4-5-6: Infraestructura, CRM, Service Orders, Notificaciones, Ahorro, Logistica, API Publica
FASE 4: - Redis cache de stock con fallback graceful - Multi-moneda (MXN/USD) con contabilidad en MXN - Proveedores y ordenes de compra completo - Meilisearch 1.5M+ partes indexadas - Metabase KPIs con dashboard auto-generado FASE 5: - CRM mejorado: activities, tags, loyalty program, analytics - Imagenes de partes: upload, resize, thumbnails WebP - Ordenes de servicio Kanban: received->diagnosis->repair->ready->delivered - Garantias/RMA, alertas de reorden, multi-sucursal - Stubs BNPL (APLAZO) y ERP Sync (Aspel/Contpaqi) FASE 6: - Notificaciones automaticas: push/WhatsApp/email/in-app - Reportes de ahorro vs retail_price - Logistica + tracking: DHL, FedEx, Estafeta, 99min, Uber - API Publica: API keys, rate limiting, catalog search Migraciones: v1.9-v3.0 Tests: 93/93 pasando Backup: nexus_backup_20260427_045859.tar.gz
This commit is contained in:
@@ -273,6 +273,10 @@ def update_currency():
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
# Invalidate cached exchange rate so next sale picks up the new value
|
||||
from services.currency import invalidate_rate_cache
|
||||
invalidate_rate_cache()
|
||||
|
||||
return jsonify({'message': 'Currency config updated', 'currency': currency})
|
||||
|
||||
|
||||
|
||||
233
pos/blueprints/crm_bp.py
Normal file
233
pos/blueprints/crm_bp.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""CRM Blueprint: activities, tags, loyalty, analytics.
|
||||
|
||||
Prefixes:
|
||||
/pos/api/customers/<id>/activities
|
||||
/pos/api/customers/<id>/tags
|
||||
/pos/api/customers/<id>/loyalty
|
||||
/pos/api/customers/<id>/analytics
|
||||
/pos/api/tags
|
||||
/pos/api/rewards
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.crm_engine import (
|
||||
log_activity, get_activities,
|
||||
create_tag, list_tags, assign_tag, remove_tag, get_customer_tags,
|
||||
add_loyalty_points, redeem_points, get_loyalty_history,
|
||||
create_reward, list_rewards,
|
||||
get_customer_analytics,
|
||||
)
|
||||
|
||||
crm_bp = Blueprint('crm', __name__, url_prefix='/pos/api')
|
||||
|
||||
|
||||
# ─── Customer Activities ─────────────────────────────
|
||||
|
||||
@crm_bp.route('/customers/<int:customer_id>/activities', methods=['GET'])
|
||||
@require_auth('customers.view')
|
||||
def customer_activities(customer_id):
|
||||
activity_type = request.args.get('type')
|
||||
limit = min(int(request.args.get('limit', 50)), 200)
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
activities = get_activities(conn, customer_id, activity_type=activity_type, limit=limit)
|
||||
return jsonify({'activities': activities})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@crm_bp.route('/customers/<int:customer_id>/activities', methods=['POST'])
|
||||
@require_auth('customers.edit')
|
||||
def add_customer_activity(customer_id):
|
||||
data = request.get_json() or {}
|
||||
activity_type = data.get('activity_type', 'note')
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
activity_id = log_activity(
|
||||
conn, customer_id, activity_type,
|
||||
title=data.get('title'),
|
||||
description=data.get('description'),
|
||||
metadata=data.get('metadata'),
|
||||
employee_id=getattr(g, 'employee_id', None),
|
||||
)
|
||||
return jsonify({'id': activity_id, 'message': 'Activity logged'}), 201
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── Customer Tags ─────────────────────────────
|
||||
|
||||
@crm_bp.route('/tags', methods=['GET'])
|
||||
@require_auth('customers.view')
|
||||
def get_tags():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
tags = list_tags(conn, g.tenant_id)
|
||||
return jsonify({'tags': tags})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@crm_bp.route('/tags', methods=['POST'])
|
||||
@require_auth('customers.edit')
|
||||
def create_new_tag():
|
||||
data = request.get_json() or {}
|
||||
if not data.get('name'):
|
||||
return jsonify({'error': 'name is required'}), 400
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
tag_id = create_tag(conn, g.tenant_id, data['name'],
|
||||
color=data.get('color', '#6B7280'),
|
||||
description=data.get('description'))
|
||||
return jsonify({'id': tag_id, 'message': 'Tag created'}), 201
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@crm_bp.route('/customers/<int:customer_id>/tags', methods=['GET'])
|
||||
@require_auth('customers.view')
|
||||
def get_customer_tags_endpoint(customer_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
tags = get_customer_tags(conn, customer_id)
|
||||
return jsonify({'tags': tags})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@crm_bp.route('/customers/<int:customer_id>/tags', methods=['POST'])
|
||||
@require_auth('customers.edit')
|
||||
def assign_customer_tag(customer_id):
|
||||
data = request.get_json() or {}
|
||||
tag_id = data.get('tag_id')
|
||||
if not tag_id:
|
||||
return jsonify({'error': 'tag_id is required'}), 400
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
assign_tag(conn, customer_id, tag_id, assigned_by=getattr(g, 'employee_id', None))
|
||||
return jsonify({'message': 'Tag assigned'})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@crm_bp.route('/customers/<int:customer_id>/tags/<int:tag_id>', methods=['DELETE'])
|
||||
@require_auth('customers.edit')
|
||||
def remove_customer_tag(customer_id, tag_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
remove_tag(conn, customer_id, tag_id)
|
||||
return jsonify({'message': 'Tag removed'})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── Loyalty ─────────────────────────────
|
||||
|
||||
@crm_bp.route('/customers/<int:customer_id>/loyalty', methods=['GET'])
|
||||
@require_auth('customers.view')
|
||||
def get_loyalty(customer_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
history = get_loyalty_history(conn, customer_id)
|
||||
# Get current balance
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT loyalty_points_balance, loyalty_tier FROM customers WHERE id = %s", (customer_id,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
return jsonify({
|
||||
'balance': row[0] or 0,
|
||||
'tier': row[1] or 'bronze',
|
||||
'history': history,
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@crm_bp.route('/customers/<int:customer_id>/loyalty/add', methods=['POST'])
|
||||
@require_auth('customers.edit')
|
||||
def add_loyalty(customer_id):
|
||||
data = request.get_json() or {}
|
||||
points = int(data.get('points', 0))
|
||||
if points <= 0:
|
||||
return jsonify({'error': 'points must be > 0'}), 400
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
point_id = add_loyalty_points(
|
||||
conn, customer_id, points,
|
||||
points_type=data.get('points_type', 'earned'),
|
||||
source_type=data.get('source_type'),
|
||||
source_id=data.get('source_id'),
|
||||
description=data.get('description'),
|
||||
expires_at=data.get('expires_at'),
|
||||
)
|
||||
return jsonify({'id': point_id, 'message': f'{points} points added'}), 201
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@crm_bp.route('/customers/<int:customer_id>/loyalty/redeem', methods=['POST'])
|
||||
@require_auth('customers.edit')
|
||||
def redeem_loyalty(customer_id):
|
||||
data = request.get_json() or {}
|
||||
points = int(data.get('points', 0))
|
||||
if points <= 0:
|
||||
return jsonify({'error': 'points must be > 0'}), 400
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
redemption_id = redeem_points(
|
||||
conn, customer_id, points,
|
||||
reward_id=data.get('reward_id'),
|
||||
reward_value=data.get('reward_value'),
|
||||
description=data.get('description'),
|
||||
employee_id=getattr(g, 'employee_id', None),
|
||||
)
|
||||
return jsonify({'id': redemption_id, 'message': f'{points} points redeemed'}), 201
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
|
||||
|
||||
# ─── Rewards Catalog ─────────────────────────────
|
||||
|
||||
@crm_bp.route('/rewards', methods=['GET'])
|
||||
@require_auth('customers.view')
|
||||
def get_rewards():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
rewards = list_rewards(conn, g.tenant_id)
|
||||
return jsonify({'rewards': rewards})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@crm_bp.route('/rewards', methods=['POST'])
|
||||
@require_auth('customers.edit')
|
||||
def create_new_reward():
|
||||
data = request.get_json() or {}
|
||||
if not data.get('name') or data.get('points_cost') is None:
|
||||
return jsonify({'error': 'name and points_cost are required'}), 400
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
reward_id = create_reward(
|
||||
conn, g.tenant_id, data['name'], int(data['points_cost']),
|
||||
reward_type=data.get('reward_type', 'discount'),
|
||||
reward_value=data.get('reward_value'),
|
||||
description=data.get('description'),
|
||||
)
|
||||
return jsonify({'id': reward_id, 'message': 'Reward created'}), 201
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── Customer Analytics ─────────────────────────────
|
||||
|
||||
@crm_bp.route('/customers/<int:customer_id>/analytics', methods=['GET'])
|
||||
@require_auth('customers.view')
|
||||
def customer_analytics(customer_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
analytics = get_customer_analytics(conn, customer_id)
|
||||
return jsonify(analytics)
|
||||
finally:
|
||||
conn.close()
|
||||
136
pos/blueprints/image_bp.py
Normal file
136
pos/blueprints/image_bp.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""Image Blueprint: part image upload and management.
|
||||
|
||||
Prefix: /pos/api/inventory
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify, g, send_from_directory
|
||||
from middleware import require_auth
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.image_service import save_image, delete_image, get_image_info
|
||||
import os
|
||||
|
||||
image_bp = Blueprint('images', __name__, url_prefix='/pos/api/inventory')
|
||||
|
||||
|
||||
@image_bp.route('/items/<int:item_id>/image', methods=['POST'])
|
||||
@require_auth()
|
||||
def upload_item_image(item_id):
|
||||
"""Upload an image for an inventory item.
|
||||
|
||||
Supports multipart/form-data with 'image' file, or JSON with 'image_url'.
|
||||
"""
|
||||
tenant_id = g.tenant_id
|
||||
|
||||
# Check if item exists
|
||||
conn = get_tenant_conn(tenant_id)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT id FROM inventory WHERE id = %s", (item_id,))
|
||||
if not cur.fetchone():
|
||||
cur.close()
|
||||
return jsonify({'error': 'Inventory item not found'}), 404
|
||||
cur.close()
|
||||
|
||||
file_obj = None
|
||||
image_url = None
|
||||
filename_hint = None
|
||||
|
||||
if 'image' in request.files:
|
||||
file = request.files['image']
|
||||
if file.filename:
|
||||
file_obj = file.stream
|
||||
filename_hint = file.filename
|
||||
elif request.is_json:
|
||||
data = request.get_json() or {}
|
||||
image_url = data.get('image_url')
|
||||
|
||||
if not file_obj and not image_url:
|
||||
return jsonify({'error': 'No image provided. Upload via multipart "image" field or JSON "image_url"'}), 400
|
||||
|
||||
result = save_image(tenant_id, item_id, file_obj=file_obj,
|
||||
image_url=image_url, filename_hint=filename_hint)
|
||||
|
||||
# Update inventory.image_url
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
UPDATE inventory SET image_url = %s WHERE id = %s
|
||||
""", (result['image_url'], item_id))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
|
||||
return jsonify(result), 201
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@image_bp.route('/items/<int:item_id>/image', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_item_image(item_id):
|
||||
"""Get image info for an inventory item."""
|
||||
tenant_id = g.tenant_id
|
||||
info = get_image_info(tenant_id, item_id)
|
||||
return jsonify(info)
|
||||
|
||||
|
||||
@image_bp.route('/items/<int:item_id>/image', methods=['DELETE'])
|
||||
@require_auth()
|
||||
def delete_item_image(item_id):
|
||||
"""Delete the image for an inventory item."""
|
||||
tenant_id = g.tenant_id
|
||||
result = delete_image(tenant_id, item_id)
|
||||
|
||||
conn = get_tenant_conn(tenant_id)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("UPDATE inventory SET image_url = NULL WHERE id = %s", (item_id,))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return jsonify({'message': 'Image deleted', 'deleted': result['deleted']})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@image_bp.route('/images/bulk', methods=['POST'])
|
||||
@require_auth()
|
||||
def bulk_import_images():
|
||||
"""Bulk import images from a list of {item_id, image_url} objects.
|
||||
|
||||
Body: {"items": [{"item_id": 1, "image_url": "https://..."}, ...]}
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
items = data.get('items', [])
|
||||
if not items:
|
||||
return jsonify({'error': 'items array is required'}), 400
|
||||
|
||||
tenant_id = g.tenant_id
|
||||
results = {'successful': [], 'failed': []}
|
||||
|
||||
conn = get_tenant_conn(tenant_id)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
for item in items:
|
||||
item_id = item.get('item_id')
|
||||
image_url = item.get('image_url')
|
||||
if not item_id or not image_url:
|
||||
results['failed'].append({'item_id': item_id, 'error': 'Missing item_id or image_url'})
|
||||
continue
|
||||
|
||||
# Verify item exists
|
||||
cur.execute("SELECT id FROM inventory WHERE id = %s", (item_id,))
|
||||
if not cur.fetchone():
|
||||
results['failed'].append({'item_id': item_id, 'error': 'Item not found'})
|
||||
continue
|
||||
|
||||
try:
|
||||
result = save_image(tenant_id, item_id, image_url=image_url)
|
||||
cur.execute("UPDATE inventory SET image_url = %s WHERE id = %s",
|
||||
(result['image_url'], item_id))
|
||||
results['successful'].append({'item_id': item_id, 'image_url': result['image_url']})
|
||||
except Exception as e:
|
||||
results['failed'].append({'item_id': item_id, 'error': str(e)})
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return jsonify(results)
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -1068,3 +1068,179 @@ def api_generate_barcode():
|
||||
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)
|
||||
|
||||
131
pos/blueprints/logistics_bp.py
Normal file
131
pos/blueprints/logistics_bp.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""Logistics Blueprint: shipments, couriers, tracking.
|
||||
|
||||
Prefix: /pos/api/logistics
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.logistics_engine import (
|
||||
create_shipment, get_shipment, list_shipments, update_shipment_status,
|
||||
get_couriers, add_courier,
|
||||
)
|
||||
|
||||
logistics_bp = Blueprint('logistics', __name__, url_prefix='/pos/api/logistics')
|
||||
|
||||
|
||||
@logistics_bp.route('/shipments', methods=['GET'])
|
||||
@require_auth()
|
||||
def list_all_shipments():
|
||||
status = request.args.get('status')
|
||||
courier_id = request.args.get('courier_id', type=int)
|
||||
related_type = request.args.get('related_type')
|
||||
related_id = request.args.get('related_id', type=int)
|
||||
page = int(request.args.get('page', 1))
|
||||
per_page = min(int(request.args.get('per_page', 50)), 200)
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = list_shipments(
|
||||
conn, g.tenant_id, status=status, courier_id=courier_id,
|
||||
related_type=related_type, related_id=related_id,
|
||||
page=page, per_page=per_page,
|
||||
)
|
||||
return jsonify(result)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@logistics_bp.route('/shipments', methods=['POST'])
|
||||
@require_auth()
|
||||
def create_new_shipment():
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = create_shipment(conn, {
|
||||
'tenant_id': g.tenant_id,
|
||||
'branch_id': data.get('branch_id', g.branch_id),
|
||||
'shipment_type': data.get('shipment_type', 'outbound'),
|
||||
'related_type': data.get('related_type'),
|
||||
'related_id': data.get('related_id'),
|
||||
'courier_id': data.get('courier_id'),
|
||||
'tracking_number': data.get('tracking_number'),
|
||||
'origin_address': data.get('origin_address'),
|
||||
'destination_address': data.get('destination_address'),
|
||||
'recipient_name': data.get('recipient_name'),
|
||||
'recipient_phone': data.get('recipient_phone'),
|
||||
'estimated_delivery': data.get('estimated_delivery'),
|
||||
'shipping_cost': data.get('shipping_cost'),
|
||||
'weight_kg': data.get('weight_kg'),
|
||||
'dimensions_cm': data.get('dimensions_cm'),
|
||||
'notes': data.get('notes'),
|
||||
'created_by': getattr(g, 'employee_id', None),
|
||||
})
|
||||
return jsonify(result), 201
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@logistics_bp.route('/shipments/<int:shipment_id>', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_shipment_detail(shipment_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
shipment = get_shipment(conn, shipment_id)
|
||||
if not shipment:
|
||||
return jsonify({'error': 'Shipment not found'}), 404
|
||||
return jsonify(shipment)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@logistics_bp.route('/shipments/<int:shipment_id>/status', methods=['PUT'])
|
||||
@require_auth()
|
||||
def update_status_endpoint(shipment_id):
|
||||
data = request.get_json() or {}
|
||||
new_status = data.get('status')
|
||||
if not new_status:
|
||||
return jsonify({'error': 'status is required'}), 400
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = update_shipment_status(
|
||||
conn, shipment_id, new_status,
|
||||
location=data.get('location'),
|
||||
description=data.get('description'),
|
||||
raw_response=data.get('raw_response'),
|
||||
)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@logistics_bp.route('/couriers', methods=['GET'])
|
||||
@require_auth()
|
||||
def list_couriers():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
couriers = get_couriers(conn, g.tenant_id)
|
||||
return jsonify({'couriers': couriers})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@logistics_bp.route('/couriers', methods=['POST'])
|
||||
@require_auth()
|
||||
def create_courier():
|
||||
data = request.get_json() or {}
|
||||
if not data.get('name') or not data.get('code'):
|
||||
return jsonify({'error': 'name and code are required'}), 400
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cid = add_courier(
|
||||
conn, g.tenant_id, data['name'], data['code'],
|
||||
tracking_url_template=data.get('tracking_url_template'),
|
||||
api_endpoint=data.get('api_endpoint'),
|
||||
is_active=data.get('is_active', True),
|
||||
)
|
||||
return jsonify({'id': cid, 'message': 'Courier created'}), 201
|
||||
finally:
|
||||
conn.close()
|
||||
136
pos/blueprints/notification_bp.py
Normal file
136
pos/blueprints/notification_bp.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""Notification Blueprint: templates, logs, preferences.
|
||||
|
||||
Prefix: /pos/api/notifications
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.notification_engine import (
|
||||
get_templates, create_template, update_template,
|
||||
dispatch_notification, get_notification_logs, mark_as_read,
|
||||
notify_low_stock, notify_order_ready, notify_maintenance_due,
|
||||
notify_new_sale, notify_po_received,
|
||||
)
|
||||
|
||||
notification_bp = Blueprint('notifications', __name__, url_prefix='/pos/api/notifications')
|
||||
|
||||
|
||||
@notification_bp.route('/templates', methods=['GET'])
|
||||
@require_auth()
|
||||
def list_templates():
|
||||
event_type = request.args.get('event_type')
|
||||
channel = request.args.get('channel')
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
templates = get_templates(conn, g.tenant_id, event_type=event_type, channel=channel)
|
||||
return jsonify({'templates': templates})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@notification_bp.route('/templates', methods=['POST'])
|
||||
@require_auth()
|
||||
def create_new_template():
|
||||
data = request.get_json() or {}
|
||||
required = ['event_type', 'channel', 'name', 'body_template']
|
||||
for field in required:
|
||||
if not data.get(field):
|
||||
return jsonify({'error': f'{field} is required'}), 400
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
tid = create_template(
|
||||
conn, g.tenant_id, data['event_type'], data['channel'], data['name'],
|
||||
data['body_template'], subject_template=data.get('subject_template'),
|
||||
is_active=data.get('is_active', True),
|
||||
)
|
||||
return jsonify({'id': tid, 'message': 'Template created'}), 201
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@notification_bp.route('/templates/<int:template_id>', methods=['PUT'])
|
||||
@require_auth()
|
||||
def update_existing_template(template_id):
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
ok = update_template(conn, template_id, data)
|
||||
if not ok:
|
||||
return jsonify({'error': 'No fields to update'}), 400
|
||||
return jsonify({'message': 'Template updated'})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@notification_bp.route('/logs', methods=['GET'])
|
||||
@require_auth()
|
||||
def list_logs():
|
||||
recipient_type = request.args.get('recipient_type')
|
||||
recipient_id = request.args.get('recipient_id', type=int)
|
||||
status = request.args.get('status')
|
||||
limit = min(int(request.args.get('limit', 50)), 200)
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
logs = get_notification_logs(
|
||||
conn, g.tenant_id, recipient_type=recipient_type,
|
||||
recipient_id=recipient_id, status=status, limit=limit,
|
||||
)
|
||||
return jsonify({'logs': logs})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@notification_bp.route('/logs/<int:log_id>/read', methods=['PUT'])
|
||||
@require_auth()
|
||||
def mark_log_read(log_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
mark_as_read(conn, log_id)
|
||||
return jsonify({'message': 'Marked as read'})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@notification_bp.route('/dispatch', methods=['POST'])
|
||||
@require_auth()
|
||||
def manual_dispatch():
|
||||
"""Manually dispatch a notification."""
|
||||
data = request.get_json() or {}
|
||||
event_type = data.get('event_type')
|
||||
context = data.get('context', {})
|
||||
if not event_type:
|
||||
return jsonify({'error': 'event_type is required'}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
log_ids = dispatch_notification(
|
||||
conn, g.tenant_id, event_type, context,
|
||||
recipient_type=data.get('recipient_type', 'owner'),
|
||||
recipient_id=data.get('recipient_id'),
|
||||
channels=data.get('channels'),
|
||||
)
|
||||
return jsonify({'log_ids': log_ids, 'message': 'Notification dispatched'})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── Convenience endpoints ─────────────────────────────
|
||||
|
||||
@notification_bp.route('/test/low-stock', methods=['POST'])
|
||||
@require_auth()
|
||||
def test_low_stock():
|
||||
data = request.get_json() or {}
|
||||
inventory_id = data.get('inventory_id')
|
||||
if not inventory_id:
|
||||
return jsonify({'error': 'inventory_id required'}), 400
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
log_ids = notify_low_stock(
|
||||
conn, g.tenant_id, inventory_id,
|
||||
stock=data.get('stock', 0),
|
||||
reorder_point=data.get('reorder_point', 5),
|
||||
)
|
||||
return jsonify({'log_ids': log_ids, 'message': 'Low stock notification sent'})
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -22,8 +22,29 @@ pos_bp = Blueprint('pos', __name__, url_prefix='/pos/api')
|
||||
def _enrich_items(cur, items, customer_id=None):
|
||||
"""Look up inventory data for items that lack unit_price/tax_rate.
|
||||
|
||||
Uses batch queries to avoid N+1 performance issues.
|
||||
Returns list of dicts with all fields needed by calculate_totals.
|
||||
"""
|
||||
inv_ids = [item.get('inventory_id') for item in items if item.get('inventory_id')]
|
||||
if not inv_ids:
|
||||
raise ValueError("No valid inventory items provided")
|
||||
|
||||
# Batch fetch all inventory items in one query
|
||||
cur.execute("""
|
||||
SELECT id, part_number, name, cost, price_1, price_2, price_3,
|
||||
tax_rate, branch_id
|
||||
FROM inventory WHERE id = ANY(%s) AND is_active = true
|
||||
""", (inv_ids,))
|
||||
inv_map = {r[0]: r for r in cur.fetchall()}
|
||||
|
||||
# Fetch customer price tier once (if provided)
|
||||
price_tier = 1
|
||||
if customer_id:
|
||||
cur.execute("SELECT price_tier FROM customers WHERE id = %s", (customer_id,))
|
||||
cust = cur.fetchone()
|
||||
if cust and cust[0]:
|
||||
price_tier = int(cust[0])
|
||||
|
||||
enriched = []
|
||||
for item in items:
|
||||
inv_id = item.get('inventory_id')
|
||||
@@ -31,23 +52,10 @@ def _enrich_items(cur, items, customer_id=None):
|
||||
if qty <= 0:
|
||||
raise ValueError(f"Invalid quantity for inventory_id {inv_id}")
|
||||
|
||||
cur.execute("""
|
||||
SELECT id, part_number, name, cost, price_1, price_2, price_3,
|
||||
tax_rate, branch_id
|
||||
FROM inventory WHERE id = %s AND is_active = true
|
||||
""", (inv_id,))
|
||||
inv = cur.fetchone()
|
||||
inv = inv_map.get(inv_id)
|
||||
if not inv:
|
||||
raise ValueError(f"Inventory item {inv_id} not found or inactive")
|
||||
|
||||
# Determine price tier from customer if provided
|
||||
price_tier = 1
|
||||
if customer_id:
|
||||
cur.execute("SELECT price_tier FROM customers WHERE id = %s", (customer_id,))
|
||||
cust = cur.fetchone()
|
||||
if cust and cust[0]:
|
||||
price_tier = int(cust[0])
|
||||
|
||||
# price_1=inv[4], price_2=inv[5], price_3=inv[6]
|
||||
tier_prices = {1: inv[4], 2: inv[5], 3: inv[6]}
|
||||
default_price = float(tier_prices.get(price_tier, inv[4]) or inv[4])
|
||||
@@ -85,7 +93,9 @@ def create_sale():
|
||||
register_id: int,
|
||||
amount_paid: float,
|
||||
payment_details: [{method, amount, reference}], (for mixed payments)
|
||||
notes: str
|
||||
notes: str,
|
||||
currency: 'MXN' | 'USD' (default 'MXN'),
|
||||
exchange_rate: float (optional, auto-fetched from tenant config if omitted)
|
||||
}
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
@@ -402,7 +412,9 @@ def create_quotation():
|
||||
items: [{inventory_id, quantity, unit_price, discount_pct, tax_rate}],
|
||||
customer_id: int | null,
|
||||
valid_days: int (default 7),
|
||||
notes: str
|
||||
notes: str,
|
||||
currency: 'MXN' | 'USD' (default 'MXN'),
|
||||
exchange_rate: float (optional, auto-fetched if not provided)
|
||||
}
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
@@ -426,17 +438,29 @@ def create_quotation():
|
||||
valid_days = int(data.get('valid_days', 7))
|
||||
valid_until = (date.today() + timedelta(days=valid_days)).isoformat()
|
||||
|
||||
# Multi-currency for quotations
|
||||
from services.currency import get_exchange_rate
|
||||
currency = data.get('currency', 'MXN')
|
||||
if currency not in ('MXN', 'USD'):
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': f'Unsupported currency: {currency}'}), 400
|
||||
exchange_rate = data.get('exchange_rate')
|
||||
if currency != 'MXN' and exchange_rate is None:
|
||||
exchange_rate = float(get_exchange_rate(conn, currency, 'MXN'))
|
||||
exchange_rate = float(exchange_rate) if exchange_rate else 1.0
|
||||
|
||||
try:
|
||||
cur.execute("""
|
||||
INSERT INTO quotations
|
||||
(branch_id, customer_id, employee_id, subtotal,
|
||||
tax_total, total, status, valid_until, notes)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,'active',%s,%s)
|
||||
tax_total, total, status, valid_until, notes, currency, exchange_rate)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,'active',%s,%s,%s,%s)
|
||||
RETURNING id, created_at
|
||||
""", (
|
||||
g.branch_id, data.get('customer_id'), g.employee_id,
|
||||
totals['subtotal'], totals['tax_total'],
|
||||
totals['total'], valid_until, data.get('notes')
|
||||
totals['total'], valid_until, data.get('notes'),
|
||||
currency, exchange_rate
|
||||
))
|
||||
quot_id, created_at = cur.fetchone()
|
||||
|
||||
@@ -452,12 +476,13 @@ def create_quotation():
|
||||
cur.execute("""
|
||||
INSERT INTO quotation_items
|
||||
(quotation_id, inventory_id, part_number, name, quantity,
|
||||
unit_price, discount_pct, tax_rate, subtotal)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
unit_price, discount_pct, tax_rate, subtotal, currency, exchange_rate)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
""", (
|
||||
quot_id, item['inventory_id'], part_number, name,
|
||||
item['quantity'], item['unit_price'], item['discount_pct'],
|
||||
item['tax_rate'], line_subtotal
|
||||
item['tax_rate'], line_subtotal,
|
||||
currency, exchange_rate
|
||||
))
|
||||
|
||||
log_action(conn, 'QUOTATION_CREATE', 'quotation', quot_id,
|
||||
@@ -930,8 +955,8 @@ def convert_quotation(quot_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
# Get quotation
|
||||
cur.execute("SELECT id, customer_id, status FROM quotations WHERE id = %s", (quot_id,))
|
||||
# Get quotation (include currency)
|
||||
cur.execute("SELECT id, customer_id, status, currency, exchange_rate FROM quotations WHERE id = %s", (quot_id,))
|
||||
quot = cur.fetchone()
|
||||
if not quot:
|
||||
cur.close(); conn.close()
|
||||
@@ -940,6 +965,9 @@ def convert_quotation(quot_id):
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': f'Quotation is {quot[2]}, cannot convert'}), 400
|
||||
|
||||
quot_currency = quot[3] or 'MXN'
|
||||
quot_rate = quot[4] or 1.0
|
||||
|
||||
# Get quotation items
|
||||
cur.execute("""
|
||||
SELECT inventory_id, quantity, unit_price, discount_pct, tax_rate
|
||||
@@ -953,7 +981,7 @@ def convert_quotation(quot_id):
|
||||
'tax_rate': float(r[4]) if r[4] else 0.16,
|
||||
})
|
||||
|
||||
# Build sale_data
|
||||
# Build sale_data (preserve quotation currency)
|
||||
sale_data = {
|
||||
'items': items,
|
||||
'customer_id': quot[1],
|
||||
@@ -963,6 +991,8 @@ def convert_quotation(quot_id):
|
||||
'amount_paid': data.get('amount_paid', 0),
|
||||
'payment_details': data.get('payment_details', []),
|
||||
'notes': f'Convertida de cotizacion #{quot_id}',
|
||||
'currency': quot_currency,
|
||||
'exchange_rate': quot_rate,
|
||||
}
|
||||
|
||||
try:
|
||||
|
||||
199
pos/blueprints/public_api_bp.py
Normal file
199
pos/blueprints/public_api_bp.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""Public API Blueprint: API key management and public endpoints.
|
||||
|
||||
Prefix: /api/v1
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth
|
||||
from tenant_db import get_tenant_conn, get_master_conn
|
||||
from services.public_api_engine import (
|
||||
create_api_key, validate_api_key, check_rate_limit, increment_rate_limit,
|
||||
log_api_request, list_api_keys, revoke_api_key, delete_api_key,
|
||||
)
|
||||
|
||||
public_api_bp = Blueprint('public_api', __name__, url_prefix='/api/v1')
|
||||
|
||||
|
||||
# ─── Admin endpoints (require auth) ─────────────────────────────
|
||||
|
||||
@public_api_bp.route('/keys', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_keys():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
keys = list_api_keys(conn, g.tenant_id)
|
||||
return jsonify({'keys': keys})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@public_api_bp.route('/keys', methods=['POST'])
|
||||
@require_auth()
|
||||
def create_key():
|
||||
data = request.get_json() or {}
|
||||
if not data.get('name'):
|
||||
return jsonify({'error': 'name is required'}), 400
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
key_id, full_key = create_api_key(
|
||||
conn, g.tenant_id, data['name'],
|
||||
scopes=data.get('scopes', ['read']),
|
||||
rate_limit_rpm=data.get('rate_limit_rpm', 60),
|
||||
rate_limit_rpd=data.get('rate_limit_rpd', 10000),
|
||||
created_by=getattr(g, 'employee_id', None),
|
||||
expires_at=data.get('expires_at'),
|
||||
)
|
||||
return jsonify({
|
||||
'id': key_id,
|
||||
'api_key': full_key, # Only shown once!
|
||||
'message': 'Store this key safely — it will not be shown again',
|
||||
}), 201
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@public_api_bp.route('/keys/<int:key_id>/revoke', methods=['PUT'])
|
||||
@require_auth()
|
||||
def revoke_key(key_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
revoke_api_key(conn, key_id)
|
||||
return jsonify({'message': 'API key revoked'})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@public_api_bp.route('/keys/<int:key_id>', methods=['DELETE'])
|
||||
@require_auth()
|
||||
def delete_key(key_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
delete_api_key(conn, key_id)
|
||||
return jsonify({'message': 'API key deleted'})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── Public endpoints (API key auth) ─────────────────────────────
|
||||
|
||||
def _require_api_key():
|
||||
"""Decorator-like helper to validate API key from header."""
|
||||
api_key = request.headers.get('X-API-Key') or request.headers.get('Authorization', '').replace('Bearer ', '')
|
||||
if not api_key:
|
||||
return None, {'error': 'API key required. Provide via X-API-Key header or Authorization: Bearer <key>'}, 401
|
||||
|
||||
conn = get_master_conn()
|
||||
try:
|
||||
key_info = validate_api_key(conn, api_key)
|
||||
if not key_info or not key_info.get('valid'):
|
||||
reason = key_info.get('reason', 'invalid') if key_info else 'invalid'
|
||||
return None, {'error': f'API key {reason}'}, 401
|
||||
|
||||
# Check rate limit
|
||||
allowed, headers = check_rate_limit(conn, key_info['key_id'], key_info['rate_limit_rpm'], key_info['rate_limit_rpd'])
|
||||
if not allowed:
|
||||
return None, {'error': 'Rate limit exceeded'}, 429, headers
|
||||
|
||||
increment_rate_limit(conn, key_info['key_id'])
|
||||
return key_info, None, None, headers
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@public_api_bp.route('/health', methods=['GET'])
|
||||
def public_health():
|
||||
return jsonify({'status': 'ok', 'service': 'Nexus Public API'})
|
||||
|
||||
|
||||
@public_api_bp.route('/catalog/search', methods=['GET'])
|
||||
def public_catalog_search():
|
||||
key_info, error, status, *extra = _require_api_key()
|
||||
if error:
|
||||
headers = extra[0] if extra else {}
|
||||
return jsonify(error), status, headers
|
||||
|
||||
q = request.args.get('q', '').strip()
|
||||
limit = min(int(request.args.get('limit', 50)), 200)
|
||||
if not q or len(q) < 2:
|
||||
return jsonify({'error': 'Query must be at least 2 characters'}), 400
|
||||
|
||||
start_time = __import__('time').time()
|
||||
conn = get_tenant_conn(key_info['tenant_id'])
|
||||
try:
|
||||
from services.catalog_service import smart_search
|
||||
master = get_master_conn()
|
||||
try:
|
||||
results = smart_search(master, q, conn, branch_id=None, limit=limit)
|
||||
finally:
|
||||
master.close()
|
||||
|
||||
response_time_ms = int((__import__('time').time() - start_time) * 1000)
|
||||
|
||||
# Log the request
|
||||
master2 = get_master_conn()
|
||||
try:
|
||||
log_api_request(
|
||||
master2, key_info['key_id'], key_info['tenant_id'],
|
||||
request.method, request.path, 200,
|
||||
response_time_ms, request.remote_addr,
|
||||
request.headers.get('User-Agent'),
|
||||
)
|
||||
finally:
|
||||
master2.close()
|
||||
|
||||
headers = extra[0] if extra else {}
|
||||
return jsonify({
|
||||
'query': q,
|
||||
'results': results,
|
||||
'count': len(results),
|
||||
}), 200, headers
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@public_api_bp.route('/catalog/parts/<int:part_id>', methods=['GET'])
|
||||
def public_part_detail(part_id):
|
||||
key_info, error, status, *extra = _require_api_key()
|
||||
if error:
|
||||
headers = extra[0] if extra else {}
|
||||
return jsonify(error), status, headers
|
||||
|
||||
start_time = __import__('time').time()
|
||||
conn = get_tenant_conn(key_info['tenant_id'])
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT id, part_number, name, brand, price_1, price_2, price_3,
|
||||
image_url, description, is_active
|
||||
FROM inventory WHERE id = %s AND is_active = true
|
||||
""", (part_id,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
|
||||
if not row:
|
||||
return jsonify({'error': 'Part not found'}), 404
|
||||
|
||||
part = {
|
||||
'id': row[0], 'part_number': row[1], 'name': row[2],
|
||||
'brand': row[3], 'price_1': float(row[4]) if row[4] else None,
|
||||
'price_2': float(row[5]) if row[5] else None,
|
||||
'price_3': float(row[6]) if row[6] else None,
|
||||
'image_url': row[7], 'description': row[8], 'is_active': row[9],
|
||||
}
|
||||
|
||||
response_time_ms = int((__import__('time').time() - start_time) * 1000)
|
||||
master = get_master_conn()
|
||||
try:
|
||||
log_api_request(
|
||||
master, key_info['key_id'], key_info['tenant_id'],
|
||||
request.method, request.path, 200,
|
||||
response_time_ms, request.remote_addr,
|
||||
request.headers.get('User-Agent'),
|
||||
)
|
||||
finally:
|
||||
master.close()
|
||||
|
||||
headers = extra[0] if extra else {}
|
||||
return jsonify(part), 200, headers
|
||||
finally:
|
||||
conn.close()
|
||||
108
pos/blueprints/savings_bp.py
Normal file
108
pos/blueprints/savings_bp.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Savings Blueprint: retail price management and savings reports.
|
||||
|
||||
Prefix: /pos/api/savings
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.savings_engine import (
|
||||
get_customer_savings_report, get_global_savings_stats, calculate_item_savings,
|
||||
)
|
||||
|
||||
savings_bp = Blueprint('savings', __name__, url_prefix='/pos/api/savings')
|
||||
|
||||
|
||||
@savings_bp.route('/inventory/<int:item_id>/retail-price', methods=['PUT'])
|
||||
@require_auth()
|
||||
def set_retail_price(item_id):
|
||||
data = request.get_json() or {}
|
||||
retail_price = data.get('retail_price')
|
||||
if retail_price is None:
|
||||
return jsonify({'error': 'retail_price is required'}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
UPDATE inventory SET retail_price = %s WHERE id = %s
|
||||
""", (retail_price, item_id))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return jsonify({'message': 'Retail price updated'})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@savings_bp.route('/inventory/bulk-retail-price', methods=['POST'])
|
||||
@require_auth()
|
||||
def bulk_set_retail_price():
|
||||
"""Bulk update retail prices from CSV/JSON.
|
||||
|
||||
Body: {"items": [{"item_id": 1, "retail_price": 100.00}, ...]}
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
items = data.get('items', [])
|
||||
if not items:
|
||||
return jsonify({'error': 'items array is required'}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
updated = 0
|
||||
for item in items:
|
||||
item_id = item.get('item_id')
|
||||
retail_price = item.get('retail_price')
|
||||
if item_id and retail_price is not None:
|
||||
cur.execute("""
|
||||
UPDATE inventory SET retail_price = %s WHERE id = %s
|
||||
""", (retail_price, item_id))
|
||||
updated += cur.rowcount
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return jsonify({'updated': updated, 'message': f'{updated} prices updated'})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@savings_bp.route('/customers/<int:customer_id>', methods=['GET'])
|
||||
@require_auth()
|
||||
def customer_savings(customer_id):
|
||||
months = int(request.args.get('months', 12))
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
report = get_customer_savings_report(conn, customer_id, months=months)
|
||||
return jsonify(report)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@savings_bp.route('/stats', methods=['GET'])
|
||||
@require_auth()
|
||||
def global_savings():
|
||||
from_date = request.args.get('from_date')
|
||||
to_date = request.args.get('to_date')
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
stats = get_global_savings_stats(conn, g.tenant_id, from_date=from_date, to_date=to_date)
|
||||
return jsonify(stats)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@savings_bp.route('/calculate', methods=['POST'])
|
||||
@require_auth()
|
||||
def calculate_savings():
|
||||
"""Calculate savings for a given price vs retail."""
|
||||
data = request.get_json() or {}
|
||||
unit_price = float(data.get('unit_price', 0))
|
||||
retail_price = float(data.get('retail_price', 0))
|
||||
quantity = int(data.get('quantity', 1))
|
||||
savings, pct = calculate_item_savings(unit_price, retail_price, quantity)
|
||||
return jsonify({
|
||||
'unit_price': unit_price,
|
||||
'retail_price': retail_price,
|
||||
'quantity': quantity,
|
||||
'savings_amount': savings,
|
||||
'savings_percentage': pct,
|
||||
})
|
||||
204
pos/blueprints/service_order_bp.py
Normal file
204
pos/blueprints/service_order_bp.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""Service Order Blueprint: workshop Kanban management.
|
||||
|
||||
Prefix: /pos/api/service-orders
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.service_order_engine import (
|
||||
create_service_order, get_service_order, list_service_orders,
|
||||
update_status, add_item, update_item, remove_item,
|
||||
add_labor, update_labor, remove_labor,
|
||||
update_service_order, get_kanban_summary,
|
||||
)
|
||||
|
||||
service_order_bp = Blueprint('service_orders', __name__, url_prefix='/pos/api/service-orders')
|
||||
|
||||
|
||||
@service_order_bp.route('', methods=['GET'])
|
||||
@require_auth()
|
||||
def list_orders():
|
||||
status = request.args.get('status')
|
||||
priority = request.args.get('priority')
|
||||
customer_id = request.args.get('customer_id', type=int)
|
||||
employee_id = request.args.get('employee_id', type=int)
|
||||
page = int(request.args.get('page', 1))
|
||||
per_page = min(int(request.args.get('per_page', 50)), 200)
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = list_service_orders(
|
||||
conn, status=status, branch_id=g.branch_id,
|
||||
customer_id=customer_id, priority=priority,
|
||||
employee_id=employee_id, page=page, per_page=per_page
|
||||
)
|
||||
return jsonify(result)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@service_order_bp.route('', methods=['POST'])
|
||||
@require_auth()
|
||||
def create_order():
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = create_service_order(conn, {
|
||||
'tenant_id': g.tenant_id,
|
||||
'branch_id': data.get('branch_id', g.branch_id),
|
||||
'customer_id': data.get('customer_id'),
|
||||
'vehicle_id': data.get('vehicle_id'),
|
||||
'priority': data.get('priority', 'normal'),
|
||||
'reception_notes': data.get('reception_notes'),
|
||||
'estimated_cost': data.get('estimated_cost'),
|
||||
'estimated_completion': data.get('estimated_completion'),
|
||||
'employee_id': data.get('employee_id'),
|
||||
'mileage_in': data.get('mileage_in'),
|
||||
'fuel_level': data.get('fuel_level'),
|
||||
'created_by': getattr(g, 'employee_id', None),
|
||||
})
|
||||
return jsonify(result), 201
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_order(so_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
order = get_service_order(conn, so_id)
|
||||
if not order:
|
||||
return jsonify({'error': 'Service order not found'}), 404
|
||||
return jsonify(order)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>', methods=['PUT'])
|
||||
@require_auth()
|
||||
def update_order(so_id):
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
ok = update_service_order(conn, so_id, data)
|
||||
if not ok:
|
||||
return jsonify({'error': 'No fields to update'}), 400
|
||||
return jsonify({'message': 'Service order updated'})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/status', methods=['PUT'])
|
||||
@require_auth()
|
||||
def change_status(so_id):
|
||||
data = request.get_json() or {}
|
||||
new_status = data.get('status')
|
||||
if not new_status:
|
||||
return jsonify({'error': 'status is required'}), 400
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = update_status(
|
||||
conn, so_id, new_status,
|
||||
changed_by=getattr(g, 'employee_id', None),
|
||||
notes=data.get('notes'),
|
||||
)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── Items (Parts) ─────────────────────────────
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/items', methods=['POST'])
|
||||
@require_auth()
|
||||
def add_order_item(so_id):
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
item_id = add_item(conn, so_id, data)
|
||||
return jsonify({'id': item_id, 'message': 'Item added'}), 201
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@service_order_bp.route('/items/<int:item_id>', methods=['PUT'])
|
||||
@require_auth()
|
||||
def update_order_item(item_id):
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
ok = update_item(conn, item_id, data)
|
||||
if not ok:
|
||||
return jsonify({'error': 'No fields to update'}), 400
|
||||
return jsonify({'message': 'Item updated'})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@service_order_bp.route('/items/<int:item_id>', methods=['DELETE'])
|
||||
@require_auth()
|
||||
def delete_order_item(item_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
remove_item(conn, item_id)
|
||||
return jsonify({'message': 'Item removed'})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── Labor ─────────────────────────────
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/labor', methods=['POST'])
|
||||
@require_auth()
|
||||
def add_order_labor(so_id):
|
||||
data = request.get_json() or {}
|
||||
if not data.get('description'):
|
||||
return jsonify({'error': 'description is required'}), 400
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
labor_id = add_labor(conn, so_id, data)
|
||||
return jsonify({'id': labor_id, 'message': 'Labor added'}), 201
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@service_order_bp.route('/labor/<int:labor_id>', methods=['PUT'])
|
||||
@require_auth()
|
||||
def update_order_labor(labor_id):
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
ok = update_labor(conn, labor_id, data)
|
||||
if not ok:
|
||||
return jsonify({'error': 'No fields to update'}), 400
|
||||
return jsonify({'message': 'Labor updated'})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@service_order_bp.route('/labor/<int:labor_id>', methods=['DELETE'])
|
||||
@require_auth()
|
||||
def delete_order_labor(labor_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
remove_labor(conn, labor_id)
|
||||
return jsonify({'message': 'Labor removed'})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── Kanban Summary ─────────────────────────────
|
||||
|
||||
@service_order_bp.route('/kanban/summary', methods=['GET'])
|
||||
@require_auth()
|
||||
def kanban_summary():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
summary = get_kanban_summary(conn, branch_id=g.branch_id)
|
||||
return jsonify(summary)
|
||||
finally:
|
||||
conn.close()
|
||||
223
pos/blueprints/supplier_bp.py
Normal file
223
pos/blueprints/supplier_bp.py
Normal file
@@ -0,0 +1,223 @@
|
||||
"""Supplier and purchase order blueprint.
|
||||
|
||||
Endpoints (all under /pos/api):
|
||||
GET/POST /suppliers
|
||||
GET/PUT /suppliers/<id>
|
||||
GET /suppliers/<id>/purchase-orders
|
||||
POST /purchase-orders
|
||||
GET /purchase-orders
|
||||
GET /purchase-orders/<id>
|
||||
PUT /purchase-orders/<id>/send
|
||||
PUT /purchase-orders/<id>/receive
|
||||
PUT /purchase-orders/<id>/cancel
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.supplier_engine import (
|
||||
create_supplier, update_supplier, get_supplier, list_suppliers,
|
||||
create_po, send_po, receive_po, cancel_po, get_po, list_pos,
|
||||
)
|
||||
|
||||
supplier_bp = Blueprint('supplier', __name__, url_prefix='/pos/api')
|
||||
|
||||
|
||||
# ── SUPPLIERS ──────────────────────────────────────────────────────────────
|
||||
|
||||
@supplier_bp.route('/suppliers', methods=['GET'])
|
||||
@require_auth('inventory.view')
|
||||
def get_suppliers():
|
||||
"""List suppliers."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
active_only = request.args.get('active_only', 'true').lower() == 'true'
|
||||
limit = request.args.get('limit', 100, type=int)
|
||||
offset = request.args.get('offset', 0, type=int)
|
||||
data = list_suppliers(conn, active_only=active_only, limit=limit, offset=offset)
|
||||
conn.close()
|
||||
return jsonify({'data': data})
|
||||
|
||||
|
||||
@supplier_bp.route('/suppliers', methods=['POST'])
|
||||
@require_auth('inventory.edit')
|
||||
def post_supplier():
|
||||
"""Create a supplier."""
|
||||
data = request.get_json() or {}
|
||||
if not data.get('name'):
|
||||
return jsonify({'error': 'name is required'}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
supplier_id = create_supplier(conn, data)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'id': supplier_id, 'message': 'Supplier created'}), 201
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@supplier_bp.route('/suppliers/<int:supplier_id>', methods=['GET'])
|
||||
@require_auth('inventory.view')
|
||||
def get_supplier_detail(supplier_id):
|
||||
"""Get supplier by ID."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
supplier = get_supplier(conn, supplier_id)
|
||||
conn.close()
|
||||
if not supplier:
|
||||
return jsonify({'error': 'Supplier not found'}), 404
|
||||
return jsonify(supplier)
|
||||
|
||||
|
||||
@supplier_bp.route('/suppliers/<int:supplier_id>', methods=['PUT'])
|
||||
@require_auth('inventory.edit')
|
||||
def put_supplier(supplier_id):
|
||||
"""Update supplier."""
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
updated = update_supplier(conn, supplier_id, data)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
if not updated:
|
||||
return jsonify({'error': 'Supplier not found or no changes'}), 404
|
||||
return jsonify({'message': 'Supplier updated'})
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@supplier_bp.route('/suppliers/<int:supplier_id>/purchase-orders', methods=['GET'])
|
||||
@require_auth('inventory.view')
|
||||
def get_supplier_pos(supplier_id):
|
||||
"""List POs for a supplier."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
status = request.args.get('status')
|
||||
limit = request.args.get('limit', 50, type=int)
|
||||
offset = request.args.get('offset', 0, type=int)
|
||||
data = list_pos(conn, status=status, supplier_id=supplier_id, limit=limit, offset=offset)
|
||||
conn.close()
|
||||
return jsonify({'data': data})
|
||||
|
||||
|
||||
# ── PURCHASE ORDERS ────────────────────────────────────────────────────────
|
||||
|
||||
@supplier_bp.route('/purchase-orders', methods=['POST'])
|
||||
@require_auth('inventory.edit')
|
||||
def post_purchase_order():
|
||||
"""Create a purchase order."""
|
||||
data = request.get_json() or {}
|
||||
if not data.get('items'):
|
||||
return jsonify({'error': 'items are required'}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = create_po(conn, data, branch_id=g.branch_id, employee_id=g.employee_id)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify(result), 201
|
||||
except ValueError as e:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@supplier_bp.route('/purchase-orders', methods=['GET'])
|
||||
@require_auth('inventory.view')
|
||||
def get_purchase_orders():
|
||||
"""List purchase orders."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
status = request.args.get('status')
|
||||
supplier_id = request.args.get('supplier_id', type=int)
|
||||
limit = request.args.get('limit', 50, type=int)
|
||||
offset = request.args.get('offset', 0, type=int)
|
||||
data = list_pos(conn, status=status, supplier_id=supplier_id, limit=limit, offset=offset)
|
||||
conn.close()
|
||||
return jsonify({'data': data})
|
||||
|
||||
|
||||
@supplier_bp.route('/purchase-orders/<int:po_id>', methods=['GET'])
|
||||
@require_auth('inventory.view')
|
||||
def get_purchase_order(po_id):
|
||||
"""Get PO detail with items."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
po = get_po(conn, po_id)
|
||||
conn.close()
|
||||
if not po:
|
||||
return jsonify({'error': 'Purchase order not found'}), 404
|
||||
return jsonify(po)
|
||||
|
||||
|
||||
@supplier_bp.route('/purchase-orders/<int:po_id>/send', methods=['PUT'])
|
||||
@require_auth('inventory.edit')
|
||||
def put_send_po(po_id):
|
||||
"""Mark PO as sent."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
ok = send_po(conn, po_id)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
if not ok:
|
||||
return jsonify({'error': 'PO not found or not in draft status'}), 400
|
||||
return jsonify({'message': 'PO marked as sent'})
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@supplier_bp.route('/purchase-orders/<int:po_id>/receive', methods=['PUT'])
|
||||
@require_auth('inventory.edit')
|
||||
def put_receive_po(po_id):
|
||||
"""Receive items from a PO."""
|
||||
data = request.get_json() or {}
|
||||
received_items = data.get('items', [])
|
||||
if not received_items:
|
||||
return jsonify({'error': 'items are required'}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = receive_po(
|
||||
conn, po_id, received_items,
|
||||
supplier_invoice=data.get('supplier_invoice'),
|
||||
notes=data.get('notes')
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@supplier_bp.route('/purchase-orders/<int:po_id>/cancel', methods=['PUT'])
|
||||
@require_auth('inventory.edit')
|
||||
def put_cancel_po(po_id):
|
||||
"""Cancel a PO."""
|
||||
data = request.get_json() or {}
|
||||
reason = data.get('reason', '')
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cancel_po(conn, po_id, reason)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'message': 'PO cancelled'})
|
||||
except ValueError as e:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
208
pos/blueprints/warranty_bp.py
Normal file
208
pos/blueprints/warranty_bp.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""Warranty / RMA blueprint.
|
||||
|
||||
Endpoints (all under /pos/api):
|
||||
GET/POST /warranties
|
||||
GET /warranties/<id>
|
||||
GET /customers/<id>/warranties
|
||||
POST /warranty-claims
|
||||
GET /warranty-claims
|
||||
GET /warranty-claims/<id>
|
||||
PUT /warranty-claims/<id>/resolve
|
||||
PUT /warranty-claims/<id>/close
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.warranty_engine import (
|
||||
register_warranty, create_claim, resolve_claim, close_claim,
|
||||
get_warranty, list_warranties, get_claim, list_claims, expire_warranties,
|
||||
)
|
||||
|
||||
warranty_bp = Blueprint('warranty', __name__, url_prefix='/pos/api')
|
||||
|
||||
|
||||
# ── WARRANTIES ─────────────────────────────────────────────────────────────
|
||||
|
||||
@warranty_bp.route('/warranties', methods=['GET'])
|
||||
@require_auth('inventory.view')
|
||||
def get_warranties():
|
||||
"""List warranties."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
status = request.args.get('status')
|
||||
customer_id = request.args.get('customer_id', type=int)
|
||||
limit = request.args.get('limit', 50, type=int)
|
||||
offset = request.args.get('offset', 0, type=int)
|
||||
data = list_warranties(conn, customer_id=customer_id, status=status, limit=limit, offset=offset)
|
||||
conn.close()
|
||||
return jsonify({'data': data})
|
||||
|
||||
|
||||
@warranty_bp.route('/warranties', methods=['POST'])
|
||||
@require_auth('pos.sell')
|
||||
def post_warranty():
|
||||
"""Register a warranty (usually called at sale time)."""
|
||||
data = request.get_json() or {}
|
||||
required = ['sale_id', 'sale_item_id', 'inventory_id', 'customer_id', 'warranty_months']
|
||||
missing = [f for f in required if f not in data]
|
||||
if missing:
|
||||
return jsonify({'error': f'Missing fields: {missing}'}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
w_id = register_warranty(
|
||||
conn,
|
||||
sale_id=data['sale_id'],
|
||||
sale_item_id=data['sale_item_id'],
|
||||
inventory_id=data['inventory_id'],
|
||||
customer_id=data['customer_id'],
|
||||
warranty_months=int(data['warranty_months']),
|
||||
supplier_id=data.get('supplier_id'),
|
||||
part_number=data.get('part_number'),
|
||||
name=data.get('name'),
|
||||
notes=data.get('notes'),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'id': w_id, 'message': 'Warranty registered'}), 201
|
||||
except ValueError as e:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@warranty_bp.route('/warranties/<int:warranty_id>', methods=['GET'])
|
||||
@require_auth('inventory.view')
|
||||
def get_warranty_detail(warranty_id):
|
||||
"""Get warranty by ID."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
w = get_warranty(conn, warranty_id)
|
||||
conn.close()
|
||||
if not w:
|
||||
return jsonify({'error': 'Warranty not found'}), 404
|
||||
return jsonify(w)
|
||||
|
||||
|
||||
@warranty_bp.route('/customers/<int:customer_id>/warranties', methods=['GET'])
|
||||
@require_auth('inventory.view')
|
||||
def get_customer_warranties(customer_id):
|
||||
"""List warranties for a customer."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
data = list_warranties(conn, customer_id=customer_id)
|
||||
conn.close()
|
||||
return jsonify({'data': data})
|
||||
|
||||
|
||||
# ── WARRANTY CLAIMS ────────────────────────────────────────────────────────
|
||||
|
||||
@warranty_bp.route('/warranty-claims', methods=['POST'])
|
||||
@require_auth('inventory.edit')
|
||||
def post_claim():
|
||||
"""File a warranty claim."""
|
||||
data = request.get_json() or {}
|
||||
if not data.get('warranty_id') or not data.get('reason'):
|
||||
return jsonify({'error': 'warranty_id and reason are required'}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
claim_id = create_claim(
|
||||
conn,
|
||||
warranty_id=data['warranty_id'],
|
||||
reason=data['reason'],
|
||||
employee_id=g.employee_id,
|
||||
notes=data.get('notes')
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'id': claim_id, 'message': 'Claim filed'}), 201
|
||||
except ValueError as e:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@warranty_bp.route('/warranty-claims', methods=['GET'])
|
||||
@require_auth('inventory.view')
|
||||
def get_claims():
|
||||
"""List warranty claims."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
status = request.args.get('status')
|
||||
warranty_id = request.args.get('warranty_id', type=int)
|
||||
limit = request.args.get('limit', 50, type=int)
|
||||
offset = request.args.get('offset', 0, type=int)
|
||||
data = list_claims(conn, status=status, warranty_id=warranty_id, limit=limit, offset=offset)
|
||||
conn.close()
|
||||
return jsonify({'data': data})
|
||||
|
||||
|
||||
@warranty_bp.route('/warranty-claims/<int:claim_id>', methods=['GET'])
|
||||
@require_auth('inventory.view')
|
||||
def get_claim_detail(claim_id):
|
||||
"""Get claim by ID."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
c = get_claim(conn, claim_id)
|
||||
conn.close()
|
||||
if not c:
|
||||
return jsonify({'error': 'Claim not found'}), 404
|
||||
return jsonify(c)
|
||||
|
||||
|
||||
@warranty_bp.route('/warranty-claims/<int:claim_id>/resolve', methods=['PUT'])
|
||||
@require_auth('inventory.edit')
|
||||
def put_resolve_claim(claim_id):
|
||||
"""Resolve a claim."""
|
||||
data = request.get_json() or {}
|
||||
resolution = data.get('resolution')
|
||||
if not resolution:
|
||||
return jsonify({'error': 'resolution is required'}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
ok = resolve_claim(
|
||||
conn, claim_id, resolution,
|
||||
diagnosis=data.get('diagnosis'),
|
||||
replacement_inventory_id=data.get('replacement_inventory_id'),
|
||||
refund_amount=data.get('refund_amount'),
|
||||
labor_cost=data.get('labor_cost'),
|
||||
supplier_rma_number=data.get('supplier_rma_number'),
|
||||
notes=data.get('notes')
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
if not ok:
|
||||
return jsonify({'error': 'Claim not found or already closed'}), 400
|
||||
return jsonify({'message': 'Claim resolved'})
|
||||
except ValueError as e:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@warranty_bp.route('/warranty-claims/<int:claim_id>/close', methods=['PUT'])
|
||||
@require_auth('inventory.edit')
|
||||
def put_close_claim(claim_id):
|
||||
"""Close a resolved claim."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
ok = close_claim(conn, claim_id)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
if not ok:
|
||||
return jsonify({'error': 'Claim not found or not resolved'}), 400
|
||||
return jsonify({'message': 'Claim closed'})
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
Reference in New Issue
Block a user