370 lines
13 KiB
Python
370 lines
13 KiB
Python
"""
|
||
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}"
|
||
)
|