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:
Nexus Dev
2026-04-27 05:23:30 +00:00
parent b70cb3042b
commit 9ff3dc4c8b
71 changed files with 10939 additions and 420 deletions

View File

@@ -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
View 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
View 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()

View File

@@ -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)

View 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()

View 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()

View File

@@ -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:

View 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()

View 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,
})

View 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()

View 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

View 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