288 lines
14 KiB
Python
288 lines
14 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
|
# All rights reserved.
|
|
"""Accounting bridge: standardised interface between Trustee and external accounting systems.
|
|
|
|
Encapsulates: config loading -> connector resolution -> duplicate check -> push -> sync record.
|
|
"""
|
|
|
|
import logging
|
|
import time
|
|
from typing import List, Dict, Any, Optional
|
|
|
|
from .accountingConnectorBase import (
|
|
AccountingBooking,
|
|
AccountingBookingLine,
|
|
AccountingChart,
|
|
SyncResult,
|
|
)
|
|
from .accountingRegistry import _getAccountingRegistry
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class AccountingBridge:
|
|
"""Routes accounting operations through the correct connector for a feature instance."""
|
|
|
|
def __init__(self, trusteeInterface):
|
|
self._trusteeInterface = trusteeInterface
|
|
self._registry = _getAccountingRegistry()
|
|
|
|
async def getActiveConfig(self, featureInstanceId: str) -> Optional[Dict[str, Any]]:
|
|
"""Load the active TrusteeAccountingConfig for a feature instance."""
|
|
from modules.features.trustee.datamodelFeatureTrustee import TrusteeAccountingConfig
|
|
records = self._trusteeInterface.db.getRecordset(
|
|
TrusteeAccountingConfig,
|
|
recordFilter={"featureInstanceId": featureInstanceId, "isActive": True},
|
|
)
|
|
if not records:
|
|
return None
|
|
record = records[0]
|
|
return {k: v for k, v in record.items() if not k.startswith("_")}
|
|
|
|
def _decryptConfig(self, encryptedConfig: str) -> Dict[str, Any]:
|
|
"""Decrypt the stored connector config JSON."""
|
|
from modules.shared.configuration import decryptValue
|
|
import json
|
|
try:
|
|
if not encryptedConfig:
|
|
logger.error("Accounting config encryptedConfig is empty")
|
|
return {}
|
|
decrypted = decryptValue(encryptedConfig, keyName="accountingConfig")
|
|
result = json.loads(decrypted) if isinstance(decrypted, str) else decrypted
|
|
configKeys = list(result.keys()) if isinstance(result, dict) else []
|
|
logger.info(f"Decrypted accounting config successfully, keys: {configKeys}")
|
|
return result
|
|
except Exception as e:
|
|
logger.error(f"Failed to decrypt accounting config: {e}")
|
|
return {}
|
|
|
|
async def _resolveConnectorAndConfig(self, featureInstanceId: str):
|
|
"""Load config, decrypt, resolve connector. Returns (connector, plainConfig, accountingConfigRecord)."""
|
|
configRecord = await self.getActiveConfig(featureInstanceId)
|
|
if not configRecord:
|
|
logger.warning(f"No active accounting config for instance {featureInstanceId}")
|
|
return None, None, None
|
|
|
|
connectorType = configRecord.get("connectorType")
|
|
logger.info(f"Resolving connector '{connectorType}' for instance {featureInstanceId}")
|
|
connector = self._registry.getConnector(connectorType)
|
|
if not connector:
|
|
logger.error(f"Accounting connector '{connectorType}' not found in registry")
|
|
return None, None, configRecord
|
|
|
|
plainConfig = self._decryptConfig(configRecord.get("encryptedConfig", ""))
|
|
if not plainConfig:
|
|
logger.error(f"Decrypted config is empty for connector '{connectorType}'")
|
|
return connector, plainConfig, configRecord
|
|
|
|
def _buildBookingFromPosition(self, position: Dict[str, Any]) -> AccountingBooking:
|
|
"""Build a standardised AccountingBooking from a TrusteePosition record."""
|
|
lines = []
|
|
debitAccount = position.get("debitAccountNumber")
|
|
creditAccount = position.get("creditAccountNumber")
|
|
amount = abs(position.get("bookingAmount", 0))
|
|
|
|
if debitAccount:
|
|
lines.append(AccountingBookingLine(
|
|
accountNumber=debitAccount,
|
|
debitAmount=amount,
|
|
currency=position.get("bookingCurrency", "CHF"),
|
|
taxCode=position.get("taxCode"),
|
|
taxRate=position.get("vatPercentage"),
|
|
description=position.get("desc", ""),
|
|
costCenter=position.get("costCenter"),
|
|
reference=position.get("bookingReference"),
|
|
))
|
|
if creditAccount:
|
|
lines.append(AccountingBookingLine(
|
|
accountNumber=creditAccount,
|
|
creditAmount=amount,
|
|
currency=position.get("bookingCurrency", "CHF"),
|
|
description=position.get("desc", ""),
|
|
costCenter=position.get("costCenter"),
|
|
))
|
|
|
|
return AccountingBooking(
|
|
reference=position.get("bookingReference") or position.get("id", ""),
|
|
bookingDate=position.get("valuta") or "",
|
|
description=position.get("desc", ""),
|
|
lines=lines,
|
|
)
|
|
|
|
async def pushPositionToAccounting(self, featureInstanceId: str, positionId: str) -> SyncResult:
|
|
"""Push a single position to the configured accounting system.
|
|
|
|
1. Load config and connector
|
|
2. Load position data
|
|
3. Check for existing successful sync (duplicate guard)
|
|
4. Build AccountingBooking
|
|
5. Push via connector
|
|
6. Create TrusteeAccountingSync record
|
|
"""
|
|
from modules.features.trustee.datamodelFeatureTrustee import TrusteePosition, TrusteeAccountingSync
|
|
|
|
connector, plainConfig, configRecord = await self._resolveConnectorAndConfig(featureInstanceId)
|
|
if not connector or not plainConfig:
|
|
return SyncResult(success=False, errorMessage="No active accounting configuration found")
|
|
|
|
connectorType = configRecord.get("connectorType", "")
|
|
|
|
# Load position
|
|
posRecords = self._trusteeInterface.db.getRecordset(TrusteePosition, recordFilter={"id": positionId})
|
|
if not posRecords:
|
|
return SyncResult(success=False, errorMessage=f"Position {positionId} not found")
|
|
position = posRecords[0]
|
|
|
|
# Build booking once (for push; externalDocumentIds filled after document upload)
|
|
booking = self._buildBookingFromPosition(position)
|
|
|
|
# 1) First: ensure all documents are in RMA (upload or duplicate); collect Beleg-IDs for linking
|
|
documentIds = []
|
|
for key in ("documentId", "bankDocumentId"):
|
|
docId = position.get(key)
|
|
if docId:
|
|
documentIds.append(docId)
|
|
if documentIds:
|
|
from modules.features.trustee.datamodelFeatureTrustee import TrusteeDocument as TrusteeDocumentModel
|
|
logger.info("Accounting sync: positionId=%s, syncing %s document(s) to RMA ...", positionId, len(documentIds))
|
|
belegIds = []
|
|
belegLabels = []
|
|
for documentId in documentIds:
|
|
doc = self._trusteeInterface.getDocument(documentId)
|
|
if not doc:
|
|
continue
|
|
fileName = getattr(doc, "documentName", None) or "beleg.pdf"
|
|
existingBelegId = getattr(doc, "externalBelegId", None)
|
|
if existingBelegId:
|
|
logger.info("Accounting sync: document %s already has belegId=%s, skipping upload", documentId, existingBelegId)
|
|
belegIds.append(existingBelegId)
|
|
belegLabels.append(fileName)
|
|
continue
|
|
docData = self._trusteeInterface.getDocumentData(documentId)
|
|
if docData is None:
|
|
continue
|
|
mimeType = getattr(doc, "documentMimeType", None) or "application/pdf"
|
|
uploadResult = await connector.uploadDocument(
|
|
plainConfig,
|
|
fileName=fileName,
|
|
fileContent=docData,
|
|
mimeType=mimeType,
|
|
comment=booking.reference,
|
|
)
|
|
if not uploadResult.success:
|
|
errMsg = f"Dokument konnte nicht nach RMA hochgeladen werden: {uploadResult.errorMessage}"
|
|
logger.error(
|
|
"Accounting sync failed (document upload): positionId=%s, documentId=%s, error=%s",
|
|
positionId, documentId, uploadResult.errorMessage,
|
|
)
|
|
return SyncResult(success=False, errorMessage=errMsg)
|
|
belegId = uploadResult.externalId
|
|
if belegId:
|
|
self._trusteeInterface.db.recordModify(TrusteeDocumentModel, documentId, {"externalBelegId": belegId})
|
|
logger.info("Accounting sync: document uploaded & belegId=%s stored on document %s", belegId, documentId)
|
|
else:
|
|
logger.info("Accounting sync: document uploaded but no belegId in response (409 duplicate?), fileName=%s", fileName)
|
|
belegIds.append(belegId)
|
|
belegLabels.append(fileName)
|
|
if belegIds or belegLabels:
|
|
booking.externalDocumentIds = belegIds
|
|
booking.externalDocumentLabels = belegLabels
|
|
logger.info("Accounting sync: positionId=%s, document sync done, pushing GL booking (POST /gl) ...", positionId)
|
|
|
|
# Duplicate check: if locally marked as synced, verify with Buha system
|
|
accountingSyncId = position.get("accountingSyncId")
|
|
existingSyncs = self._trusteeInterface.db.getRecordset(
|
|
TrusteeAccountingSync,
|
|
recordFilter={"positionId": positionId, "connectorType": connectorType, "syncStatus": "synced"},
|
|
)
|
|
if accountingSyncId or existingSyncs:
|
|
checkResult = await connector.isBookingSynced(plainConfig, booking)
|
|
if checkResult.success:
|
|
logger.info(
|
|
"Accounting sync skipped (verified in Buha): positionId=%s, reference=%s",
|
|
positionId, booking.reference,
|
|
)
|
|
return SyncResult(success=False, errorMessage="Position already synced to this system")
|
|
# Not found in Buha (e.g. deleted there): clear local records and re-push
|
|
logger.info(
|
|
"Accounting sync: reference %s not found in Buha (deleted?), clearing local records and re-pushing positionId=%s",
|
|
booking.reference, positionId,
|
|
)
|
|
if accountingSyncId:
|
|
self._trusteeInterface.db.recordModify(TrusteePosition, positionId, {"accountingSyncId": None})
|
|
for rec in existingSyncs:
|
|
rid = rec.get("id")
|
|
if rid:
|
|
self._trusteeInterface.db.recordDelete(TrusteeAccountingSync, rid)
|
|
|
|
# 2) Then: push booking (with reference to document IDs so RMA can link)
|
|
if not documentIds:
|
|
logger.info("Accounting sync: positionId=%s, no documents, pushing GL booking (POST /gl) ...", positionId)
|
|
result = await connector.pushBooking(plainConfig, booking)
|
|
if not result.success:
|
|
logger.error(
|
|
"Accounting sync failed: positionId=%s, error=%s",
|
|
positionId,
|
|
result.errorMessage or "unknown",
|
|
)
|
|
|
|
# Save sync record
|
|
import uuid
|
|
syncRecord = {
|
|
"id": str(uuid.uuid4()),
|
|
"positionId": positionId,
|
|
"featureInstanceId": featureInstanceId,
|
|
"connectorType": connectorType,
|
|
"externalId": result.externalId,
|
|
"externalReference": result.externalReference,
|
|
"syncStatus": "synced" if result.success else "error",
|
|
"syncDirection": "push",
|
|
"syncedAt": time.time() if result.success else None,
|
|
"errorMessage": result.errorMessage,
|
|
"bookingPayload": booking.model_dump(),
|
|
"mandateId": self._trusteeInterface.mandateId,
|
|
}
|
|
self._trusteeInterface.db.recordCreate(TrusteeAccountingSync, syncRecord)
|
|
|
|
# Write back external ID to position (source of truth for sync check)
|
|
if result.success and result.externalId:
|
|
self._trusteeInterface.db.recordModify(
|
|
TrusteePosition, positionId, {"accountingSyncId": result.externalId}
|
|
)
|
|
|
|
# Update last sync on config record
|
|
if configRecord:
|
|
from modules.features.trustee.datamodelFeatureTrustee import TrusteeAccountingConfig
|
|
updatePayload = {
|
|
"lastSyncAt": time.time(),
|
|
"lastSyncStatus": "success" if result.success else "error",
|
|
}
|
|
if result.success:
|
|
updatePayload["lastSyncErrorMessage"] = None
|
|
else:
|
|
updatePayload["lastSyncErrorMessage"] = result.errorMessage or "Sync failed"
|
|
self._trusteeInterface.db.recordModify(TrusteeAccountingConfig, configRecord["id"], updatePayload)
|
|
|
|
return result
|
|
|
|
async def pushBatchToAccounting(self, featureInstanceId: str, positionIds: List[str]) -> List[SyncResult]:
|
|
"""Push multiple positions sequentially."""
|
|
results = []
|
|
for positionId in positionIds:
|
|
result = await self.pushPositionToAccounting(featureInstanceId, positionId)
|
|
results.append(result)
|
|
return results
|
|
|
|
async def getChartOfAccounts(self, featureInstanceId: str) -> List[AccountingChart]:
|
|
"""Load the chart of accounts from the configured external system."""
|
|
connector, plainConfig, _ = await self._resolveConnectorAndConfig(featureInstanceId)
|
|
if not connector or not plainConfig:
|
|
return []
|
|
return await connector.getChartOfAccounts(plainConfig)
|
|
|
|
async def testConnection(self, featureInstanceId: str) -> SyncResult:
|
|
"""Test the connection with the configured accounting system."""
|
|
connector, plainConfig, _ = await self._resolveConnectorAndConfig(featureInstanceId)
|
|
if not connector or not plainConfig:
|
|
return SyncResult(success=False, errorMessage="No active accounting configuration found")
|
|
return await connector.testConnection(plainConfig)
|