feat(billing): 2d dual-run reconciliation (Odoo-computed vs NexaCloud-actual)
fusion.billing.reconciliation gains the compute: _compute_reconciliation (flat + charge overage vs external, status match/delta at a tolerance) and _reconcile_rows (resolve shadow sub -> flat + charge, upsert one row per service/partner/period, per-row isolated). The wizard gains a read-only _read_reconciliation_rows (NexaCloud usage cpu_hours*3600 + invoice-item subtotals per YYYY-MM) and a "Run Reconciliation" button. 2a amended to stamp x_fc_nexacloud_plan_id on shadow subs so reconciliation can find the charge. Read-only on NexaCloud; writes only reconciliation rows (shadow guarantees intact). 8 new tests, full suite green on odoo-trial.
This commit is contained in:
@@ -76,6 +76,26 @@ class FusionBillingImportWizard(models.TransientModel):
|
||||
"type": "success", "sticky": False},
|
||||
}
|
||||
|
||||
def action_run_reconciliation(self):
|
||||
"""Read NexaCloud usage + invoice actuals and record per-subscription/period
|
||||
Odoo-vs-NexaCloud deltas in fusion.billing.reconciliation. Read-only on
|
||||
NexaCloud; writes only reconciliation rows (shadow-safe)."""
|
||||
self.ensure_one()
|
||||
rows = self._read_reconciliation_rows()
|
||||
summary = self.env["fusion.billing.reconciliation"]._reconcile_rows(rows)
|
||||
self.result_summary = json.dumps(summary, indent=2, default=str)
|
||||
self.failed_count = len(summary.get("failed") or [])
|
||||
self.skipped_count = len(summary.get("skipped") or [])
|
||||
if summary.get("delta") or summary.get("failed"):
|
||||
_logger.error(
|
||||
"NexaCloud reconciliation: %s delta, %s failed, %s skipped row(s): %s",
|
||||
summary.get("delta"), len(summary.get("failed") or []),
|
||||
len(summary.get("skipped") or []), summary)
|
||||
return {
|
||||
"type": "ir.actions.act_window", "res_model": self._name,
|
||||
"res_id": self.id, "view_mode": "form", "target": "new",
|
||||
}
|
||||
|
||||
# ----- read side (the ONLY code that touches NexaCloud) ------------------
|
||||
def _read_nexacloud_rows(self):
|
||||
"""Open a READ-ONLY psycopg2 connection to the nexacloud Postgres (DSN in
|
||||
@@ -122,6 +142,55 @@ class FusionBillingImportWizard(models.TransientModel):
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _read_reconciliation_rows(self):
|
||||
"""Read-only: per (subscription, YYYY-MM period), NexaCloud's CPU usage
|
||||
(cpu_hours*3600 = cpu_seconds) and its actual pre-tax invoice amount. Shaped for
|
||||
fusion.billing.reconciliation._reconcile_rows. Reuses the 2a DSN + guards.
|
||||
(Integration glue — validate the SQL against the live schema, like the importer
|
||||
reader; the reconciliation math itself is unit-tested.)"""
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
dsn = self.env["ir.config_parameter"].sudo().get_param(
|
||||
"fusion_billing.nexacloud_dsn")
|
||||
if not dsn:
|
||||
raise UserError("NexaCloud DSN not configured (fusion_billing.nexacloud_dsn).")
|
||||
try:
|
||||
conn = psycopg2.connect(dsn)
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise UserError("Could not connect to the NexaCloud database: %s" % e)
|
||||
try:
|
||||
conn.set_session(readonly=True)
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(
|
||||
"SELECT subscription_id::text AS sub, "
|
||||
"to_char(period_start, 'YYYY-MM') AS period, "
|
||||
"COALESCE(SUM(cpu_hours), 0) * 3600.0 AS cpu_seconds "
|
||||
"FROM usage_records "
|
||||
"GROUP BY subscription_id, to_char(period_start, 'YYYY-MM')")
|
||||
usage = {(r["sub"], r["period"]): float(r["cpu_seconds"] or 0.0)
|
||||
for r in cur.fetchall()}
|
||||
cur.execute(
|
||||
"SELECT i.subscription_id::text AS sub, "
|
||||
"to_char(ii.period_start, 'YYYY-MM') AS period, "
|
||||
"COALESCE(SUM(ii.amount), 0) AS external_amount "
|
||||
"FROM invoices i JOIN invoice_items ii ON ii.invoice_id = i.id "
|
||||
"GROUP BY i.subscription_id, to_char(ii.period_start, 'YYYY-MM')")
|
||||
rows = []
|
||||
for r in cur.fetchall():
|
||||
key = (r["sub"], r["period"])
|
||||
rows.append({
|
||||
"subscription_external_id": r["sub"], "period": r["period"],
|
||||
"cpu_seconds": usage.get(key, 0.0),
|
||||
"external_amount": float(r["external_amount"] or 0.0)})
|
||||
return rows
|
||||
except psycopg2.Error as e:
|
||||
raise UserError(
|
||||
"Failed reading NexaCloud actuals — the source schema may have changed. "
|
||||
"Underlying error:\n%s" % e)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# ----- import side (pure Odoo; unit-tested) ------------------------------
|
||||
@api.model
|
||||
def _import_rows(self, data, dry_run=False):
|
||||
@@ -346,6 +415,7 @@ class FusionBillingImportWizard(models.TransientModel):
|
||||
# order that may since have been confirmed.
|
||||
shadow_vals = {
|
||||
"x_fc_nexacloud_deployment_id": str(srow.get("deployment_id") or ""),
|
||||
"x_fc_nexacloud_plan_id": str(srow.get("plan_id") or ""),
|
||||
"x_fc_billing_service_id": service.id, "x_fc_shadow": True,
|
||||
}
|
||||
existing = SaleOrder.search(
|
||||
|
||||
Reference in New Issue
Block a user