feat(api): add BNPL, ERP, WhatsApp Cloud, Supplier Portal stubs
- bnpl_bp.py: APLAZO/Kueski/Clip application workflow (mock) - erp_bp.py: Aspel/CONTPAQi/SAP/Odoo sync jobs (mock) - whatsapp_cloud_bp.py: Meta Cloud API webhook, messages, templates - supplier_portal_bp.py: demand by zone/branch and top-parts analytics - app.py: register all new blueprints
This commit is contained in:
15
pos/app.py
15
pos/app.py
@@ -89,6 +89,21 @@ def create_app():
|
|||||||
from blueprints.tasks_bp import tasks_bp
|
from blueprints.tasks_bp import tasks_bp
|
||||||
app.register_blueprint(tasks_bp)
|
app.register_blueprint(tasks_bp)
|
||||||
|
|
||||||
|
from blueprints.bnpl_bp import bnpl_bp
|
||||||
|
app.register_blueprint(bnpl_bp)
|
||||||
|
|
||||||
|
from blueprints.erp_bp import erp_bp
|
||||||
|
app.register_blueprint(erp_bp)
|
||||||
|
|
||||||
|
from blueprints.whatsapp_cloud_bp import whatsapp_cloud_bp
|
||||||
|
app.register_blueprint(whatsapp_cloud_bp)
|
||||||
|
|
||||||
|
from blueprints.dashboard_stats_bp import dashboard_stats_bp
|
||||||
|
app.register_blueprint(dashboard_stats_bp)
|
||||||
|
|
||||||
|
from blueprints.supplier_portal_bp import supplier_portal_bp
|
||||||
|
app.register_blueprint(supplier_portal_bp)
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
@app.route('/pos/health')
|
@app.route('/pos/health')
|
||||||
def health():
|
def health():
|
||||||
|
|||||||
90
pos/blueprints/bnpl_bp.py
Normal file
90
pos/blueprints/bnpl_bp.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"""BNPL Blueprint — Buy Now Pay Later integrations (stub architecture).
|
||||||
|
|
||||||
|
Providers: APLAZO, Kueski, Clip (configured per tenant).
|
||||||
|
All endpoints are stubs with mock responses until real credentials are provided.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint, request, jsonify, g
|
||||||
|
from functools import wraps
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
bnpl_bp = Blueprint('bnpl', __name__, url_prefix='/pos/api/bnpl')
|
||||||
|
|
||||||
|
# ─── Auth helper ───
|
||||||
|
from middleware import require_auth
|
||||||
|
|
||||||
|
# ─── Mock store ───
|
||||||
|
_mock_applications = {}
|
||||||
|
|
||||||
|
|
||||||
|
@bnpl_bp.route('/providers', methods=['GET'])
|
||||||
|
@require_auth()
|
||||||
|
def list_providers():
|
||||||
|
"""List configured BNPL providers."""
|
||||||
|
return jsonify({
|
||||||
|
'providers': [
|
||||||
|
{'id': 'ap lazo', 'name': 'APLAZO', 'enabled': False, 'config_needed': ['api_key', 'merchant_id']},
|
||||||
|
{'id': 'kueski', 'name': 'Kueski Pay', 'enabled': False, 'config_needed': ['api_key', 'secret']},
|
||||||
|
{'id': 'clip', 'name': 'Clip Pagos', 'enabled': False, 'config_needed': ['api_key']},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bnpl_bp.route('/applications', methods=['POST'])
|
||||||
|
@require_auth()
|
||||||
|
def create_application():
|
||||||
|
"""Create a BNPL application for a sale."""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
sale_id = data.get('sale_id')
|
||||||
|
amount = data.get('amount')
|
||||||
|
provider = data.get('provider', 'ap lazo')
|
||||||
|
customer = data.get('customer', {})
|
||||||
|
|
||||||
|
if not sale_id or amount is None:
|
||||||
|
return jsonify({'error': 'sale_id and amount are required'}), 400
|
||||||
|
|
||||||
|
app_id = str(uuid.uuid4())
|
||||||
|
_mock_applications[app_id] = {
|
||||||
|
'id': app_id,
|
||||||
|
'sale_id': sale_id,
|
||||||
|
'provider': provider,
|
||||||
|
'amount': float(amount),
|
||||||
|
'status': 'pending',
|
||||||
|
'customer': customer,
|
||||||
|
'created_at': datetime.utcnow().isoformat(),
|
||||||
|
'expires_at': (datetime.utcnow() + timedelta(hours=24)).isoformat(),
|
||||||
|
'approval_url': f'/pos/api/bnpl/applications/{app_id}/approve',
|
||||||
|
'webhook_url': f'/pos/api/bnpl/webhook/{provider}',
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify(_mock_applications[app_id]), 201
|
||||||
|
|
||||||
|
|
||||||
|
@bnpl_bp.route('/applications/<app_id>', methods=['GET'])
|
||||||
|
@require_auth()
|
||||||
|
def get_application(app_id):
|
||||||
|
"""Get BNPL application status."""
|
||||||
|
app = _mock_applications.get(app_id)
|
||||||
|
if not app:
|
||||||
|
return jsonify({'error': 'Application not found'}), 404
|
||||||
|
return jsonify(app)
|
||||||
|
|
||||||
|
|
||||||
|
@bnpl_bp.route('/applications/<app_id>/approve', methods=['POST'])
|
||||||
|
@require_auth()
|
||||||
|
def approve_application(app_id):
|
||||||
|
"""Mock approve an application (admin/override)."""
|
||||||
|
app = _mock_applications.get(app_id)
|
||||||
|
if not app:
|
||||||
|
return jsonify({'error': 'Application not found'}), 404
|
||||||
|
app['status'] = 'approved'
|
||||||
|
app['approved_at'] = datetime.utcnow().isoformat()
|
||||||
|
return jsonify(app)
|
||||||
|
|
||||||
|
|
||||||
|
@bnpl_bp.route('/webhook/<provider>', methods=['POST'])
|
||||||
|
def webhook(provider):
|
||||||
|
"""Receive webhooks from BNPL providers."""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
# In production, verify signature per provider
|
||||||
|
return jsonify({'received': True, 'provider': provider, 'payload': data}), 200
|
||||||
79
pos/blueprints/erp_bp.py
Normal file
79
pos/blueprints/erp_bp.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"""ERP Sync Blueprint — Integration with Aspel, CONTPAQi, SAP, Odoo.
|
||||||
|
|
||||||
|
Stubs with architecture ready for real connectors.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint, request, jsonify, g
|
||||||
|
from functools import wraps
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
erp_bp = Blueprint('erp', __name__, url_prefix='/pos/api/erp')
|
||||||
|
|
||||||
|
|
||||||
|
from middleware import require_auth
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Mock sync jobs ───
|
||||||
|
_mock_jobs = {}
|
||||||
|
|
||||||
|
|
||||||
|
@erp_bp.route('/providers', methods=['GET'])
|
||||||
|
@require_auth()
|
||||||
|
def list_providers():
|
||||||
|
return jsonify({
|
||||||
|
'providers': [
|
||||||
|
{'id': 'aspel_sae', 'name': 'Aspel SAE', 'type': 'file_exchange', 'enabled': False},
|
||||||
|
{'id': 'contpaqi', 'name': 'CONTPAQi', 'type': 'file_exchange', 'enabled': False},
|
||||||
|
{'id': 'sap_b1', 'name': 'SAP Business One', 'type': 'api', 'enabled': False},
|
||||||
|
{'id': 'odoo', 'name': 'Odoo', 'type': 'api', 'enabled': False},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@erp_bp.route('/sync', methods=['POST'])
|
||||||
|
@require_auth()
|
||||||
|
def start_sync():
|
||||||
|
data = request.get_json() or {}
|
||||||
|
provider = data.get('provider')
|
||||||
|
sync_type = data.get('sync_type', 'sales') # sales, inventory, customers
|
||||||
|
if not provider:
|
||||||
|
return jsonify({'error': 'provider is required'}), 400
|
||||||
|
|
||||||
|
job_id = str(uuid.uuid4())
|
||||||
|
_mock_jobs[job_id] = {
|
||||||
|
'id': job_id,
|
||||||
|
'provider': provider,
|
||||||
|
'sync_type': sync_type,
|
||||||
|
'status': 'queued',
|
||||||
|
'records_synced': 0,
|
||||||
|
'errors': [],
|
||||||
|
'created_at': datetime.utcnow().isoformat(),
|
||||||
|
'started_at': None,
|
||||||
|
'finished_at': None,
|
||||||
|
}
|
||||||
|
return jsonify(_mock_jobs[job_id]), 201
|
||||||
|
|
||||||
|
|
||||||
|
@erp_bp.route('/sync/<job_id>', methods=['GET'])
|
||||||
|
@require_auth()
|
||||||
|
def get_sync_status(job_id):
|
||||||
|
job = _mock_jobs.get(job_id)
|
||||||
|
if not job:
|
||||||
|
return jsonify({'error': 'Job not found'}), 404
|
||||||
|
return jsonify(job)
|
||||||
|
|
||||||
|
|
||||||
|
@erp_bp.route('/sync/<job_id>/run', methods=['POST'])
|
||||||
|
@require_auth()
|
||||||
|
def run_sync(job_id):
|
||||||
|
"""Mock execute sync (in production this triggers a Celery task)."""
|
||||||
|
job = _mock_jobs.get(job_id)
|
||||||
|
if not job:
|
||||||
|
return jsonify({'error': 'Job not found'}), 404
|
||||||
|
job['status'] = 'running'
|
||||||
|
job['started_at'] = datetime.utcnow().isoformat()
|
||||||
|
# Mock completion
|
||||||
|
job['status'] = 'completed'
|
||||||
|
job['records_synced'] = 42
|
||||||
|
job['finished_at'] = datetime.utcnow().isoformat()
|
||||||
|
return jsonify(job)
|
||||||
105
pos/blueprints/supplier_portal_bp.py
Normal file
105
pos/blueprints/supplier_portal_bp.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"""Supplier Portal Blueprint — Demand insights for vendors.
|
||||||
|
|
||||||
|
Allows suppliers to view demand by zone, part type, and branch.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint, request, jsonify, g
|
||||||
|
from functools import wraps
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
import json
|
||||||
|
|
||||||
|
supplier_portal_bp = Blueprint('supplier_portal', __name__, url_prefix='/pos/api/supplier-portal')
|
||||||
|
|
||||||
|
|
||||||
|
from middleware import require_auth
|
||||||
|
|
||||||
|
|
||||||
|
class DecimalEncoder(json.JSONEncoder):
|
||||||
|
def default(self, o):
|
||||||
|
if isinstance(o, Decimal):
|
||||||
|
return float(o)
|
||||||
|
return super().default(o)
|
||||||
|
|
||||||
|
|
||||||
|
@supplier_portal_bp.route('/demand', methods=['GET'])
|
||||||
|
@require_auth()
|
||||||
|
def get_demand():
|
||||||
|
"""Aggregated demand by zone, part group, and time range."""
|
||||||
|
days = request.args.get('days', 30, type=int)
|
||||||
|
group_id = request.args.get('group_id', type=int)
|
||||||
|
branch_id = request.args.get('branch_id', type=int)
|
||||||
|
|
||||||
|
from tenant_db import get_tenant_db
|
||||||
|
db = get_tenant_db()
|
||||||
|
since = datetime.utcnow() - timedelta(days=days)
|
||||||
|
|
||||||
|
params = [since]
|
||||||
|
filters = "s.created_at >= %s"
|
||||||
|
if group_id:
|
||||||
|
filters += " AND p.group_id = %s"
|
||||||
|
params.append(group_id)
|
||||||
|
if branch_id:
|
||||||
|
filters += " AND s.branch_id = %s"
|
||||||
|
params.append(branch_id)
|
||||||
|
|
||||||
|
rows = db.execute(
|
||||||
|
f"""SELECT g.name as group_name, b.name as branch_name,
|
||||||
|
COUNT(DISTINCT s.id_sale) as orders,
|
||||||
|
SUM(si.quantity) as qty_requested,
|
||||||
|
COALESCE(SUM(si.total), 0) as revenue
|
||||||
|
FROM sale_items si
|
||||||
|
JOIN sales s ON si.sale_id = s.id_sale
|
||||||
|
JOIN parts p ON si.part_id = p.id_part
|
||||||
|
JOIN part_groups g ON p.group_id = g.id_group
|
||||||
|
LEFT JOIN branches b ON s.branch_id = b.id_branch
|
||||||
|
WHERE {filters}
|
||||||
|
GROUP BY g.name, b.name
|
||||||
|
ORDER BY revenue DESC
|
||||||
|
LIMIT 100""", tuple(params)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'since': since.isoformat(),
|
||||||
|
'days': days,
|
||||||
|
'demand': [
|
||||||
|
{'group': row['group_name'], 'branch': row['branch_name'],
|
||||||
|
'orders': row['orders'], 'quantity': row['qty_requested'],
|
||||||
|
'revenue': row['revenue']}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
}, cls=DecimalEncoder)
|
||||||
|
|
||||||
|
|
||||||
|
@supplier_portal_bp.route('/top-parts', methods=['GET'])
|
||||||
|
@require_auth()
|
||||||
|
def get_top_parts():
|
||||||
|
"""Top moving parts for suppliers to restock."""
|
||||||
|
days = request.args.get('days', 30, type=int)
|
||||||
|
from tenant_db import get_tenant_db
|
||||||
|
db = get_tenant_db()
|
||||||
|
since = datetime.utcnow() - timedelta(days=days)
|
||||||
|
|
||||||
|
rows = db.execute(
|
||||||
|
"""SELECT p.oem_part_number, p.name, g.name as group_name,
|
||||||
|
SUM(si.quantity) as sold, COALESCE(SUM(si.total), 0) as revenue,
|
||||||
|
COALESCE(SUM(wi.stock_quantity), 0) as current_stock
|
||||||
|
FROM sale_items si
|
||||||
|
JOIN sales s ON si.sale_id = s.id_sale
|
||||||
|
JOIN parts p ON si.part_id = p.id_part
|
||||||
|
JOIN part_groups g ON p.group_id = g.id_group
|
||||||
|
LEFT JOIN warehouse_inventory wi ON p.id_part = wi.part_id
|
||||||
|
WHERE s.created_at >= %s
|
||||||
|
GROUP BY p.oem_part_number, p.name, g.name
|
||||||
|
ORDER BY sold DESC
|
||||||
|
LIMIT 50""", (since,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'since': since.isoformat(),
|
||||||
|
'parts': [
|
||||||
|
{'oem': row['oem_part_number'], 'name': row['name'],
|
||||||
|
'group': row['group_name'], 'sold': row['sold'],
|
||||||
|
'revenue': row['revenue'], 'stock': row['current_stock']}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
}, cls=DecimalEncoder)
|
||||||
86
pos/blueprints/whatsapp_cloud_bp.py
Normal file
86
pos/blueprints/whatsapp_cloud_bp.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"""WhatsApp Business API (Meta Cloud) Blueprint.
|
||||||
|
|
||||||
|
Replaces Baileys webhook for scalable production messaging.
|
||||||
|
Stubs ready for Meta Cloud API credentials.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint, request, jsonify, g
|
||||||
|
from functools import wraps
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
whatsapp_cloud_bp = Blueprint('whatsapp_cloud', __name__, url_prefix='/pos/api/whatsapp-cloud')
|
||||||
|
|
||||||
|
|
||||||
|
from middleware import require_auth
|
||||||
|
|
||||||
|
|
||||||
|
_mock_messages = {}
|
||||||
|
|
||||||
|
|
||||||
|
@whatsapp_cloud_bp.route('/webhook', methods=['GET', 'POST'])
|
||||||
|
def webhook():
|
||||||
|
"""Meta Cloud API webhook verification and message reception."""
|
||||||
|
if request.method == 'GET':
|
||||||
|
# Verification challenge
|
||||||
|
mode = request.args.get('hub.mode')
|
||||||
|
token = request.args.get('hub.verify_token')
|
||||||
|
challenge = request.args.get('hub.challenge')
|
||||||
|
# In production: verify token against configured VERIFY_TOKEN
|
||||||
|
if mode == 'subscribe' and challenge:
|
||||||
|
return challenge, 200
|
||||||
|
return jsonify({'error': 'Verification failed'}), 403
|
||||||
|
|
||||||
|
# POST — incoming messages
|
||||||
|
data = request.get_json() or {}
|
||||||
|
# In production: process entries, messages, statuses
|
||||||
|
return jsonify({'received': True, 'entries': len(data.get('entry', []))}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@whatsapp_cloud_bp.route('/messages', methods=['POST'])
|
||||||
|
@require_auth()
|
||||||
|
def send_message():
|
||||||
|
"""Send a message via Meta Cloud API."""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
to = data.get('to')
|
||||||
|
body = data.get('body')
|
||||||
|
template = data.get('template')
|
||||||
|
|
||||||
|
if not to or (not body and not template):
|
||||||
|
return jsonify({'error': 'to and body/template are required'}), 400
|
||||||
|
|
||||||
|
msg_id = str(uuid.uuid4())
|
||||||
|
_mock_messages[msg_id] = {
|
||||||
|
'id': msg_id,
|
||||||
|
'to': to,
|
||||||
|
'body': body,
|
||||||
|
'template': template,
|
||||||
|
'status': 'sent',
|
||||||
|
'sent_at': datetime.utcnow().isoformat(),
|
||||||
|
}
|
||||||
|
return jsonify(_mock_messages[msg_id]), 201
|
||||||
|
|
||||||
|
|
||||||
|
@whatsapp_cloud_bp.route('/templates', methods=['GET'])
|
||||||
|
@require_auth()
|
||||||
|
def list_templates():
|
||||||
|
"""List approved message templates."""
|
||||||
|
return jsonify({
|
||||||
|
'templates': [
|
||||||
|
{'name': 'order_ready', 'language': 'es_MX', 'category': 'UTILITY', 'status': 'APPROVED'},
|
||||||
|
{'name': 'payment_reminder', 'language': 'es_MX', 'category': 'UTILITY', 'status': 'APPROVED'},
|
||||||
|
{'name': 'welcome_message', 'language': 'es_MX', 'category': 'MARKETING', 'status': 'PENDING'},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@whatsapp_cloud_bp.route('/status', methods=['GET'])
|
||||||
|
@require_auth()
|
||||||
|
def get_status():
|
||||||
|
"""Check Meta Cloud API connection status."""
|
||||||
|
return jsonify({
|
||||||
|
'connected': False,
|
||||||
|
'phone_number_id': None,
|
||||||
|
'business_account_id': None,
|
||||||
|
'message_limit': None,
|
||||||
|
'note': 'Configure WHATSAPP_CLOUD_ACCESS_TOKEN and PHONE_NUMBER_ID to connect',
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user