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:
2026-04-29 06:31:03 +00:00
parent 12989e30be
commit 2cfe4b3913
5 changed files with 375 additions and 0 deletions

View File

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

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

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