diff --git a/pos/app.py b/pos/app.py index 26dd14e..52d4163 100644 --- a/pos/app.py +++ b/pos/app.py @@ -89,6 +89,21 @@ def create_app(): from blueprints.tasks_bp import 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 @app.route('/pos/health') def health(): diff --git a/pos/blueprints/bnpl_bp.py b/pos/blueprints/bnpl_bp.py new file mode 100644 index 0000000..2912dfc --- /dev/null +++ b/pos/blueprints/bnpl_bp.py @@ -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/', 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//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/', 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 diff --git a/pos/blueprints/erp_bp.py b/pos/blueprints/erp_bp.py new file mode 100644 index 0000000..aee675c --- /dev/null +++ b/pos/blueprints/erp_bp.py @@ -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/', 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//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) diff --git a/pos/blueprints/supplier_portal_bp.py b/pos/blueprints/supplier_portal_bp.py new file mode 100644 index 0000000..33a5125 --- /dev/null +++ b/pos/blueprints/supplier_portal_bp.py @@ -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) diff --git a/pos/blueprints/whatsapp_cloud_bp.py b/pos/blueprints/whatsapp_cloud_bp.py new file mode 100644 index 0000000..69935cf --- /dev/null +++ b/pos/blueprints/whatsapp_cloud_bp.py @@ -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', + })