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:
@@ -129,7 +129,10 @@ def _create_entry(cur, entry_number, entry_date, entry_type, description,
|
||||
f"Unbalanced entry: debits={total_debit} credits={total_credit}"
|
||||
)
|
||||
|
||||
created_by = getattr(g, 'employee_id', None)
|
||||
try:
|
||||
created_by = getattr(g, 'employee_id', None)
|
||||
except RuntimeError:
|
||||
created_by = None
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO journal_entries
|
||||
|
||||
@@ -25,22 +25,34 @@ def log_action(conn, action, entity_type=None, entity_id=None,
|
||||
device_id, ip_address, branch_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""", (
|
||||
getattr(g, 'employee_id', None),
|
||||
_safe_g('employee_id'),
|
||||
action,
|
||||
entity_type,
|
||||
entity_id,
|
||||
json.dumps(old_value) if old_value else None,
|
||||
json.dumps(new_value) if new_value else None,
|
||||
getattr(g, 'device_id', None),
|
||||
_safe_g('device_id'),
|
||||
_get_client_ip(),
|
||||
getattr(g, 'branch_id', None),
|
||||
_safe_g('branch_id'),
|
||||
))
|
||||
# Don't commit here — let the caller control the transaction
|
||||
|
||||
|
||||
|
||||
def _safe_g(attr, default=None):
|
||||
"""Safely read flask.g attribute outside of app context."""
|
||||
try:
|
||||
return getattr(g, attr, default)
|
||||
except RuntimeError:
|
||||
return default
|
||||
|
||||
|
||||
def _get_client_ip():
|
||||
"""Get client IP, handling proxies."""
|
||||
from flask import request
|
||||
if request.headers.get('X-Forwarded-For'):
|
||||
return request.headers['X-Forwarded-For'].split(',')[0].strip()
|
||||
return request.remote_addr
|
||||
try:
|
||||
from flask import request
|
||||
if request.headers.get('X-Forwarded-For'):
|
||||
return request.headers['X-Forwarded-For'].split(',')[0].strip()
|
||||
return request.remote_addr
|
||||
except RuntimeError:
|
||||
return None
|
||||
|
||||
188
pos/services/bnpl_engine.py
Normal file
188
pos/services/bnpl_engine.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""BNPL Engine: Buy Now Pay Later integration stub.
|
||||
|
||||
Supports APLAZO, Kueski, Clip as providers.
|
||||
This is an architecture stub — actual API integrations require:
|
||||
- APLAZO: merchant account + API credentials
|
||||
- Kueski: merchant account + API credentials
|
||||
- Clip: merchant account + API credentials
|
||||
|
||||
The tables (bnpl_transactions) are created in v2.6 migration.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
# Provider configuration stubs
|
||||
PROVIDER_CONFIGS = {
|
||||
'aplazo': {
|
||||
'api_base': 'https://api.aplazo.mx/v1',
|
||||
'auth_type': 'bearer', # OAuth2 client credentials
|
||||
'webhook_path': '/pos/api/bnpl/webhook/aplazo',
|
||||
},
|
||||
'kueski': {
|
||||
'api_base': 'https://api.kueskipay.io/v1',
|
||||
'auth_type': 'api_key',
|
||||
'webhook_path': '/pos/api/bnpl/webhook/kueski',
|
||||
},
|
||||
'clip': {
|
||||
'api_base': 'https://api.clip.mx/v1',
|
||||
'auth_type': 'bearer',
|
||||
'webhook_path': '/pos/api/bnpl/webhook/clip',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_provider_config(provider):
|
||||
return PROVIDER_CONFIGS.get(provider)
|
||||
|
||||
|
||||
def create_bnpl_transaction(conn, sale_id, customer_id, amount, provider='aplazo',
|
||||
installment_count=4, employee_id=None):
|
||||
"""Record a BNPL transaction in pending state.
|
||||
|
||||
In production, this would:
|
||||
1. Call the provider's API to create a checkout/session
|
||||
2. Store the provider's transaction ID
|
||||
3. Return a redirect URL for the customer to complete approval
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO bnpl_transactions
|
||||
(tenant_id, customer_id, sale_id, provider, amount,
|
||||
installment_count, status)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, 'pending')
|
||||
RETURNING id
|
||||
""", (None, customer_id, sale_id, provider, amount, installment_count))
|
||||
txn_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
cur.close()
|
||||
|
||||
return {
|
||||
'transaction_id': txn_id,
|
||||
'provider': provider,
|
||||
'status': 'pending',
|
||||
'amount': amount,
|
||||
'installment_count': installment_count,
|
||||
'checkout_url': None, # Would come from provider API
|
||||
'message': 'BNPL transaction recorded. Provider integration required for live checkout.',
|
||||
}
|
||||
|
||||
|
||||
def get_bnpl_transaction(conn, txn_id):
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT id, customer_id, sale_id, provider, provider_transaction_id,
|
||||
amount, status, installment_count, installment_amount,
|
||||
customer_fee, merchant_fee, provider_response, created_at, updated_at
|
||||
FROM bnpl_transactions WHERE id = %s
|
||||
""", (txn_id,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
'id': row[0], 'customer_id': row[1], 'sale_id': row[2],
|
||||
'provider': row[3], 'provider_transaction_id': row[4],
|
||||
'amount': float(row[5]) if row[5] else 0,
|
||||
'status': row[6], 'installment_count': row[7],
|
||||
'installment_amount': float(row[8]) if row[8] else None,
|
||||
'customer_fee': float(row[9]) if row[9] else 0,
|
||||
'merchant_fee': float(row[10]) if row[10] else 0,
|
||||
'provider_response': row[11],
|
||||
'created_at': str(row[12]), 'updated_at': str(row[13]),
|
||||
}
|
||||
|
||||
|
||||
def update_bnpl_status(conn, txn_id, new_status, provider_response=None,
|
||||
webhook_payload=None):
|
||||
"""Update BNPL transaction status (called by webhook or polling)."""
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
UPDATE bnpl_transactions
|
||||
SET status = %s,
|
||||
provider_response = COALESCE(provider_response, '{}'::jsonb) || COALESCE(%s, '{}'::jsonb),
|
||||
webhook_payload = COALESCE(webhook_payload, '{}'::jsonb) || COALESCE(%s, '{}'::jsonb),
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""", (new_status, json.dumps(provider_response) if provider_response else None,
|
||||
json.dumps(webhook_payload) if webhook_payload else None, txn_id))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return True
|
||||
|
||||
|
||||
def list_bnpl_transactions(conn, status=None, customer_id=None, provider=None,
|
||||
page=1, per_page=50):
|
||||
cur = conn.cursor()
|
||||
where_clauses = []
|
||||
params = []
|
||||
if status:
|
||||
where_clauses.append("status = %s")
|
||||
params.append(status)
|
||||
if customer_id:
|
||||
where_clauses.append("customer_id = %s")
|
||||
params.append(customer_id)
|
||||
if provider:
|
||||
where_clauses.append("provider = %s")
|
||||
params.append(provider)
|
||||
|
||||
where = " AND ".join(where_clauses) if where_clauses else "true"
|
||||
|
||||
cur.execute(f"SELECT count(*) FROM bnpl_transactions WHERE {where}", params)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT id, customer_id, sale_id, provider, amount, status,
|
||||
installment_count, created_at
|
||||
FROM bnpl_transactions
|
||||
WHERE {where}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""", params + [per_page, (page - 1) * per_page])
|
||||
|
||||
txns = []
|
||||
for r in cur.fetchall():
|
||||
txns.append({
|
||||
'id': r[0], 'customer_id': r[1], 'sale_id': r[2],
|
||||
'provider': r[3], 'amount': float(r[4]) if r[4] else 0,
|
||||
'status': r[5], 'installment_count': r[6],
|
||||
'created_at': str(r[7]),
|
||||
})
|
||||
cur.close()
|
||||
return {
|
||||
'data': txns,
|
||||
'pagination': {'page': page, 'per_page': per_page, 'total': total}
|
||||
}
|
||||
|
||||
|
||||
# ─── APLAZO API Stub ─────────────────────────────
|
||||
|
||||
def aplazo_create_checkout(amount, customer_email, customer_phone, order_id,
|
||||
api_key=None, api_secret=None):
|
||||
"""Stub for APLAZO checkout creation.
|
||||
|
||||
Production implementation requires:
|
||||
1. OAuth2 token exchange: POST /oauth/token
|
||||
2. Create checkout: POST /checkouts
|
||||
Body: {amount, merchantOrderId, customer: {email, phone}, callbacks: {onSuccess, onCancel, onReject}}
|
||||
3. Return checkout URL for customer redirect
|
||||
"""
|
||||
return {
|
||||
'checkout_url': None,
|
||||
'aplazo_order_id': None,
|
||||
'status': 'not_implemented',
|
||||
'message': 'APLAZO integration requires merchant credentials. Contact APLAZO to activate.',
|
||||
}
|
||||
|
||||
|
||||
def aplazo_webhook_handler(payload):
|
||||
"""Stub for APLAZO webhook handler.
|
||||
|
||||
Expected events: checkout.approved, checkout.rejected, checkout.cancelled,
|
||||
installment.paid, installment.failed
|
||||
"""
|
||||
return {
|
||||
'event_type': payload.get('event'),
|
||||
'handled': False,
|
||||
'message': 'APLAZO webhook handler stub. Implement when APLAZO credentials are available.',
|
||||
}
|
||||
@@ -1176,13 +1176,53 @@ def _get_alternatives(cur, part_id):
|
||||
# SMART SEARCH
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _search_meili_fallback(master_conn, q, limit):
|
||||
"""Search Meilisearch and hydrate from PostgreSQL.
|
||||
|
||||
Returns list of tuples (id_part, oem_part_number, name_part, name_es,
|
||||
image_url, group_id) or None if Meilisearch is unavailable.
|
||||
"""
|
||||
try:
|
||||
from services.meili_search import search_parts
|
||||
result = search_parts(q, limit=limit)
|
||||
if result is None:
|
||||
# Meilisearch error — signal fallback
|
||||
return None
|
||||
if not result.get('hits'):
|
||||
return []
|
||||
|
||||
hits = result['hits']
|
||||
part_ids = [h['id_part'] for h in hits]
|
||||
|
||||
cur = master_conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es,
|
||||
p.image_url, p.group_id
|
||||
FROM parts p
|
||||
WHERE p.id_part = ANY(%s)
|
||||
""", (part_ids,))
|
||||
pg_rows = {r[0]: r for r in cur.fetchall()}
|
||||
cur.close()
|
||||
|
||||
# Preserve Meilisearch ranking order
|
||||
rows = []
|
||||
for h in hits:
|
||||
row = pg_rows.get(h['id_part'])
|
||||
if row:
|
||||
rows.append(row)
|
||||
return rows
|
||||
except Exception:
|
||||
# Meilisearch unavailable — signal fallback
|
||||
return None
|
||||
|
||||
|
||||
def smart_search(master_conn, q, tenant_conn, branch_id, limit=50):
|
||||
"""Search parts by part number or text. Enriches with local stock.
|
||||
|
||||
Strategy:
|
||||
- If q looks like a part number (contains digits + hyphens): search oem_part_number ILIKE
|
||||
- If q is text: use PostgreSQL full-text search (search_vector) with ILIKE fallback
|
||||
- Always enriches results with local stock from tenant DB
|
||||
1. Try Meilisearch first (sub-100ms full-text + typo tolerance)
|
||||
2. Fallback to PostgreSQL tsvector / ILIKE if Meilisearch is down
|
||||
3. Always enriches results with local stock from tenant DB
|
||||
"""
|
||||
q = q.strip()
|
||||
if not q or len(q) < 2:
|
||||
@@ -1191,37 +1231,41 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50):
|
||||
limit = min(limit, 100)
|
||||
cur = master_conn.cursor()
|
||||
|
||||
is_part_number = bool(re.search(r'\d.*[-/]|[-/].*\d|\d{3,}', q))
|
||||
|
||||
if is_part_number:
|
||||
# Search by OEM part number
|
||||
clean_q = q.replace(' ', '').upper()
|
||||
cur.execute("""
|
||||
SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es,
|
||||
p.image_url, p.group_id
|
||||
FROM parts p
|
||||
WHERE REPLACE(UPPER(p.oem_part_number), ' ', '') LIKE %s
|
||||
ORDER BY p.oem_part_number
|
||||
LIMIT %s
|
||||
""", (f'%{clean_q}%', limit))
|
||||
# ── Attempt Meilisearch first ───────────────────────────────────────────
|
||||
meili_rows = _search_meili_fallback(master_conn, q, limit)
|
||||
if meili_rows is not None:
|
||||
rows = meili_rows
|
||||
else:
|
||||
# Full-text search using tsvector, fall back to ILIKE
|
||||
tsquery = ' & '.join(q.split())
|
||||
cur.execute("""
|
||||
SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es,
|
||||
p.image_url, p.group_id
|
||||
FROM parts p
|
||||
WHERE p.search_vector @@ to_tsquery('spanish', %s)
|
||||
OR p.name_part ILIKE %s
|
||||
OR p.name_es ILIKE %s
|
||||
ORDER BY
|
||||
CASE WHEN p.search_vector @@ to_tsquery('spanish', %s)
|
||||
THEN 0 ELSE 1 END,
|
||||
p.name_part
|
||||
LIMIT %s
|
||||
""", (tsquery, f'%{q}%', f'%{q}%', tsquery, limit))
|
||||
# PostgreSQL fallback
|
||||
is_part_number = bool(re.search(r'\d.*[-/]|[-/].*\d|\d{3,}', q))
|
||||
|
||||
if is_part_number:
|
||||
clean_q = q.replace(' ', '').upper()
|
||||
cur.execute("""
|
||||
SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es,
|
||||
p.image_url, p.group_id
|
||||
FROM parts p
|
||||
WHERE REPLACE(UPPER(p.oem_part_number), ' ', '') LIKE %s
|
||||
ORDER BY p.oem_part_number
|
||||
LIMIT %s
|
||||
""", (f'%{clean_q}%', limit))
|
||||
else:
|
||||
tsquery = ' & '.join(q.split())
|
||||
cur.execute("""
|
||||
SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es,
|
||||
p.image_url, p.group_id
|
||||
FROM parts p
|
||||
WHERE p.search_vector @@ to_tsquery('spanish', %s)
|
||||
OR p.name_part ILIKE %s
|
||||
OR p.name_es ILIKE %s
|
||||
ORDER BY
|
||||
CASE WHEN p.search_vector @@ to_tsquery('spanish', %s)
|
||||
THEN 0 ELSE 1 END,
|
||||
p.name_part
|
||||
LIMIT %s
|
||||
""", (tsquery, f'%{q}%', f'%{q}%', tsquery, limit))
|
||||
rows = cur.fetchall()
|
||||
|
||||
rows = cur.fetchall()
|
||||
if not rows:
|
||||
cur.close()
|
||||
return []
|
||||
|
||||
@@ -112,8 +112,16 @@ def build_ingreso_xml(sale, tenant_config, customer=None):
|
||||
if discount_total > 0:
|
||||
root.set('Descuento', _format_amount(discount_total))
|
||||
|
||||
root.set('Moneda', 'MXN')
|
||||
root.set('Total', _format_amount(sale['total']))
|
||||
sale_currency = sale.get('currency', 'MXN')
|
||||
sale_rate = sale.get('exchange_rate', 1.0)
|
||||
if sale_currency != 'MXN':
|
||||
# SAT requires MXN; convert and show exchange rate
|
||||
root.set('Moneda', 'MXN')
|
||||
root.set('TipoCambio', str(_to_dec(sale_rate).quantize(SIX, ROUND_HALF_UP)))
|
||||
root.set('Total', _format_amount(_to_dec(sale['total']) * _to_dec(sale_rate)))
|
||||
else:
|
||||
root.set('Moneda', 'MXN')
|
||||
root.set('Total', _format_amount(sale['total']))
|
||||
root.set('TipoDeComprobante', 'I') # Ingreso
|
||||
root.set('Exportacion', '01') # No aplica
|
||||
root.set('MetodoPago', sale.get('metodo_pago_sat', 'PUE'))
|
||||
@@ -237,8 +245,15 @@ def build_egreso_xml(sale, tenant_config, customer, original_uuid):
|
||||
if discount_total > 0:
|
||||
root.set('Descuento', _format_amount(discount_total))
|
||||
|
||||
root.set('Moneda', 'MXN')
|
||||
root.set('Total', _format_amount(sale['total']))
|
||||
sale_currency = sale.get('currency', 'MXN')
|
||||
sale_rate = sale.get('exchange_rate', 1.0)
|
||||
if sale_currency != 'MXN':
|
||||
root.set('Moneda', 'MXN')
|
||||
root.set('TipoCambio', str(_to_dec(sale_rate).quantize(SIX, ROUND_HALF_UP)))
|
||||
root.set('Total', _format_amount(_to_dec(sale['total']) * _to_dec(sale_rate)))
|
||||
else:
|
||||
root.set('Moneda', 'MXN')
|
||||
root.set('Total', _format_amount(sale['total']))
|
||||
root.set('TipoDeComprobante', 'E') # Egreso
|
||||
root.set('Exportacion', '01')
|
||||
root.set('MetodoPago', 'PUE')
|
||||
|
||||
370
pos/services/crm_engine.py
Normal file
370
pos/services/crm_engine.py
Normal file
@@ -0,0 +1,370 @@
|
||||
"""CRM Engine: customer activities, tags, loyalty, analytics.
|
||||
|
||||
Provides:
|
||||
- Activity timeline logging and retrieval
|
||||
- Customer tag management and assignment
|
||||
- Loyalty points accrual, redemption, and balance tracking
|
||||
- Customer analytics (LTV, frequency, churn risk)
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
# ─── Customer Activities ─────────────────────────────
|
||||
|
||||
def log_activity(conn, customer_id, activity_type, title=None, description=None,
|
||||
metadata=None, employee_id=None):
|
||||
"""Log a customer activity to the timeline."""
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO customer_activities
|
||||
(customer_id, activity_type, title, description, metadata, employee_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (customer_id, activity_type, title, description,
|
||||
metadata if metadata else None, employee_id))
|
||||
activity_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return activity_id
|
||||
|
||||
|
||||
def get_activities(conn, customer_id, activity_type=None, limit=50):
|
||||
"""Get customer activity timeline."""
|
||||
cur = conn.cursor()
|
||||
params = [customer_id]
|
||||
type_filter = ""
|
||||
if activity_type:
|
||||
type_filter = "AND activity_type = %s"
|
||||
params.append(activity_type)
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT a.id, a.activity_type, a.title, a.description, a.metadata,
|
||||
a.employee_id, e.name as employee_name, a.created_at
|
||||
FROM customer_activities a
|
||||
LEFT JOIN employees e ON a.employee_id = e.id
|
||||
WHERE a.customer_id = %s {type_filter}
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT %s
|
||||
""", params + [limit])
|
||||
|
||||
activities = []
|
||||
for r in cur.fetchall():
|
||||
activities.append({
|
||||
'id': r[0], 'activity_type': r[1], 'title': r[2],
|
||||
'description': r[3], 'metadata': r[4],
|
||||
'employee_id': r[5], 'employee_name': r[6],
|
||||
'created_at': str(r[7]),
|
||||
})
|
||||
cur.close()
|
||||
return activities
|
||||
|
||||
|
||||
# ─── Customer Tags ─────────────────────────────
|
||||
|
||||
def create_tag(conn, tenant_id, name, color='#6B7280', description=None):
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO customer_tags (tenant_id, name, color, description)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (tenant_id, name, color, description))
|
||||
tag_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return tag_id
|
||||
|
||||
|
||||
def list_tags(conn, tenant_id):
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT id, name, color, description, created_at
|
||||
FROM customer_tags
|
||||
WHERE tenant_id = %s
|
||||
ORDER BY name
|
||||
""", (tenant_id,))
|
||||
tags = []
|
||||
for r in cur.fetchall():
|
||||
tags.append({
|
||||
'id': r[0], 'name': r[1], 'color': r[2],
|
||||
'description': r[3], 'created_at': str(r[4]),
|
||||
})
|
||||
cur.close()
|
||||
return tags
|
||||
|
||||
|
||||
def assign_tag(conn, customer_id, tag_id, assigned_by=None):
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO customer_tag_assignments (customer_id, tag_id, assigned_by)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (customer_id, tag_id) DO NOTHING
|
||||
""", (customer_id, tag_id, assigned_by))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return True
|
||||
|
||||
|
||||
def remove_tag(conn, customer_id, tag_id):
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
DELETE FROM customer_tag_assignments
|
||||
WHERE customer_id = %s AND tag_id = %s
|
||||
""", (customer_id, tag_id))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return True
|
||||
|
||||
|
||||
def get_customer_tags(conn, customer_id):
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT t.id, t.name, t.color
|
||||
FROM customer_tags t
|
||||
JOIN customer_tag_assignments a ON t.id = a.tag_id
|
||||
WHERE a.customer_id = %s
|
||||
ORDER BY t.name
|
||||
""", (customer_id,))
|
||||
tags = [{'id': r[0], 'name': r[1], 'color': r[2]} for r in cur.fetchall()]
|
||||
cur.close()
|
||||
return tags
|
||||
|
||||
|
||||
# ─── Loyalty Program ─────────────────────────────
|
||||
|
||||
def add_loyalty_points(conn, customer_id, points, points_type='earned',
|
||||
source_type=None, source_id=None, description=None,
|
||||
expires_at=None):
|
||||
"""Add loyalty points to a customer. Updates denormalized balance."""
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO loyalty_points
|
||||
(customer_id, points, points_type, source_type, source_id, description, expires_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (customer_id, points, points_type, source_type, source_id, description, expires_at))
|
||||
point_id = cur.fetchone()[0]
|
||||
|
||||
# Update denormalized balance
|
||||
_recalculate_loyalty_balance(conn, customer_id, cur)
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return point_id
|
||||
|
||||
|
||||
def redeem_points(conn, customer_id, points_to_use, reward_id=None,
|
||||
reward_value=None, description=None, employee_id=None):
|
||||
"""Redeem loyalty points. Returns redemption_id or raises ValueError."""
|
||||
cur = conn.cursor()
|
||||
|
||||
# Check available balance
|
||||
cur.execute("""
|
||||
SELECT COALESCE(SUM(points), 0)
|
||||
FROM loyalty_points
|
||||
WHERE customer_id = %s
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
""", (customer_id,))
|
||||
available = cur.fetchone()[0] or 0
|
||||
|
||||
if available < points_to_use:
|
||||
cur.close()
|
||||
raise ValueError(f"Insufficient points: available={available}, requested={points_to_use}")
|
||||
|
||||
# Record redemption
|
||||
cur.execute("""
|
||||
INSERT INTO loyalty_redemptions
|
||||
(customer_id, reward_id, points_used, reward_value, description, employee_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (customer_id, reward_id, points_to_use, reward_value, description, employee_id))
|
||||
redemption_id = cur.fetchone()[0]
|
||||
|
||||
# Deduct points (record negative entry)
|
||||
cur.execute("""
|
||||
INSERT INTO loyalty_points
|
||||
(customer_id, points, points_type, source_type, description)
|
||||
VALUES (%s, %s, 'redeemed', 'redemption', %s)
|
||||
""", (customer_id, -points_to_use, description))
|
||||
|
||||
_recalculate_loyalty_balance(conn, customer_id, cur)
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return redemption_id
|
||||
|
||||
|
||||
def _recalculate_loyalty_balance(conn, customer_id, cur=None):
|
||||
should_close = cur is None
|
||||
if should_close:
|
||||
cur = conn.cursor()
|
||||
|
||||
# Calculate total non-expired points
|
||||
cur.execute("""
|
||||
SELECT COALESCE(SUM(points), 0)
|
||||
FROM loyalty_points
|
||||
WHERE customer_id = %s
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
""", (customer_id,))
|
||||
balance = cur.fetchone()[0] or 0
|
||||
|
||||
# Determine tier
|
||||
tier = 'bronze'
|
||||
if balance >= 5000:
|
||||
tier = 'platinum'
|
||||
elif balance >= 2000:
|
||||
tier = 'gold'
|
||||
elif balance >= 500:
|
||||
tier = 'silver'
|
||||
|
||||
cur.execute("""
|
||||
UPDATE customers
|
||||
SET loyalty_points_balance = %s, loyalty_tier = %s
|
||||
WHERE id = %s
|
||||
""", (balance, tier, customer_id))
|
||||
|
||||
if should_close:
|
||||
conn.commit()
|
||||
cur.close()
|
||||
|
||||
|
||||
def get_loyalty_history(conn, customer_id, limit=50):
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT id, points, points_type, source_type, source_id,
|
||||
description, expires_at, created_at
|
||||
FROM loyalty_points
|
||||
WHERE customer_id = %s
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %s
|
||||
""", (customer_id, limit))
|
||||
history = []
|
||||
for r in cur.fetchall():
|
||||
history.append({
|
||||
'id': r[0], 'points': r[1], 'points_type': r[2],
|
||||
'source_type': r[3], 'source_id': r[4],
|
||||
'description': r[5], 'expires_at': str(r[6]) if r[6] else None,
|
||||
'created_at': str(r[7]),
|
||||
})
|
||||
cur.close()
|
||||
return history
|
||||
|
||||
|
||||
def create_reward(conn, tenant_id, name, points_cost, reward_type='discount',
|
||||
reward_value=None, description=None):
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO loyalty_rewards
|
||||
(tenant_id, name, description, points_cost, reward_type, reward_value)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (tenant_id, name, description, points_cost, reward_type, reward_value))
|
||||
reward_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return reward_id
|
||||
|
||||
|
||||
def list_rewards(conn, tenant_id):
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT id, name, description, points_cost, reward_type, reward_value, is_active
|
||||
FROM loyalty_rewards
|
||||
WHERE tenant_id = %s AND is_active = true
|
||||
ORDER BY points_cost
|
||||
""", (tenant_id,))
|
||||
rewards = []
|
||||
for r in cur.fetchall():
|
||||
rewards.append({
|
||||
'id': r[0], 'name': r[1], 'description': r[2],
|
||||
'points_cost': r[3], 'reward_type': r[4], 'reward_value': float(r[5]) if r[5] else None,
|
||||
'is_active': r[6],
|
||||
})
|
||||
cur.close()
|
||||
return rewards
|
||||
|
||||
|
||||
# ─── Customer Analytics ─────────────────────────────
|
||||
|
||||
def get_customer_analytics(conn, customer_id):
|
||||
"""Compute LTV, purchase frequency, favorite categories, churn risk."""
|
||||
cur = conn.cursor()
|
||||
|
||||
# LTV and frequency
|
||||
cur.execute("""
|
||||
SELECT
|
||||
COALESCE(SUM(total), 0) as ltv,
|
||||
COUNT(*) as total_orders,
|
||||
MIN(created_at) as first_purchase,
|
||||
MAX(created_at) as last_purchase,
|
||||
COALESCE(AVG(total), 0) as avg_order_value
|
||||
FROM sales
|
||||
WHERE customer_id = %s AND status = 'completed'
|
||||
""", (customer_id,))
|
||||
ltv, total_orders, first_purchase, last_purchase, aov = cur.fetchone()
|
||||
|
||||
# Days since last purchase
|
||||
days_since_last = None
|
||||
if last_purchase:
|
||||
days_since_last = (datetime.utcnow() - last_purchase).days
|
||||
|
||||
# Purchase frequency (orders per month)
|
||||
frequency = 0.0
|
||||
if first_purchase and last_purchase and total_orders > 1:
|
||||
months = max(1, (last_purchase - first_purchase).days / 30.0)
|
||||
frequency = round(total_orders / months, 2)
|
||||
|
||||
# Churn risk: no purchase in 90+ days = high, 60-90 = medium, <60 = low
|
||||
churn_risk = 'low'
|
||||
if days_since_last is not None:
|
||||
if days_since_last > 90:
|
||||
churn_risk = 'high'
|
||||
elif days_since_last > 60:
|
||||
churn_risk = 'medium'
|
||||
|
||||
# Favorite categories (from inventory via sale_items)
|
||||
cur.execute("""
|
||||
SELECT COALESCE(i.category_id::text, 'Uncategorized') as category,
|
||||
COUNT(*) as cnt, SUM(si.subtotal) as revenue
|
||||
FROM sale_items si
|
||||
JOIN sales s ON s.id = si.sale_id
|
||||
LEFT JOIN inventory i ON si.inventory_id = i.id
|
||||
WHERE s.customer_id = %s AND s.status = 'completed'
|
||||
GROUP BY i.category_id
|
||||
ORDER BY revenue DESC
|
||||
LIMIT 5
|
||||
""", (customer_id,))
|
||||
categories = []
|
||||
for r in cur.fetchall():
|
||||
categories.append({
|
||||
'category': r[0] or 'Uncategorized',
|
||||
'order_count': r[1],
|
||||
'revenue': float(r[2]) if r[2] else 0,
|
||||
})
|
||||
|
||||
# Loyalty status
|
||||
cur.execute("""
|
||||
SELECT loyalty_points_balance, loyalty_tier
|
||||
FROM customers WHERE id = %s
|
||||
""", (customer_id,))
|
||||
row = cur.fetchone()
|
||||
loyalty_balance = row[0] or 0
|
||||
loyalty_tier = row[1] or 'bronze'
|
||||
|
||||
cur.close()
|
||||
|
||||
return {
|
||||
'ltv': float(ltv) if ltv else 0,
|
||||
'total_orders': total_orders or 0,
|
||||
'avg_order_value': float(aov) if aov else 0,
|
||||
'first_purchase': str(first_purchase) if first_purchase else None,
|
||||
'last_purchase': str(last_purchase) if last_purchase else None,
|
||||
'days_since_last_purchase': days_since_last,
|
||||
'purchase_frequency_monthly': frequency,
|
||||
'churn_risk': churn_risk,
|
||||
'favorite_categories': categories,
|
||||
'loyalty': {
|
||||
'points_balance': loyalty_balance,
|
||||
'tier': loyalty_tier,
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,131 @@
|
||||
"""Multi-currency support for border refaccionarias.
|
||||
|
||||
Supports MXN and USD with configurable exchange rate.
|
||||
Supports MXN and USD with configurable exchange rate per tenant.
|
||||
Rates are cached in Redis for 60 seconds to avoid repeated DB hits.
|
||||
|
||||
Business rule: inventory prices are ALWAYS in MXN (base currency).
|
||||
Sales can be recorded in USD with conversion at checkout time.
|
||||
Accounting and CFDI always use MXN.
|
||||
"""
|
||||
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
from config import DEFAULT_CURRENCY, EXCHANGE_RATE_USD_MXN
|
||||
from services.redis_stock_cache import _get_redis
|
||||
|
||||
CURRENCIES = {
|
||||
'MXN': {'symbol': '$', 'name': 'Peso Mexicano', 'name_en': 'Mexican Peso', 'decimals': 2},
|
||||
'USD': {'symbol': 'US$', 'name': 'Dolar Estadounidense', 'name_en': 'US Dollar', 'decimals': 2},
|
||||
}
|
||||
|
||||
# Cache TTL for exchange rates in Redis (seconds)
|
||||
_RATE_TTL = 60
|
||||
|
||||
def convert(amount, from_currency, to_currency, rate=None):
|
||||
|
||||
def _to_dec(val):
|
||||
if val is None:
|
||||
return Decimal('0')
|
||||
return Decimal(str(val))
|
||||
|
||||
|
||||
def get_exchange_rate(conn, from_currency, to_currency):
|
||||
"""Get the exchange rate from tenant_config, with Redis cache.
|
||||
|
||||
Returns:
|
||||
Decimal: rate such that amount * rate = converted amount
|
||||
(e.g., USD->MXN returns ~17.5, MXN->USD returns ~0.057)
|
||||
"""
|
||||
if from_currency == to_currency:
|
||||
return Decimal('1')
|
||||
|
||||
cache_key = f"nexus:rate:{from_currency}:{to_currency}"
|
||||
|
||||
# Try Redis first
|
||||
r = _get_redis()
|
||||
if r:
|
||||
try:
|
||||
cached = r.get(cache_key)
|
||||
if cached:
|
||||
return Decimal(str(cached))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: read from tenant_config DB
|
||||
rate = None
|
||||
if conn:
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT value FROM tenant_config WHERE key = 'exchange_rate_usd_mxn'"
|
||||
)
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
if row and row[0]:
|
||||
rate = Decimal(str(row[0]))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if rate is None:
|
||||
rate = _to_dec(EXCHANGE_RATE_USD_MXN)
|
||||
|
||||
# Compute cross rate
|
||||
if from_currency == 'USD' and to_currency == 'MXN':
|
||||
result = rate
|
||||
elif from_currency == 'MXN' and to_currency == 'USD':
|
||||
result = (Decimal('1') / rate).quantize(Decimal('0.000001'), rounding=ROUND_HALF_UP)
|
||||
else:
|
||||
result = Decimal('1')
|
||||
|
||||
# Cache in Redis
|
||||
if r:
|
||||
try:
|
||||
r.set(cache_key, str(result), ex=_RATE_TTL)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def convert(amount, from_currency, to_currency, rate=None, conn=None):
|
||||
"""Convert an amount between currencies.
|
||||
|
||||
Args:
|
||||
amount: The numeric amount to convert.
|
||||
from_currency: Source currency code ('MXN' or 'USD').
|
||||
to_currency: Target currency code ('MXN' or 'USD').
|
||||
rate: Optional custom exchange rate (USD->MXN). Defaults to config value.
|
||||
amount: numeric amount to convert.
|
||||
from_currency: source currency code.
|
||||
to_currency: target currency code.
|
||||
rate: optional pre-computed rate (skips DB lookup).
|
||||
conn: optional DB connection to look up tenant rate.
|
||||
|
||||
Returns:
|
||||
The converted amount, rounded to 2 decimals.
|
||||
float: converted amount rounded to 2 decimals.
|
||||
"""
|
||||
if from_currency == to_currency:
|
||||
return amount
|
||||
return float(amount)
|
||||
|
||||
if rate is None:
|
||||
rate = EXCHANGE_RATE_USD_MXN
|
||||
if from_currency == 'USD' and to_currency == 'MXN':
|
||||
return round(amount * rate, 2)
|
||||
if from_currency == 'MXN' and to_currency == 'USD':
|
||||
return round(amount / rate, 2)
|
||||
return amount
|
||||
rate = get_exchange_rate(conn, from_currency, to_currency)
|
||||
|
||||
amt = _to_dec(amount)
|
||||
rate_dec = _to_dec(rate)
|
||||
result = (amt * rate_dec).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
|
||||
return float(result)
|
||||
|
||||
|
||||
def to_mxn(amount, currency, rate=None, conn=None):
|
||||
"""Convert an amount to MXN (convenience wrapper)."""
|
||||
return convert(amount, currency, 'MXN', rate=rate, conn=conn)
|
||||
|
||||
|
||||
def from_mxn(amount, currency, rate=None, conn=None):
|
||||
"""Convert an amount from MXN to target currency."""
|
||||
return convert(amount, 'MXN', currency, rate=rate, conn=conn)
|
||||
|
||||
|
||||
def format_currency(amount, currency='MXN'):
|
||||
"""Format an amount with the appropriate currency symbol.
|
||||
|
||||
Args:
|
||||
amount: Numeric value.
|
||||
currency: Currency code.
|
||||
|
||||
Returns:
|
||||
Formatted string like '$1,234.56' or 'US$1,234.56'.
|
||||
str: e.g. '$1,234.56' or 'US$1,234.56'.
|
||||
"""
|
||||
info = CURRENCIES.get(currency, CURRENCIES['MXN'])
|
||||
return f"{info['symbol']}{amount:,.{info['decimals']}f}"
|
||||
@@ -52,4 +135,17 @@ def get_currency_info(code=None):
|
||||
"""Return currency metadata dict. If code is None, return all."""
|
||||
if code:
|
||||
return CURRENCIES.get(code)
|
||||
return CURRENCIES
|
||||
return CURRENCIES.copy()
|
||||
|
||||
|
||||
def invalidate_rate_cache():
|
||||
"""Clear all cached exchange rates from Redis."""
|
||||
r = _get_redis()
|
||||
if not r:
|
||||
return
|
||||
try:
|
||||
keys = r.keys('nexus:rate:*')
|
||||
if keys:
|
||||
r.delete(*keys)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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)
|
||||
165
pos/services/image_service.py
Normal file
165
pos/services/image_service.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""Image Service: part image upload, processing, and storage.
|
||||
|
||||
Stores images at:
|
||||
/home/Autopartes/data/images/parts/<tenant_id>/<item_id>_full.webp
|
||||
/home/Autopartes/data/images/parts/<tenant_id>/<item_id>_thumb.webp
|
||||
|
||||
Serves statically via Flask at /pos/static/images/parts/<tenant_id>/<filename>
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import requests
|
||||
from io import BytesIO
|
||||
from PIL import Image
|
||||
|
||||
# Base directories
|
||||
DATA_DIR = '/home/Autopartes/data/images/parts'
|
||||
STATIC_DIR = '/home/Autopartes/pos/static/images/parts'
|
||||
|
||||
# Image processing settings
|
||||
MAX_WIDTH = 1200
|
||||
THUMB_SIZE = (300, 300)
|
||||
FORMAT = 'WEBP'
|
||||
QUALITY = 85
|
||||
THUMB_QUALITY = 80
|
||||
|
||||
|
||||
def _ensure_dir(path):
|
||||
os.makedirs(path, exist_ok=True)
|
||||
|
||||
|
||||
def _build_paths(tenant_id, item_id):
|
||||
"""Return (data_dir, static_dir, basename) for an item."""
|
||||
ddir = os.path.join(DATA_DIR, str(tenant_id))
|
||||
sdir = os.path.join(STATIC_DIR, str(tenant_id))
|
||||
basename = str(item_id)
|
||||
return ddir, sdir, basename
|
||||
|
||||
|
||||
def _process_image(img):
|
||||
"""Resize and convert image to WebP. Returns (full_bytes, thumb_bytes)."""
|
||||
# Convert to RGB if necessary (e.g. RGBA -> RGB for WEBP compatibility)
|
||||
if img.mode in ('RGBA', 'P'):
|
||||
img = img.convert('RGB')
|
||||
|
||||
# Resize original if too wide
|
||||
w, h = img.size
|
||||
if w > MAX_WIDTH:
|
||||
ratio = MAX_WIDTH / w
|
||||
new_h = int(h * ratio)
|
||||
img = img.resize((MAX_WIDTH, new_h), Image.LANCZOS)
|
||||
|
||||
# Full image
|
||||
full_buf = BytesIO()
|
||||
img.save(full_buf, format=FORMAT, quality=QUALITY, optimize=True)
|
||||
full_buf.seek(0)
|
||||
|
||||
# Thumbnail (crop to square from center)
|
||||
thumb = img.copy()
|
||||
tw, th = thumb.size
|
||||
min_dim = min(tw, th)
|
||||
left = (tw - min_dim) // 2
|
||||
top = (th - min_dim) // 2
|
||||
thumb = thumb.crop((left, top, left + min_dim, top + min_dim))
|
||||
thumb = thumb.resize(THUMB_SIZE, Image.LANCZOS)
|
||||
|
||||
thumb_buf = BytesIO()
|
||||
thumb.save(thumb_buf, format=FORMAT, quality=THUMB_QUALITY, optimize=True)
|
||||
thumb_buf.seek(0)
|
||||
|
||||
return full_buf, thumb_buf
|
||||
|
||||
|
||||
def save_image(tenant_id, item_id, file_obj=None, image_url=None,
|
||||
filename_hint=None):
|
||||
"""Save an image for an inventory item.
|
||||
|
||||
Args:
|
||||
tenant_id: tenant ID for path isolation
|
||||
item_id: inventory item ID
|
||||
file_obj: file-like object (from Flask request.files)
|
||||
image_url: URL to download image from
|
||||
filename_hint: original filename for extension detection
|
||||
|
||||
Returns:
|
||||
dict with 'image_url', 'thumb_url', 'size_full', 'size_thumb'
|
||||
"""
|
||||
if not file_obj and not image_url:
|
||||
raise ValueError("Either file_obj or image_url is required")
|
||||
|
||||
# Load image
|
||||
if file_obj:
|
||||
img = Image.open(file_obj)
|
||||
else:
|
||||
resp = requests.get(image_url, timeout=30)
|
||||
resp.raise_for_status()
|
||||
img = Image.open(BytesIO(resp.content))
|
||||
|
||||
# Process
|
||||
full_buf, thumb_buf = _process_image(img)
|
||||
|
||||
# Save to filesystem
|
||||
ddir, sdir, basename = _build_paths(tenant_id, item_id)
|
||||
_ensure_dir(ddir)
|
||||
_ensure_dir(sdir)
|
||||
|
||||
full_path = os.path.join(ddir, f"{basename}_full.webp")
|
||||
thumb_path = os.path.join(ddir, f"{basename}_thumb.webp")
|
||||
|
||||
# Also symlink/copy to static dir for Flask serving
|
||||
static_full = os.path.join(sdir, f"{basename}_full.webp")
|
||||
static_thumb = os.path.join(sdir, f"{basename}_thumb.webp")
|
||||
|
||||
with open(full_path, 'wb') as f:
|
||||
f.write(full_buf.read())
|
||||
with open(thumb_path, 'wb') as f:
|
||||
f.write(thumb_buf.read())
|
||||
|
||||
# Copy to static dir
|
||||
import shutil
|
||||
shutil.copy2(full_path, static_full)
|
||||
shutil.copy2(thumb_path, static_thumb)
|
||||
|
||||
# Build URLs
|
||||
image_url = f"/pos/static/images/parts/{tenant_id}/{basename}_full.webp"
|
||||
thumb_url = f"/pos/static/images/parts/{tenant_id}/{basename}_thumb.webp"
|
||||
|
||||
return {
|
||||
'image_url': image_url,
|
||||
'thumb_url': thumb_url,
|
||||
'size_full': os.path.getsize(full_path),
|
||||
'size_thumb': os.path.getsize(thumb_path),
|
||||
}
|
||||
|
||||
|
||||
def delete_image(tenant_id, item_id):
|
||||
"""Delete all images for an inventory item."""
|
||||
ddir, sdir, basename = _build_paths(tenant_id, item_id)
|
||||
deleted = []
|
||||
|
||||
for directory in [ddir, sdir]:
|
||||
for suffix in ['_full.webp', '_thumb.webp']:
|
||||
path = os.path.join(directory, f"{basename}{suffix}")
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
deleted.append(path)
|
||||
|
||||
return {'deleted': deleted}
|
||||
|
||||
|
||||
def get_image_info(tenant_id, item_id):
|
||||
"""Get image info for an inventory item."""
|
||||
ddir, sdir, basename = _build_paths(tenant_id, item_id)
|
||||
full_path = os.path.join(ddir, f"{basename}_full.webp")
|
||||
thumb_path = os.path.join(ddir, f"{basename}_thumb.webp")
|
||||
|
||||
info = {'has_image': False, 'image_url': None, 'thumb_url': None}
|
||||
if os.path.exists(full_path):
|
||||
info['has_image'] = True
|
||||
info['image_url'] = f"/pos/static/images/parts/{tenant_id}/{basename}_full.webp"
|
||||
info['thumb_url'] = f"/pos/static/images/parts/{tenant_id}/{basename}_thumb.webp"
|
||||
info['size_full'] = os.path.getsize(full_path)
|
||||
info['size_thumb'] = os.path.getsize(thumb_path)
|
||||
info['updated_at'] = os.path.getmtime(full_path)
|
||||
return info
|
||||
@@ -9,10 +9,30 @@ Operations are append-only. No UPDATE, no DELETE on inventory_operations.
|
||||
|
||||
from flask import g
|
||||
from services.audit import log_action
|
||||
from services.redis_stock_cache import (
|
||||
get_cached_stock, set_cached_stock, invalidate_stock
|
||||
)
|
||||
|
||||
|
||||
def _safe_g(attr, default=None):
|
||||
"""Safely read flask.g attribute outside of app context."""
|
||||
try:
|
||||
return getattr(g, attr, default)
|
||||
except RuntimeError:
|
||||
return default
|
||||
|
||||
|
||||
def get_stock(conn, inventory_id, branch_id=None):
|
||||
"""Get current stock for an inventory item. Optionally filter by branch."""
|
||||
"""Get current stock for an inventory item. Optionally filter by branch.
|
||||
|
||||
Uses Redis cache first, falls back to PostgreSQL SUM query.
|
||||
"""
|
||||
# Try Redis first
|
||||
cached = get_cached_stock(inventory_id, branch_id)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# Fallback to PostgreSQL
|
||||
cur = conn.cursor()
|
||||
if branch_id:
|
||||
cur.execute(
|
||||
@@ -26,11 +46,18 @@ def get_stock(conn, inventory_id, branch_id=None):
|
||||
)
|
||||
stock = cur.fetchone()[0]
|
||||
cur.close()
|
||||
|
||||
# Cache the result
|
||||
set_cached_stock(inventory_id, stock, branch_id)
|
||||
return stock
|
||||
|
||||
|
||||
def get_stock_bulk(conn, branch_id=None):
|
||||
"""Get stock for all items. Returns dict {inventory_id: stock_quantity}."""
|
||||
"""Get stock for all items. Returns dict {inventory_id: stock_quantity}.
|
||||
|
||||
Uses PostgreSQL directly (bulk operation, Redis wouldn't help much here
|
||||
unless we pre-populated all keys).
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
if branch_id:
|
||||
cur.execute("""
|
||||
@@ -46,6 +73,11 @@ def get_stock_bulk(conn, branch_id=None):
|
||||
""")
|
||||
stock_map = {r[0]: r[1] for r in cur.fetchall()}
|
||||
cur.close()
|
||||
|
||||
# Populate Redis cache with results
|
||||
for inv_id, qty in stock_map.items():
|
||||
set_cached_stock(inv_id, qty, branch_id)
|
||||
|
||||
return stock_map
|
||||
|
||||
|
||||
@@ -67,8 +99,8 @@ def record_operation(conn, inventory_id, branch_id, operation_type, quantity,
|
||||
""", (
|
||||
inventory_id, branch_id, operation_type, quantity,
|
||||
reference_id, reference_type, cost_at_time,
|
||||
getattr(g, 'employee_id', None),
|
||||
getattr(g, 'device_id', None),
|
||||
_safe_g('employee_id'),
|
||||
_safe_g('device_id'),
|
||||
notes
|
||||
))
|
||||
op_id = cur.fetchone()[0]
|
||||
@@ -84,50 +116,72 @@ def record_purchase(conn, inventory_id, branch_id, quantity, unit_cost,
|
||||
must use TOTAL stock across ALL branches when computing the weighted average.
|
||||
Using branch-scoped stock would produce incorrect averages when the same item
|
||||
exists in multiple branches.
|
||||
|
||||
Uses SELECT ... FOR UPDATE to prevent race conditions on concurrent purchases
|
||||
of the same item.
|
||||
"""
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
TWO = Decimal('0.01')
|
||||
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT cost FROM inventory WHERE id = %s", (inventory_id,))
|
||||
current_cost = float(cur.fetchone()[0] or 0)
|
||||
cur.execute("SELECT cost FROM inventory WHERE id = %s FOR UPDATE", (inventory_id,))
|
||||
row = cur.fetchone()
|
||||
current_cost = Decimal(str(row[0] or 0)) if row else Decimal('0')
|
||||
|
||||
# Use GLOBAL stock (all branches) because cost is a global field on the inventory item
|
||||
current_stock = get_stock(conn, inventory_id, branch_id=None)
|
||||
current_stock = Decimal(str(get_stock(conn, inventory_id, branch_id=None) or 0))
|
||||
qty_dec = Decimal(str(quantity))
|
||||
unit_cost_dec = Decimal(str(unit_cost))
|
||||
|
||||
# Weighted average cost
|
||||
if current_stock + quantity > 0:
|
||||
new_cost = ((current_cost * max(current_stock, 0)) + (unit_cost * quantity)) / (max(current_stock, 0) + quantity)
|
||||
# Weighted average cost (Decimal arithmetic)
|
||||
stock_plus_qty = current_stock + qty_dec
|
||||
if stock_plus_qty > 0:
|
||||
numerator = (current_cost * current_stock) + (unit_cost_dec * qty_dec)
|
||||
new_cost = (numerator / stock_plus_qty).quantize(TWO, rounding=ROUND_HALF_UP)
|
||||
else:
|
||||
new_cost = unit_cost
|
||||
new_cost = unit_cost_dec
|
||||
|
||||
# Update cost on inventory item
|
||||
cur.execute("UPDATE inventory SET cost = %s WHERE id = %s", (round(new_cost, 2), inventory_id))
|
||||
cur.execute("UPDATE inventory SET cost = %s WHERE id = %s", (float(new_cost), inventory_id))
|
||||
cur.close()
|
||||
|
||||
ref_note = f"Compra: {quantity} uds @ ${unit_cost:.2f}"
|
||||
ref_note = f"Compra: {quantity} uds @ ${float(unit_cost_dec):.2f}"
|
||||
if supplier_invoice:
|
||||
ref_note += f" | Factura: {supplier_invoice}"
|
||||
if notes:
|
||||
ref_note += f" | {notes}"
|
||||
|
||||
return record_operation(
|
||||
result = record_operation(
|
||||
conn, inventory_id, branch_id, 'PURCHASE', quantity,
|
||||
cost_at_time=unit_cost, notes=ref_note
|
||||
cost_at_time=float(unit_cost_dec), notes=ref_note
|
||||
)
|
||||
invalidate_stock(inventory_id, branch_id)
|
||||
invalidate_stock(inventory_id, None)
|
||||
return result
|
||||
|
||||
|
||||
def record_sale(conn, inventory_id, branch_id, quantity, sale_id=None, cost_at_time=None):
|
||||
def record_sale(conn, inventory_id, branch_id, quantity, sale_id=None, cost_at_time=None, remaining_stock=None):
|
||||
"""Record a sale (negative quantity).
|
||||
|
||||
NOT exposed via HTTP endpoint — called directly by the POS blueprint (Plan 3)
|
||||
NOT exposed via HTTP endpoint — called directly by the POS blueprint
|
||||
which imports inventory_engine as part of the full sale transaction.
|
||||
|
||||
Args:
|
||||
remaining_stock: optional pre-calculated stock to avoid redundant SUM query.
|
||||
If None, stock will be calculated internally.
|
||||
"""
|
||||
op_id = record_operation(
|
||||
conn, inventory_id, branch_id, 'SALE', -abs(quantity),
|
||||
reference_id=sale_id, reference_type='sale', cost_at_time=cost_at_time
|
||||
)
|
||||
|
||||
# Invalidate cache immediately
|
||||
invalidate_stock(inventory_id, branch_id)
|
||||
invalidate_stock(inventory_id, None)
|
||||
|
||||
# Check if stock hit zero — push to owner (best-effort)
|
||||
try:
|
||||
remaining = get_stock(conn, inventory_id, branch_id)
|
||||
remaining = remaining_stock if remaining_stock is not None else get_stock(conn, inventory_id, branch_id)
|
||||
if remaining <= 0:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT part_number, name FROM inventory WHERE id = %s", (inventory_id,))
|
||||
@@ -149,10 +203,13 @@ def record_sale(conn, inventory_id, branch_id, quantity, sale_id=None, cost_at_t
|
||||
|
||||
def record_return(conn, inventory_id, branch_id, quantity, sale_id=None, notes=None):
|
||||
"""Record a customer return (positive quantity)."""
|
||||
return record_operation(
|
||||
result = record_operation(
|
||||
conn, inventory_id, branch_id, 'RETURN', abs(quantity),
|
||||
reference_id=sale_id, reference_type='return', notes=notes
|
||||
)
|
||||
invalidate_stock(inventory_id, branch_id)
|
||||
invalidate_stock(inventory_id, None)
|
||||
return result
|
||||
|
||||
|
||||
def record_adjustment(conn, inventory_id, branch_id, quantity, reason):
|
||||
@@ -164,10 +221,13 @@ def record_adjustment(conn, inventory_id, branch_id, quantity, reason):
|
||||
old_value={'stock': get_stock(conn, inventory_id, branch_id)},
|
||||
new_value={'adjustment': quantity, 'reason': reason})
|
||||
|
||||
return record_operation(
|
||||
result = record_operation(
|
||||
conn, inventory_id, branch_id, 'ADJUST', quantity,
|
||||
notes=f"Ajuste: {reason}"
|
||||
)
|
||||
invalidate_stock(inventory_id, branch_id)
|
||||
invalidate_stock(inventory_id, None)
|
||||
return result
|
||||
|
||||
|
||||
def record_transfer(conn, inventory_id, from_branch_id, to_branch_id, quantity, notes=None):
|
||||
@@ -180,15 +240,21 @@ def record_transfer(conn, inventory_id, from_branch_id, to_branch_id, quantity,
|
||||
conn, inventory_id, to_branch_id, 'TRANSFER', abs(quantity),
|
||||
notes=f"Transferencia desde sucursal {from_branch_id}" + (f" | {notes}" if notes else "")
|
||||
)
|
||||
invalidate_stock(inventory_id, from_branch_id)
|
||||
invalidate_stock(inventory_id, to_branch_id)
|
||||
invalidate_stock(inventory_id, None)
|
||||
return out_id, in_id
|
||||
|
||||
|
||||
def record_initial(conn, inventory_id, branch_id, quantity, cost=None):
|
||||
"""Record initial stock load."""
|
||||
return record_operation(
|
||||
result = record_operation(
|
||||
conn, inventory_id, branch_id, 'INITIAL', quantity,
|
||||
cost_at_time=cost, notes="Carga inicial de inventario"
|
||||
)
|
||||
invalidate_stock(inventory_id, branch_id)
|
||||
invalidate_stock(inventory_id, None)
|
||||
return result
|
||||
|
||||
|
||||
def get_alerts(conn, branch_id=None):
|
||||
|
||||
232
pos/services/logistics_engine.py
Normal file
232
pos/services/logistics_engine.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""Logistics Engine: shipment tracking and courier management.
|
||||
|
||||
Supports multiple couriers: DHL, FedEx, Estafeta, 99 Minutos, Uber Direct.
|
||||
Provides tracking URL generation, status updates, and history logging.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def create_shipment(conn, data):
|
||||
"""Create a new shipment record.
|
||||
|
||||
data: {
|
||||
tenant_id, branch_id, shipment_type, related_type, related_id,
|
||||
courier_id, tracking_number, origin_address, destination_address,
|
||||
recipient_name, recipient_phone, estimated_delivery,
|
||||
shipping_cost, weight_kg, dimensions_cm, notes, created_by
|
||||
}
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
|
||||
# Generate tracking URL if template exists
|
||||
tracking_url = None
|
||||
if data.get('courier_id') and data.get('tracking_number'):
|
||||
cur.execute("SELECT tracking_url_template FROM couriers WHERE id = %s", (data['courier_id'],))
|
||||
row = cur.fetchone()
|
||||
if row and row[0]:
|
||||
tracking_url = row[0].replace('{tracking_number}', data['tracking_number'])
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO shipments
|
||||
(tenant_id, branch_id, shipment_type, related_type, related_id,
|
||||
courier_id, tracking_number, tracking_url, status,
|
||||
origin_address, destination_address, recipient_name, recipient_phone,
|
||||
estimated_delivery, shipping_cost, weight_kg, dimensions_cm, notes, created_by)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'pending',
|
||||
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (
|
||||
data.get('tenant_id'), data.get('branch_id'), data.get('shipment_type', 'outbound'),
|
||||
data.get('related_type'), data.get('related_id'),
|
||||
data.get('courier_id'), data.get('tracking_number'), tracking_url,
|
||||
data.get('origin_address'), data.get('destination_address'),
|
||||
data.get('recipient_name'), data.get('recipient_phone'),
|
||||
data.get('estimated_delivery'), data.get('shipping_cost', 0),
|
||||
data.get('weight_kg'), data.get('dimensions_cm'),
|
||||
data.get('notes'), data.get('created_by'),
|
||||
))
|
||||
shipment_id = cur.fetchone()[0]
|
||||
|
||||
# Log initial tracking entry
|
||||
if tracking_url:
|
||||
cur.execute("""
|
||||
INSERT INTO shipment_tracking (shipment_id, status, description)
|
||||
VALUES (%s, 'pending', 'Envío registrado')
|
||||
""", (shipment_id,))
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return {'shipment_id': shipment_id, 'tracking_url': tracking_url}
|
||||
|
||||
|
||||
def get_shipment(conn, shipment_id):
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT s.id, s.shipment_type, s.related_type, s.related_id,
|
||||
s.courier_id, c.name as courier_name, s.tracking_number, s.tracking_url,
|
||||
s.status, s.origin_address, s.destination_address,
|
||||
s.recipient_name, s.recipient_phone, s.estimated_delivery,
|
||||
s.actual_delivery, s.shipping_cost, s.weight_kg, s.dimensions_cm,
|
||||
s.notes, s.created_at, s.updated_at
|
||||
FROM shipments s
|
||||
LEFT JOIN couriers c ON s.courier_id = c.id
|
||||
WHERE s.id = %s
|
||||
""", (shipment_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close()
|
||||
return None
|
||||
|
||||
shipment = {
|
||||
'id': row[0], 'shipment_type': row[1], 'related_type': row[2],
|
||||
'related_id': row[3], 'courier_id': row[4], 'courier_name': row[5],
|
||||
'tracking_number': row[6], 'tracking_url': row[7],
|
||||
'status': row[8], 'origin_address': row[9], 'destination_address': row[10],
|
||||
'recipient_name': row[11], 'recipient_phone': row[12],
|
||||
'estimated_delivery': str(row[13]) if row[13] else None,
|
||||
'actual_delivery': str(row[14]) if row[14] else None,
|
||||
'shipping_cost': float(row[15]) if row[15] else 0,
|
||||
'weight_kg': float(row[16]) if row[16] else None,
|
||||
'dimensions_cm': row[17], 'notes': row[18],
|
||||
'created_at': str(row[19]), 'updated_at': str(row[20]),
|
||||
}
|
||||
|
||||
# Tracking history
|
||||
cur.execute("""
|
||||
SELECT id, status, location, description, tracked_at
|
||||
FROM shipment_tracking
|
||||
WHERE shipment_id = %s
|
||||
ORDER BY tracked_at DESC
|
||||
""", (shipment_id,))
|
||||
shipment['tracking_history'] = []
|
||||
for r in cur.fetchall():
|
||||
shipment['tracking_history'].append({
|
||||
'id': r[0], 'status': r[1], 'location': r[2],
|
||||
'description': r[3], 'tracked_at': str(r[4]),
|
||||
})
|
||||
|
||||
cur.close()
|
||||
return shipment
|
||||
|
||||
|
||||
def list_shipments(conn, tenant_id, status=None, courier_id=None, related_type=None,
|
||||
related_id=None, page=1, per_page=50):
|
||||
cur = conn.cursor()
|
||||
where = ["tenant_id = %s"]
|
||||
params = [tenant_id]
|
||||
if status:
|
||||
where.append("status = %s")
|
||||
params.append(status)
|
||||
if courier_id:
|
||||
where.append("courier_id = %s")
|
||||
params.append(courier_id)
|
||||
if related_type:
|
||||
where.append("related_type = %s")
|
||||
params.append(related_type)
|
||||
if related_id:
|
||||
where.append("related_id = %s")
|
||||
params.append(related_id)
|
||||
|
||||
where_str = " AND ".join(where)
|
||||
|
||||
cur.execute(f"SELECT count(*) FROM shipments WHERE {where_str}", params)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
extra_where = ""
|
||||
if len(where) > 1:
|
||||
extra_where = " AND " + " AND ".join(where[1:])
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT s.id, s.shipment_type, s.related_type, s.related_id,
|
||||
c.name as courier_name, s.tracking_number, s.status,
|
||||
s.recipient_name, s.estimated_delivery, s.created_at
|
||||
FROM shipments s
|
||||
LEFT JOIN couriers c ON s.courier_id = c.id
|
||||
WHERE s.tenant_id = %s {extra_where}
|
||||
ORDER BY s.created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""", params + [per_page, (page - 1) * per_page])
|
||||
|
||||
shipments = []
|
||||
for r in cur.fetchall():
|
||||
shipments.append({
|
||||
'id': r[0], 'shipment_type': r[1], 'related_type': r[2],
|
||||
'related_id': r[3], 'courier_name': r[4], 'tracking_number': r[5],
|
||||
'status': r[6], 'recipient_name': r[7],
|
||||
'estimated_delivery': str(r[8]) if r[8] else None,
|
||||
'created_at': str(r[9]),
|
||||
})
|
||||
|
||||
cur.close()
|
||||
return {
|
||||
'data': shipments,
|
||||
'pagination': {'page': page, 'per_page': per_page, 'total': total}
|
||||
}
|
||||
|
||||
|
||||
def update_shipment_status(conn, shipment_id, new_status, location=None,
|
||||
description=None, raw_response=None):
|
||||
"""Update shipment status and log tracking history."""
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("SELECT status FROM shipments WHERE id = %s", (shipment_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close()
|
||||
raise ValueError("Shipment not found")
|
||||
|
||||
old_status = row[0]
|
||||
|
||||
# Update shipment
|
||||
extra_sets = []
|
||||
extra_vals = []
|
||||
if new_status == 'delivered':
|
||||
extra_sets.append("actual_delivery = NOW()")
|
||||
|
||||
set_clause = ", ".join(["status = %s"] + extra_sets)
|
||||
cur.execute(f"""
|
||||
UPDATE shipments SET {set_clause} WHERE id = %s
|
||||
""", [new_status] + extra_vals + [shipment_id])
|
||||
|
||||
# Log tracking history
|
||||
cur.execute("""
|
||||
INSERT INTO shipment_tracking (shipment_id, status, location, description, raw_response)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
""", (shipment_id, new_status, location, description, raw_response))
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return {'old_status': old_status, 'new_status': new_status}
|
||||
|
||||
|
||||
def get_couriers(conn, tenant_id):
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT id, name, code, tracking_url_template, api_endpoint, is_active
|
||||
FROM couriers
|
||||
WHERE tenant_id = %s
|
||||
ORDER BY name
|
||||
""", (tenant_id,))
|
||||
couriers = []
|
||||
for r in cur.fetchall():
|
||||
couriers.append({
|
||||
'id': r[0], 'name': r[1], 'code': r[2],
|
||||
'tracking_url_template': r[3], 'api_endpoint': r[4], 'is_active': r[5],
|
||||
})
|
||||
cur.close()
|
||||
return couriers
|
||||
|
||||
|
||||
def add_courier(conn, tenant_id, name, code, tracking_url_template=None,
|
||||
api_endpoint=None, is_active=True):
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO couriers (tenant_id, name, code, tracking_url_template, api_endpoint, is_active)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (tenant_id, name, code, tracking_url_template, api_endpoint, is_active))
|
||||
cid = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return cid
|
||||
159
pos/services/meili_search.py
Normal file
159
pos/services/meili_search.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""Meilisearch integration for sub-100ms catalog search.
|
||||
|
||||
Provides a thin wrapper over the meilisearch Python client with:
|
||||
- Automatic index creation and settings configuration
|
||||
- Bulk indexing from PostgreSQL
|
||||
- Search with graceful fallback to PostgreSQL tsvector
|
||||
- Incremental add/update/delete for real-time sync
|
||||
|
||||
Environment:
|
||||
MEILI_URL — Meilisearch server URL (default: http://localhost:7700)
|
||||
MEILI_API_KEY — Master key (default: nexus-master-key-change-me)
|
||||
"""
|
||||
|
||||
import os
|
||||
import meilisearch
|
||||
from meilisearch.errors import MeilisearchApiError
|
||||
|
||||
MEILI_URL = os.environ.get('MEILI_URL', 'http://localhost:7700')
|
||||
MEILI_API_KEY = os.environ.get('MEILI_API_KEY', 'nexus-master-key-change-me')
|
||||
INDEX_NAME = 'nexus_parts'
|
||||
|
||||
# Searchable attributes and ranking
|
||||
INDEX_SETTINGS = {
|
||||
'searchableAttributes': [
|
||||
'name_es',
|
||||
'name_part',
|
||||
'oem_part_number',
|
||||
'description',
|
||||
'description_es',
|
||||
],
|
||||
'rankingRules': [
|
||||
'words',
|
||||
'typo',
|
||||
'proximity',
|
||||
'attribute',
|
||||
'sort',
|
||||
'exactness',
|
||||
],
|
||||
'filterableAttributes': ['group_id'],
|
||||
'typoTolerance': {'enabled': True, 'minWordSizeForTypos': {'oneTypo': 4, 'twoTypos': 8}},
|
||||
}
|
||||
|
||||
_client = None
|
||||
_client_url = None
|
||||
|
||||
|
||||
def get_client():
|
||||
"""Get or create Meilisearch client (lazy singleton, URL-aware)."""
|
||||
global _client, _client_url
|
||||
current_url = os.environ.get('MEILI_URL', 'http://localhost:7700')
|
||||
if _client is None or _client_url != current_url:
|
||||
_client = meilisearch.Client(current_url, MEILI_API_KEY)
|
||||
_client_url = current_url
|
||||
return _client
|
||||
|
||||
|
||||
def reset_client():
|
||||
"""Force client recreation on next use (useful for tests)."""
|
||||
global _client, _client_url
|
||||
_client = None
|
||||
_client_url = None
|
||||
|
||||
|
||||
def health_check():
|
||||
"""Return True if Meilisearch is reachable."""
|
||||
try:
|
||||
return get_client().health().get('status') == 'available'
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def ensure_index():
|
||||
"""Create index if it doesn't exist and configure settings."""
|
||||
client = get_client()
|
||||
try:
|
||||
client.get_index(INDEX_NAME)
|
||||
except MeilisearchApiError as e:
|
||||
if e.code == 'index_not_found':
|
||||
client.create_index(uid=INDEX_NAME, options={'primaryKey': 'id_part'})
|
||||
else:
|
||||
raise
|
||||
|
||||
index = client.index(INDEX_NAME)
|
||||
index.update_settings(INDEX_SETTINGS)
|
||||
return index
|
||||
|
||||
|
||||
def index_parts_bulk(parts_iter, batch_size=1000):
|
||||
"""Index a large number of parts from an iterable.
|
||||
|
||||
Args:
|
||||
parts_iter: iterable of dicts with keys:
|
||||
id_part, oem_part_number, name_part, name_es,
|
||||
description, description_es, image_url, group_id
|
||||
batch_size: documents per batch upload
|
||||
"""
|
||||
index = ensure_index()
|
||||
batch = []
|
||||
total = 0
|
||||
for part in parts_iter:
|
||||
batch.append(part)
|
||||
if len(batch) >= batch_size:
|
||||
index.add_documents(batch)
|
||||
total += len(batch)
|
||||
batch = []
|
||||
if batch:
|
||||
index.add_documents(batch)
|
||||
total += len(batch)
|
||||
return total
|
||||
|
||||
|
||||
def search_parts(query, limit=50, offset=0):
|
||||
"""Search parts via Meilisearch.
|
||||
|
||||
Returns:
|
||||
dict: Meilisearch response with 'hits', 'offset', 'limit', 'totalHits'
|
||||
or None on error.
|
||||
"""
|
||||
try:
|
||||
index = get_client().index(INDEX_NAME)
|
||||
return index.search(query, {'limit': limit, 'offset': offset})
|
||||
except Exception as e:
|
||||
print(f"[meili_search] Search error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def add_part(part_doc):
|
||||
"""Add or update a single part document."""
|
||||
try:
|
||||
get_client().index(INDEX_NAME).add_documents([part_doc])
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[meili_search] Add error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def update_part(part_doc):
|
||||
"""Update a single part document (same as add)."""
|
||||
return add_part(part_doc)
|
||||
|
||||
|
||||
def delete_part(part_id):
|
||||
"""Remove a part from the index."""
|
||||
try:
|
||||
get_client().index(INDEX_NAME).delete_document(part_id)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[meili_search] Delete error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def clear_index():
|
||||
"""Delete all documents from the index."""
|
||||
try:
|
||||
get_client().index(INDEX_NAME).delete_all_documents()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[meili_search] Clear error: {e}")
|
||||
return False
|
||||
422
pos/services/notification_engine.py
Normal file
422
pos/services/notification_engine.py
Normal file
@@ -0,0 +1,422 @@
|
||||
"""Notification Engine: event-driven notifications via push, email, WhatsApp, in-app.
|
||||
|
||||
Integrates with existing push_service.py for Web Push.
|
||||
Supports template rendering with Jinja2-style variable substitution.
|
||||
"""
|
||||
|
||||
import re
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
def _render_template(template, context):
|
||||
"""Simple variable substitution: {var_name} -> value."""
|
||||
if not template:
|
||||
return ''
|
||||
result = template
|
||||
for key, value in context.items():
|
||||
if value is None:
|
||||
value = ''
|
||||
result = result.replace(f'{{{key}}}', str(value))
|
||||
return result
|
||||
|
||||
|
||||
def get_templates(conn, tenant_id, event_type=None, channel=None):
|
||||
cur = conn.cursor()
|
||||
params = [tenant_id]
|
||||
filters = "tenant_id = %s"
|
||||
if event_type:
|
||||
filters += " AND event_type = %s"
|
||||
params.append(event_type)
|
||||
if channel:
|
||||
filters += " AND channel = %s"
|
||||
params.append(channel)
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT id, event_type, channel, name, subject_template, body_template, is_active
|
||||
FROM notification_templates
|
||||
WHERE {filters}
|
||||
ORDER BY event_type, channel
|
||||
""", params)
|
||||
|
||||
templates = []
|
||||
for r in cur.fetchall():
|
||||
templates.append({
|
||||
'id': r[0], 'event_type': r[1], 'channel': r[2], 'name': r[3],
|
||||
'subject_template': r[4], 'body_template': r[5], 'is_active': r[6],
|
||||
})
|
||||
cur.close()
|
||||
return templates
|
||||
|
||||
|
||||
def create_template(conn, tenant_id, event_type, channel, name, body_template,
|
||||
subject_template=None, is_active=True):
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO notification_templates
|
||||
(tenant_id, event_type, channel, name, subject_template, body_template, is_active)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (tenant_id, event_type, channel) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
subject_template = EXCLUDED.subject_template,
|
||||
body_template = EXCLUDED.body_template,
|
||||
is_active = EXCLUDED.is_active,
|
||||
updated_at = NOW()
|
||||
RETURNING id
|
||||
""", (tenant_id, event_type, channel, name, subject_template, body_template, is_active))
|
||||
tid = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return tid
|
||||
|
||||
|
||||
def update_template(conn, template_id, data):
|
||||
cur = conn.cursor()
|
||||
allowed = ['event_type', 'channel', 'name', 'subject_template', 'body_template', 'is_active']
|
||||
sets = []
|
||||
vals = []
|
||||
for field in allowed:
|
||||
if field in data:
|
||||
sets.append(f"{field} = %s")
|
||||
vals.append(data[field])
|
||||
if not sets:
|
||||
cur.close()
|
||||
return False
|
||||
vals.append(template_id)
|
||||
cur.execute(f"UPDATE notification_templates SET {', '.join(sets)} WHERE id = %s", vals)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return True
|
||||
|
||||
|
||||
# ─── Event Dispatch ─────────────────────────────
|
||||
|
||||
def dispatch_notification(conn, tenant_id, event_type, context, recipient_type='owner',
|
||||
recipient_id=None, channels=None):
|
||||
"""Dispatch a notification event to all configured channels.
|
||||
|
||||
Args:
|
||||
conn: DB connection
|
||||
tenant_id: tenant ID
|
||||
event_type: e.g. 'low_stock', 'order_ready'
|
||||
context: dict with template variables
|
||||
recipient_type: 'owner', 'employee', 'customer', 'role'
|
||||
recipient_id: specific recipient ID
|
||||
channels: list of channels to use, or None for all active templates
|
||||
|
||||
Returns:
|
||||
list of notification log IDs
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
|
||||
# Get active templates for this event
|
||||
if channels:
|
||||
placeholders = ','.join(['%s'] * len(channels))
|
||||
cur.execute(f"""
|
||||
SELECT id, channel, subject_template, body_template
|
||||
FROM notification_templates
|
||||
WHERE tenant_id = %s AND event_type = %s AND is_active = true
|
||||
AND channel IN ({placeholders})
|
||||
""", [tenant_id, event_type] + list(channels))
|
||||
else:
|
||||
cur.execute("""
|
||||
SELECT id, channel, subject_template, body_template
|
||||
FROM notification_templates
|
||||
WHERE tenant_id = %s AND event_type = %s AND is_active = true
|
||||
""", (tenant_id, event_type))
|
||||
|
||||
templates = cur.fetchall()
|
||||
if not templates:
|
||||
cur.close()
|
||||
return []
|
||||
|
||||
log_ids = []
|
||||
for tid, channel, subject_tmpl, body_tmpl in templates:
|
||||
subject = _render_template(subject_tmpl, context)
|
||||
body = _render_template(body_tmpl, context)
|
||||
|
||||
# Insert log as pending
|
||||
cur.execute("""
|
||||
INSERT INTO notification_logs
|
||||
(tenant_id, recipient_type, recipient_id, event_type, channel,
|
||||
subject, body, status, metadata)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, 'pending', %s)
|
||||
RETURNING id
|
||||
""", (tenant_id, recipient_type, recipient_id, event_type, channel,
|
||||
subject, body, json.dumps(context) if isinstance(context, dict) else None))
|
||||
log_id = cur.fetchone()[0]
|
||||
log_ids.append(log_id)
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
|
||||
# Send asynchronously (in production, this would go to a queue)
|
||||
for log_id in log_ids:
|
||||
_send_notification(conn, log_id)
|
||||
|
||||
return log_ids
|
||||
|
||||
|
||||
def _send_notification(conn, log_id):
|
||||
"""Send a single notification by its log entry."""
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT channel, subject, body, recipient_type, recipient_id, metadata, tenant_id
|
||||
FROM notification_logs WHERE id = %s
|
||||
""", (log_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close()
|
||||
return
|
||||
|
||||
channel, subject, body, recipient_type, recipient_id, metadata, tenant_id = row
|
||||
|
||||
try:
|
||||
if channel == 'push':
|
||||
_send_push(conn, tenant_id, recipient_type, recipient_id, subject, body, metadata)
|
||||
elif channel == 'whatsapp':
|
||||
_send_whatsapp(conn, tenant_id, recipient_type, recipient_id, body, metadata)
|
||||
elif channel == 'email':
|
||||
_send_email(conn, tenant_id, recipient_type, recipient_id, subject, body, metadata)
|
||||
elif channel == 'in_app':
|
||||
# In-app is just the log entry; UI polls notification_logs
|
||||
pass
|
||||
|
||||
cur.execute("""
|
||||
UPDATE notification_logs SET status = 'sent', sent_at = NOW() WHERE id = %s
|
||||
""", (log_id,))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
cur2 = conn.cursor()
|
||||
try:
|
||||
cur2.execute("""
|
||||
UPDATE notification_logs SET status = 'failed', error_message = %s WHERE id = %s
|
||||
""", (str(e)[:500], log_id))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
finally:
|
||||
cur2.close()
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
|
||||
def _send_push(conn, tenant_id, recipient_type, recipient_id, title, body, metadata):
|
||||
"""Send Web Push notification."""
|
||||
try:
|
||||
from services.push_service import notify_owner
|
||||
# Try to find push subscriptions for recipient
|
||||
cur = conn.cursor()
|
||||
if recipient_type == 'owner':
|
||||
cur.execute("""
|
||||
SELECT s.subscription_json
|
||||
FROM push_subscriptions s
|
||||
JOIN employees e ON s.employee_id = e.id
|
||||
WHERE e.tenant_id = %s AND e.role = 'owner' AND s.is_active = true
|
||||
""", (tenant_id,))
|
||||
elif recipient_type == 'employee' and recipient_id:
|
||||
cur.execute("""
|
||||
SELECT subscription_json FROM push_subscriptions
|
||||
WHERE employee_id = %s AND is_active = true
|
||||
""", (recipient_id,))
|
||||
else:
|
||||
cur.close()
|
||||
return
|
||||
|
||||
subs = [r[0] for r in cur.fetchall()]
|
||||
cur.close()
|
||||
|
||||
if not subs:
|
||||
return
|
||||
|
||||
# Send to all subscriptions
|
||||
for sub_json in subs:
|
||||
try:
|
||||
from services.push_service import send_push
|
||||
send_push(sub_json, title, body, metadata)
|
||||
except Exception:
|
||||
pass
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
def _send_whatsapp(conn, tenant_id, recipient_type, recipient_id, body, metadata):
|
||||
"""Send WhatsApp notification via existing service."""
|
||||
try:
|
||||
from services.whatsapp_service import send_message
|
||||
cur = conn.cursor()
|
||||
phone = None
|
||||
if recipient_type == 'customer' and recipient_id:
|
||||
cur.execute("SELECT phone FROM customers WHERE id = %s", (recipient_id,))
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
phone = row[0]
|
||||
cur.close()
|
||||
|
||||
if phone:
|
||||
send_message(phone, body)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _send_email(conn, tenant_id, recipient_type, recipient_id, subject, body, metadata):
|
||||
"""Send email notification. Stub — requires SMTP config."""
|
||||
# Stub: would integrate with SMTP or SendGrid/Postmark
|
||||
pass
|
||||
|
||||
|
||||
# ─── Convenience Event Dispatchers ─────────────────────────────
|
||||
|
||||
def notify_low_stock(conn, tenant_id, inventory_id, stock, reorder_point, branch_id=None):
|
||||
"""Notify when stock is below reorder point."""
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT part_number, name FROM inventory WHERE id = %s", (inventory_id,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
if not row:
|
||||
return []
|
||||
|
||||
return dispatch_notification(
|
||||
conn, tenant_id, 'low_stock',
|
||||
{
|
||||
'part_number': row[0], 'part_name': row[1],
|
||||
'stock': stock, 'reorder_point': reorder_point,
|
||||
'inventory_id': inventory_id,
|
||||
},
|
||||
recipient_type='owner',
|
||||
)
|
||||
|
||||
|
||||
def notify_order_ready(conn, tenant_id, service_order_id, customer_id):
|
||||
"""Notify customer when service order is ready."""
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT order_number, c.name, c.phone
|
||||
FROM service_orders so
|
||||
LEFT JOIN customers c ON so.customer_id = c.id
|
||||
WHERE so.id = %s
|
||||
""", (service_order_id,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
if not row:
|
||||
return []
|
||||
|
||||
order_number, customer_name, phone = row
|
||||
|
||||
# Push to owners
|
||||
push_ids = dispatch_notification(
|
||||
conn, tenant_id, 'order_ready',
|
||||
{'order_number': order_number, 'customer_name': customer_name or 'Cliente'},
|
||||
recipient_type='owner',
|
||||
)
|
||||
|
||||
# WhatsApp to customer if phone exists
|
||||
if phone:
|
||||
wa_ids = dispatch_notification(
|
||||
conn, tenant_id, 'order_ready',
|
||||
{'order_number': order_number, 'customer_name': customer_name or 'Cliente'},
|
||||
recipient_type='customer', recipient_id=customer_id,
|
||||
channels=['whatsapp'],
|
||||
)
|
||||
return push_ids + wa_ids
|
||||
|
||||
return push_ids
|
||||
|
||||
|
||||
def notify_maintenance_due(conn, tenant_id, vehicle_id, schedule_id):
|
||||
"""Notify when vehicle maintenance is due."""
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT v.plate, v.current_mileage, s.maintenance_type, s.next_due_km, s.next_due_at
|
||||
FROM fleet_vehicles v
|
||||
JOIN fleet_maintenance_schedules s ON s.vehicle_id = v.id
|
||||
WHERE v.id = %s AND s.id = %s
|
||||
""", (vehicle_id, schedule_id))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
if not row:
|
||||
return []
|
||||
|
||||
plate, mileage, mtype, next_km, next_at = row
|
||||
return dispatch_notification(
|
||||
conn, tenant_id, 'maintenance_due',
|
||||
{
|
||||
'vehicle_plate': plate, 'current_mileage': mileage or 0,
|
||||
'maintenance_type': mtype,
|
||||
'next_due_km': next_km or 'N/A',
|
||||
'next_due_date': str(next_at)[:10] if next_at else 'N/A',
|
||||
},
|
||||
recipient_type='owner',
|
||||
)
|
||||
|
||||
|
||||
def notify_new_sale(conn, tenant_id, sale_id, total, payment_method, employee_id=None):
|
||||
"""Notify owners of new sale."""
|
||||
return dispatch_notification(
|
||||
conn, tenant_id, 'new_sale',
|
||||
{
|
||||
'sale_id': sale_id, 'total': f"${float(total):,.2f}",
|
||||
'payment_method': payment_method or 'N/A',
|
||||
},
|
||||
recipient_type='owner',
|
||||
)
|
||||
|
||||
|
||||
def notify_po_received(conn, tenant_id, po_id, total):
|
||||
"""Notify when purchase order is received."""
|
||||
return dispatch_notification(
|
||||
conn, tenant_id, 'po_received',
|
||||
{'po_id': po_id, 'total': f"${float(total):,.2f}"},
|
||||
recipient_type='owner',
|
||||
)
|
||||
|
||||
|
||||
def get_notification_logs(conn, tenant_id, recipient_type=None, recipient_id=None,
|
||||
status=None, event_type=None, limit=50):
|
||||
cur = conn.cursor()
|
||||
where = ["tenant_id = %s"]
|
||||
params = [tenant_id]
|
||||
if recipient_type:
|
||||
where.append("recipient_type = %s")
|
||||
params.append(recipient_type)
|
||||
if recipient_id:
|
||||
where.append("recipient_id = %s")
|
||||
params.append(recipient_id)
|
||||
if status:
|
||||
where.append("status = %s")
|
||||
params.append(status)
|
||||
if event_type:
|
||||
where.append("event_type = %s")
|
||||
params.append(event_type)
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT id, event_type, channel, subject, body, status,
|
||||
sent_at, read_at, created_at
|
||||
FROM notification_logs
|
||||
WHERE {' AND '.join(where)}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %s
|
||||
""", params + [limit])
|
||||
|
||||
logs = []
|
||||
for r in cur.fetchall():
|
||||
logs.append({
|
||||
'id': r[0], 'event_type': r[1], 'channel': r[2],
|
||||
'subject': r[3], 'body': r[4], 'status': r[5],
|
||||
'sent_at': str(r[6]) if r[6] else None,
|
||||
'read_at': str(r[7]) if r[7] else None,
|
||||
'created_at': str(r[8]),
|
||||
})
|
||||
cur.close()
|
||||
return logs
|
||||
|
||||
|
||||
def mark_as_read(conn, log_id):
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
UPDATE notification_logs SET status = 'read', read_at = NOW() WHERE id = %s
|
||||
""", (log_id,))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return True
|
||||
@@ -17,8 +17,19 @@ from services.inventory_engine import (
|
||||
record_sale as inventory_record_sale,
|
||||
record_operation,
|
||||
get_stock,
|
||||
get_stock_bulk,
|
||||
)
|
||||
from services.accounting_engine import record_sale_entry, record_cancellation_entry
|
||||
from services.currency import convert, to_mxn, get_exchange_rate
|
||||
from services.savings_engine import record_sale_savings
|
||||
|
||||
|
||||
def _safe_g(attr, default=None):
|
||||
"""Safely read flask.g attribute outside of app context."""
|
||||
try:
|
||||
return getattr(g, attr, default)
|
||||
except RuntimeError:
|
||||
return default
|
||||
|
||||
|
||||
def _to_dec(val):
|
||||
@@ -182,8 +193,18 @@ def process_sale(conn, sale_data):
|
||||
amount_paid = float(sale_data.get('amount_paid', 0))
|
||||
payment_details = sale_data.get('payment_details', [])
|
||||
notes = sale_data.get('notes')
|
||||
branch_id = getattr(g, 'branch_id', None)
|
||||
employee_id = getattr(g, 'employee_id', None)
|
||||
branch_id = _safe_g('branch_id')
|
||||
employee_id = _safe_g('employee_id')
|
||||
|
||||
# ── Multi-currency support ───────────────────────────────────────────
|
||||
currency = sale_data.get('currency', 'MXN')
|
||||
if currency not in ('MXN', 'USD'):
|
||||
raise ValueError(f"Unsupported currency: {currency}. Only MXN and USD are supported.")
|
||||
|
||||
exchange_rate = sale_data.get('exchange_rate')
|
||||
if currency != 'MXN' and exchange_rate is None:
|
||||
exchange_rate = float(get_exchange_rate(conn, currency, 'MXN'))
|
||||
exchange_rate = float(exchange_rate) if exchange_rate else 1.0
|
||||
|
||||
if not items:
|
||||
raise ValueError("No items in sale")
|
||||
@@ -195,7 +216,26 @@ def process_sale(conn, sale_data):
|
||||
if not reg or reg[0] != 'open':
|
||||
raise ValueError("Cash register is not open")
|
||||
|
||||
# Validate and enrich items from inventory
|
||||
# ─── Batch preload: inventory items + stock + customer credit ─────────
|
||||
inv_ids = [item.get('inventory_id') for item in items]
|
||||
if not inv_ids:
|
||||
raise ValueError("No items in sale")
|
||||
|
||||
# Lock inventory rows to prevent race conditions on concurrent sales
|
||||
cur.execute("""
|
||||
SELECT id, part_number, name, cost, price_1, price_2, price_3,
|
||||
tax_rate, branch_id
|
||||
FROM inventory
|
||||
WHERE id = ANY(%s) AND is_active = true
|
||||
ORDER BY id
|
||||
FOR UPDATE
|
||||
""", (inv_ids,))
|
||||
inv_rows = {r[0]: r for r in cur.fetchall()}
|
||||
|
||||
# Batch stock check
|
||||
stock_map = get_stock_bulk(conn, branch_id)
|
||||
|
||||
# Validate and enrich items
|
||||
enriched_items = []
|
||||
for item in items:
|
||||
inv_id = item.get('inventory_id')
|
||||
@@ -203,17 +243,11 @@ def process_sale(conn, sale_data):
|
||||
if qty <= 0:
|
||||
raise ValueError(f"Invalid quantity for inventory_id {inv_id}")
|
||||
|
||||
cur.execute("""
|
||||
SELECT id, part_number, name, cost, price_1, price_2, price_3,
|
||||
tax_rate, branch_id
|
||||
FROM inventory WHERE id = %s AND is_active = true
|
||||
""", (inv_id,))
|
||||
inv = cur.fetchone()
|
||||
inv = inv_rows.get(inv_id)
|
||||
if not inv:
|
||||
raise ValueError(f"Inventory item {inv_id} not found or inactive")
|
||||
|
||||
# Check stock (allow negative stock for offline tolerance, but warn)
|
||||
current_stock = get_stock(conn, inv_id, inv[8]) # inv[8] = branch_id
|
||||
current_stock = stock_map.get(inv_id, 0)
|
||||
|
||||
# Use provided price or fetch from inventory
|
||||
unit_price = float(item.get('unit_price', inv[4])) # default to price_1
|
||||
@@ -222,8 +256,8 @@ def process_sale(conn, sale_data):
|
||||
unit_cost = float(inv[3]) if inv[3] else 0
|
||||
|
||||
# Validate discount against employee max
|
||||
max_discount = float(getattr(g, 'max_discount_pct', 100) or 100)
|
||||
if g.employee_role not in ('owner', 'admin') and discount_pct > max_discount:
|
||||
max_discount = float(_safe_g('max_discount_pct', 100) or 100)
|
||||
if _safe_g('employee_role', 'cashier') not in ('owner', 'admin') and discount_pct > max_discount:
|
||||
raise ValueError(
|
||||
f"Discount {discount_pct}% exceeds your maximum allowed {max_discount}% "
|
||||
f"for item {inv[2]}"
|
||||
@@ -245,9 +279,9 @@ def process_sale(conn, sale_data):
|
||||
# Calculate totals
|
||||
totals = calculate_totals(enriched_items)
|
||||
|
||||
# Validate credit sale
|
||||
# Validate credit sale (with row lock to prevent race conditions)
|
||||
if sale_type == 'credit' and customer_id:
|
||||
cur.execute("SELECT credit_limit, credit_balance FROM customers WHERE id = %s", (customer_id,))
|
||||
cur.execute("SELECT credit_limit, credit_balance FROM customers WHERE id = %s FOR UPDATE", (customer_id,))
|
||||
cust = cur.fetchone()
|
||||
if cust:
|
||||
credit_limit = float(cust[0] or 0)
|
||||
@@ -271,54 +305,67 @@ def process_sale(conn, sale_data):
|
||||
}
|
||||
forma_pago_sat = forma_pago_map.get(payment_method, '99')
|
||||
|
||||
# Create sale record
|
||||
# Create sale record (with currency)
|
||||
cur.execute("""
|
||||
INSERT INTO sales
|
||||
(branch_id, customer_id, employee_id, register_id, sale_type,
|
||||
payment_method, subtotal, discount_total, tax_total, total,
|
||||
amount_paid, change_given, metodo_pago_sat, forma_pago_sat,
|
||||
status, device_id, notes)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'completed',%s,%s)
|
||||
status, device_id, notes, currency, exchange_rate)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'completed',%s,%s,%s,%s)
|
||||
RETURNING id, created_at
|
||||
""", (
|
||||
branch_id, customer_id, employee_id, register_id, sale_type,
|
||||
payment_method, totals['subtotal'], totals['discount_total'],
|
||||
totals['tax_total'], totals['total'], amount_paid, change_given,
|
||||
metodo_pago_sat, forma_pago_sat,
|
||||
getattr(g, 'device_id', None), notes
|
||||
_safe_g('device_id'), notes,
|
||||
currency, exchange_rate
|
||||
))
|
||||
sale_id, created_at = cur.fetchone()
|
||||
|
||||
# Create sale items and deduct inventory
|
||||
sale_items = []
|
||||
for idx, item in enumerate(totals['items']):
|
||||
cur.execute("""
|
||||
INSERT INTO sale_items
|
||||
(sale_id, inventory_id, part_number, name, quantity,
|
||||
unit_price, unit_cost, discount_pct, discount_amount,
|
||||
tax_rate, tax_amount, subtotal)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
RETURNING id
|
||||
""", (
|
||||
# Create sale items (batch insert) and deduct inventory
|
||||
sale_items_data = []
|
||||
for item in totals['items']:
|
||||
# Fetch retail_price for savings calculation
|
||||
cur.execute("SELECT retail_price FROM inventory WHERE id = %s", (item['inventory_id'],))
|
||||
rp_row = cur.fetchone()
|
||||
retail_price = rp_row[0] if rp_row else None
|
||||
|
||||
sale_items_data.append((
|
||||
sale_id, item['inventory_id'], item['part_number'], item['name'],
|
||||
item['quantity'], item['unit_price'], item.get('unit_cost', 0),
|
||||
item['discount_pct'], item['discount_amount'],
|
||||
item['tax_rate'], item['tax_amount'], item['subtotal']
|
||||
item['tax_rate'], item['tax_amount'], item['subtotal'],
|
||||
retail_price
|
||||
))
|
||||
sale_item_id = cur.fetchone()[0]
|
||||
|
||||
# Deduct inventory via inventory_engine (NEVER create operations directly)
|
||||
cur.executemany("""
|
||||
INSERT INTO sale_items
|
||||
(sale_id, inventory_id, part_number, name, quantity,
|
||||
unit_price, unit_cost, discount_pct, discount_amount,
|
||||
tax_rate, tax_amount, subtotal, retail_price, currency, exchange_rate)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
""", [row + (currency, exchange_rate) for row in sale_items_data])
|
||||
|
||||
# Deduct inventory via inventory_engine
|
||||
sale_items = []
|
||||
for item in totals['items']:
|
||||
# Pre-calculate remaining stock to avoid redundant get_stock() call
|
||||
stock_before = next((i['stock_before'] for i in enriched_items if i['inventory_id'] == item['inventory_id']), 0)
|
||||
remaining_after = stock_before - item['quantity']
|
||||
|
||||
inventory_record_sale(
|
||||
conn,
|
||||
item['inventory_id'],
|
||||
item.get('branch_id', branch_id),
|
||||
item['quantity'],
|
||||
sale_id=sale_id,
|
||||
cost_at_time=item.get('unit_cost')
|
||||
cost_at_time=item.get('unit_cost'),
|
||||
remaining_stock=remaining_after
|
||||
)
|
||||
|
||||
sale_items.append({
|
||||
'id': sale_item_id,
|
||||
'inventory_id': item['inventory_id'],
|
||||
'part_number': item['part_number'],
|
||||
'name': item['name'],
|
||||
@@ -340,15 +387,15 @@ def process_sale(conn, sale_data):
|
||||
ref = pd.get('reference', '')
|
||||
cur.execute("""
|
||||
INSERT INTO sale_payments
|
||||
(sale_id, register_id, method, amount, reference)
|
||||
VALUES (%s,%s,%s,%s,%s)
|
||||
""", (sale_id, register_id, method, amt, ref))
|
||||
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s)
|
||||
""", (sale_id, register_id, method, amt, ref, currency, exchange_rate))
|
||||
elif register_id:
|
||||
cur.execute("""
|
||||
INSERT INTO sale_payments
|
||||
(sale_id, register_id, method, amount, reference)
|
||||
VALUES (%s,%s,%s,%s,%s)
|
||||
""", (sale_id, register_id, payment_method, amount_paid, sale_data.get('reference', '')))
|
||||
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s)
|
||||
""", (sale_id, register_id, payment_method, amount_paid, sale_data.get('reference', ''), currency, exchange_rate))
|
||||
|
||||
# Update customer credit balance if credit sale
|
||||
if sale_type == 'credit' and customer_id:
|
||||
@@ -371,19 +418,29 @@ def process_sale(conn, sale_data):
|
||||
cur.close()
|
||||
|
||||
# Auto-generate accounting entry (non-blocking)
|
||||
# Accounting is always in MXN — convert if sale was in another currency
|
||||
try:
|
||||
total_mxn = to_mxn(totals['total'], currency, rate=exchange_rate, conn=conn)
|
||||
tax_mxn = to_mxn(totals['tax_total'], currency, rate=exchange_rate, conn=conn)
|
||||
sub_mxn = to_mxn(totals['subtotal'] - totals['discount_total'], currency, rate=exchange_rate, conn=conn)
|
||||
record_sale_entry(conn, {
|
||||
'id': sale_id,
|
||||
'sale_type': sale_type,
|
||||
'total': totals['total'],
|
||||
'tax_total': totals['tax_total'],
|
||||
'subtotal': totals['subtotal'] - totals['discount_total'],
|
||||
'total': total_mxn,
|
||||
'tax_total': tax_mxn,
|
||||
'subtotal': sub_mxn,
|
||||
'cost_total': sum(item.get('unit_cost', 0) * item['quantity'] for item in enriched_items),
|
||||
'payment_method': payment_method,
|
||||
})
|
||||
except Exception:
|
||||
pass # Accounting errors never block sales
|
||||
|
||||
# Calculate and record savings vs retail price (non-blocking)
|
||||
try:
|
||||
record_sale_savings(conn, sale_id)
|
||||
except Exception:
|
||||
pass # Savings errors never block sales
|
||||
|
||||
return {
|
||||
'id': sale_id,
|
||||
'branch_id': branch_id,
|
||||
@@ -403,6 +460,8 @@ def process_sale(conn, sale_data):
|
||||
'status': 'completed',
|
||||
'items': sale_items,
|
||||
'created_at': str(created_at),
|
||||
'currency': currency,
|
||||
'exchange_rate': exchange_rate,
|
||||
}
|
||||
|
||||
|
||||
@@ -448,8 +507,8 @@ def cancel_sale(conn, sale_id, reason):
|
||||
raise ValueError("Sale is already cancelled")
|
||||
|
||||
# Permission check: cashiers can only cancel own sales within 30 min
|
||||
role = getattr(g, 'employee_role', 'cashier')
|
||||
emp_id = getattr(g, 'employee_id', None)
|
||||
role = _safe_g('employee_role', 'cashier')
|
||||
emp_id = _safe_g('employee_id')
|
||||
|
||||
if role == 'cashier':
|
||||
if s_emp_id != emp_id:
|
||||
@@ -513,7 +572,7 @@ def cancel_sale(conn, sale_id, reason):
|
||||
# Push notification to owner/admin (best-effort, non-blocking)
|
||||
try:
|
||||
from services.push_service import notify_owner
|
||||
emp_name = getattr(g, 'employee_name', 'Empleado')
|
||||
emp_name = _safe_g('employee_name', 'Empleado')
|
||||
notify_owner(
|
||||
conn,
|
||||
'Venta Cancelada',
|
||||
|
||||
197
pos/services/public_api_engine.py
Normal file
197
pos/services/public_api_engine.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""Public API Engine: API key management, rate limiting, request logging.
|
||||
|
||||
Provides:
|
||||
- API key generation and validation (SHA-256 hashed)
|
||||
- Per-key rate limiting (requests per minute / day)
|
||||
- Request logging for analytics and abuse detection
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
def generate_api_key():
|
||||
"""Generate a secure API key. Returns (full_key, key_hash, key_prefix)."""
|
||||
full_key = 'nx_' + secrets.token_urlsafe(32)
|
||||
key_hash = hashlib.sha256(full_key.encode()).hexdigest()
|
||||
key_prefix = full_key[:8]
|
||||
return full_key, key_hash, key_prefix
|
||||
|
||||
|
||||
def hash_api_key(full_key):
|
||||
return hashlib.sha256(full_key.encode()).hexdigest()
|
||||
|
||||
|
||||
def create_api_key(conn, tenant_id, name, scopes=None, rate_limit_rpm=60,
|
||||
rate_limit_rpd=10000, created_by=None, expires_at=None):
|
||||
"""Create a new API key. Returns (key_id, full_key)."""
|
||||
full_key, key_hash, key_prefix = generate_api_key()
|
||||
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO api_keys
|
||||
(tenant_id, name, key_hash, key_prefix, scopes, rate_limit_rpm,
|
||||
rate_limit_rpd, created_by, expires_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (tenant_id, name, key_hash, key_prefix,
|
||||
json.dumps(scopes) if scopes else '["read"]', rate_limit_rpm, rate_limit_rpd,
|
||||
created_by, expires_at))
|
||||
key_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return key_id, full_key
|
||||
|
||||
|
||||
def validate_api_key(conn, full_key):
|
||||
"""Validate an API key. Returns dict with key info or None."""
|
||||
key_hash = hash_api_key(full_key)
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT id, tenant_id, name, scopes, rate_limit_rpm, rate_limit_rpd,
|
||||
is_active, expires_at
|
||||
FROM api_keys
|
||||
WHERE key_hash = %s
|
||||
""", (key_hash,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
key_id, tenant_id, name, scopes, rpm, rpd, is_active, expires = row
|
||||
|
||||
if not is_active:
|
||||
return {'valid': False, 'reason': 'inactive'}
|
||||
|
||||
if expires and datetime.utcnow() > expires:
|
||||
return {'valid': False, 'reason': 'expired'}
|
||||
|
||||
return {
|
||||
'valid': True,
|
||||
'key_id': key_id,
|
||||
'tenant_id': tenant_id,
|
||||
'name': name,
|
||||
'scopes': scopes,
|
||||
'rate_limit_rpm': rpm,
|
||||
'rate_limit_rpd': rpd,
|
||||
}
|
||||
|
||||
|
||||
def check_rate_limit(conn, key_id, rpm, rpd):
|
||||
"""Check if API key is within rate limits. Returns (allowed, headers)."""
|
||||
cur = conn.cursor()
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Minute window
|
||||
minute_start = now.replace(second=0, microsecond=0)
|
||||
cur.execute("""
|
||||
SELECT COALESCE(SUM(request_count), 0)
|
||||
FROM api_rate_limit_counters
|
||||
WHERE api_key_id = %s AND window_type = 'minute'
|
||||
AND window_start = %s
|
||||
""", (key_id, minute_start))
|
||||
minute_count = cur.fetchone()[0] or 0
|
||||
|
||||
# Day window
|
||||
day_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
cur.execute("""
|
||||
SELECT COALESCE(SUM(request_count), 0)
|
||||
FROM api_rate_limit_counters
|
||||
WHERE api_key_id = %s AND window_type = 'day'
|
||||
AND window_start = %s
|
||||
""", (key_id, day_start))
|
||||
day_count = cur.fetchone()[0] or 0
|
||||
|
||||
allowed = minute_count < rpm and day_count < rpd
|
||||
|
||||
headers = {
|
||||
'X-RateLimit-Limit-Minute': str(rpm),
|
||||
'X-RateLimit-Remaining-Minute': str(max(0, rpm - minute_count - 1)),
|
||||
'X-RateLimit-Limit-Day': str(rpd),
|
||||
'X-RateLimit-Remaining-Day': str(max(0, rpd - day_count - 1)),
|
||||
}
|
||||
|
||||
cur.close()
|
||||
return allowed, headers
|
||||
|
||||
|
||||
def increment_rate_limit(conn, key_id):
|
||||
"""Increment request counters for an API key."""
|
||||
cur = conn.cursor()
|
||||
now = datetime.utcnow()
|
||||
minute_start = now.replace(second=0, microsecond=0)
|
||||
day_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
for window_type, window_start in [('minute', minute_start), ('day', day_start)]:
|
||||
cur.execute("""
|
||||
INSERT INTO api_rate_limit_counters (api_key_id, window_start, window_type, request_count)
|
||||
VALUES (%s, %s, %s, 1)
|
||||
ON CONFLICT (api_key_id, window_start, window_type)
|
||||
DO UPDATE SET request_count = api_rate_limit_counters.request_count + 1
|
||||
""", (key_id, window_start, window_type))
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
|
||||
|
||||
def log_api_request(conn, key_id, tenant_id, method, path, status_code,
|
||||
response_time_ms, ip_address, user_agent):
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO api_request_logs
|
||||
(api_key_id, tenant_id, method, path, status_code,
|
||||
response_time_ms, ip_address, user_agent)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""", (key_id, tenant_id, method, path, status_code,
|
||||
response_time_ms, ip_address, user_agent))
|
||||
|
||||
# Update last_used_at
|
||||
if key_id:
|
||||
cur.execute("""
|
||||
UPDATE api_keys SET last_used_at = NOW() WHERE id = %s
|
||||
""", (key_id,))
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
|
||||
|
||||
def list_api_keys(conn, tenant_id):
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT id, name, key_prefix, scopes, rate_limit_rpm, rate_limit_rpd,
|
||||
is_active, last_used_at, expires_at, created_at
|
||||
FROM api_keys
|
||||
WHERE tenant_id = %s
|
||||
ORDER BY created_at DESC
|
||||
""", (tenant_id,))
|
||||
keys = []
|
||||
for r in cur.fetchall():
|
||||
keys.append({
|
||||
'id': r[0], 'name': r[1], 'key_prefix': r[2],
|
||||
'scopes': r[3], 'rate_limit_rpm': r[4], 'rate_limit_rpd': r[5],
|
||||
'is_active': r[6], 'last_used_at': str(r[7]) if r[7] else None,
|
||||
'expires_at': str(r[8]) if r[8] else None,
|
||||
'created_at': str(r[9]),
|
||||
})
|
||||
cur.close()
|
||||
return keys
|
||||
|
||||
|
||||
def revoke_api_key(conn, key_id):
|
||||
cur = conn.cursor()
|
||||
cur.execute("UPDATE api_keys SET is_active = false WHERE id = %s", (key_id,))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return True
|
||||
|
||||
|
||||
def delete_api_key(conn, key_id):
|
||||
cur = conn.cursor()
|
||||
cur.execute("DELETE FROM api_keys WHERE id = %s", (key_id,))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return True
|
||||
120
pos/services/redis_stock_cache.py
Normal file
120
pos/services/redis_stock_cache.py
Normal file
@@ -0,0 +1,120 @@
|
||||
# /home/Autopartes/pos/services/redis_stock_cache.py
|
||||
"""Redis cache layer for inventory stock calculations.
|
||||
|
||||
Provides sub-millisecond stock lookups by caching SUM(inventory_operations)
|
||||
results in Redis. Cache is invalidated on every stock mutation.
|
||||
|
||||
Fallback: if Redis is unavailable, queries PostgreSQL directly.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import redis
|
||||
from decimal import Decimal
|
||||
|
||||
# Connection settings from environment
|
||||
REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
|
||||
REDIS_STOCK_TTL = int(os.environ.get('REDIS_STOCK_TTL', '300')) # 5 minutes default
|
||||
REDIS_ENABLED = os.environ.get('REDIS_ENABLED', 'true').lower() == 'true'
|
||||
|
||||
# Lazy connection
|
||||
_redis_client = None
|
||||
|
||||
|
||||
def _get_redis():
|
||||
"""Get or create Redis connection (lazy singleton)."""
|
||||
global _redis_client
|
||||
if _redis_client is None and REDIS_ENABLED:
|
||||
try:
|
||||
_redis_client = redis.from_url(REDIS_URL, decode_responses=True)
|
||||
_redis_client.ping()
|
||||
except Exception as e:
|
||||
print(f"[redis_stock_cache] Redis unavailable: {e}")
|
||||
_redis_client = False # Disable for this session
|
||||
return _redis_client if _redis_client is not False else None
|
||||
|
||||
|
||||
def _stock_key(inventory_id, branch_id=None):
|
||||
"""Generate Redis key for a stock entry."""
|
||||
if branch_id:
|
||||
return f"nexus:stock:{inventory_id}:b{branch_id}"
|
||||
return f"nexus:stock:{inventory_id}"
|
||||
|
||||
|
||||
def get_cached_stock(inventory_id, branch_id=None):
|
||||
"""Get stock from Redis cache.
|
||||
|
||||
Returns:
|
||||
int/None: Stock quantity if cached, None if miss or Redis down.
|
||||
"""
|
||||
r = _get_redis()
|
||||
if not r:
|
||||
return None
|
||||
try:
|
||||
val = r.get(_stock_key(inventory_id, branch_id))
|
||||
if val is not None:
|
||||
return int(val)
|
||||
except Exception as e:
|
||||
print(f"[redis_stock_cache] GET error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def set_cached_stock(inventory_id, quantity, branch_id=None):
|
||||
"""Store stock in Redis cache with TTL."""
|
||||
r = _get_redis()
|
||||
if not r:
|
||||
return
|
||||
try:
|
||||
key = _stock_key(inventory_id, branch_id)
|
||||
r.set(key, int(quantity), ex=REDIS_STOCK_TTL)
|
||||
except Exception as e:
|
||||
print(f"[redis_stock_cache] SET error: {e}")
|
||||
|
||||
|
||||
def invalidate_stock(inventory_id, branch_id=None):
|
||||
"""Remove stock entry from Redis cache.
|
||||
|
||||
Called after any inventory mutation (sale, purchase, adjust, transfer).
|
||||
If branch_id is None, invalidates both global and branch-specific keys.
|
||||
"""
|
||||
r = _get_redis()
|
||||
if not r:
|
||||
return
|
||||
try:
|
||||
keys = [_stock_key(inventory_id)]
|
||||
if branch_id:
|
||||
keys.append(_stock_key(inventory_id, branch_id))
|
||||
else:
|
||||
# Wildcard invalidation for all branches of this item
|
||||
pattern = _stock_key(inventory_id, '*')
|
||||
keys = r.keys(pattern)
|
||||
keys.append(_stock_key(inventory_id))
|
||||
if keys:
|
||||
r.delete(*keys)
|
||||
except Exception as e:
|
||||
print(f"[redis_stock_cache] DELETE error: {e}")
|
||||
|
||||
|
||||
def invalidate_all_stock():
|
||||
"""Flush all stock keys from Redis. Use with caution (e.g., after bulk import)."""
|
||||
r = _get_redis()
|
||||
if not r:
|
||||
return
|
||||
try:
|
||||
keys = r.keys('nexus:stock:*')
|
||||
if keys:
|
||||
r.delete(*keys)
|
||||
print(f"[redis_stock_cache] Flushed {len(keys)} stock keys")
|
||||
except Exception as e:
|
||||
print(f"[redis_stock_cache] FLUSH error: {e}")
|
||||
|
||||
|
||||
def health_check():
|
||||
"""Return True if Redis is reachable."""
|
||||
r = _get_redis()
|
||||
if not r:
|
||||
return False
|
||||
try:
|
||||
return r.ping()
|
||||
except Exception:
|
||||
return False
|
||||
228
pos/services/reorder_engine.py
Normal file
228
pos/services/reorder_engine.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""Reorder alert engine (Mejora #7).
|
||||
|
||||
Generates alerts when stock hits zero or falls below reorder_point/min_stock.
|
||||
Can auto-suggest purchase orders to restock.
|
||||
|
||||
Alert lifecycle:
|
||||
1. Detect low/zero stock
|
||||
2. Create reorder_alert record (deduplicated per inventory_id)
|
||||
3. Notify owner (push notification)
|
||||
4. Employee acknowledges or generates PO
|
||||
5. When PO is received, alert is auto-resolved
|
||||
"""
|
||||
|
||||
from services.inventory_engine import get_stock, get_stock_bulk
|
||||
from services.audit import log_action
|
||||
|
||||
|
||||
ALERT_TYPES = {
|
||||
'zero': {'severity': 'critical', 'message': 'Sin existencias'},
|
||||
'low': {'severity': 'warning', 'message': 'Stock bajo'},
|
||||
'over': {'severity': 'info', 'message': 'Sobre-stock'},
|
||||
}
|
||||
|
||||
|
||||
def generate_alerts(conn, branch_id=None, auto_notify=True):
|
||||
"""Scan inventory and create reorder_alerts for items that need attention.
|
||||
|
||||
Deduplicates: won't create a new open alert for the same inventory_id
|
||||
if one already exists.
|
||||
|
||||
Args:
|
||||
conn: psycopg2 connection
|
||||
branch_id: optional branch filter
|
||||
auto_notify: if True, sends push notification for new alerts
|
||||
|
||||
Returns:
|
||||
dict: {created: int, by_type: {'zero': n, 'low': n, 'over': n}}
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
|
||||
# Get current open alert inventory_ids to avoid duplicates
|
||||
cur.execute("""
|
||||
SELECT inventory_id, alert_type FROM reorder_alerts
|
||||
WHERE status = 'open'
|
||||
""")
|
||||
existing = {(r[0], r[1]) for r in cur.fetchall()}
|
||||
|
||||
# Build inventory query
|
||||
where = "WHERE i.is_active = true"
|
||||
params = []
|
||||
if branch_id:
|
||||
where += " AND i.branch_id = %s"
|
||||
params.append(branch_id)
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT i.id, i.part_number, i.name, i.min_stock, i.max_stock,
|
||||
i.reorder_point, i.reorder_qty, i.branch_id
|
||||
FROM inventory i {where}
|
||||
""", params)
|
||||
inv_rows = cur.fetchall()
|
||||
|
||||
# Batch stock lookup
|
||||
stock_map = get_stock_bulk(conn, branch_id)
|
||||
|
||||
created = 0
|
||||
by_type = {'zero': 0, 'low': 0, 'over': 0}
|
||||
new_alerts = []
|
||||
|
||||
for row in inv_rows:
|
||||
inv_id, part_num, name, min_s, max_s, reorder_pt, reorder_qty, br_id = row
|
||||
stock = stock_map.get(inv_id, 0)
|
||||
|
||||
alert_type = None
|
||||
threshold = None
|
||||
if stock <= 0:
|
||||
alert_type = 'zero'
|
||||
threshold = 0
|
||||
elif reorder_pt is not None and stock <= reorder_pt:
|
||||
alert_type = 'low'
|
||||
threshold = reorder_pt
|
||||
elif min_s and stock < min_s:
|
||||
alert_type = 'low'
|
||||
threshold = min_s
|
||||
elif max_s and stock > max_s:
|
||||
alert_type = 'over'
|
||||
threshold = max_s
|
||||
|
||||
if alert_type and (inv_id, alert_type) not in existing:
|
||||
cur.execute("""
|
||||
INSERT INTO reorder_alerts
|
||||
(inventory_id, branch_id, alert_type, stock_at_alert, threshold, status)
|
||||
VALUES (%s, %s, %s, %s, %s, 'open')
|
||||
""", (inv_id, br_id, alert_type, stock, threshold))
|
||||
created += 1
|
||||
by_type[alert_type] += 1
|
||||
new_alerts.append({
|
||||
'inventory_id': inv_id,
|
||||
'part_number': part_num,
|
||||
'name': name,
|
||||
'type': alert_type,
|
||||
'stock': stock,
|
||||
})
|
||||
|
||||
cur.close()
|
||||
|
||||
# Push notifications (best-effort)
|
||||
if auto_notify and new_alerts:
|
||||
try:
|
||||
from services.push_service import notify_owner
|
||||
for alert in new_alerts[:5]: # limit to first 5 to avoid spam
|
||||
notify_owner(
|
||||
conn,
|
||||
f"Alerta: {ALERT_TYPES[alert['type']]['message']}",
|
||||
f"{alert['name'] or alert['part_number']} — Stock: {alert['stock']}",
|
||||
'/inventory'
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {'created': created, 'by_type': by_type}
|
||||
|
||||
|
||||
def list_alerts(conn, status=None, branch_id=None, limit=50, offset=0):
|
||||
"""List reorder alerts with inventory details."""
|
||||
cur = conn.cursor()
|
||||
filters = []
|
||||
params = []
|
||||
if status:
|
||||
filters.append("ra.status = %s")
|
||||
params.append(status)
|
||||
if branch_id:
|
||||
filters.append("ra.branch_id = %s")
|
||||
params.append(branch_id)
|
||||
where = "WHERE " + " AND ".join(filters) if filters else ""
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT ra.id, ra.inventory_id, i.part_number, i.name,
|
||||
ra.alert_type, ra.stock_at_alert, ra.threshold, ra.status,
|
||||
ra.created_at, ra.resolved_at, b.name as branch_name
|
||||
FROM reorder_alerts ra
|
||||
JOIN inventory i ON ra.inventory_id = i.id
|
||||
LEFT JOIN branches b ON ra.branch_id = b.id
|
||||
{where}
|
||||
ORDER BY
|
||||
CASE ra.alert_type WHEN 'zero' THEN 0 WHEN 'low' THEN 1 ELSE 2 END,
|
||||
ra.created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""", params + [limit, offset])
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
return [{
|
||||
'id': r[0], 'inventory_id': r[1], 'part_number': r[2], 'name': r[3],
|
||||
'alert_type': r[4], 'stock_at_alert': r[5], 'threshold': r[6],
|
||||
'status': r[7], 'created_at': str(r[8]),
|
||||
'resolved_at': str(r[9]) if r[9] else None,
|
||||
'branch_name': r[10],
|
||||
} for r in rows]
|
||||
|
||||
|
||||
def acknowledge_alert(conn, alert_id, employee_id=None, notes=None):
|
||||
"""Mark an alert as acknowledged."""
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
UPDATE reorder_alerts
|
||||
SET status = 'acknowledged', employee_id = %s, notes = COALESCE(notes || ' | ', '') || %s
|
||||
WHERE id = %s AND status = 'open'
|
||||
""", (employee_id, notes or 'Revisado', alert_id))
|
||||
updated = cur.rowcount > 0
|
||||
cur.close()
|
||||
return updated
|
||||
|
||||
|
||||
def resolve_alert(conn, alert_id, po_id=None, notes=None):
|
||||
"""Resolve an alert (e.g., when PO is received)."""
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
UPDATE reorder_alerts
|
||||
SET status = 'resolved', po_id = COALESCE(%s, po_id),
|
||||
notes = COALESCE(notes || ' | ', '') || %s,
|
||||
resolved_at = NOW()
|
||||
WHERE id = %s
|
||||
""", (po_id, notes or 'Resuelto', alert_id))
|
||||
updated = cur.rowcount > 0
|
||||
cur.close()
|
||||
return updated
|
||||
|
||||
|
||||
def suggest_po_from_alerts(conn, supplier_id=None, branch_id=None):
|
||||
"""Generate a suggested PO based on open low/zero stock alerts.
|
||||
|
||||
Returns a dict ready to be passed to supplier_engine.create_po().
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
where = "WHERE ra.status = 'open' AND ra.alert_type IN ('zero', 'low')"
|
||||
params = []
|
||||
if branch_id:
|
||||
where += " AND ra.branch_id = %s"
|
||||
params.append(branch_id)
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT ra.inventory_id, i.part_number, i.name,
|
||||
i.reorder_qty, i.min_stock, ra.stock_at_alert
|
||||
FROM reorder_alerts ra
|
||||
JOIN inventory i ON ra.inventory_id = i.id
|
||||
{where}
|
||||
ORDER BY i.name
|
||||
""", params)
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
inv_id, part_num, name, reorder_qty, min_stock, stock = r
|
||||
# Suggested qty: reorder_qty if set, otherwise min_stock * 2 - stock
|
||||
suggested = reorder_qty if reorder_qty else max((min_stock or 1) * 2 - (stock or 0), 1)
|
||||
items.append({
|
||||
'inventory_id': inv_id,
|
||||
'part_number': part_num,
|
||||
'name': name,
|
||||
'quantity': suggested,
|
||||
'unit_price': 0, # employee must fill in
|
||||
})
|
||||
|
||||
return {
|
||||
'supplier_id': supplier_id,
|
||||
'items': items,
|
||||
'notes': 'Orden sugerida automaticamente desde alertas de reorden',
|
||||
}
|
||||
189
pos/services/savings_engine.py
Normal file
189
pos/services/savings_engine.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""Savings Engine: calculate and track how much customers save vs retail price.
|
||||
|
||||
Provides:
|
||||
- Calculate savings per item at checkout
|
||||
- Update customer total savings
|
||||
- Generate savings reports
|
||||
"""
|
||||
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
|
||||
def calculate_item_savings(unit_price, retail_price, quantity=1):
|
||||
"""Calculate savings for a single item.
|
||||
|
||||
Returns:
|
||||
savings_amount (float), savings_pct (float)
|
||||
"""
|
||||
if not retail_price or retail_price <= 0:
|
||||
return 0.0, 0.0
|
||||
if not unit_price or unit_price <= 0:
|
||||
return 0.0, 0.0
|
||||
|
||||
savings = (Decimal(str(retail_price)) - Decimal(str(unit_price))) * Decimal(str(quantity))
|
||||
savings = savings.quantize(Decimal('0.01'), ROUND_HALF_UP)
|
||||
|
||||
pct = (savings / (Decimal(str(retail_price)) * Decimal(str(quantity)))) * 100
|
||||
pct = float(pct.quantize(Decimal('0.1'), ROUND_HALF_UP))
|
||||
|
||||
return float(savings), pct
|
||||
|
||||
|
||||
def record_sale_savings(conn, sale_id):
|
||||
"""Recalculate and record savings for all items in a sale.
|
||||
|
||||
Called after sale is created. Updates sale_items.savings_amount and sales.total_savings.
|
||||
Also updates customers.total_savings.
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
|
||||
# Get all items with their retail prices
|
||||
cur.execute("""
|
||||
SELECT si.id, si.inventory_id, si.unit_price, si.quantity, i.retail_price
|
||||
FROM sale_items si
|
||||
LEFT JOIN inventory i ON si.inventory_id = i.id
|
||||
WHERE si.sale_id = %s
|
||||
""", (sale_id,))
|
||||
|
||||
total_savings = Decimal('0')
|
||||
for row in cur.fetchall():
|
||||
item_id, inv_id, unit_price, qty, retail_price = row
|
||||
savings, _ = calculate_item_savings(unit_price, retail_price, qty)
|
||||
if savings > 0:
|
||||
cur.execute("""
|
||||
UPDATE sale_items SET savings_amount = %s WHERE id = %s
|
||||
""", (savings, item_id))
|
||||
total_savings += Decimal(str(savings))
|
||||
|
||||
# Update sale total savings
|
||||
cur.execute("""
|
||||
UPDATE sales SET total_savings = %s WHERE id = %s
|
||||
""", (total_savings, sale_id))
|
||||
|
||||
# Update customer total savings
|
||||
cur.execute("""
|
||||
UPDATE customers
|
||||
SET total_savings = COALESCE(total_savings, 0) + %s
|
||||
WHERE id = (SELECT customer_id FROM sales WHERE id = %s)
|
||||
""", (total_savings, sale_id))
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return float(total_savings)
|
||||
|
||||
|
||||
def get_customer_savings_report(conn, customer_id, months=12):
|
||||
"""Get savings report for a customer."""
|
||||
cur = conn.cursor()
|
||||
|
||||
# Overall savings
|
||||
cur.execute("""
|
||||
SELECT COALESCE(SUM(total_savings), 0), COUNT(*)
|
||||
FROM sales
|
||||
WHERE customer_id = %s AND status = 'completed' AND total_savings > 0
|
||||
""", (customer_id,))
|
||||
total_saved, orders_with_savings = cur.fetchone()
|
||||
|
||||
# Monthly breakdown
|
||||
cur.execute("""
|
||||
SELECT
|
||||
date_trunc('month', created_at) as month,
|
||||
COUNT(*) as orders,
|
||||
SUM(total) as spent,
|
||||
SUM(total_savings) as saved
|
||||
FROM sales
|
||||
WHERE customer_id = %s AND status = 'completed' AND total_savings > 0
|
||||
AND created_at >= NOW() - interval '%s months'
|
||||
GROUP BY date_trunc('month', created_at)
|
||||
ORDER BY month DESC
|
||||
""", (customer_id, months))
|
||||
|
||||
monthly = []
|
||||
for r in cur.fetchall():
|
||||
monthly.append({
|
||||
'month': str(r[0])[:7],
|
||||
'orders': r[1],
|
||||
'spent': float(r[2]) if r[2] else 0,
|
||||
'saved': float(r[3]) if r[3] else 0,
|
||||
'savings_pct': round(float(r[3]) / float(r[2]) * 100, 1) if r[2] else 0,
|
||||
})
|
||||
|
||||
# Top savings items
|
||||
cur.execute("""
|
||||
SELECT si.name, si.part_number, si.unit_price, si.retail_price, si.savings_amount
|
||||
FROM sale_items si
|
||||
JOIN sales s ON s.id = si.sale_id
|
||||
WHERE s.customer_id = %s AND s.status = 'completed' AND si.savings_amount > 0
|
||||
ORDER BY si.savings_amount DESC
|
||||
LIMIT 10
|
||||
""", (customer_id,))
|
||||
|
||||
top_items = []
|
||||
for r in cur.fetchall():
|
||||
top_items.append({
|
||||
'name': r[0], 'part_number': r[1],
|
||||
'unit_price': float(r[2]) if r[2] else 0,
|
||||
'retail_price': float(r[3]) if r[3] else 0,
|
||||
'savings': float(r[4]) if r[4] else 0,
|
||||
})
|
||||
|
||||
cur.close()
|
||||
|
||||
return {
|
||||
'customer_id': customer_id,
|
||||
'total_saved': float(total_saved) if total_saved else 0,
|
||||
'orders_with_savings': orders_with_savings or 0,
|
||||
'monthly_breakdown': monthly,
|
||||
'top_items': top_items,
|
||||
}
|
||||
|
||||
|
||||
def get_global_savings_stats(conn, tenant_id, from_date=None, to_date=None):
|
||||
"""Get global savings stats for a tenant."""
|
||||
cur = conn.cursor()
|
||||
params = [tenant_id]
|
||||
date_filter = ""
|
||||
if from_date:
|
||||
date_filter += " AND s.created_at >= %s"
|
||||
params.append(from_date)
|
||||
if to_date:
|
||||
date_filter += " AND s.created_at < %s::date + interval '1 day'"
|
||||
params.append(to_date)
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT
|
||||
COALESCE(SUM(s.total_savings), 0),
|
||||
COUNT(DISTINCT s.customer_id),
|
||||
COUNT(*) as orders,
|
||||
COALESCE(AVG(s.total_savings), 0)
|
||||
FROM sales s
|
||||
JOIN customers c ON s.customer_id = c.id
|
||||
WHERE s.status = 'completed' AND s.total_savings > 0
|
||||
{date_filter}
|
||||
""", params[1:] if len(params) > 1 else [])
|
||||
|
||||
total_saved, customers_count, orders, avg_savings = cur.fetchone()
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT c.name, c.id, SUM(s.total_savings) as saved
|
||||
FROM sales s
|
||||
JOIN customers c ON s.customer_id = c.id
|
||||
WHERE s.status = 'completed' AND s.total_savings > 0
|
||||
{date_filter}
|
||||
GROUP BY c.id, c.name
|
||||
ORDER BY saved DESC
|
||||
LIMIT 10
|
||||
""", params[1:] if len(params) > 1 else [])
|
||||
|
||||
top_customers = []
|
||||
for r in cur.fetchall():
|
||||
top_customers.append({'name': r[0], 'id': r[1], 'saved': float(r[2]) if r[2] else 0})
|
||||
|
||||
cur.close()
|
||||
return {
|
||||
'total_saved': float(total_saved) if total_saved else 0,
|
||||
'customers_count': customers_count or 0,
|
||||
'orders_count': orders or 0,
|
||||
'avg_savings_per_order': float(avg_savings) if avg_savings else 0,
|
||||
'top_customers': top_customers,
|
||||
}
|
||||
440
pos/services/service_order_engine.py
Normal file
440
pos/services/service_order_engine.py
Normal file
@@ -0,0 +1,440 @@
|
||||
"""Service Order Engine: workshop Kanban management.
|
||||
|
||||
States: received -> diagnosis -> waiting_parts -> repair -> quality_check -> ready -> delivered
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
VALID_TRANSITIONS = {
|
||||
'received': ['diagnosis', 'cancelled'],
|
||||
'diagnosis': ['waiting_parts', 'repair', 'cancelled'],
|
||||
'waiting_parts': ['repair', 'cancelled'],
|
||||
'repair': ['quality_check', 'cancelled'],
|
||||
'quality_check': ['ready', 'repair', 'cancelled'],
|
||||
'ready': ['delivered', 'cancelled'],
|
||||
'delivered': [],
|
||||
'cancelled': [],
|
||||
}
|
||||
|
||||
|
||||
def _generate_order_number(conn):
|
||||
"""Generate SO-YYYY-NNNN order number."""
|
||||
cur = conn.cursor()
|
||||
year = datetime.utcnow().year
|
||||
prefix = f"SO-{year}-"
|
||||
cur.execute("""
|
||||
SELECT order_number FROM service_orders
|
||||
WHERE order_number LIKE %s
|
||||
ORDER BY order_number DESC LIMIT 1
|
||||
""", (f"{prefix}%",))
|
||||
row = cur.fetchone()
|
||||
last_num = 0
|
||||
if row and row[0]:
|
||||
try:
|
||||
last_num = int(row[0].split('-')[-1])
|
||||
except ValueError:
|
||||
pass
|
||||
new_num = last_num + 1
|
||||
cur.close()
|
||||
return f"{prefix}{new_num:04d}"
|
||||
|
||||
|
||||
def create_service_order(conn, data):
|
||||
"""Create a new service order.
|
||||
|
||||
data: {
|
||||
customer_id, vehicle_id, branch_id, priority,
|
||||
reception_notes, estimated_cost, estimated_completion,
|
||||
employee_id, mileage_in, fuel_level, created_by
|
||||
}
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
order_number = _generate_order_number(conn)
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO service_orders
|
||||
(tenant_id, branch_id, customer_id, vehicle_id, order_number, status,
|
||||
priority, reception_notes, estimated_cost, estimated_completion,
|
||||
employee_id, mileage_in, fuel_level, created_by)
|
||||
VALUES (%s, %s, %s, %s, %s, 'received', %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (
|
||||
data.get('tenant_id'), data.get('branch_id'), data.get('customer_id'),
|
||||
data.get('vehicle_id'), order_number,
|
||||
data.get('priority', 'normal'), data.get('reception_notes'),
|
||||
data.get('estimated_cost'), data.get('estimated_completion'),
|
||||
data.get('employee_id'), data.get('mileage_in'),
|
||||
data.get('fuel_level'), data.get('created_by'),
|
||||
))
|
||||
so_id = cur.fetchone()[0]
|
||||
|
||||
# Log initial status
|
||||
cur.execute("""
|
||||
INSERT INTO service_order_status_history
|
||||
(service_order_id, new_status, changed_by, notes)
|
||||
VALUES (%s, 'received', %s, 'Orden creada')
|
||||
""", (so_id, data.get('created_by')))
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return {'service_order_id': so_id, 'order_number': order_number}
|
||||
|
||||
|
||||
def get_service_order(conn, so_id):
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT so.id, so.order_number, so.status, so.priority,
|
||||
so.customer_id, c.name as customer_name, c.phone as customer_phone,
|
||||
so.vehicle_id, fv.plate as vehicle_plate, fv.make as vehicle_make, fv.model as vehicle_model,
|
||||
so.branch_id, so.reception_notes, so.diagnosis_notes, so.repair_notes,
|
||||
so.delivery_notes, so.estimated_cost, so.final_cost,
|
||||
so.estimated_completion, so.actual_completion, so.delivered_at,
|
||||
so.mileage_in, so.mileage_out, so.fuel_level,
|
||||
so.employee_id, e.name as employee_name,
|
||||
so.created_by, so.created_at, so.updated_at
|
||||
FROM service_orders so
|
||||
LEFT JOIN customers c ON so.customer_id = c.id
|
||||
LEFT JOIN fleet_vehicles fv ON so.vehicle_id = fv.id
|
||||
LEFT JOIN employees e ON so.employee_id = e.id
|
||||
WHERE so.id = %s
|
||||
""", (so_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close()
|
||||
return None
|
||||
|
||||
so = {
|
||||
'id': row[0], 'order_number': row[1], 'status': row[2], 'priority': row[3],
|
||||
'customer_id': row[4], 'customer_name': row[5], 'customer_phone': row[6],
|
||||
'vehicle_id': row[7], 'vehicle_plate': row[8], 'vehicle_make': row[9], 'vehicle_model': row[10],
|
||||
'branch_id': row[11], 'reception_notes': row[12], 'diagnosis_notes': row[13],
|
||||
'repair_notes': row[14], 'delivery_notes': row[15],
|
||||
'estimated_cost': float(row[16]) if row[16] else None,
|
||||
'final_cost': float(row[17]) if row[17] else None,
|
||||
'estimated_completion': str(row[18]) if row[18] else None,
|
||||
'actual_completion': str(row[19]) if row[19] else None,
|
||||
'delivered_at': str(row[20]) if row[20] else None,
|
||||
'mileage_in': row[21], 'mileage_out': row[22], 'fuel_level': row[23],
|
||||
'employee_id': row[24], 'employee_name': row[25],
|
||||
'created_by': row[26], 'created_at': str(row[27]), 'updated_at': str(row[28]),
|
||||
}
|
||||
|
||||
# Items
|
||||
cur.execute("""
|
||||
SELECT id, inventory_id, part_number, name, quantity, unit_cost, unit_price, status, notes
|
||||
FROM service_order_items
|
||||
WHERE service_order_id = %s
|
||||
ORDER BY id
|
||||
""", (so_id,))
|
||||
so['items'] = []
|
||||
for r in cur.fetchall():
|
||||
so['items'].append({
|
||||
'id': r[0], 'inventory_id': r[1], 'part_number': r[2], 'name': r[3],
|
||||
'quantity': float(r[4]) if r[4] else 0,
|
||||
'unit_cost': float(r[5]) if r[5] else None,
|
||||
'unit_price': float(r[6]) if r[6] else None,
|
||||
'status': r[7], 'notes': r[8],
|
||||
})
|
||||
|
||||
# Labor
|
||||
cur.execute("""
|
||||
SELECT id, description, hours, hourly_rate, total_cost, employee_id, status
|
||||
FROM service_order_labor
|
||||
WHERE service_order_id = %s
|
||||
ORDER BY id
|
||||
""", (so_id,))
|
||||
so['labor'] = []
|
||||
for r in cur.fetchall():
|
||||
so['labor'].append({
|
||||
'id': r[0], 'description': r[1],
|
||||
'hours': float(r[2]) if r[2] else 0,
|
||||
'hourly_rate': float(r[3]) if r[3] else 0,
|
||||
'total_cost': float(r[4]) if r[4] else 0,
|
||||
'employee_id': r[5], 'status': r[6],
|
||||
})
|
||||
|
||||
# Status history
|
||||
cur.execute("""
|
||||
SELECT id, old_status, new_status, changed_by, notes, created_at
|
||||
FROM service_order_status_history
|
||||
WHERE service_order_id = %s
|
||||
ORDER BY created_at
|
||||
""", (so_id,))
|
||||
so['status_history'] = []
|
||||
for r in cur.fetchall():
|
||||
so['status_history'].append({
|
||||
'id': r[0], 'old_status': r[1], 'new_status': r[2],
|
||||
'changed_by': r[3], 'notes': r[4], 'created_at': str(r[5]),
|
||||
})
|
||||
|
||||
cur.close()
|
||||
return so
|
||||
|
||||
|
||||
def list_service_orders(conn, status=None, branch_id=None, customer_id=None,
|
||||
priority=None, employee_id=None, page=1, per_page=50):
|
||||
cur = conn.cursor()
|
||||
where_clauses = []
|
||||
params = []
|
||||
|
||||
if status:
|
||||
where_clauses.append("so.status = %s")
|
||||
params.append(status)
|
||||
if branch_id:
|
||||
where_clauses.append("so.branch_id = %s")
|
||||
params.append(branch_id)
|
||||
if customer_id:
|
||||
where_clauses.append("so.customer_id = %s")
|
||||
params.append(customer_id)
|
||||
if priority:
|
||||
where_clauses.append("so.priority = %s")
|
||||
params.append(priority)
|
||||
if employee_id:
|
||||
where_clauses.append("so.employee_id = %s")
|
||||
params.append(employee_id)
|
||||
|
||||
where = " AND ".join(where_clauses) if where_clauses else "true"
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT count(*) FROM service_orders so WHERE {where}
|
||||
""", params)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT so.id, so.order_number, so.status, so.priority,
|
||||
so.customer_id, c.name as customer_name,
|
||||
so.vehicle_id, fv.plate as vehicle_plate,
|
||||
so.estimated_cost, so.estimated_completion, so.created_at
|
||||
FROM service_orders so
|
||||
LEFT JOIN customers c ON so.customer_id = c.id
|
||||
LEFT JOIN fleet_vehicles fv ON so.vehicle_id = fv.id
|
||||
WHERE {where}
|
||||
ORDER BY
|
||||
CASE so.priority
|
||||
WHEN 'urgent' THEN 1
|
||||
WHEN 'high' THEN 2
|
||||
WHEN 'normal' THEN 3
|
||||
WHEN 'low' THEN 4
|
||||
END,
|
||||
so.created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""", params + [per_page, (page - 1) * per_page])
|
||||
|
||||
orders = []
|
||||
for r in cur.fetchall():
|
||||
orders.append({
|
||||
'id': r[0], 'order_number': r[1], 'status': r[2], 'priority': r[3],
|
||||
'customer_id': r[4], 'customer_name': r[5],
|
||||
'vehicle_id': r[6], 'vehicle_plate': r[7],
|
||||
'estimated_cost': float(r[8]) if r[8] else None,
|
||||
'estimated_completion': str(r[9]) if r[9] else None,
|
||||
'created_at': str(r[10]),
|
||||
})
|
||||
|
||||
cur.close()
|
||||
total_pages = (total + per_page - 1) // per_page
|
||||
return {
|
||||
'data': orders,
|
||||
'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages}
|
||||
}
|
||||
|
||||
|
||||
def update_status(conn, so_id, new_status, changed_by=None, notes=None):
|
||||
"""Update service order status with validation."""
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT status FROM service_orders WHERE id = %s", (so_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close()
|
||||
raise ValueError("Service order not found")
|
||||
|
||||
old_status = row[0]
|
||||
if new_status not in VALID_TRANSITIONS.get(old_status, []):
|
||||
cur.close()
|
||||
raise ValueError(f"Invalid transition: {old_status} -> {new_status}")
|
||||
|
||||
# Update status
|
||||
extra_sets = []
|
||||
extra_vals = []
|
||||
if new_status == 'ready':
|
||||
extra_sets.append("actual_completion = NOW()")
|
||||
if new_status == 'delivered':
|
||||
extra_sets.append("delivered_at = NOW()")
|
||||
extra_sets.append("delivered_by = %s")
|
||||
extra_vals.append(changed_by)
|
||||
|
||||
set_clause = ", ".join(["status = %s"] + extra_sets)
|
||||
cur.execute(f"""
|
||||
UPDATE service_orders
|
||||
SET {set_clause}
|
||||
WHERE id = %s
|
||||
""", [new_status] + extra_vals + [so_id])
|
||||
|
||||
# Log history
|
||||
cur.execute("""
|
||||
INSERT INTO service_order_status_history
|
||||
(service_order_id, old_status, new_status, changed_by, notes)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
""", (so_id, old_status, new_status, changed_by, notes))
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return {'old_status': old_status, 'new_status': new_status}
|
||||
|
||||
|
||||
def add_item(conn, so_id, item_data):
|
||||
"""Add a part/item to the service order."""
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO service_order_items
|
||||
(service_order_id, inventory_id, part_number, name, quantity, unit_cost, unit_price, status, notes)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (
|
||||
so_id, item_data.get('inventory_id'), item_data.get('part_number'),
|
||||
item_data.get('name'), item_data.get('quantity', 1),
|
||||
item_data.get('unit_cost'), item_data.get('unit_price'),
|
||||
item_data.get('status', 'pending'), item_data.get('notes'),
|
||||
))
|
||||
item_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return item_id
|
||||
|
||||
|
||||
def update_item(conn, item_id, data):
|
||||
cur = conn.cursor()
|
||||
allowed = ['part_number', 'name', 'quantity', 'unit_cost', 'unit_price', 'status', 'notes']
|
||||
sets = []
|
||||
vals = []
|
||||
for field in allowed:
|
||||
if field in data:
|
||||
sets.append(f"{field} = %s")
|
||||
vals.append(data[field])
|
||||
if not sets:
|
||||
cur.close()
|
||||
return False
|
||||
vals.append(item_id)
|
||||
cur.execute(f"UPDATE service_order_items SET {', '.join(sets)} WHERE id = %s", vals)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return True
|
||||
|
||||
|
||||
def remove_item(conn, item_id):
|
||||
cur = conn.cursor()
|
||||
cur.execute("DELETE FROM service_order_items WHERE id = %s", (item_id,))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return True
|
||||
|
||||
|
||||
def add_labor(conn, so_id, labor_data):
|
||||
cur = conn.cursor()
|
||||
total_cost = labor_data.get('hours', 0) * labor_data.get('hourly_rate', 0)
|
||||
cur.execute("""
|
||||
INSERT INTO service_order_labor
|
||||
(service_order_id, description, hours, hourly_rate, total_cost, employee_id, status)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (
|
||||
so_id, labor_data['description'], labor_data.get('hours', 0),
|
||||
labor_data.get('hourly_rate', 0), total_cost,
|
||||
labor_data.get('employee_id'), labor_data.get('status', 'pending'),
|
||||
))
|
||||
labor_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return labor_id
|
||||
|
||||
|
||||
def update_labor(conn, labor_id, data):
|
||||
cur = conn.cursor()
|
||||
allowed = ['description', 'hours', 'hourly_rate', 'employee_id', 'status']
|
||||
sets = []
|
||||
vals = []
|
||||
for field in allowed:
|
||||
if field in data:
|
||||
sets.append(f"{field} = %s")
|
||||
vals.append(data[field])
|
||||
if not sets:
|
||||
cur.close()
|
||||
return False
|
||||
|
||||
# Recalculate total_cost if hours or rate changed
|
||||
cur.execute("SELECT hours, hourly_rate FROM service_order_labor WHERE id = %s", (labor_id,))
|
||||
row = cur.fetchone()
|
||||
hours = data.get('hours', row[0]) if row else data.get('hours', 0)
|
||||
rate = data.get('hourly_rate', row[1]) if row else data.get('hourly_rate', 0)
|
||||
sets.append("total_cost = %s")
|
||||
vals.append((hours or 0) * (rate or 0))
|
||||
|
||||
vals.append(labor_id)
|
||||
cur.execute(f"UPDATE service_order_labor SET {', '.join(sets)} WHERE id = %s", vals)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return True
|
||||
|
||||
|
||||
def remove_labor(conn, labor_id):
|
||||
cur = conn.cursor()
|
||||
cur.execute("DELETE FROM service_order_labor WHERE id = %s", (labor_id,))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return True
|
||||
|
||||
|
||||
def update_service_order(conn, so_id, data):
|
||||
"""Update general service order fields."""
|
||||
cur = conn.cursor()
|
||||
allowed = ['priority', 'reception_notes', 'diagnosis_notes', 'repair_notes',
|
||||
'delivery_notes', 'estimated_cost', 'estimated_completion',
|
||||
'employee_id', 'mileage_out', 'fuel_level', 'final_cost']
|
||||
sets = []
|
||||
vals = []
|
||||
for field in allowed:
|
||||
if field in data:
|
||||
sets.append(f"{field} = %s")
|
||||
vals.append(data[field])
|
||||
if not sets:
|
||||
cur.close()
|
||||
return False
|
||||
vals.append(so_id)
|
||||
cur.execute(f"UPDATE service_orders SET {', '.join(sets)} WHERE id = %s", vals)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return True
|
||||
|
||||
|
||||
def get_kanban_summary(conn, branch_id=None):
|
||||
"""Get counts per status for Kanban board."""
|
||||
cur = conn.cursor()
|
||||
params = []
|
||||
branch_filter = ""
|
||||
if branch_id:
|
||||
branch_filter = "AND branch_id = %s"
|
||||
params.append(branch_id)
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT status, COUNT(*) as cnt
|
||||
FROM service_orders
|
||||
WHERE status != 'cancelled' {branch_filter}
|
||||
GROUP BY status
|
||||
""", params)
|
||||
|
||||
summary = {status: 0 for status in VALID_TRANSITIONS.keys() if status != 'cancelled'}
|
||||
for r in cur.fetchall():
|
||||
summary[r[0]] = r[1]
|
||||
|
||||
# Overdue orders (estimated_completion passed and not ready/delivered)
|
||||
cur.execute(f"""
|
||||
SELECT count(*) FROM service_orders
|
||||
WHERE estimated_completion < NOW()
|
||||
AND status NOT IN ('ready', 'delivered', 'cancelled')
|
||||
{branch_filter}
|
||||
""", params)
|
||||
overdue = cur.fetchone()[0]
|
||||
|
||||
cur.close()
|
||||
summary['overdue'] = overdue
|
||||
return summary
|
||||
448
pos/services/supplier_engine.py
Normal file
448
pos/services/supplier_engine.py
Normal file
@@ -0,0 +1,448 @@
|
||||
"""Supplier and purchase order engine.
|
||||
|
||||
Provides CRUD for suppliers and the full purchase order lifecycle:
|
||||
create_po → send_po → receive_po → (optional: cancel_po)
|
||||
|
||||
On receive, automatically:
|
||||
1. Updates inventory stock via inventory_engine.record_purchase()
|
||||
2. Creates accounting entry via accounting_engine.record_purchase_entry()
|
||||
"""
|
||||
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from datetime import date
|
||||
|
||||
from services.inventory_engine import record_purchase
|
||||
from services.accounting_engine import record_purchase_entry
|
||||
from services.audit import log_action
|
||||
|
||||
TWO = Decimal('0.01')
|
||||
|
||||
|
||||
def _to_dec(val):
|
||||
if val is None:
|
||||
return Decimal('0')
|
||||
return Decimal(str(val))
|
||||
|
||||
|
||||
# ── SUPPLIER CRUD ──────────────────────────────────────────────────────────
|
||||
|
||||
def create_supplier(conn, data):
|
||||
"""Create a new supplier."""
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO suppliers (name, contact_name, phone, email, rfc, address,
|
||||
payment_terms, notes)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (
|
||||
data['name'], data.get('contact_name'), data.get('phone'),
|
||||
data.get('email'), data.get('rfc'), data.get('address'),
|
||||
data.get('payment_terms'), data.get('notes')
|
||||
))
|
||||
supplier_id = cur.fetchone()[0]
|
||||
cur.close()
|
||||
log_action(conn, 'SUPPLIER_CREATE', 'supplier', supplier_id,
|
||||
new_value={'name': data['name']})
|
||||
return supplier_id
|
||||
|
||||
|
||||
def update_supplier(conn, supplier_id, data):
|
||||
"""Update supplier fields."""
|
||||
allowed = ['name', 'contact_name', 'phone', 'email', 'rfc',
|
||||
'address', 'payment_terms', 'notes', 'is_active']
|
||||
sets = []
|
||||
vals = []
|
||||
for k in allowed:
|
||||
if k in data:
|
||||
sets.append(f"{k} = %s")
|
||||
vals.append(data[k])
|
||||
if not sets:
|
||||
return False
|
||||
vals.append(supplier_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute(f"""
|
||||
UPDATE suppliers SET {', '.join(sets)}, updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""", vals)
|
||||
updated = cur.rowcount > 0
|
||||
cur.close()
|
||||
if updated:
|
||||
log_action(conn, 'SUPPLIER_UPDATE', 'supplier', supplier_id,
|
||||
new_value=data)
|
||||
return updated
|
||||
|
||||
|
||||
def get_supplier(conn, supplier_id):
|
||||
"""Get single supplier by ID."""
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT id, name, contact_name, phone, email, rfc, address,
|
||||
payment_terms, notes, is_active, created_at
|
||||
FROM suppliers WHERE id = %s
|
||||
""", (supplier_id,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
'id': row[0], 'name': row[1], 'contact_name': row[2],
|
||||
'phone': row[3], 'email': row[4], 'rfc': row[5],
|
||||
'address': row[6], 'payment_terms': row[7],
|
||||
'notes': row[8], 'is_active': row[9], 'created_at': str(row[10]),
|
||||
}
|
||||
|
||||
|
||||
def list_suppliers(conn, active_only=True, limit=100, offset=0):
|
||||
"""List suppliers."""
|
||||
cur = conn.cursor()
|
||||
where = "WHERE is_active = true" if active_only else ""
|
||||
cur.execute(f"""
|
||||
SELECT id, name, contact_name, phone, email, rfc, is_active
|
||||
FROM suppliers {where}
|
||||
ORDER BY name
|
||||
LIMIT %s OFFSET %s
|
||||
""", (limit, offset))
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
return [{
|
||||
'id': r[0], 'name': r[1], 'contact_name': r[2],
|
||||
'phone': r[3], 'email': r[4], 'rfc': r[5], 'is_active': r[6],
|
||||
} for r in rows]
|
||||
|
||||
|
||||
# ── PURCHASE ORDERS ────────────────────────────────────────────────────────
|
||||
|
||||
def create_po(conn, data, branch_id=None, employee_id=None):
|
||||
"""Create a purchase order with items.
|
||||
|
||||
Args:
|
||||
data: dict with keys:
|
||||
supplier_id: int
|
||||
items: [{inventory_id|part_number|name, quantity, unit_price, notes}]
|
||||
notes: str (optional)
|
||||
expected_date: str 'YYYY-MM-DD' (optional)
|
||||
currency: 'MXN'|'USD' (default 'MXN')
|
||||
exchange_rate: float (optional)
|
||||
Returns:
|
||||
dict: {po_id, status, total, item_count}
|
||||
"""
|
||||
supplier_id = data.get('supplier_id')
|
||||
items = data.get('items', [])
|
||||
if not items:
|
||||
raise ValueError("No items in purchase order")
|
||||
|
||||
currency = data.get('currency', 'MXN')
|
||||
exchange_rate = float(data.get('exchange_rate', 1.0))
|
||||
|
||||
# Calculate totals
|
||||
subtotal = Decimal('0')
|
||||
po_items = []
|
||||
for item in items:
|
||||
qty = int(item.get('quantity', 1))
|
||||
price = _to_dec(item.get('unit_price', 0))
|
||||
line_sub = (price * qty).quantize(TWO, ROUND_HALF_UP)
|
||||
subtotal += line_sub
|
||||
po_items.append({
|
||||
'inventory_id': item.get('inventory_id'),
|
||||
'part_number': item.get('part_number', ''),
|
||||
'name': item.get('name', ''),
|
||||
'quantity': qty,
|
||||
'unit_price': float(price),
|
||||
'subtotal': float(line_sub),
|
||||
'notes': item.get('notes'),
|
||||
})
|
||||
|
||||
tax_rate = Decimal('0.16')
|
||||
tax_total = (subtotal * tax_rate).quantize(TWO, ROUND_HALF_UP)
|
||||
total = (subtotal + tax_total).quantize(TWO, ROUND_HALF_UP)
|
||||
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO purchase_orders
|
||||
(supplier_id, branch_id, employee_id, status, subtotal, tax_total, total,
|
||||
currency, exchange_rate, notes, expected_date)
|
||||
VALUES (%s, %s, %s, 'draft', %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (
|
||||
supplier_id, branch_id, employee_id,
|
||||
float(subtotal), float(tax_total), float(total),
|
||||
currency, exchange_rate,
|
||||
data.get('notes'), data.get('expected_date')
|
||||
))
|
||||
po_id = cur.fetchone()[0]
|
||||
|
||||
for item in po_items:
|
||||
cur.execute("""
|
||||
INSERT INTO purchase_order_items
|
||||
(po_id, inventory_id, part_number, name, quantity,
|
||||
unit_price, subtotal, notes)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""", (
|
||||
po_id, item['inventory_id'], item['part_number'], item['name'],
|
||||
item['quantity'], item['unit_price'], item['subtotal'], item['notes']
|
||||
))
|
||||
|
||||
cur.close()
|
||||
log_action(conn, 'PO_CREATE', 'purchase_order', po_id,
|
||||
new_value={'supplier_id': supplier_id, 'total': float(total)})
|
||||
return {
|
||||
'po_id': po_id,
|
||||
'status': 'draft',
|
||||
'total': float(total),
|
||||
'item_count': len(po_items),
|
||||
}
|
||||
|
||||
|
||||
def send_po(conn, po_id):
|
||||
"""Mark PO as sent to supplier."""
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
UPDATE purchase_orders
|
||||
SET status = 'sent', sent_at = NOW()
|
||||
WHERE id = %s AND status = 'draft'
|
||||
""", (po_id,))
|
||||
updated = cur.rowcount > 0
|
||||
cur.close()
|
||||
if updated:
|
||||
log_action(conn, 'PO_SEND', 'purchase_order', po_id)
|
||||
return updated
|
||||
|
||||
|
||||
def receive_po(conn, po_id, received_items, supplier_invoice=None, notes=None):
|
||||
"""Receive items from a PO. Updates stock and accounting.
|
||||
|
||||
Args:
|
||||
received_items: list of {po_item_id, quantity} (quantity = qty received now)
|
||||
Returns:
|
||||
dict: {po_id, status, received_total}
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
|
||||
# Lock PO row
|
||||
cur.execute("""
|
||||
SELECT id, supplier_id, branch_id, status, subtotal, tax_total, total,
|
||||
currency, exchange_rate
|
||||
FROM purchase_orders WHERE id = %s FOR UPDATE
|
||||
""", (po_id,))
|
||||
po = cur.fetchone()
|
||||
if not po:
|
||||
raise ValueError("Purchase order not found")
|
||||
if po[3] == 'cancelled':
|
||||
raise ValueError("Cannot receive a cancelled PO")
|
||||
if po[3] == 'received':
|
||||
raise ValueError("PO already fully received")
|
||||
|
||||
po_supplier_id = po[1]
|
||||
po_branch_id = po[2]
|
||||
po_currency = po[7]
|
||||
po_rate = float(po[8])
|
||||
|
||||
# Process each received item
|
||||
total_received_qty = 0
|
||||
purchase_total_mxn = Decimal('0')
|
||||
|
||||
for recv in received_items:
|
||||
poi_id = recv['po_item_id']
|
||||
qty = int(recv['quantity'])
|
||||
if qty <= 0:
|
||||
continue
|
||||
|
||||
cur.execute("""
|
||||
SELECT inventory_id, part_number, name, quantity, received_qty,
|
||||
unit_price
|
||||
FROM purchase_order_items WHERE id = %s AND po_id = %s
|
||||
""", (poi_id, po_id))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"PO item {poi_id} not found")
|
||||
|
||||
inv_id, part_num, name, ordered_qty, already_received, unit_price = row
|
||||
already_received = already_received or 0
|
||||
new_received = already_received + qty
|
||||
if new_received > ordered_qty:
|
||||
raise ValueError(
|
||||
f"Cannot receive {qty} of {name or part_num}: "
|
||||
f"ordered={ordered_qty}, already_received={already_received}"
|
||||
)
|
||||
|
||||
# Update received quantity
|
||||
cur.execute("""
|
||||
UPDATE purchase_order_items
|
||||
SET received_qty = %s
|
||||
WHERE id = %s
|
||||
""", (new_received, poi_id))
|
||||
|
||||
# Record inventory purchase (if linked to inventory)
|
||||
if inv_id and po_branch_id:
|
||||
record_purchase(
|
||||
conn, inv_id, po_branch_id, qty, unit_price,
|
||||
supplier_invoice=supplier_invoice,
|
||||
notes=f"Recepcion OC #{po_id}: {notes or ''}"
|
||||
)
|
||||
|
||||
# Accumulate for accounting (convert to MXN if needed)
|
||||
line_mxn = _to_dec(unit_price) * qty * _to_dec(po_rate)
|
||||
purchase_total_mxn += line_mxn.quantize(TWO, ROUND_HALF_UP)
|
||||
total_received_qty += qty
|
||||
|
||||
# Determine new PO status
|
||||
cur.execute("""
|
||||
SELECT SUM(quantity), SUM(received_qty)
|
||||
FROM purchase_order_items WHERE po_id = %s
|
||||
""", (po_id,))
|
||||
totals = cur.fetchone()
|
||||
ordered_total = totals[0] or 0
|
||||
received_total = totals[1] or 0
|
||||
|
||||
new_status = 'partial' if received_total < ordered_total else 'received'
|
||||
cur.execute("""
|
||||
UPDATE purchase_orders
|
||||
SET status = %s, received_at = NOW(),
|
||||
supplier_invoice = COALESCE(%s, supplier_invoice),
|
||||
notes = COALESCE(notes || ' | ', '') || %s
|
||||
WHERE id = %s
|
||||
""", (new_status, supplier_invoice,
|
||||
f"Recepcion: {received_total} de {ordered_total} uds" +
|
||||
(f" | Factura: {supplier_invoice}" if supplier_invoice else ""),
|
||||
po_id))
|
||||
|
||||
cur.close()
|
||||
|
||||
# Accounting entry (non-blocking)
|
||||
if purchase_total_mxn > 0:
|
||||
try:
|
||||
tax_mxn = (purchase_total_mxn * Decimal('0.16')).quantize(TWO, ROUND_HALF_UP)
|
||||
total_mxn = (purchase_total_mxn + tax_mxn).quantize(TWO, ROUND_HALF_UP)
|
||||
record_purchase_entry(conn, {
|
||||
'reference_id': po_id,
|
||||
'subtotal': float(purchase_total_mxn),
|
||||
'tax_amount': float(tax_mxn),
|
||||
'total': float(total_mxn),
|
||||
'supplier_name': _get_supplier_name(conn, po_supplier_id),
|
||||
})
|
||||
except Exception:
|
||||
pass # Accounting errors never block receiving
|
||||
|
||||
log_action(conn, 'PO_RECEIVE', 'purchase_order', po_id,
|
||||
new_value={'received_qty': received_total, 'status': new_status})
|
||||
|
||||
return {
|
||||
'po_id': po_id,
|
||||
'status': new_status,
|
||||
'received_total': received_total,
|
||||
'ordered_total': ordered_total,
|
||||
}
|
||||
|
||||
|
||||
def cancel_po(conn, po_id, reason):
|
||||
"""Cancel a PO. Only allowed if not fully received."""
|
||||
if not reason or len(reason.strip()) < 3:
|
||||
raise ValueError("Cancellation reason is mandatory (min 3 characters)")
|
||||
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT status FROM purchase_orders WHERE id = %s", (po_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise ValueError("PO not found")
|
||||
if row[0] == 'received':
|
||||
raise ValueError("Cannot cancel a fully received PO")
|
||||
if row[0] == 'cancelled':
|
||||
raise ValueError("PO is already cancelled")
|
||||
|
||||
cur.execute("""
|
||||
UPDATE purchase_orders
|
||||
SET status = 'cancelled', cancelled_at = NOW(),
|
||||
notes = COALESCE(notes || ' | ', '') || %s
|
||||
WHERE id = %s
|
||||
""", (f"CANCELADA: {reason}", po_id))
|
||||
cur.close()
|
||||
log_action(conn, 'PO_CANCEL', 'purchase_order', po_id,
|
||||
new_value={'reason': reason})
|
||||
return True
|
||||
|
||||
|
||||
def get_po(conn, po_id):
|
||||
"""Get full PO with items."""
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT po.id, po.status, po.subtotal, po.tax_total, po.total,
|
||||
po.currency, po.exchange_rate, po.notes, po.supplier_invoice,
|
||||
po.expected_date, po.sent_at, po.received_at, po.cancelled_at,
|
||||
po.created_at, s.name as supplier_name
|
||||
FROM purchase_orders po
|
||||
LEFT JOIN suppliers s ON po.supplier_id = s.id
|
||||
WHERE po.id = %s
|
||||
""", (po_id,))
|
||||
po_row = cur.fetchone()
|
||||
if not po_row:
|
||||
cur.close()
|
||||
return None
|
||||
|
||||
cur.execute("""
|
||||
SELECT id, inventory_id, part_number, name, quantity, received_qty,
|
||||
unit_price, subtotal, notes
|
||||
FROM purchase_order_items WHERE po_id = %s
|
||||
""", (po_id,))
|
||||
items = []
|
||||
for r in cur.fetchall():
|
||||
items.append({
|
||||
'id': r[0], 'inventory_id': r[1], 'part_number': r[2],
|
||||
'name': r[3], 'quantity': r[4], 'received_qty': r[5],
|
||||
'unit_price': float(r[6]), 'subtotal': float(r[7]), 'notes': r[8],
|
||||
})
|
||||
cur.close()
|
||||
|
||||
return {
|
||||
'id': po_row[0], 'status': po_row[1],
|
||||
'subtotal': float(po_row[2]), 'tax_total': float(po_row[3]),
|
||||
'total': float(po_row[4]), 'currency': po_row[5],
|
||||
'exchange_rate': float(po_row[6]), 'notes': po_row[7],
|
||||
'supplier_invoice': po_row[8], 'expected_date': str(po_row[9]) if po_row[9] else None,
|
||||
'sent_at': str(po_row[10]) if po_row[10] else None,
|
||||
'received_at': str(po_row[11]) if po_row[11] else None,
|
||||
'cancelled_at': str(po_row[12]) if po_row[12] else None,
|
||||
'created_at': str(po_row[13]), 'supplier_name': po_row[14],
|
||||
'items': items,
|
||||
}
|
||||
|
||||
|
||||
def list_pos(conn, status=None, supplier_id=None, limit=50, offset=0):
|
||||
"""List purchase orders."""
|
||||
cur = conn.cursor()
|
||||
filters = []
|
||||
vals = []
|
||||
if status:
|
||||
filters.append("po.status = %s")
|
||||
vals.append(status)
|
||||
if supplier_id:
|
||||
filters.append("po.supplier_id = %s")
|
||||
vals.append(supplier_id)
|
||||
where = "WHERE " + " AND ".join(filters) if filters else ""
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT po.id, po.status, po.total, po.currency, s.name as supplier_name,
|
||||
po.created_at
|
||||
FROM purchase_orders po
|
||||
LEFT JOIN suppliers s ON po.supplier_id = s.id
|
||||
{where}
|
||||
ORDER BY po.created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""", vals + [limit, offset])
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
return [{
|
||||
'id': r[0], 'status': r[1], 'total': float(r[2]),
|
||||
'currency': r[3], 'supplier_name': r[4], 'created_at': str(r[5]),
|
||||
} for r in rows]
|
||||
|
||||
|
||||
# ── HELPERS ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_supplier_name(conn, supplier_id):
|
||||
if not supplier_id:
|
||||
return 'Proveedor'
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT name FROM suppliers WHERE id = %s", (supplier_id,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
return row[0] if row else 'Proveedor'
|
||||
@@ -116,33 +116,65 @@ def create_template_db():
|
||||
return True # Created
|
||||
|
||||
|
||||
def _generate_db_name(name):
|
||||
"""Generate a safe database name from business name.
|
||||
|
||||
Only lowercase ASCII letters, digits, and underscores.
|
||||
"""
|
||||
nfkd = unicodedata.normalize('NFKD', name)
|
||||
ascii_name = nfkd.encode('ascii', 'ignore').decode('ascii')
|
||||
slug = re.sub(r'[^a-z0-9]+', '_', ascii_name.lower()).strip('_')
|
||||
slug = re.sub(r'_{2,}', '_', slug)
|
||||
return f"tenant_{slug[:30]}"
|
||||
|
||||
|
||||
def provision_tenant(name, rfc=None, owner_name="Admin", owner_email=None, owner_pin="0000", subdomain=None):
|
||||
"""Create a new tenant: register in master, create DB from template, create owner employee.
|
||||
|
||||
If subdomain is not provided, one is auto-generated from the business name.
|
||||
Includes automatic rollback on failure to avoid orphaned databases.
|
||||
"""
|
||||
import bcrypt
|
||||
|
||||
ensure_master_tables()
|
||||
create_template_db()
|
||||
|
||||
# Run master migrations before creating tenant (ensures marketplace tables exist)
|
||||
from migrations.runner_master import run_master_migrations
|
||||
run_master_migrations()
|
||||
|
||||
# Generate subdomain if not provided
|
||||
if not subdomain:
|
||||
subdomain = generate_subdomain(name)
|
||||
|
||||
# Generate db_name
|
||||
# Generate safe db_name
|
||||
db_name = _generate_db_name(name)
|
||||
|
||||
conn = get_master_conn()
|
||||
cur = conn.cursor()
|
||||
|
||||
# Validate uniqueness before inserting
|
||||
cur.execute("SELECT 1 FROM tenants WHERE db_name = %s LIMIT 1", (db_name,))
|
||||
if cur.fetchone():
|
||||
cur.close()
|
||||
conn.close()
|
||||
raise ValueError(f"A tenant with db_name '{db_name}' already exists.")
|
||||
|
||||
cur.execute("SELECT 1 FROM tenants WHERE subdomain = %s LIMIT 1", (subdomain,))
|
||||
if cur.fetchone():
|
||||
cur.close()
|
||||
conn.close()
|
||||
raise ValueError(f"A tenant with subdomain '{subdomain}' already exists.")
|
||||
|
||||
# Insert tenant
|
||||
cur.execute("""
|
||||
INSERT INTO tenants (name, db_name, rfc, subdomain)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
RETURNING id, db_name
|
||||
""", (name, f"tenant_{name.lower().replace(' ', '_')[:30]}", rfc, subdomain))
|
||||
""", (name, db_name, rfc, subdomain))
|
||||
tenant_id, db_name = cur.fetchone()
|
||||
|
||||
# Track schema version
|
||||
# Track schema version (will be updated after migrations)
|
||||
cur.execute("""
|
||||
INSERT INTO tenant_schema_version (tenant_id, version)
|
||||
VALUES (%s, 'v1.0')
|
||||
@@ -151,72 +183,121 @@ def provision_tenant(name, rfc=None, owner_name="Admin", owner_email=None, owner
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
# Create DB from template — use psycopg2.sql.Identifier for safe dynamic names
|
||||
master_conn = psycopg2.connect(MASTER_DB_URL)
|
||||
master_conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
|
||||
master_cur = master_conn.cursor()
|
||||
master_cur.execute(
|
||||
sql.SQL('CREATE DATABASE {} TEMPLATE {}').format(
|
||||
sql.Identifier(db_name),
|
||||
sql.Identifier(TENANT_TEMPLATE_DB)
|
||||
tenant_conn = None
|
||||
try:
|
||||
# Create DB from template
|
||||
master_conn = psycopg2.connect(MASTER_DB_URL)
|
||||
master_conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
|
||||
master_cur = master_conn.cursor()
|
||||
master_cur.execute(
|
||||
sql.SQL('CREATE DATABASE {} TEMPLATE {}').format(
|
||||
sql.Identifier(db_name),
|
||||
sql.Identifier(TENANT_TEMPLATE_DB)
|
||||
)
|
||||
)
|
||||
)
|
||||
master_cur.close()
|
||||
master_conn.close()
|
||||
master_cur.close()
|
||||
master_conn.close()
|
||||
|
||||
# Create default branch and owner employee
|
||||
tenant_conn = get_tenant_conn_by_dbname(db_name)
|
||||
tenant_cur = tenant_conn.cursor()
|
||||
# Apply pending migrations post-v1.0
|
||||
from migrations.runner import MIGRATIONS, apply_migration
|
||||
sorted_versions = sorted(MIGRATIONS.keys())
|
||||
for version in sorted_versions:
|
||||
if version <= 'v1.0':
|
||||
continue
|
||||
success = apply_migration(db_name, version)
|
||||
if not success:
|
||||
raise RuntimeError(f"Migration {version} failed for tenant {db_name}")
|
||||
# Update version in master
|
||||
mconn = get_master_conn()
|
||||
mcur = mconn.cursor()
|
||||
mcur.execute("""
|
||||
INSERT INTO tenant_schema_version (tenant_id, version)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT (tenant_id) DO UPDATE SET version = %s, updated_at = NOW()
|
||||
""", (tenant_id, version, version))
|
||||
mconn.commit()
|
||||
mcur.close()
|
||||
mconn.close()
|
||||
|
||||
tenant_cur.execute("""
|
||||
INSERT INTO branches (name) VALUES ('Principal') RETURNING id
|
||||
""")
|
||||
branch_id = tenant_cur.fetchone()[0]
|
||||
# Create default branch and owner employee
|
||||
tenant_conn = get_tenant_conn_by_dbname(db_name)
|
||||
tenant_cur = tenant_conn.cursor()
|
||||
|
||||
pin_hash = bcrypt.hashpw(owner_pin.encode(), bcrypt.gensalt()).decode()
|
||||
pwd_hash = bcrypt.hashpw(owner_pin.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
tenant_cur.execute("""
|
||||
INSERT INTO employees (name, email, pin, password_hash, role, branch_id, max_discount_pct, is_active)
|
||||
VALUES (%s, %s, %s, %s, 'owner', %s, 100, true)
|
||||
RETURNING id
|
||||
""", (owner_name, owner_email, pin_hash, pwd_hash, branch_id))
|
||||
owner_id = tenant_cur.fetchone()[0]
|
||||
|
||||
# Grant all permissions to owner
|
||||
permissions = [
|
||||
'pos.sell', 'pos.discount', 'pos.cancel', 'pos.view_cost',
|
||||
'inventory.view', 'inventory.create', 'inventory.edit', 'inventory.adjust', 'inventory.transfer',
|
||||
'catalog.view', 'catalog.edit',
|
||||
'customers.view', 'customers.create', 'customers.edit', 'customers.edit_credit', 'customers.delete',
|
||||
'accounting.view', 'accounting.create', 'accounting.close',
|
||||
'invoicing.view', 'invoicing.create', 'invoicing.cancel',
|
||||
'reports.view', 'reports.financial',
|
||||
'config.view', 'config.edit', 'config.edit_prices'
|
||||
]
|
||||
for perm in permissions:
|
||||
tenant_cur.execute(
|
||||
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s)",
|
||||
(owner_id, perm)
|
||||
)
|
||||
|
||||
# Seed tenant_config with RFC and defaults
|
||||
if rfc:
|
||||
tenant_cur.execute("""
|
||||
INSERT INTO tenant_config (key, value) VALUES
|
||||
('tenant_rfc', %s),
|
||||
('tenant_razon_social', %s),
|
||||
('tenant_cp', '00000'),
|
||||
('cfdi_regimen_fiscal', '601'),
|
||||
('cfdi_serie', 'A')
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
""", (rfc, name))
|
||||
INSERT INTO branches (name) VALUES ('Principal') RETURNING id
|
||||
""")
|
||||
branch_id = tenant_cur.fetchone()[0]
|
||||
|
||||
tenant_conn.commit()
|
||||
tenant_cur.close()
|
||||
tenant_conn.close()
|
||||
pin_hash = bcrypt.hashpw(owner_pin.encode(), bcrypt.gensalt()).decode()
|
||||
pwd_hash = bcrypt.hashpw(owner_pin.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
return {'tenant_id': tenant_id, 'db_name': db_name, 'owner_id': owner_id, 'subdomain': subdomain}
|
||||
tenant_cur.execute("""
|
||||
INSERT INTO employees (name, email, pin, password_hash, role, branch_id, max_discount_pct, is_active)
|
||||
VALUES (%s, %s, %s, %s, 'owner', %s, 100, true)
|
||||
RETURNING id
|
||||
""", (owner_name, owner_email, pin_hash, pwd_hash, branch_id))
|
||||
owner_id = tenant_cur.fetchone()[0]
|
||||
|
||||
# Grant all permissions to owner (batch insert)
|
||||
permissions = [
|
||||
'pos.sell', 'pos.discount', 'pos.cancel', 'pos.view_cost',
|
||||
'inventory.view', 'inventory.create', 'inventory.edit', 'inventory.adjust', 'inventory.transfer',
|
||||
'catalog.view', 'catalog.edit',
|
||||
'customers.view', 'customers.create', 'customers.edit', 'customers.edit_credit', 'customers.delete',
|
||||
'accounting.view', 'accounting.create', 'accounting.close',
|
||||
'invoicing.view', 'invoicing.create', 'invoicing.cancel',
|
||||
'reports.view', 'reports.financial',
|
||||
'config.view', 'config.edit', 'config.edit_prices'
|
||||
]
|
||||
tenant_cur.executemany(
|
||||
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s)",
|
||||
[(owner_id, perm) for perm in permissions]
|
||||
)
|
||||
|
||||
# Seed tenant_config with RFC and defaults
|
||||
if rfc:
|
||||
tenant_cur.execute("""
|
||||
INSERT INTO tenant_config (key, value) VALUES
|
||||
('tenant_rfc', %s),
|
||||
('tenant_razon_social', %s),
|
||||
('tenant_cp', '00000'),
|
||||
('cfdi_regimen_fiscal', '601'),
|
||||
('cfdi_serie', 'A')
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
""", (rfc, name))
|
||||
|
||||
tenant_conn.commit()
|
||||
tenant_cur.close()
|
||||
tenant_conn.close()
|
||||
|
||||
return {'tenant_id': tenant_id, 'db_name': db_name, 'owner_id': owner_id, 'subdomain': subdomain}
|
||||
|
||||
except Exception as e:
|
||||
# Rollback: drop tenant DB and remove from master
|
||||
try:
|
||||
drop_conn = psycopg2.connect(MASTER_DB_URL)
|
||||
drop_conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
|
||||
drop_cur = drop_conn.cursor()
|
||||
drop_cur.execute(
|
||||
sql.SQL('DROP DATABASE IF EXISTS {}').format(sql.Identifier(db_name))
|
||||
)
|
||||
drop_cur.close()
|
||||
drop_conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
cleanup_conn = get_master_conn()
|
||||
cleanup_cur = cleanup_conn.cursor()
|
||||
cleanup_cur.execute("DELETE FROM tenant_schema_version WHERE tenant_id = %s", (tenant_id,))
|
||||
cleanup_cur.execute("DELETE FROM tenants WHERE id = %s", (tenant_id,))
|
||||
cleanup_conn.commit()
|
||||
cleanup_cur.close()
|
||||
cleanup_conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise RuntimeError(f"Failed to provision tenant: {e}")
|
||||
|
||||
|
||||
def list_tenants():
|
||||
|
||||
273
pos/services/warranty_engine.py
Normal file
273
pos/services/warranty_engine.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""Warranty / RMA engine (Mejora #10).
|
||||
|
||||
Registers warranties at sale time and manages the claim lifecycle.
|
||||
|
||||
Tables:
|
||||
warranties — one row per warranted item sold
|
||||
warranty_claims — one row per claim filed
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from services.audit import log_action
|
||||
|
||||
|
||||
def register_warranty(conn, sale_id, sale_item_id, inventory_id,
|
||||
customer_id, warranty_months, supplier_id=None,
|
||||
part_number=None, name=None, notes=None):
|
||||
"""Register a warranty for a sold item.
|
||||
|
||||
Args:
|
||||
warranty_months: int (e.g., 12, 24, 36)
|
||||
Returns:
|
||||
int: warranty_id
|
||||
"""
|
||||
if not warranty_months or warranty_months <= 0:
|
||||
raise ValueError("warranty_months must be a positive integer")
|
||||
|
||||
start = date.today()
|
||||
end = start + timedelta(days=30 * warranty_months)
|
||||
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO warranties
|
||||
(sale_id, sale_item_id, inventory_id, customer_id, supplier_id,
|
||||
part_number, name, warranty_months, start_date, end_date, notes)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (
|
||||
sale_id, sale_item_id, inventory_id, customer_id, supplier_id,
|
||||
part_number, name, warranty_months, start, end, notes
|
||||
))
|
||||
w_id = cur.fetchone()[0]
|
||||
cur.close()
|
||||
|
||||
log_action(conn, 'WARRANTY_REGISTER', 'warranty', w_id,
|
||||
new_value={'months': warranty_months, 'end_date': str(end)})
|
||||
return w_id
|
||||
|
||||
|
||||
def create_claim(conn, warranty_id, reason, employee_id=None, notes=None):
|
||||
"""File a warranty claim.
|
||||
|
||||
Args:
|
||||
warranty_id: int
|
||||
reason: str (min 10 chars)
|
||||
Returns:
|
||||
int: claim_id
|
||||
"""
|
||||
if not reason or len(reason.strip()) < 10:
|
||||
raise ValueError("Claim reason must be at least 10 characters")
|
||||
|
||||
cur = conn.cursor()
|
||||
# Verify warranty exists and is active
|
||||
cur.execute("SELECT status FROM warranties WHERE id = %s", (warranty_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise ValueError("Warranty not found")
|
||||
if row[0] != 'active':
|
||||
raise ValueError(f"Cannot claim a warranty with status '{row[0]}'")
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO warranty_claims
|
||||
(warranty_id, reason, employee_id, notes)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (warranty_id, reason, employee_id, notes))
|
||||
claim_id = cur.fetchone()[0]
|
||||
cur.close()
|
||||
|
||||
log_action(conn, 'WARRANTY_CLAIM', 'warranty_claim', claim_id,
|
||||
new_value={'warranty_id': warranty_id, 'reason': reason})
|
||||
return claim_id
|
||||
|
||||
|
||||
def resolve_claim(conn, claim_id, resolution, diagnosis=None,
|
||||
replacement_inventory_id=None, refund_amount=None,
|
||||
labor_cost=None, supplier_rma_number=None, notes=None):
|
||||
"""Resolve a warranty claim.
|
||||
|
||||
Args:
|
||||
resolution: 'approved'|'rejected'|'repaired'|'replaced'|'refunded'
|
||||
"""
|
||||
if resolution not in ('approved', 'rejected', 'repaired', 'replaced', 'refunded'):
|
||||
raise ValueError(f"Invalid resolution: {resolution}")
|
||||
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
UPDATE warranty_claims
|
||||
SET resolution = %s, diagnosis = COALESCE(%s, diagnosis),
|
||||
replacement_inventory_id = COALESCE(%s, replacement_inventory_id),
|
||||
refund_amount = COALESCE(%s, refund_amount),
|
||||
labor_cost = COALESCE(%s, labor_cost),
|
||||
supplier_rma_number = COALESCE(%s, supplier_rma_number),
|
||||
notes = COALESCE(notes || ' | ', '') || %s,
|
||||
status = 'resolved', resolved_at = NOW()
|
||||
WHERE id = %s AND status != 'closed'
|
||||
""", (
|
||||
resolution, diagnosis, replacement_inventory_id,
|
||||
refund_amount, labor_cost, supplier_rma_number,
|
||||
notes or 'Resuelto', claim_id
|
||||
))
|
||||
updated = cur.rowcount > 0
|
||||
|
||||
# If replaced or refunded, mark warranty as claimed
|
||||
if updated and resolution in ('replaced', 'refunded', 'approved'):
|
||||
cur.execute("""
|
||||
UPDATE warranties SET status = 'claimed'
|
||||
WHERE id = (SELECT warranty_id FROM warranty_claims WHERE id = %s)
|
||||
""", (claim_id,))
|
||||
|
||||
cur.close()
|
||||
return updated
|
||||
|
||||
|
||||
def close_claim(conn, claim_id):
|
||||
"""Close a resolved claim (final status)."""
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
UPDATE warranty_claims
|
||||
SET status = 'closed'
|
||||
WHERE id = %s AND status = 'resolved'
|
||||
""", (claim_id,))
|
||||
updated = cur.rowcount > 0
|
||||
cur.close()
|
||||
return updated
|
||||
|
||||
|
||||
def get_warranty(conn, warranty_id):
|
||||
"""Get warranty detail."""
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT w.id, w.sale_id, w.sale_item_id, w.inventory_id, w.customer_id,
|
||||
w.supplier_id, w.part_number, w.name, w.warranty_months,
|
||||
w.start_date, w.end_date, w.status, w.notes, w.created_at,
|
||||
c.name as customer_name, s.name as supplier_name
|
||||
FROM warranties w
|
||||
LEFT JOIN customers c ON w.customer_id = c.id
|
||||
LEFT JOIN suppliers s ON w.supplier_id = s.id
|
||||
WHERE w.id = %s
|
||||
""", (warranty_id,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
'id': row[0], 'sale_id': row[1], 'sale_item_id': row[2],
|
||||
'inventory_id': row[3], 'customer_id': row[4], 'supplier_id': row[5],
|
||||
'part_number': row[6], 'name': row[7], 'warranty_months': row[8],
|
||||
'start_date': str(row[9]), 'end_date': str(row[10]),
|
||||
'status': row[11], 'notes': row[12], 'created_at': str(row[13]),
|
||||
'customer_name': row[14], 'supplier_name': row[15],
|
||||
}
|
||||
|
||||
|
||||
def list_warranties(conn, customer_id=None, status=None, limit=50, offset=0):
|
||||
"""List warranties."""
|
||||
cur = conn.cursor()
|
||||
filters = []
|
||||
params = []
|
||||
if customer_id:
|
||||
filters.append("w.customer_id = %s")
|
||||
params.append(customer_id)
|
||||
if status:
|
||||
filters.append("w.status = %s")
|
||||
params.append(status)
|
||||
where = "WHERE " + " AND ".join(filters) if filters else ""
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT w.id, w.part_number, w.name, w.warranty_months,
|
||||
w.start_date, w.end_date, w.status,
|
||||
c.name as customer_name
|
||||
FROM warranties w
|
||||
LEFT JOIN customers c ON w.customer_id = c.id
|
||||
{where}
|
||||
ORDER BY w.end_date ASC
|
||||
LIMIT %s OFFSET %s
|
||||
""", params + [limit, offset])
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
return [{
|
||||
'id': r[0], 'part_number': r[1], 'name': r[2],
|
||||
'warranty_months': r[3], 'start_date': str(r[4]),
|
||||
'end_date': str(r[5]), 'status': r[6],
|
||||
'customer_name': r[7],
|
||||
} for r in rows]
|
||||
|
||||
|
||||
def get_claim(conn, claim_id):
|
||||
"""Get claim detail."""
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT wc.id, wc.warranty_id, wc.claim_date, wc.reason, wc.diagnosis,
|
||||
wc.resolution, wc.replacement_inventory_id, wc.refund_amount,
|
||||
wc.labor_cost, wc.status, wc.supplier_rma_number, wc.notes,
|
||||
wc.created_at, wc.resolved_at,
|
||||
w.part_number, w.name, w.customer_id, c.name as customer_name
|
||||
FROM warranty_claims wc
|
||||
JOIN warranties w ON wc.warranty_id = w.id
|
||||
LEFT JOIN customers c ON w.customer_id = c.id
|
||||
WHERE wc.id = %s
|
||||
""", (claim_id,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
'id': row[0], 'warranty_id': row[1], 'claim_date': str(row[2]),
|
||||
'reason': row[3], 'diagnosis': row[4], 'resolution': row[5],
|
||||
'replacement_inventory_id': row[6], 'refund_amount': float(row[7]) if row[7] else None,
|
||||
'labor_cost': float(row[8]) if row[8] else None,
|
||||
'status': row[9], 'supplier_rma_number': row[10], 'notes': row[11],
|
||||
'created_at': str(row[12]), 'resolved_at': str(row[13]) if row[13] else None,
|
||||
'part_number': row[14], 'name': row[15],
|
||||
'customer_id': row[16], 'customer_name': row[17],
|
||||
}
|
||||
|
||||
|
||||
def list_claims(conn, status=None, warranty_id=None, limit=50, offset=0):
|
||||
"""List warranty claims."""
|
||||
cur = conn.cursor()
|
||||
filters = []
|
||||
params = []
|
||||
if status:
|
||||
filters.append("wc.status = %s")
|
||||
params.append(status)
|
||||
if warranty_id:
|
||||
filters.append("wc.warranty_id = %s")
|
||||
params.append(warranty_id)
|
||||
where = "WHERE " + " AND ".join(filters) if filters else ""
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT wc.id, wc.claim_date, wc.reason, wc.resolution, wc.status,
|
||||
w.part_number, w.name, c.name as customer_name
|
||||
FROM warranty_claims wc
|
||||
JOIN warranties w ON wc.warranty_id = w.id
|
||||
LEFT JOIN customers c ON w.customer_id = c.id
|
||||
{where}
|
||||
ORDER BY wc.created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""", params + [limit, offset])
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
return [{
|
||||
'id': r[0], 'claim_date': str(r[1]), 'reason': r[2],
|
||||
'resolution': r[3], 'status': r[4],
|
||||
'part_number': r[5], 'name': r[6], 'customer_name': r[7],
|
||||
} for r in rows]
|
||||
|
||||
|
||||
def expire_warranties(conn):
|
||||
"""Batch-update warranties whose end_date has passed to 'expired'.
|
||||
|
||||
Should be run periodically (e.g., daily via cron).
|
||||
Returns number of warranties expired.
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
UPDATE warranties
|
||||
SET status = 'expired'
|
||||
WHERE status = 'active' AND end_date < CURRENT_DATE
|
||||
""")
|
||||
count = cur.rowcount
|
||||
cur.close()
|
||||
return count
|
||||
Reference in New Issue
Block a user