# 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