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:
316
pos/services/erp_sync_engine.py
Normal file
316
pos/services/erp_sync_engine.py
Normal file
@@ -0,0 +1,316 @@
|
||||
"""ERP Sync Engine: Aspel SAE, Contpaqi, SAP B1, Odoo integration stubs.
|
||||
|
||||
This module provides the architecture for bidirectional sync between Nexus POS
|
||||
and external ERP systems. Actual implementations require:
|
||||
- Aspel SAE: ODBC connection or REST API via Aspel NOI/SAE middleware
|
||||
- Contpaqi: Contpaqi SDK or REST API via Facturama/Contpaqi middleware
|
||||
- SAP B1: Service Layer REST API
|
||||
- Odoo: XML-RPC or JSON-RPC API
|
||||
|
||||
Tables (erp_sync_configs, erp_sync_logs, erp_sync_queue) created in v2.6.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
ERP_HANDLERS = {}
|
||||
|
||||
|
||||
def register_erp_handler(erp_type, handler_class):
|
||||
ERP_HANDLERS[erp_type] = handler_class
|
||||
|
||||
|
||||
def get_erp_handler(erp_type):
|
||||
return ERP_HANDLERS.get(erp_type)
|
||||
|
||||
|
||||
# ─── ERP Sync Configuration ─────────────────────────────
|
||||
|
||||
def create_sync_config(conn, tenant_id, erp_type, api_endpoint, api_username,
|
||||
api_password, database_name=None, company_code=None,
|
||||
sync_direction='bidirectional', sync_inventory=False,
|
||||
sync_sales=False, sync_customers=False,
|
||||
sync_frequency_minutes=60):
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO erp_sync_configs
|
||||
(tenant_id, erp_type, api_endpoint, api_username, api_password_encrypted,
|
||||
database_name, company_code, sync_direction, sync_inventory,
|
||||
sync_sales, sync_customers, sync_frequency_minutes)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (tenant_id, erp_type, api_endpoint, api_username, api_password,
|
||||
database_name, company_code, sync_direction, sync_inventory,
|
||||
sync_sales, sync_customers, sync_frequency_minutes))
|
||||
config_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return config_id
|
||||
|
||||
|
||||
def get_sync_config(conn, tenant_id, erp_type):
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT id, tenant_id, erp_type, is_active, api_endpoint, api_username,
|
||||
database_name, company_code, sync_direction, sync_inventory,
|
||||
sync_sales, sync_customers, sync_frequency_minutes,
|
||||
last_sync_at, last_sync_error, created_at
|
||||
FROM erp_sync_configs
|
||||
WHERE tenant_id = %s AND erp_type = %s
|
||||
""", (tenant_id, erp_type))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
'id': row[0], 'tenant_id': row[1], 'erp_type': row[2],
|
||||
'is_active': row[3], 'api_endpoint': row[4], 'api_username': row[5],
|
||||
'database_name': row[6], 'company_code': row[7],
|
||||
'sync_direction': row[8], 'sync_inventory': row[9],
|
||||
'sync_sales': row[10], 'sync_customers': row[11],
|
||||
'sync_frequency_minutes': row[12],
|
||||
'last_sync_at': str(row[13]) if row[13] else None,
|
||||
'last_sync_error': row[14], 'created_at': str(row[15]),
|
||||
}
|
||||
|
||||
|
||||
# ─── Sync Queue ─────────────────────────────
|
||||
|
||||
def queue_for_sync(conn, config_id, entity_type, entity_id, action='update',
|
||||
priority=5):
|
||||
"""Add an entity to the sync queue."""
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO erp_sync_queue
|
||||
(config_id, entity_type, entity_id, action, priority)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING id
|
||||
""", (config_id, entity_type, entity_id, action, priority))
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
def get_pending_queue(conn, config_id, limit=100):
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT id, entity_type, entity_id, action, priority, retry_count, created_at
|
||||
FROM erp_sync_queue
|
||||
WHERE config_id = %s AND status IN ('pending', 'failed')
|
||||
ORDER BY priority ASC, created_at ASC
|
||||
LIMIT %s
|
||||
""", (config_id, limit))
|
||||
items = []
|
||||
for r in cur.fetchall():
|
||||
items.append({
|
||||
'id': r[0], 'entity_type': r[1], 'entity_id': r[2],
|
||||
'action': r[3], 'priority': r[4], 'retry_count': r[5],
|
||||
'created_at': str(r[6]),
|
||||
})
|
||||
cur.close()
|
||||
return items
|
||||
|
||||
|
||||
def mark_queue_item(conn, queue_id, status, error=None):
|
||||
cur = conn.cursor()
|
||||
if status == 'completed':
|
||||
cur.execute("""
|
||||
UPDATE erp_sync_queue
|
||||
SET status = %s, processed_at = NOW(), last_error = NULL
|
||||
WHERE id = %s
|
||||
""", (status, queue_id))
|
||||
elif status == 'failed':
|
||||
cur.execute("""
|
||||
UPDATE erp_sync_queue
|
||||
SET status = %s, retry_count = retry_count + 1, last_error = %s
|
||||
WHERE id = %s
|
||||
""", (status, error, queue_id))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
|
||||
|
||||
# ─── Sync Logs ─────────────────────────────
|
||||
|
||||
def log_sync_run(conn, config_id, sync_type, direction, status,
|
||||
records_processed=0, records_failed=0, error_message=None,
|
||||
details=None):
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO erp_sync_logs
|
||||
(config_id, sync_type, direction, status, records_processed,
|
||||
records_failed, error_message, details)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (config_id, sync_type, direction, status, records_processed,
|
||||
records_failed, error_message, details))
|
||||
log_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return log_id
|
||||
|
||||
|
||||
def complete_sync_run(conn, log_id, status, records_processed, records_failed,
|
||||
error_message=None, details=None):
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
UPDATE erp_sync_logs
|
||||
SET status = %s, records_processed = %s, records_failed = %s,
|
||||
error_message = %s, details = COALESCE(details, '{}'::jsonb) || COALESCE(%s, '{}'::jsonb),
|
||||
completed_at = NOW()
|
||||
WHERE id = %s
|
||||
""", (status, records_processed, records_failed, error_message, details, log_id))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
|
||||
|
||||
# ─── Generic Sync Runner ─────────────────────────────
|
||||
|
||||
def run_sync(conn, config_id, sync_type='full'):
|
||||
"""Run a sync job for a configured ERP.
|
||||
|
||||
This is the main entry point. It looks up the config, instantiates
|
||||
the appropriate handler, and executes the sync.
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT erp_type, tenant_id FROM erp_sync_configs WHERE id = %s", (config_id,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
if not row:
|
||||
raise ValueError(f"Sync config {config_id} not found")
|
||||
|
||||
erp_type, tenant_id = row
|
||||
handler_class = get_erp_handler(erp_type)
|
||||
|
||||
if not handler_class:
|
||||
log_sync_run(conn, config_id, sync_type, 'to_erp', 'failed',
|
||||
error_message=f"No handler registered for ERP type: {erp_type}")
|
||||
return {'status': 'failed', 'error': f'No handler for {erp_type}'}
|
||||
|
||||
handler = handler_class(conn, config_id, tenant_id)
|
||||
return handler.sync(sync_type)
|
||||
|
||||
|
||||
# ─── Base ERP Handler Class ─────────────────────────────
|
||||
|
||||
class BaseERPHandler:
|
||||
"""Base class for ERP-specific sync handlers."""
|
||||
|
||||
def __init__(self, conn, config_id, tenant_id):
|
||||
self.conn = conn
|
||||
self.config_id = config_id
|
||||
self.tenant_id = tenant_id
|
||||
|
||||
def sync(self, sync_type):
|
||||
raise NotImplementedError
|
||||
|
||||
def sync_inventory_to_erp(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def sync_inventory_from_erp(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def sync_sales_to_erp(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def sync_customers_to_erp(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def sync_customers_from_erp(self):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
# ─── Aspel SAE Stub ─────────────────────────────
|
||||
|
||||
class AspelSAEHandler(BaseERPHandler):
|
||||
"""Stub for Aspel SAE sync.
|
||||
|
||||
Aspel SAE is a desktop ERP. Integration options:
|
||||
1. ODBC direct to the SQL Server / Interbase database
|
||||
2. Aspel REST API (via Aspel NOI middleware)
|
||||
3. File-based sync (CSV/XML export/import)
|
||||
|
||||
Recommended: Option 2 (REST API) if available, else Option 3.
|
||||
"""
|
||||
|
||||
def sync(self, sync_type):
|
||||
log_id = log_sync_run(self.conn, self.config_id, sync_type, 'bidirectional', 'running')
|
||||
|
||||
# Stub: would call Aspel API here
|
||||
complete_sync_run(
|
||||
self.conn, log_id, 'failed', 0, 0,
|
||||
error_message='Aspel SAE handler not implemented. Requires Aspel REST API or ODBC connection.',
|
||||
details={'message': 'Stub implementation. Activate when Aspel credentials are available.'}
|
||||
)
|
||||
return {'status': 'failed', 'error': 'Aspel SAE handler not implemented'}
|
||||
|
||||
|
||||
# ─── Contpaqi Stub ─────────────────────────────
|
||||
|
||||
class ContpaqiHandler(BaseERPHandler):
|
||||
"""Stub for Contpaqi sync.
|
||||
|
||||
Contpaqi integration options:
|
||||
1. Contpaqi SDK (COM/ActiveX on Windows)
|
||||
2. Contpaqi REST API (via middleware like Facturama)
|
||||
3. Direct database access to Firebird/Interbase
|
||||
|
||||
Recommended: Option 2 for cloud deployments.
|
||||
"""
|
||||
|
||||
def sync(self, sync_type):
|
||||
log_id = log_sync_run(self.conn, self.config_id, sync_type, 'bidirectional', 'running')
|
||||
|
||||
complete_sync_run(
|
||||
self.conn, log_id, 'failed', 0, 0,
|
||||
error_message='Contpaqi handler not implemented. Requires Contpaqi SDK or REST middleware.',
|
||||
details={'message': 'Stub implementation. Activate when Contpaqi credentials are available.'}
|
||||
)
|
||||
return {'status': 'failed', 'error': 'Contpaqi handler not implemented'}
|
||||
|
||||
|
||||
# ─── SAP B1 Stub ─────────────────────────────
|
||||
|
||||
class SAPB1Handler(BaseERPHandler):
|
||||
"""Stub for SAP Business One sync.
|
||||
|
||||
SAP B1 provides a Service Layer REST API (OData-based).
|
||||
Requires: Service Layer URL, Company DB, username, password.
|
||||
"""
|
||||
|
||||
def sync(self, sync_type):
|
||||
log_id = log_sync_run(self.conn, self.config_id, sync_type, 'bidirectional', 'running')
|
||||
|
||||
complete_sync_run(
|
||||
self.conn, log_id, 'failed', 0, 0,
|
||||
error_message='SAP B1 handler not implemented. Requires Service Layer endpoint.',
|
||||
details={'message': 'Stub implementation. Activate when SAP B1 Service Layer is available.'}
|
||||
)
|
||||
return {'status': 'failed', 'error': 'SAP B1 handler not implemented'}
|
||||
|
||||
|
||||
# ─── Odoo Stub ─────────────────────────────
|
||||
|
||||
class OdooHandler(BaseERPHandler):
|
||||
"""Stub for Odoo sync.
|
||||
|
||||
Odoo provides XML-RPC and JSON-RPC APIs.
|
||||
Requires: URL, database name, username, API key.
|
||||
"""
|
||||
|
||||
def sync(self, sync_type):
|
||||
log_id = log_sync_run(self.conn, self.config_id, sync_type, 'bidirectional', 'running')
|
||||
|
||||
complete_sync_run(
|
||||
self.conn, log_id, 'failed', 0, 0,
|
||||
error_message='Odoo handler not implemented. Requires Odoo URL and API credentials.',
|
||||
details={'message': 'Stub implementation. Activate when Odoo credentials are available.'}
|
||||
)
|
||||
return {'status': 'failed', 'error': 'Odoo handler not implemented'}
|
||||
|
||||
|
||||
# Register handlers
|
||||
register_erp_handler('aspel_sae', AspelSAEHandler)
|
||||
register_erp_handler('contpaqi', ContpaqiHandler)
|
||||
register_erp_handler('sap_b1', SAPB1Handler)
|
||||
register_erp_handler('odoo', OdooHandler)
|
||||
Reference in New Issue
Block a user