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
132 lines
4.4 KiB
Python
132 lines
4.4 KiB
Python
"""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()
|