gateway/modules/workflows/methods/methodTrustee/actions/refreshAccountingData.py
2026-04-26 18:11:42 +02:00

250 lines
9.7 KiB
Python

# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Refresh accounting data from external system (e.g. Abacus) into local TrusteeData* tables.
Checks lastSyncAt to avoid redundant imports unless forceRefresh is set.
"""
import json
import logging
import time
from datetime import datetime as _dt, timezone as _tz
from typing import Dict, Any, Optional
from modules.datamodels.datamodelChat import ActionResult
logger = logging.getLogger(__name__)
def _isoToTs(isoDate: Optional[str]) -> Optional[float]:
"""``YYYY-MM-DD`` → UTC midnight unix timestamp (or None)."""
if not isoDate:
return None
try:
return _dt.strptime(isoDate.strip()[:10], "%Y-%m-%d").replace(tzinfo=_tz.utc).timestamp()
except (ValueError, AttributeError):
return None
def _tsToIso(ts) -> Optional[str]:
"""Unix timestamp → ``YYYY-MM-DD`` (or None)."""
if ts is None:
return None
try:
return _dt.fromtimestamp(float(ts), tz=_tz.utc).strftime("%Y-%m-%d")
except (ValueError, TypeError, OSError):
return None
_SYNC_THRESHOLD_SECONDS = 3600
async def refreshAccountingData(self, parameters: Dict[str, Any]) -> ActionResult:
"""Import/refresh accounting data from the configured external system.
If data was synced within the last hour and forceRefresh is not set,
returns cached counts without triggering an external sync.
"""
featureInstanceId = parameters.get("featureInstanceId") or (
self.services.featureInstanceId if hasattr(self.services, "featureInstanceId") else None
)
forceRefresh = parameters.get("forceRefresh", False)
if isinstance(forceRefresh, str):
forceRefresh = forceRefresh.lower() in ("true", "1", "yes")
dateFrom = parameters.get("dateFrom") or None
dateTo = parameters.get("dateTo") or None
if not featureInstanceId:
return ActionResult.isFailure(error="featureInstanceId is required")
try:
from modules.features.trustee.interfaceFeatureTrustee import getInterface as getTrusteeInterface
from modules.features.trustee.datamodelFeatureTrustee import (
TrusteeAccountingConfig,
TrusteeDataAccount,
TrusteeDataJournalEntry,
TrusteeDataJournalLine,
TrusteeDataContact,
TrusteeDataAccountBalance,
)
trusteeInterface = getTrusteeInterface(
self.services.user,
mandateId=self.services.mandateId,
featureInstanceId=featureInstanceId,
)
cfgRecords = trusteeInterface.db.getRecordset(
TrusteeAccountingConfig,
recordFilter={"featureInstanceId": featureInstanceId, "isActive": True},
)
if not cfgRecords:
return ActionResult.isFailure(error="No active accounting configuration found for this Trustee instance")
cfgRecord = cfgRecords[0]
lastSyncAt = cfgRecord.get("lastSyncAt") or 0
lastSyncStatus = cfgRecord.get("lastSyncStatus") or ""
isFresh = (
lastSyncAt
and (time.time() - lastSyncAt) < _SYNC_THRESHOLD_SECONDS
and lastSyncStatus in ("success", "partial")
)
if isFresh and not forceRefresh:
counts = _getCachedCounts(trusteeInterface, featureInstanceId)
counts["synced"] = False
counts["lastSyncAt"] = lastSyncAt
counts["lastSyncStatus"] = lastSyncStatus
counts["message"] = f"Data is fresh (synced {int(time.time() - lastSyncAt)}s ago). Use forceRefresh=true to re-import."
dataExport = _exportAccountingData(trusteeInterface, featureInstanceId, dateFrom, dateTo)
return ActionResult.isSuccess(data={
"summary": counts,
"accountingData": dataExport,
})
from modules.features.trustee.accounting.accountingDataSync import AccountingDataSync
sync = AccountingDataSync(trusteeInterface)
summary = await sync.importData(
featureInstanceId=featureInstanceId,
mandateId=self.services.mandateId,
dateFrom=dateFrom,
dateTo=dateTo,
)
summary["synced"] = True
summary.pop("startedAt", None)
summary.pop("finishedAt", None)
try:
from modules.serviceCenter.services.serviceAgent.coreTools._featureSubAgentTools import clearFeatureQueryCache
clearFeatureQueryCache(featureInstanceId)
logger.info("Cleared feature query cache for instance %s after accounting import", featureInstanceId)
except Exception as cacheErr:
logger.warning("Could not clear feature query cache: %s", cacheErr)
dataExport = _exportAccountingData(trusteeInterface, featureInstanceId, dateFrom, dateTo)
return ActionResult.isSuccess(data={
"summary": summary,
"accountingData": dataExport,
})
except Exception as e:
logger.exception("refreshAccountingData failed")
return ActionResult.isFailure(error=str(e))
def _exportAccountingData(trusteeInterface, featureInstanceId: str, dateFrom: str = None, dateTo: str = None) -> str:
"""Export accounting data (accounts, balances, journal entries+lines) as compact JSON for downstream AI nodes."""
from modules.features.trustee.datamodelFeatureTrustee import (
TrusteeDataAccount,
TrusteeDataJournalEntry,
TrusteeDataJournalLine,
TrusteeDataAccountBalance,
)
try:
baseFilter = {"featureInstanceId": featureInstanceId}
accounts = trusteeInterface.db.getRecordset(TrusteeDataAccount, recordFilter=baseFilter) or []
accountMap = {}
for a in accounts:
nr = a.get("accountNumber", "")
accountMap[nr] = {
"nr": nr,
"label": a.get("label", ""),
"type": a.get("accountType", ""),
"group": a.get("accountGroup", ""),
}
balances = trusteeInterface.db.getRecordset(TrusteeDataAccountBalance, recordFilter=baseFilter) or []
balanceList = []
for b in balances:
balanceList.append({
"account": b.get("accountNumber", ""),
"year": b.get("periodYear", 0),
"month": b.get("periodMonth", 0),
"opening": b.get("openingBalance", 0),
"debit": b.get("debitTotal", 0),
"credit": b.get("creditTotal", 0),
"closing": b.get("closingBalance", 0),
})
entries = trusteeInterface.db.getRecordset(TrusteeDataJournalEntry, recordFilter=baseFilter) or []
fromTs = _isoToTs(dateFrom)
toTs = _isoToTs(dateTo)
entryMap = {}
for e in entries:
eid = e.get("id", "")
bDate = e.get("bookingDate")
if fromTs is not None and bDate is not None and float(bDate) < fromTs:
continue
if toTs is not None and bDate is not None and float(bDate) > toTs + 86399:
continue
entryMap[eid] = {
"date": _tsToIso(bDate),
"ref": e.get("reference", ""),
"desc": e.get("description", ""),
"amount": e.get("totalAmount", 0),
}
lines = trusteeInterface.db.getRecordset(TrusteeDataJournalLine, recordFilter=baseFilter) or []
lineList = []
for ln in lines:
jeId = ln.get("journalEntryId", "")
if jeId not in entryMap:
continue
entry = entryMap[jeId]
lineList.append({
"date": entry["date"],
"ref": entry["ref"],
"account": ln.get("accountNumber", ""),
"accountLabel": accountMap.get(ln.get("accountNumber", ""), {}).get("label", ""),
"debit": ln.get("debitAmount", 0),
"credit": ln.get("creditAmount", 0),
"desc": ln.get("description", "") or entry["desc"],
"taxCode": ln.get("taxCode", ""),
"costCenter": ln.get("costCenter", ""),
})
export = {
"accounts": list(accountMap.values()),
"balances": balanceList,
"journalLines": lineList,
"meta": {
"accountCount": len(accountMap),
"entryCount": len(entryMap),
"lineCount": len(lineList),
"balanceCount": len(balanceList),
"dateFrom": dateFrom,
"dateTo": dateTo,
},
}
result = json.dumps(export, ensure_ascii=False, default=str)
logger.info("Exported accounting data: %d accounts, %d entries, %d lines, %d balances (%d bytes)",
len(accountMap), len(entryMap), len(lineList), len(balanceList), len(result))
return result
except Exception as e:
logger.warning("Could not export accounting data: %s", e)
return ""
def _getCachedCounts(trusteeInterface, featureInstanceId: str) -> Dict[str, Any]:
"""Count existing records per TrusteeData* table without triggering an external sync."""
from modules.features.trustee.datamodelFeatureTrustee import (
TrusteeDataAccount,
TrusteeDataJournalEntry,
TrusteeDataJournalLine,
TrusteeDataContact,
TrusteeDataAccountBalance,
)
counts = {}
for label, model in [
("accounts", TrusteeDataAccount),
("journalEntries", TrusteeDataJournalEntry),
("journalLines", TrusteeDataJournalLine),
("contacts", TrusteeDataContact),
("accountBalances", TrusteeDataAccountBalance),
]:
records = trusteeInterface.db.getRecordset(
model, recordFilter={"featureInstanceId": featureInstanceId}
)
counts[label] = len(records) if records else 0
return counts