cloud_portal/cloud_portal/api/erpnext_sync.py

370 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Sync customer subscriptions between local ERPNext and remote ERPNext.
Configuration (site_config.json keys):
erpnext_sync_url base URL of remote ERPNext, e.g. https://api.host.jeyerp.az
erpnext_sync_api_key API key of remote ERPNext user
erpnext_sync_api_secret API secret of remote ERPNext user
"""
import frappe
import requests
from frappe import _
from urllib.parse import quote as url_quote
# ---------------------------------------------------------------------------
# Config / session helpers
# ---------------------------------------------------------------------------
def _get_config():
conf = frappe.conf
return {
"base_url": conf.get("erpnext_sync_url", ""),
"api_key": conf.get("erpnext_sync_api_key", ""),
"api_secret": conf.get("erpnext_sync_api_secret", ""),
# Company name in remote ERPNext (may differ from local)
"remote_company": conf.get("erpnext_sync_company", ""),
}
def _get_session():
"""Return (requests.Session, base_url, config) or (None, None, None) if not configured."""
config = _get_config()
if not config["base_url"] or not config["api_key"] or not config["api_secret"]:
return None, None, None
session = requests.Session()
session.auth = (config["api_key"], config["api_secret"])
session.headers.update({"Content-Type": "application/json"})
return session, config["base_url"].rstrip("/"), config
def _get_remote_company(session, base_url, config):
"""Return the company name to use in the remote ERPNext."""
if config.get("remote_company"):
return config["remote_company"]
# Auto-detect from remote Global Defaults
try:
resp = session.get(
f"{base_url}/api/method/frappe.client.get_value",
params={"doctype": "Global Defaults", "fieldname": "default_company"},
timeout=10,
)
resp.raise_for_status()
company = resp.json().get("message", {}).get("default_company", "")
if company:
frappe.logger().info(f"Auto-detected remote company: {company}")
return company
except Exception as e:
frappe.log_error(f"Could not detect remote company: {e}", "Subscription Sync")
return ""
# ---------------------------------------------------------------------------
# Core sync functions
# ---------------------------------------------------------------------------
def push_subscription_to_remote(local_sub_name):
"""
Create a mirror of the local Subscription in remote ERPNext.
Returns the remote subscription name (str) on success, None on failure.
"""
session, base_url, config = _get_session()
if not session:
frappe.log_error("ERPNext sync credentials not configured in site_config", "Subscription Sync")
return None
try:
sub = frappe.get_doc("Subscription", local_sub_name)
except Exception as e:
frappe.log_error(f"Cannot load local subscription {local_sub_name}: {e}", "Subscription Sync")
return None
plans = [{"plan": p.plan, "qty": p.qty} for p in (sub.plans or [])]
if not plans:
frappe.log_error(f"Subscription {local_sub_name} has no plans skipping push", "Subscription Sync")
return None
remote_company = _get_remote_company(session, base_url, config)
if not remote_company:
frappe.log_error("Cannot determine remote company name", "Subscription Sync")
return None
# Ensure the customer exists in remote before creating the subscription
if not ensure_customer_in_remote(sub.party):
frappe.log_error(
f"Cannot push subscription {local_sub_name}: customer '{sub.party}' not found in remote and could not be created",
"Subscription Sync"
)
return None
payload = {
"party_type": sub.party_type or "Customer",
"party": sub.party,
"start_date": str(sub.start_date) if sub.start_date else frappe.utils.today(),
"generate_invoice_at": sub.generate_invoice_at or "End of the current subscription period",
"plans": plans,
"company": remote_company,
}
if sub.trial_period_start:
payload["trial_period_start"] = str(sub.trial_period_start)
if sub.trial_period_end:
payload["trial_period_end"] = str(sub.trial_period_end)
try:
resp = session.post(
f"{base_url}/api/resource/Subscription",
json=payload,
timeout=15,
)
resp.raise_for_status()
remote_name = resp.json().get("data", {}).get("name")
if remote_name:
frappe.logger().info(f"Pushed subscription {local_sub_name} → remote {remote_name}")
return remote_name
except requests.HTTPError as e:
body = ""
try:
body = e.response.text[:500]
except Exception:
pass
frappe.log_error(
f"HTTP error pushing subscription {local_sub_name}: {e}\n{body}",
"Subscription Sync"
)
return None
except Exception as e:
frappe.log_error(f"Error pushing subscription {local_sub_name}: {e}", "Subscription Sync")
return None
def pull_subscription_status(local_sub_name, remote_sub_name):
"""
Fetch the subscription status from remote ERPNext and update local if changed.
Returns False if the remote subscription no longer exists (caller should re-push).
"""
session, base_url, _ = _get_session()
if not session:
return True
try:
resp = session.get(
f"{base_url}/api/resource/Subscription/{remote_sub_name}",
timeout=10,
)
if resp.status_code == 404:
# Remote subscription was deleted signal caller to re-push
return False
resp.raise_for_status()
remote_status = resp.json().get("data", {}).get("status")
if not remote_status:
return True
local_status = frappe.db.get_value("Subscription", local_sub_name, "status")
if remote_status != local_status:
frappe.db.set_value("Subscription", local_sub_name, "status", remote_status)
frappe.db.commit()
frappe.logger().info(
f"Subscription {local_sub_name} status updated: {local_status}{remote_status}"
)
return True
except Exception as e:
frappe.log_error(
f"Error pulling status for {local_sub_name} (remote: {remote_sub_name}): {e}",
"Subscription Sync"
)
return True
# ---------------------------------------------------------------------------
# Customer sync
# ---------------------------------------------------------------------------
def ensure_customer_in_remote(customer_name):
"""
Ensure the Customer exists in remote ERPNext.
Creates it if missing. Returns True on success (exists or created), False on failure.
"""
session, base_url, _ = _get_session()
if not session:
return False
# Check if already exists
try:
resp = session.get(
f"{base_url}/api/resource/Customer/{url_quote(customer_name)}",
timeout=10,
)
if resp.status_code == 200:
return True # Already exists
except Exception:
pass
# Create customer in remote
try:
customer = frappe.get_doc("Customer", customer_name)
payload = {
"customer_name": customer.customer_name,
"customer_type": customer.customer_type or "Individual",
}
if customer.tax_id:
payload["tax_id"] = customer.tax_id
resp = session.post(
f"{base_url}/api/resource/Customer",
json=payload,
timeout=15,
)
resp.raise_for_status()
frappe.logger().info(f"Created customer '{customer_name}' in remote ERPNext")
return True
except Exception as e:
frappe.log_error(f"Failed to create customer '{customer_name}' in remote: {e}", "Subscription Sync")
return False
# ---------------------------------------------------------------------------
# DocType event hooks
# ---------------------------------------------------------------------------
def on_customer_insert(doc, method=None):
"""Frappe hook: sync new Customer to remote ERPNext after insert."""
frappe.enqueue(
"cloud_portal.api.erpnext_sync.ensure_customer_in_remote",
queue="short",
timeout=30,
customer_name=doc.name,
)
def on_subscription_insert(doc, method=None):
"""Frappe hook: sync new Subscription to remote ERPNext after insert."""
frappe.enqueue(
"cloud_portal.api.erpnext_sync.push_and_store_remote_subscription_by_name",
queue="default",
timeout=60,
local_sub_name=doc.name,
)
# ---------------------------------------------------------------------------
# Background job helpers
# ---------------------------------------------------------------------------
def push_and_store_remote_subscription(container_name, local_sub_name):
"""
Background job: push a newly created subscription to remote ERPNext
and store the remote ID on both the Cloud Container and the Subscription.
"""
remote_sub_name = push_subscription_to_remote(local_sub_name)
if remote_sub_name:
frappe.db.set_value("Cloud Container", container_name, "remote_subscription", remote_sub_name)
_store_remote_sub_on_subscription(local_sub_name, remote_sub_name)
frappe.db.commit()
def push_and_store_remote_subscription_by_name(local_sub_name):
"""
Background job (called from on_subscription_insert hook):
push a newly created subscription to remote ERPNext and store the remote ID.
Skips if already synced.
"""
# Avoid double-push if already handled by push_and_store_remote_subscription
existing = frappe.db.get_value("Subscription", local_sub_name, "custom_remote_sub_id")
if existing:
return
remote_sub_name = push_subscription_to_remote(local_sub_name)
if remote_sub_name:
_store_remote_sub_on_subscription(local_sub_name, remote_sub_name)
# Also update Cloud Container if linked
container = frappe.db.get_value("Cloud Container", {"subscription": local_sub_name}, "name")
if container:
frappe.db.set_value("Cloud Container", container, "remote_subscription", remote_sub_name)
frappe.db.commit()
def _store_remote_sub_on_subscription(local_sub_name, remote_sub_name):
"""Store the remote subscription ID in a custom field on the local Subscription."""
try:
frappe.db.set_value("Subscription", local_sub_name, "custom_remote_sub_id", remote_sub_name)
except Exception:
# Custom field might not exist yet — safe to ignore, Container field is the fallback
pass
# ---------------------------------------------------------------------------
# Scheduled job
# ---------------------------------------------------------------------------
def sync_all_subscriptions():
"""
Scheduled job: push any un-synced subscriptions and pull status updates
from remote ERPNext for all already-synced subscriptions.
Covers both container-linked and standalone subscriptions.
"""
session, _, __ = _get_session()
if not session:
return
# Build a map of all local subscriptions with their remote IDs
# Source 1: Cloud Container records
containers = frappe.get_all(
"Cloud Container",
filters=[["subscription", "is", "set"]],
fields=["name", "subscription", "remote_subscription"],
)
container_by_sub = {c["subscription"]: c for c in containers}
# Source 2: All local subscriptions (catches ones not linked to a container)
all_subs = frappe.get_all(
"Subscription",
fields=["name", "custom_remote_sub_id"],
)
pushed = 0
pulled = 0
for sub in all_subs:
local_sub = sub["name"]
# Prefer remote ID from the subscription's own field; fall back to container
container = container_by_sub.get(local_sub)
remote_sub = sub.get("custom_remote_sub_id") or (container or {}).get("remote_subscription")
# Backfill custom_remote_sub_id from container if missing
if remote_sub and not sub.get("custom_remote_sub_id"):
_store_remote_sub_on_subscription(local_sub, remote_sub)
frappe.db.commit()
# Push if not yet synced
if not remote_sub:
remote_sub = push_subscription_to_remote(local_sub)
if remote_sub:
_store_remote_sub_on_subscription(local_sub, remote_sub)
if container:
frappe.db.set_value("Cloud Container", container["name"], "remote_subscription", remote_sub)
frappe.db.commit()
pushed += 1
# Pull status; if deleted remotely, clear and re-push next cycle
if remote_sub:
still_exists = pull_subscription_status(local_sub, remote_sub)
if not still_exists:
_store_remote_sub_on_subscription(local_sub, None)
if container:
frappe.db.set_value("Cloud Container", container["name"], "remote_subscription", None)
frappe.db.commit()
else:
pulled += 1
if pushed or pulled:
frappe.logger().info(
f"Subscription sync complete: pushed={pushed}, status_pulls={pulled}"
)