feat(pos/facturapi): finalize Horux-to-Facturapi migration
- Normalize Facturapi key/org_id resolution (supports both cfdi_ prefixed tenant_config keys and short names used by invoicing_bp). - Add CSD upload end-to-end (backend + frontend). - Add helper scripts: setup_facturapi_orgs.py and check_facturapi_tenants.py. - Add 20 unit tests with mocks (pos/tests/test_facturapi_service.py). - Add CI workflow for lint + console tests on Python 3.11/3.13. - Add pyproject.toml and requirements-dev.txt with ruff/pytest config. - Update FASES_IMPLEMENTADAS.md with FASE 8 documentation. Tests: 81 passing (61 console + 20 Facturapi).
This commit is contained in:
181
scripts/setup_facturapi_orgs.py
Normal file
181
scripts/setup_facturapi_orgs.py
Normal file
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create Facturapi organizations for tenants that don't have one.
|
||||
|
||||
Requires FACTURAPI_USER_KEY environment variable (user key with permission to
|
||||
manage organizations).
|
||||
|
||||
Usage:
|
||||
export MASTER_DB_URL=postgresql://user:pass@host/nexus_autoparts
|
||||
export TENANT_DB_URL_TEMPLATE="postgresql://user:pass@host/{db_name}"
|
||||
export FACTURAPI_USER_KEY=sk_user_xxxxxxxxxxxxxxxx
|
||||
python3 scripts/setup_facturapi_orgs.py
|
||||
|
||||
# Preview only
|
||||
python3 scripts/setup_facturapi_orgs.py --dry-run
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
POS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "pos")
|
||||
sys.path.insert(0, POS_DIR)
|
||||
|
||||
import psycopg2 # noqa: E402
|
||||
from services import facturapi_service # noqa: E402
|
||||
|
||||
|
||||
def get_tenants(master_dsn: str):
|
||||
conn = psycopg2.connect(master_dsn)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT t.id, t.db_name, t.name, t.subdomain
|
||||
FROM tenants t
|
||||
WHERE t.is_active = true
|
||||
ORDER BY t.id
|
||||
"""
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
return rows
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_tenant_fiscal_data(db_name: str, template_dsn: str) -> dict:
|
||||
dsn = template_dsn.format(db_name=db_name)
|
||||
conn = psycopg2.connect(dsn)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT key, value FROM tenant_config
|
||||
WHERE key IN ('tenant_rfc', 'tenant_razon_social', 'tenant_cp', 'cfdi_regimen_fiscal')
|
||||
"""
|
||||
)
|
||||
config = {row[0]: row[1] or "" for row in cur.fetchall()}
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT rfc, razon_social, regimen_fiscal, codigo_postal
|
||||
FROM branches WHERE is_main = true LIMIT 1
|
||||
"""
|
||||
)
|
||||
branch = cur.fetchone()
|
||||
if branch:
|
||||
config["rfc"] = (branch[0] or config.get("tenant_rfc", "")).strip()
|
||||
config["razon_social"] = (branch[1] or config.get("tenant_razon_social", "")).strip()
|
||||
config["regimen_fiscal"] = (branch[2] or config.get("cfdi_regimen_fiscal", "")).strip()
|
||||
config["cp"] = (branch[3] or config.get("tenant_cp", "")).strip()
|
||||
else:
|
||||
config["rfc"] = config.get("tenant_rfc", "").strip()
|
||||
config["razon_social"] = config.get("tenant_razon_social", "").strip()
|
||||
config["regimen_fiscal"] = config.get("cfdi_regimen_fiscal", "").strip()
|
||||
config["cp"] = config.get("tenant_cp", "").strip()
|
||||
|
||||
# Existing facturapi keys
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT key, value FROM tenant_config
|
||||
WHERE key IN ('cfdi_facturapi_org_id', 'cfdi_facturapi_key')
|
||||
"""
|
||||
)
|
||||
for key, value in cur.fetchall():
|
||||
config[key] = value or ""
|
||||
|
||||
cur.close()
|
||||
return config
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def save_facturapi_config(db_name: str, template_dsn: str, org_id: str, api_key: str):
|
||||
dsn = template_dsn.format(db_name=db_name)
|
||||
conn = psycopg2.connect(dsn)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO tenant_config (key, value) VALUES ('cfdi_facturapi_org_id', %s)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""",
|
||||
(org_id,),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO tenant_config (key, value) VALUES ('cfdi_facturapi_key', %s)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""",
|
||||
(api_key,),
|
||||
)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Create Facturapi organizations for tenants")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Preview changes without applying")
|
||||
args = parser.parse_args()
|
||||
|
||||
master_dsn = os.environ.get("MASTER_DB_URL")
|
||||
template_dsn = os.environ.get("TENANT_DB_URL_TEMPLATE")
|
||||
user_key = os.environ.get("FACTURAPI_USER_KEY")
|
||||
|
||||
if not master_dsn or not template_dsn:
|
||||
print("Set MASTER_DB_URL and TENANT_DB_URL_TEMPLATE", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not user_key:
|
||||
print("Set FACTURAPI_USER_KEY (user key required to manage organizations)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
tenants = get_tenants(master_dsn)
|
||||
if not tenants:
|
||||
print("No active tenants found.")
|
||||
return
|
||||
|
||||
created = 0
|
||||
skipped = 0
|
||||
errors = 0
|
||||
|
||||
for tenant_id, db_name, name, _subdomain in tenants:
|
||||
print(f"\n[{tenant_id}] {name} ({db_name})")
|
||||
try:
|
||||
config = get_tenant_fiscal_data(db_name, template_dsn)
|
||||
|
||||
if config.get("cfdi_facturapi_org_id") and config.get("cfdi_facturapi_key"):
|
||||
print(" SKIP: already has org_id and key")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
if not config.get("rfc"):
|
||||
print(" ERROR: tenant RFC not configured")
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
print(f" RFC: {config['rfc']}")
|
||||
print(f" Razon social: {config['razon_social'] or '(will use RFC)'}")
|
||||
|
||||
if args.dry_run:
|
||||
print(" DRY-RUN: would create organization")
|
||||
continue
|
||||
|
||||
result = facturapi_service.create_organization(config)
|
||||
save_facturapi_config(db_name, template_dsn, result["org_id"], result["api_key"])
|
||||
print(f" CREATED: org_id={result['org_id']}")
|
||||
created += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f" ERROR: {type(e).__name__}: {str(e)[:200]}")
|
||||
errors += 1
|
||||
|
||||
print(f"\nDone. Created: {created} | Skipped: {skipped} | Errors: {errors}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user