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:
Nexus Dev
2026-04-27 05:23:30 +00:00
parent b70cb3042b
commit 9ff3dc4c8b
71 changed files with 10939 additions and 420 deletions

View File

@@ -16,6 +16,21 @@ MIGRATIONS = {
'v1.1': 'v1.1_pos_tables.sql',
'v1.3': 'v1.3_fleet.sql',
'v1.4': 'v1.4_whatsapp.sql',
'v1.5': 'v1.5_returns.sql',
'v1.7': 'v1.7_plates.sql',
'v1.8': 'v1.8_performance_indexes.sql',
'v1.9': 'v1.9_redis_cache.sql',
'v2.0': 'v2.0_multi_currency.sql',
'v2.1': 'v2.1_suppliers.sql',
'v2.2': 'v2.2_alerts_warranty.sql',
'v2.3': 'v2.3_metabase.sql',
'v2.4': 'v2.4_crm_enhanced.sql',
'v2.5': 'v2.5_service_orders.sql',
'v2.6': 'v2.6_bnpl_erp.sql',
'v2.7': 'v2.7_notifications.sql',
'v2.8': 'v2.8_savings.sql',
'v2.9': 'v2.9_logistics.sql',
'v3.0': 'v3.0_public_api.sql',
}

101
pos/migrations/runner_master.py Executable file
View File

@@ -0,0 +1,101 @@
#!/usr/bin/env python3
# /home/Autopartes/pos/migrations/runner_master.py
"""Apply schema migrations to the master database (nexus_autoparts)."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from tenant_db import get_master_conn
MIGRATIONS_DIR = os.path.dirname(os.path.abspath(__file__))
# Master DB migration registry: version -> filename
MASTER_MIGRATIONS = {
'v1.6': 'v1.6_marketplace.sql',
}
def get_current_master_version():
"""Get current schema version of master DB."""
conn = get_master_conn()
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS master_schema_version (
id INTEGER PRIMARY KEY CHECK (id = 1),
version VARCHAR(20) NOT NULL DEFAULT 'v0.0',
updated_at TIMESTAMPTZ DEFAULT NOW()
)
""")
cur.execute("""
INSERT INTO master_schema_version (id, version)
VALUES (1, 'v0.0')
ON CONFLICT (id) DO NOTHING
""")
conn.commit()
cur.execute("SELECT version FROM master_schema_version WHERE id = 1")
version = cur.fetchone()[0]
cur.close()
conn.close()
return version
def apply_master_migration(version):
"""Apply a single migration to the master DB."""
filename = MASTER_MIGRATIONS[version]
filepath = os.path.join(MIGRATIONS_DIR, filename)
if not os.path.exists(filepath):
print(f" ERROR: Migration file not found: {filepath}")
return False
conn = get_master_conn()
cur = conn.cursor()
try:
with open(filepath) as f:
cur.execute(f.read())
conn.commit()
return True
except Exception as e:
conn.rollback()
print(f" ERROR: {e}")
return False
finally:
cur.close()
conn.close()
def run_master_migrations():
"""Apply pending migrations to master DB."""
current_version = get_current_master_version()
sorted_versions = sorted(MASTER_MIGRATIONS.keys())
print(f"Master DB current version: {current_version}")
print(f"Available migrations: {sorted_versions}")
for version in sorted_versions:
if version <= current_version:
continue
print(f" Applying {version}...", end=' ')
if apply_master_migration(version):
conn = get_master_conn()
cur = conn.cursor()
cur.execute("""
INSERT INTO master_schema_version (id, version)
VALUES (1, %s)
ON CONFLICT (id) DO UPDATE SET version = %s, updated_at = NOW()
""", (version, version))
conn.commit()
cur.close()
conn.close()
print("OK")
else:
print("FAILED — stopping master migrations")
break
print("Done.")
if __name__ == '__main__':
run_master_migrations()

View File

@@ -0,0 +1,100 @@
-- v1.8 Performance indexes and FK fixes
-- Applied to each tenant database
-- ═══════════════════════════════════════════════════════════════════════════
-- PERFORMANCE INDEXES
-- ═══════════════════════════════════════════════════════════════════════════
-- Stock queries (used thousands of times per day)
CREATE INDEX IF NOT EXISTS idx_inv_ops_inventory_branch ON inventory_operations(inventory_id, branch_id);
CREATE INDEX IF NOT EXISTS idx_inv_ops_inventory_type_created ON inventory_operations(inventory_id, operation_type, created_at);
CREATE INDEX IF NOT EXISTS idx_inv_ops_inventory_created_desc ON inventory_operations(inventory_id, created_at DESC);
-- Cash register lookups
CREATE INDEX IF NOT EXISTS idx_cash_movements_register ON cash_movements(register_id);
CREATE INDEX IF NOT EXISTS idx_sales_register_status ON sales(register_id, status);
-- Inventory filtering
CREATE INDEX IF NOT EXISTS idx_inventory_branch_active ON inventory(branch_id, is_active);
-- Transaction tables
CREATE INDEX IF NOT EXISTS idx_sale_items_inventory ON sale_items(inventory_id);
CREATE INDEX IF NOT EXISTS idx_sale_payments_sale ON sale_payments(sale_id);
CREATE INDEX IF NOT EXISTS idx_sale_payments_register ON sale_payments(register_id);
CREATE INDEX IF NOT EXISTS idx_layaway_items_inventory ON layaway_items(inventory_id);
CREATE INDEX IF NOT EXISTS idx_layaway_items_layaway ON layaway_items(layaway_id);
CREATE INDEX IF NOT EXISTS idx_return_items_inventory ON return_items(inventory_id);
CREATE INDEX IF NOT EXISTS idx_return_items_return ON return_items(return_id);
-- Employees and permissions
CREATE INDEX IF NOT EXISTS idx_employees_email ON employees(email);
CREATE INDEX IF NOT EXISTS idx_employees_branch ON employees(branch_id);
CREATE INDEX IF NOT EXISTS idx_employee_permissions_employee ON employee_permissions(employee_id);
-- Customers and fleet
CREATE INDEX IF NOT EXISTS idx_customers_phone ON customers(phone);
CREATE INDEX IF NOT EXISTS idx_fleet_vehicles_branch_vin ON fleet_vehicles(branch_id, vin);
CREATE INDEX IF NOT EXISTS idx_fleet_maintenance_schedules_next_due ON fleet_maintenance_schedules(next_due_at);
-- WhatsApp and quotations
CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_status ON whatsapp_messages(status);
CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_related ON whatsapp_messages(related_type, related_id);
CREATE INDEX IF NOT EXISTS idx_quotations_branch ON quotations(branch_id);
-- Accounting
CREATE INDEX IF NOT EXISTS idx_accounts_parent ON accounts(parent_id);
CREATE INDEX IF NOT EXISTS idx_journal_entries_date ON journal_entries(date);
CREATE INDEX IF NOT EXISTS idx_fiscal_periods_year_month ON fiscal_periods(year, month);
-- Physical counts
CREATE INDEX IF NOT EXISTS idx_physical_counts_branch_status ON physical_counts(branch_id, status);
CREATE INDEX IF NOT EXISTS idx_physical_count_lines_inventory ON physical_count_lines(inventory_id);
-- ═══════════════════════════════════════════════════════════════════════════
-- FOREIGN KEY FIXES
-- ═══════════════════════════════════════════════════════════════════════════
-- Sale dependencies
ALTER TABLE sale_items
ADD CONSTRAINT fk_sale_items_sale FOREIGN KEY (sale_id) REFERENCES sales(id) ON DELETE CASCADE;
ALTER TABLE sale_payments
ADD CONSTRAINT fk_sale_payments_sale FOREIGN KEY (sale_id) REFERENCES sales(id) ON DELETE CASCADE;
-- Quotation dependencies
ALTER TABLE quotation_items
ADD CONSTRAINT fk_quotation_items_quotation FOREIGN KEY (quotation_id) REFERENCES quotations(id) ON DELETE CASCADE;
-- Layaway dependencies
ALTER TABLE layaway_items
ADD CONSTRAINT fk_layaway_items_layaway FOREIGN KEY (layaway_id) REFERENCES layaways(id) ON DELETE CASCADE;
-- Return dependencies
ALTER TABLE return_items
ADD CONSTRAINT fk_return_items_return FOREIGN KEY (return_id) REFERENCES returns(id) ON DELETE CASCADE;
ALTER TABLE return_items
ADD CONSTRAINT fk_return_items_sale_item FOREIGN KEY (sale_item_id) REFERENCES sale_items(id) ON DELETE SET NULL;
-- Cash movements
ALTER TABLE cash_movements
ADD CONSTRAINT fk_cash_movements_register FOREIGN KEY (register_id) REFERENCES cash_registers(id) ON DELETE CASCADE;
-- ═══════════════════════════════════════════════════════════════════════════
-- DATA TYPE NORMALIZATION
-- ═══════════════════════════════════════════════════════════════════════════
ALTER TABLE physical_counts ALTER COLUMN created_at TYPE TIMESTAMPTZ;
-- ═══════════════════════════════════════════════════════════════════════════
-- CONSTRAINT IMPROVEMENTS
-- ═══════════════════════════════════════════════════════════════════════════
-- Ensure unique entry numbers per date (fiscal period approximation)
-- Note: journal_entries uses 'date' column, not period_year/month
CREATE UNIQUE INDEX IF NOT EXISTS idx_journal_entries_unique_number
ON journal_entries(date, entry_number);
-- Add CHECK constraint for sales.status (idempotent for VARCHAR)
-- Note: PostgreSQL does not support adding CHECK constraints via IF NOT EXISTS
-- This is left as documentation; apply manually if needed:
-- ALTER TABLE sales ADD CONSTRAINT chk_sales_status
-- CHECK (status IN ('completed', 'cancelled', 'returned', 'partially_returned'));

View File

@@ -0,0 +1,18 @@
-- v1.9_redis_cache.sql
-- Mejora #9: Caché de Stock con Redis
--
-- Adds Redis-backed stock caching for sub-millisecond lookups.
-- No database schema changes required — caching is handled entirely
-- in the application layer (pos/services/redis_stock_cache.py).
--
-- Invalidation strategy:
-- - Every stock mutation (SALE, PURCHASE, RETURN, ADJUST, TRANSFER, INITIAL)
-- invalidates the affected Redis keys immediately in Python code.
-- - Cache TTL is 5 minutes (configurable via REDIS_STOCK_TTL env var).
-- - On Redis miss, stock is computed from PostgreSQL SUM query and cached.
--
-- Prerequisites:
-- - Redis server installed and running (default: localhost:6379)
-- - redis-py library installed
--
SELECT 'v1.9 redis cache migration applied' as status;

View File

@@ -0,0 +1,36 @@
-- v2.0_multi_currency.sql
-- Mejora #8: Soporte Multi-moneda
--
-- Adds currency and exchange_rate columns to sales, sale_items,
-- quotations, quotation_items, and sale_payments.
--
-- Business rule: inventory prices are ALWAYS in MXN (base currency).
-- Sales can be recorded in USD (or other currencies) with conversion
-- at checkout time. Accounting and CFDI always use MXN.
-- sales
ALTER TABLE sales
ADD COLUMN IF NOT EXISTS currency VARCHAR(3) DEFAULT 'MXN',
ADD COLUMN IF NOT EXISTS exchange_rate NUMERIC(12,6) DEFAULT 1.0;
CREATE INDEX IF NOT EXISTS idx_sales_currency ON sales(currency);
-- sale_items
ALTER TABLE sale_items
ADD COLUMN IF NOT EXISTS currency VARCHAR(3) DEFAULT 'MXN',
ADD COLUMN IF NOT EXISTS exchange_rate NUMERIC(12,6) DEFAULT 1.0;
-- quotations
ALTER TABLE quotations
ADD COLUMN IF NOT EXISTS currency VARCHAR(3) DEFAULT 'MXN',
ADD COLUMN IF NOT EXISTS exchange_rate NUMERIC(12,6) DEFAULT 1.0;
-- quotation_items
ALTER TABLE quotation_items
ADD COLUMN IF NOT EXISTS currency VARCHAR(3) DEFAULT 'MXN',
ADD COLUMN IF NOT EXISTS exchange_rate NUMERIC(12,6) DEFAULT 1.0;
-- sale_payments
ALTER TABLE sale_payments
ADD COLUMN IF NOT EXISTS currency VARCHAR(3) DEFAULT 'MXN',
ADD COLUMN IF NOT EXISTS exchange_rate NUMERIC(12,6) DEFAULT 1.0;

View File

@@ -0,0 +1,81 @@
-- v2.1_suppliers.sql
-- Mejora #3: Proveedores y Órdenes de Compra
--
-- Adds supplier management and purchase order workflow to tenant databases.
--
-- Workflow:
-- 1. Create supplier (suppliers table)
-- 2. Create PO with items (purchase_orders + purchase_order_items)
-- 3. Send PO to supplier (status = 'sent')
-- 4. Receive partial or full delivery (status = 'partial' | 'received')
-- → On receive: update stock via inventory_engine.record_purchase()
-- → On receive: create accounting entry via record_purchase_entry()
-- ═══════════════════════════════════════════════════════════════════════════
-- SUPPLIERS
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS suppliers (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
contact_name VARCHAR(200),
phone VARCHAR(50),
email VARCHAR(200),
rfc VARCHAR(13),
address TEXT,
payment_terms VARCHAR(100), -- e.g., "30 dias", "contado"
notes TEXT,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_suppliers_active ON suppliers(is_active);
CREATE INDEX IF NOT EXISTS idx_suppliers_name ON suppliers(name);
-- ═══════════════════════════════════════════════════════════════════════════
-- PURCHASE ORDERS
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS purchase_orders (
id SERIAL PRIMARY KEY,
supplier_id INTEGER REFERENCES suppliers(id) ON DELETE SET NULL,
branch_id INTEGER REFERENCES branches(id),
employee_id INTEGER REFERENCES employees(id),
status VARCHAR(20) DEFAULT 'draft' NOT NULL, -- draft, sent, partial, received, cancelled
subtotal NUMERIC(12,2) DEFAULT 0 NOT NULL,
tax_total NUMERIC(12,2) DEFAULT 0 NOT NULL,
total NUMERIC(12,2) DEFAULT 0 NOT NULL,
currency VARCHAR(3) DEFAULT 'MXN',
exchange_rate NUMERIC(12,6) DEFAULT 1.0,
notes TEXT,
supplier_invoice VARCHAR(100),
expected_date DATE,
sent_at TIMESTAMPTZ,
received_at TIMESTAMPTZ,
cancelled_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_po_supplier ON purchase_orders(supplier_id);
CREATE INDEX IF NOT EXISTS idx_po_status ON purchase_orders(status);
CREATE INDEX IF NOT EXISTS idx_po_branch ON purchase_orders(branch_id);
CREATE INDEX IF NOT EXISTS idx_po_created ON purchase_orders(created_at DESC);
-- ═══════════════════════════════════════════════════════════════════════════
-- PURCHASE ORDER ITEMS
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS purchase_order_items (
id SERIAL PRIMARY KEY,
po_id INTEGER NOT NULL REFERENCES purchase_orders(id) ON DELETE CASCADE,
inventory_id INTEGER REFERENCES inventory(id) ON DELETE SET NULL,
part_number VARCHAR(100),
name VARCHAR(300),
quantity INTEGER NOT NULL DEFAULT 1,
received_qty INTEGER DEFAULT 0,
unit_price NUMERIC(12,2) NOT NULL DEFAULT 0,
subtotal NUMERIC(12,2) NOT NULL DEFAULT 0,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_poi_po ON purchase_order_items(po_id);
CREATE INDEX IF NOT EXISTS idx_poi_inventory ON purchase_order_items(inventory_id);

View File

@@ -0,0 +1,82 @@
-- v2.2_alerts_warranty.sql
-- Mejora #7: Alertas de Reorden mejoradas
-- Mejora #10: Garantías / RMA
-- Mejora #1: Multi-sucursal sync helpers
-- ═══════════════════════════════════════════════════════════════════════════
-- ALERTAS DE REORDER
-- ═══════════════════════════════════════════════════════════════════════════
-- Add reorder columns to inventory
ALTER TABLE inventory
ADD COLUMN IF NOT EXISTS reorder_point INTEGER,
ADD COLUMN IF NOT EXISTS reorder_qty INTEGER;
-- Table to track generated reorder alerts (prevents duplicate notifications)
CREATE TABLE IF NOT EXISTS reorder_alerts (
id SERIAL PRIMARY KEY,
inventory_id INTEGER NOT NULL REFERENCES inventory(id) ON DELETE CASCADE,
branch_id INTEGER REFERENCES branches(id),
alert_type VARCHAR(20) NOT NULL, -- 'zero', 'low', 'over'
stock_at_alert INTEGER NOT NULL,
threshold INTEGER,
status VARCHAR(20) DEFAULT 'open', -- open, acknowledged, resolved
po_id INTEGER REFERENCES purchase_orders(id),
employee_id INTEGER REFERENCES employees(id),
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
resolved_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_reorder_alerts_inventory ON reorder_alerts(inventory_id);
CREATE INDEX IF NOT EXISTS idx_reorder_alerts_status ON reorder_alerts(status);
CREATE INDEX IF NOT EXISTS idx_reorder_alerts_created ON reorder_alerts(created_at DESC);
-- ═══════════════════════════════════════════════════════════════════════════
-- GARANTÍAS / RMA
-- ═══════════════════════════════════════════════════════════════════════════
-- Warranty registry attached to sale_items
CREATE TABLE IF NOT EXISTS warranties (
id SERIAL PRIMARY KEY,
sale_id INTEGER REFERENCES sales(id) ON DELETE SET NULL,
sale_item_id INTEGER REFERENCES sale_items(id) ON DELETE SET NULL,
inventory_id INTEGER REFERENCES inventory(id) ON DELETE SET NULL,
customer_id INTEGER REFERENCES customers(id),
supplier_id INTEGER REFERENCES suppliers(id),
part_number VARCHAR(100),
name VARCHAR(300),
warranty_months INTEGER DEFAULT 0,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
status VARCHAR(20) DEFAULT 'active', -- active, claimed, expired, void
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_warranties_sale ON warranties(sale_id);
CREATE INDEX IF NOT EXISTS idx_warranties_customer ON warranties(customer_id);
CREATE INDEX IF NOT EXISTS idx_warranties_status ON warranties(status);
CREATE INDEX IF NOT EXISTS idx_warranties_end_date ON warranties(end_date);
-- Warranty claims (RMA process)
CREATE TABLE IF NOT EXISTS warranty_claims (
id SERIAL PRIMARY KEY,
warranty_id INTEGER NOT NULL REFERENCES warranties(id) ON DELETE CASCADE,
claim_date DATE NOT NULL DEFAULT CURRENT_DATE,
reason TEXT NOT NULL,
diagnosis TEXT,
resolution VARCHAR(20), -- approved, rejected, repaired, replaced, refunded
replacement_inventory_id INTEGER REFERENCES inventory(id),
refund_amount NUMERIC(12,2),
labor_cost NUMERIC(12,2),
status VARCHAR(20) DEFAULT 'open', -- open, in_review, resolved, closed
employee_id INTEGER REFERENCES employees(id),
supplier_rma_number VARCHAR(100),
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
resolved_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_warranty_claims_warranty ON warranty_claims(warranty_id);
CREATE INDEX IF NOT EXISTS idx_warranty_claims_status ON warranty_claims(status);

View File

@@ -0,0 +1,18 @@
-- v2.3_metabase.sql
-- Mejora #5: Metabase KPIs
--
-- No schema changes required in tenant DB.
-- Metabase runs as a separate Docker container and connects
-- to PostgreSQL as a read-only BI user.
--
-- Prerequisites:
-- - Docker and docker-compose installed
-- - docker-compose.metabase.yml deployed
-- - scripts/setup_metabase.py executed
--
-- Post-deployment:
-- 1. Start Metabase: docker compose -f docker-compose.metabase.yml up -d
-- 2. Run setup: python3 scripts/setup_metabase.py
-- 3. Access dashboard at http://<host>:3000
--
SELECT 'v2.3 metabase KPIs migration applied' as status;

View File

@@ -0,0 +1,81 @@
-- v2.4 CRM Enhanced: activities, tags, loyalty
-- Customer activity timeline (notes, calls, visits, emails, whatsapp)
CREATE TABLE IF NOT EXISTS customer_activities (
id SERIAL PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
activity_type VARCHAR(50) NOT NULL, -- 'note', 'call', 'visit', 'email', 'whatsapp', 'sale', 'payment', 'claim'
title VARCHAR(200),
description TEXT,
metadata JSONB, -- e.g. {"call_duration": 120, "email_subject": "..."}
employee_id INTEGER REFERENCES employees(id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_cust_act_customer ON customer_activities(customer_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_cust_act_type ON customer_activities(activity_type);
-- Customer tags for segmentation
CREATE TABLE IF NOT EXISTS customer_tags (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL,
name VARCHAR(100) NOT NULL,
color VARCHAR(7) DEFAULT '#6B7280', -- hex color
description TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(tenant_id, name)
);
CREATE INDEX IF NOT EXISTS idx_cust_tags_tenant ON customer_tags(tenant_id);
-- Many-to-many: customers <-> tags
CREATE TABLE IF NOT EXISTS customer_tag_assignments (
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES customer_tags(id) ON DELETE CASCADE,
assigned_at TIMESTAMPTZ DEFAULT NOW(),
assigned_by INTEGER REFERENCES employees(id),
PRIMARY KEY (customer_id, tag_id)
);
-- Loyalty / rewards program
CREATE TABLE IF NOT EXISTS loyalty_points (
id SERIAL PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
points INTEGER NOT NULL DEFAULT 0,
points_type VARCHAR(20) NOT NULL DEFAULT 'earned', -- 'earned', 'redeemed', 'expired', 'bonus'
source_type VARCHAR(50), -- 'sale', 'referral', 'promotion', 'manual', 'redemption'
source_id INTEGER, -- sale_id or other reference
description TEXT,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_loyalty_customer ON loyalty_points(customer_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_loyalty_expires ON loyalty_points(expires_at) WHERE expires_at IS NOT NULL;
-- Loyalty redemptions (rewards catalog)
CREATE TABLE IF NOT EXISTS loyalty_rewards (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL,
name VARCHAR(200) NOT NULL,
description TEXT,
points_cost INTEGER NOT NULL,
reward_type VARCHAR(50) DEFAULT 'discount', -- 'discount', 'free_product', 'service'
reward_value NUMERIC(12,2), -- discount amount or product ID
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Loyalty redemption history
CREATE TABLE IF NOT EXISTS loyalty_redemptions (
id SERIAL PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
reward_id INTEGER REFERENCES loyalty_rewards(id),
points_used INTEGER NOT NULL,
reward_value NUMERIC(12,2),
description TEXT,
employee_id INTEGER REFERENCES employees(id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_loyalty_redem_customer ON loyalty_redemptions(customer_id);
-- Add loyalty balance to customers (denormalized for quick lookup)
ALTER TABLE customers ADD COLUMN IF NOT EXISTS loyalty_points_balance INTEGER DEFAULT 0;
ALTER TABLE customers ADD COLUMN IF NOT EXISTS loyalty_tier VARCHAR(50) DEFAULT 'bronze';

View File

@@ -0,0 +1,90 @@
-- v2.5 Service Orders (Kanban) for workshop management
CREATE TABLE IF NOT EXISTS service_orders (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL,
branch_id INTEGER REFERENCES branches(id),
customer_id INTEGER REFERENCES customers(id),
vehicle_id INTEGER REFERENCES fleet_vehicles(id), -- optional link to fleet
order_number VARCHAR(50) UNIQUE, -- human-readable SO-2026-0001
status VARCHAR(30) DEFAULT 'received', -- received, diagnosis, waiting_parts, repair, quality_check, ready, delivered, cancelled
priority VARCHAR(20) DEFAULT 'normal', -- low, normal, high, urgent
reception_notes TEXT,
diagnosis_notes TEXT,
repair_notes TEXT,
delivery_notes TEXT,
estimated_cost NUMERIC(12,2),
final_cost NUMERIC(12,2),
estimated_completion TIMESTAMPTZ,
actual_completion TIMESTAMPTZ,
delivered_at TIMESTAMPTZ,
delivered_by INTEGER REFERENCES employees(id),
employee_id INTEGER REFERENCES employees(id), -- assigned mechanic/technician
mileage_in INTEGER,
mileage_out INTEGER,
fuel_level VARCHAR(20), -- empty, quarter, half, three_quarters, full
created_by INTEGER REFERENCES employees(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_so_status ON service_orders(status);
CREATE INDEX IF NOT EXISTS idx_so_customer ON service_orders(customer_id);
CREATE INDEX IF NOT EXISTS idx_so_branch ON service_orders(branch_id);
CREATE INDEX IF NOT EXISTS idx_so_created ON service_orders(created_at DESC);
-- Service order items (parts needed/used)
CREATE TABLE IF NOT EXISTS service_order_items (
id SERIAL PRIMARY KEY,
service_order_id INTEGER NOT NULL REFERENCES service_orders(id) ON DELETE CASCADE,
inventory_id INTEGER REFERENCES inventory(id),
part_number VARCHAR(50),
name VARCHAR(300),
quantity NUMERIC(10,2) DEFAULT 1,
unit_cost NUMERIC(12,2),
unit_price NUMERIC(12,2),
status VARCHAR(20) DEFAULT 'pending', -- pending, ordered, received, installed, cancelled
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_soi_order ON service_order_items(service_order_id);
-- Service order labor / work items
CREATE TABLE IF NOT EXISTS service_order_labor (
id SERIAL PRIMARY KEY,
service_order_id INTEGER NOT NULL REFERENCES service_orders(id) ON DELETE CASCADE,
description TEXT NOT NULL,
hours NUMERIC(6,2) DEFAULT 0,
hourly_rate NUMERIC(12,2) DEFAULT 0,
total_cost NUMERIC(12,2) DEFAULT 0,
employee_id INTEGER REFERENCES employees(id), -- mechanic who did the work
status VARCHAR(20) DEFAULT 'pending', -- pending, in_progress, completed
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sol_order ON service_order_labor(service_order_id);
-- Status history for audit trail
CREATE TABLE IF NOT EXISTS service_order_status_history (
id SERIAL PRIMARY KEY,
service_order_id INTEGER NOT NULL REFERENCES service_orders(id) ON DELETE CASCADE,
old_status VARCHAR(30),
new_status VARCHAR(30) NOT NULL,
changed_by INTEGER REFERENCES employees(id),
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sosh_order ON service_order_status_history(service_order_id);
-- Trigger to auto-update updated_at
CREATE OR REPLACE FUNCTION update_so_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_service_orders_updated_at ON service_orders;
CREATE TRIGGER trg_service_orders_updated_at
BEFORE UPDATE ON service_orders
FOR EACH ROW
EXECUTE FUNCTION update_so_updated_at();

View File

@@ -0,0 +1,80 @@
-- v2.6 BNPL (APLAZO) and ERP Sync stubs
-- BNPL / External credit provider transactions
CREATE TABLE IF NOT EXISTS bnpl_transactions (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL,
customer_id INTEGER REFERENCES customers(id),
sale_id INTEGER REFERENCES sales(id),
provider VARCHAR(50) NOT NULL DEFAULT 'aplazo', -- 'aplazo', 'kueski', 'clip'
provider_transaction_id VARCHAR(200),
amount NUMERIC(12,2) NOT NULL,
status VARCHAR(30) DEFAULT 'pending', -- pending, approved, rejected, funded, cancelled, refunded
installment_count INTEGER DEFAULT 1,
installment_amount NUMERIC(12,2),
customer_fee NUMERIC(12,2) DEFAULT 0,
merchant_fee NUMERIC(12,2) DEFAULT 0,
provider_response JSONB,
webhook_payload JSONB,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_bnpl_sale ON bnpl_transactions(sale_id);
CREATE INDEX IF NOT EXISTS idx_bnpl_customer ON bnpl_transactions(customer_id);
CREATE INDEX IF NOT EXISTS idx_bnpl_status ON bnpl_transactions(status);
-- ERP Sync configurations per tenant
CREATE TABLE IF NOT EXISTS erp_sync_configs (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL,
erp_type VARCHAR(50) NOT NULL, -- 'aspel_sae', 'contpaqi', 'sap_b1', 'odoo'
is_active BOOLEAN DEFAULT FALSE,
api_endpoint VARCHAR(500),
api_username VARCHAR(200),
api_password_encrypted TEXT,
database_name VARCHAR(200),
company_code VARCHAR(50),
sync_direction VARCHAR(20) DEFAULT 'bidirectional', -- 'to_erp', 'from_erp', 'bidirectional'
sync_inventory BOOLEAN DEFAULT FALSE,
sync_sales BOOLEAN DEFAULT FALSE,
sync_customers BOOLEAN DEFAULT FALSE,
sync_frequency_minutes INTEGER DEFAULT 60,
last_sync_at TIMESTAMPTZ,
last_sync_error TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_erp_config_tenant ON erp_sync_configs(tenant_id, erp_type);
-- ERP Sync logs (each sync run)
CREATE TABLE IF NOT EXISTS erp_sync_logs (
id SERIAL PRIMARY KEY,
config_id INTEGER REFERENCES erp_sync_configs(id),
sync_type VARCHAR(50) NOT NULL, -- 'inventory', 'sales', 'customers', 'full'
direction VARCHAR(20) NOT NULL, -- 'to_erp', 'from_erp'
status VARCHAR(20) DEFAULT 'running', -- running, success, partial, failed
records_processed INTEGER DEFAULT 0,
records_failed INTEGER DEFAULT 0,
error_message TEXT,
details JSONB,
started_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_erp_logs_config ON erp_sync_logs(config_id, started_at DESC);
-- ERP Sync queue (pending items to sync)
CREATE TABLE IF NOT EXISTS erp_sync_queue (
id SERIAL PRIMARY KEY,
config_id INTEGER NOT NULL REFERENCES erp_sync_configs(id),
entity_type VARCHAR(50) NOT NULL, -- 'inventory', 'sale', 'customer'
entity_id INTEGER NOT NULL,
action VARCHAR(20) NOT NULL, -- 'create', 'update', 'delete'
priority INTEGER DEFAULT 5, -- 1=urgent, 10=low
status VARCHAR(20) DEFAULT 'pending', -- pending, processing, completed, failed
retry_count INTEGER DEFAULT 0,
last_error TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
processed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_erp_queue_pending ON erp_sync_queue(config_id, status, priority, created_at)
WHERE status IN ('pending', 'failed');

View File

@@ -0,0 +1,67 @@
-- v2.7 Automatic Notifications Engine
-- Notification templates per tenant
CREATE TABLE IF NOT EXISTS notification_templates (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL,
event_type VARCHAR(50) NOT NULL, -- 'low_stock', 'order_ready', 'maintenance_due', 'new_sale', 'po_received', 'reorder_alert', 'warranty_expiring'
channel VARCHAR(20) NOT NULL DEFAULT 'push', -- 'push', 'email', 'whatsapp', 'sms', 'in_app'
name VARCHAR(200) NOT NULL,
subject_template VARCHAR(500),
body_template TEXT NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(tenant_id, event_type, channel)
);
-- Notification delivery log
CREATE TABLE IF NOT EXISTS notification_logs (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL,
recipient_type VARCHAR(20) NOT NULL DEFAULT 'employee', -- 'employee', 'customer', 'owner', 'role'
recipient_id INTEGER,
event_type VARCHAR(50) NOT NULL,
channel VARCHAR(20) NOT NULL,
subject TEXT,
body TEXT,
status VARCHAR(20) DEFAULT 'pending', -- pending, sent, delivered, failed, read
error_message TEXT,
metadata JSONB, -- {sale_id, po_id, inventory_id, etc.}
sent_at TIMESTAMPTZ,
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_notif_logs_recipient ON notification_logs(recipient_type, recipient_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_notif_logs_status ON notification_logs(status) WHERE status IN ('pending', 'failed');
CREATE INDEX IF NOT EXISTS idx_notif_logs_event ON notification_logs(event_type, created_at DESC);
-- Notification preferences per employee
CREATE TABLE IF NOT EXISTS notification_preferences (
id SERIAL PRIMARY KEY,
employee_id INTEGER NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
event_type VARCHAR(50) NOT NULL,
channel VARCHAR(20) NOT NULL,
is_enabled BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(employee_id, event_type, channel)
);
-- Push subscriptions table (if not already created by push_service)
CREATE TABLE IF NOT EXISTS push_subscriptions (
id SERIAL PRIMARY KEY,
employee_id INTEGER NOT NULL UNIQUE,
subscription_data TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Default templates
INSERT INTO notification_templates (tenant_id, event_type, channel, name, subject_template, body_template) VALUES
(1, 'low_stock', 'push', 'Stock Bajo', 'Stock bajo: {part_name}', 'El inventario de {part_name} ({part_number}) tiene solo {stock} unidades. Punto de reorden: {reorder_point}.'),
(1, 'order_ready', 'push', 'Orden Lista', 'Orden #{order_number} lista', 'La orden de servicio #{order_number} está lista para entrega. Cliente: {customer_name}.'),
(1, 'maintenance_due', 'push', 'Mantenimiento Vencido', 'Mantenimiento vencido', 'El vehículo {vehicle_plate} tiene mantenimiento de {maintenance_type} vencido. Kilometraje: {current_mileage}.'),
(1, 'new_sale', 'push', 'Nueva Venta', 'Venta registrada', 'Venta #{sale_id} por ${total} registrada. Método: {payment_method}.'),
(1, 'po_received', 'push', 'OC Recibida', 'Orden de compra recibida', 'La orden de compra #{po_id} fue recibida. Total: ${total}.'),
(1, 'reorder_alert', 'push', 'Alerta de Reorden', 'Reorden requerida', '{part_name} ({part_number}) está por debajo del punto de reorden. Stock: {stock}, Reorden: {reorder_point}.'),
(1, 'warranty_expiring', 'push', 'Garantía por vencer', 'Garantía por vencer', 'La garantía del item {part_name} vence el {expiry_date}. Cliente: {customer_name}.')
ON CONFLICT DO NOTHING;

View File

@@ -0,0 +1,31 @@
-- v2.8 Savings Reports
-- Add retail_price (MSRP) to inventory for savings calculation
ALTER TABLE inventory ADD COLUMN IF NOT EXISTS retail_price NUMERIC(12,2);
ALTER TABLE inventory ADD COLUMN IF NOT EXISTS reference_price NUMERIC(12,2); -- competitor price or market price
-- Track savings per sale item
ALTER TABLE sale_items ADD COLUMN IF NOT EXISTS retail_price NUMERIC(12,2);
ALTER TABLE sale_items ADD COLUMN IF NOT EXISTS savings_amount NUMERIC(12,2) DEFAULT 0;
-- Savings summary per sale
ALTER TABLE sales ADD COLUMN IF NOT EXISTS total_savings NUMERIC(12,2) DEFAULT 0;
-- Customer savings history (denormalized for quick lookup)
ALTER TABLE customers ADD COLUMN IF NOT EXISTS total_savings NUMERIC(12,2) DEFAULT 0;
-- Savings report view
CREATE OR REPLACE VIEW v_customer_savings AS
SELECT
s.customer_id,
c.name as customer_name,
date_trunc('month', s.created_at) as month,
COUNT(*) as orders_count,
SUM(s.total) as total_spent,
SUM(s.total_savings) as total_saved,
AVG(s.total_savings / NULLIF(s.total, 0) * 100) as avg_savings_pct
FROM sales s
JOIN customers c ON s.customer_id = c.id
WHERE s.status = 'completed' AND s.total_savings > 0
GROUP BY s.customer_id, c.name, date_trunc('month', s.created_at)
ORDER BY month DESC, total_saved DESC;

View File

@@ -0,0 +1,82 @@
-- v2.9 Logistics & Shipment Tracking
-- Couriers / carriers
CREATE TABLE IF NOT EXISTS couriers (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL,
name VARCHAR(100) NOT NULL, -- 'DHL', 'FedEx', 'Estafeta', '99minutos', 'Uber Direct'
code VARCHAR(20) NOT NULL, -- internal code
tracking_url_template VARCHAR(500), -- e.g. "https://www.dhl.com/track?trackingNumber={tracking_number}"
api_endpoint VARCHAR(500),
api_key_encrypted TEXT,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_couriers_code ON couriers(tenant_id, code);
-- Shipments
CREATE TABLE IF NOT EXISTS shipments (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL,
branch_id INTEGER REFERENCES branches(id),
shipment_type VARCHAR(20) DEFAULT 'outbound', -- outbound, inbound, return
related_type VARCHAR(50), -- 'sale', 'purchase_order', 'service_order', 'marketplace_order'
related_id INTEGER,
courier_id INTEGER REFERENCES couriers(id),
tracking_number VARCHAR(100),
tracking_url VARCHAR(500),
status VARCHAR(30) DEFAULT 'pending', -- pending, label_created, picked_up, in_transit, out_for_delivery, delivered, failed, returned
origin_address TEXT,
destination_address TEXT,
recipient_name VARCHAR(200),
recipient_phone VARCHAR(50),
estimated_delivery DATE,
actual_delivery TIMESTAMPTZ,
shipping_cost NUMERIC(12,2) DEFAULT 0,
weight_kg NUMERIC(8,3),
dimensions_cm VARCHAR(50), -- "30x20x15"
notes TEXT,
created_by INTEGER REFERENCES employees(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_shipments_tracking ON shipments(tracking_number);
CREATE INDEX IF NOT EXISTS idx_shipments_status ON shipments(status);
CREATE INDEX IF NOT EXISTS idx_shipments_related ON shipments(related_type, related_id);
-- Shipment tracking history
CREATE TABLE IF NOT EXISTS shipment_tracking (
id SERIAL PRIMARY KEY,
shipment_id INTEGER NOT NULL REFERENCES shipments(id) ON DELETE CASCADE,
status VARCHAR(30) NOT NULL,
location VARCHAR(200),
description TEXT,
raw_response JSONB,
tracked_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_shipment_tracking_shipment ON shipment_tracking(shipment_id, tracked_at DESC);
-- Trigger for updated_at
CREATE OR REPLACE FUNCTION update_shipment_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_shipments_updated_at ON shipments;
CREATE TRIGGER trg_shipments_updated_at
BEFORE UPDATE ON shipments
FOR EACH ROW
EXECUTE FUNCTION update_shipment_updated_at();
-- Insert default couriers
INSERT INTO couriers (tenant_id, name, code, tracking_url_template, is_active) VALUES
(1, 'DHL Express', 'dhl', 'https://www.dhl.com/mx/es/home/tracking/tracking-ecommerce.html?tracking-id={tracking_number}', true),
(1, 'FedEx', 'fedex', 'https://www.fedex.com/apps/fedextrack/?tracknumbers={tracking_number}', true),
(1, 'Estafeta', 'estafeta', 'https://www.estafeta.com/herramientas/rastreo?guias={tracking_number}', true),
(1, '99 Minutos', '99minutos', 'https://99minutos.com/track/{tracking_number}', true),
(1, 'Uber Direct', 'uber_direct', NULL, true),
(1, 'Recolección en tienda', 'pickup', NULL, true)
ON CONFLICT DO NOTHING;

View File

@@ -0,0 +1,47 @@
-- v3.0 Public API: API keys and rate limiting
CREATE TABLE IF NOT EXISTS api_keys (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL,
name VARCHAR(200) NOT NULL, -- e.g. "Integration Acme Corp"
key_hash VARCHAR(64) NOT NULL, -- SHA-256 hash of the API key
key_prefix VARCHAR(8) NOT NULL, -- first 8 chars for display
scopes JSONB DEFAULT '["read"]'::jsonb, -- ["read", "write", "admin"]
rate_limit_rpm INTEGER DEFAULT 60, -- requests per minute
rate_limit_rpd INTEGER DEFAULT 10000, -- requests per day
is_active BOOLEAN DEFAULT TRUE,
last_used_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
created_by INTEGER REFERENCES employees(id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);
CREATE INDEX IF NOT EXISTS idx_api_keys_tenant ON api_keys(tenant_id, is_active);
-- API request log for analytics and abuse detection
CREATE TABLE IF NOT EXISTS api_request_logs (
id BIGSERIAL PRIMARY KEY,
api_key_id INTEGER REFERENCES api_keys(id),
tenant_id INTEGER,
method VARCHAR(10) NOT NULL,
path TEXT NOT NULL,
status_code INTEGER,
response_time_ms INTEGER,
ip_address INET,
user_agent TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_api_logs_key ON api_request_logs(api_key_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_api_logs_tenant ON api_request_logs(tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_api_logs_path ON api_request_logs(path, created_at DESC);
-- Rate limit counters (in-memory/redis preferred, but table as fallback)
CREATE TABLE IF NOT EXISTS api_rate_limit_counters (
id SERIAL PRIMARY KEY,
api_key_id INTEGER NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
window_start TIMESTAMPTZ NOT NULL,
window_type VARCHAR(10) NOT NULL, -- 'minute', 'day'
request_count INTEGER DEFAULT 0,
UNIQUE(api_key_id, window_start, window_type)
);
CREATE INDEX IF NOT EXISTS idx_rate_limit_window ON api_rate_limit_counters(api_key_id, window_type, window_start);