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:
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user