fix critical trustee db sync

This commit is contained in:
ValueOn AG 2026-04-21 10:45:14 +02:00
parent 5ef311a82e
commit be43876461
8 changed files with 789 additions and 175 deletions

View file

@ -82,6 +82,8 @@ TEAMSBOT_BROWSER_BOT_URL = https://cae-poweron-shared.redwater-53d21339.switzerl
# Debug Configuration
APP_DEBUG_CHAT_WORKFLOW_ENABLED = True
APP_DEBUG_CHAT_WORKFLOW_DIR = D:/Athi/Local/Web/poweron/local/debug
APP_DEBUG_ACCOUNTING_SYNC_ENABLED = True
APP_DEBUG_ACCOUNTING_SYNC_DIR = D:/Athi/Local/Web/poweron/local/debug/sync
# Manadate Pre-Processing Servers
PREPROCESS_ALTHAUS_CHAT_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGbEphQ3ZUMlFMQ2EwSGpoSE9NNzRJNTJtaGk1N0RGakdIYnVVeVFHZmF5OXB3QTVWLVNaZk9wNkhfQkZWRnVwRGRxem9iRzJIWXdpX1NIN2FwSExfT3c9PQ==

View file

@ -80,6 +80,8 @@ TEAMSBOT_BROWSER_BOT_URL = https://cae-poweron-shared.redwater-53d21339.switzerl
# Debug Configuration
APP_DEBUG_CHAT_WORKFLOW_ENABLED = FALSE
APP_DEBUG_CHAT_WORKFLOW_DIR = ./test-chat
APP_DEBUG_ACCOUNTING_SYNC_ENABLED = FALSE
APP_DEBUG_ACCOUNTING_SYNC_DIR = ./debug/sync
# Manadate Pre-Processing Servers
PREPROCESS_ALTHAUS_CHAT_SECRET = INT_ENC:Z0FBQUFBQnBaSnM4UkNBelhvckxCQUVjZm94N3BZUDcxaEMyckE2dm1lRVhqODhrWU1SUjNXZ3dQZlVJOWhveXFkZXpobW5xT0NneGZ2SkNUblFmYXd0WTBYNTl3UmRnSWc9PQ==

View file

@ -81,6 +81,8 @@ TEAMSBOT_BROWSER_BOT_URL = https://cae-poweron-shared.redwater-53d21339.switzerl
# Debug Configuration
APP_DEBUG_CHAT_WORKFLOW_ENABLED = FALSE
APP_DEBUG_CHAT_WORKFLOW_DIR = ./test-chat
APP_DEBUG_ACCOUNTING_SYNC_ENABLED = FALSE
APP_DEBUG_ACCOUNTING_SYNC_DIR = ./debug/sync
# Manadate Pre-Processing Servers
PREPROCESS_ALTHAUS_CHAT_SECRET = PROD_ENC:Z0FBQUFBQnBaSnM4RVRmYW5IelNIbklTUDZIMEoycEN4ZFF0YUJoWWlUTUh2M0dhSXpYRXcwVkRGd1VieDNsYkdCRlpxMUR5Rjk1RDhPRkE5bmVtc2VDMURfLW9QNkxMVHN0M1JhbU9sa3JHWmdDZnlHS3BQRVBGTERVMHhXOVdDOWVqNkhfSUQyOHo=

View file

@ -1396,6 +1396,192 @@ class DatabaseConnector:
self.connection.rollback()
return False
def recordCreateBulk(
self, model_class: type, records: List[Union[Dict[str, Any], BaseModel]]
) -> int:
"""Bulk-insert many records in a single transaction.
Use this instead of calling recordCreate() in a tight loop when importing
large datasets (>100 rows). Performance gain is roughly two orders of
magnitude because:
- one network round-trip via execute_values() instead of N
- one COMMIT instead of N
- initial ID is registered once for the whole batch instead of every row
Returns the number of rows successfully inserted. Caller is responsible
for catching exceptions; on any error the transaction is rolled back so
the table stays consistent (all-or-nothing).
"""
if not records:
return 0
table = model_class.__name__
if not self._ensureTableExists(model_class):
raise ValueError(f"Table {table} does not exist")
fields = _get_model_fields(model_class)
columns = ["id"] + [f for f in fields.keys() if f != "id"]
modelFields = model_class.model_fields
effectiveUserId = _current_user_id.get()
if effectiveUserId is None:
effectiveUserId = self.userId
currentTime = getUtcTimestamp()
normalised: List[Dict[str, Any]] = []
for raw in records:
if isinstance(raw, BaseModel):
rec = raw.model_dump()
elif isinstance(raw, dict):
rec = raw.copy()
else:
raise ValueError("Bulk record must be a Pydantic model or dictionary")
if "id" not in rec or not rec["id"]:
rec["id"] = str(uuid.uuid4())
createdTs = rec.get("sysCreatedAt")
if createdTs is None or createdTs == 0 or createdTs == 0.0:
rec["sysCreatedAt"] = currentTime
if effectiveUserId:
rec["sysCreatedBy"] = effectiveUserId
elif not rec.get("sysCreatedBy") and effectiveUserId:
rec["sysCreatedBy"] = effectiveUserId
rec["sysModifiedAt"] = currentTime
if effectiveUserId:
rec["sysModifiedBy"] = effectiveUserId
normalised.append(rec)
rows = [self._coerceRowForInsert(rec, columns, fields, modelFields) for rec in normalised]
col_names = ", ".join([f'"{c}"' for c in columns])
updates = ", ".join(
[f'"{c}" = EXCLUDED."{c}"' for c in columns[1:]
if c not in ("sysCreatedAt", "sysCreatedBy")]
)
sql = (
f'INSERT INTO "{table}" ({col_names}) VALUES %s '
f'ON CONFLICT ("id") DO UPDATE SET {updates}'
)
try:
self._ensure_connection()
with self.connection.cursor() as cursor:
psycopg2.extras.execute_values(cursor, sql, rows, page_size=500)
self.connection.commit()
except Exception as e:
logger.error(f"Bulk insert into {table} failed (n={len(rows)}): {e}")
try:
self.connection.rollback()
except Exception:
pass
raise
if self.getInitialId(model_class) is None and normalised:
self._registerInitialId(table, normalised[0]["id"])
logger.info(f"Registered initial ID {normalised[0]['id']} for table {table}")
return len(rows)
def _coerceRowForInsert(
self,
record: Dict[str, Any],
columns: List[str],
fields: Dict[str, str],
modelFields: Dict[str, Any],
) -> tuple:
"""Convert one record dict to a positional tuple matching `columns`.
Mirrors the per-column coercion logic in `_save_record` so that bulk and
single inserts produce identical on-disk values (timestamps as floats,
enums as strings, vectors as pgvector text, JSONB as JSON strings).
"""
import json as _json
out = []
for col in columns:
value = record.get(col)
if col in ("sysCreatedAt", "sysModifiedAt") and value is not None:
if isinstance(value, str):
try:
value = float(value)
except Exception:
pass
elif hasattr(value, "value"):
value = value.value
elif col in fields and _isVectorType(fields[col]) and value is not None:
if isinstance(value, list):
value = f"[{','.join(str(v) for v in value)}]"
elif col in fields and fields[col] == "JSONB" and value is not None:
if isinstance(value, (dict, list)):
value = _json.dumps(value)
elif isinstance(value, str):
try:
_json.loads(value)
except (ValueError, TypeError):
value = _json.dumps(value)
elif hasattr(value, "model_dump"):
value = _json.dumps(value.model_dump())
else:
value = _json.dumps(value)
out.append(value)
return tuple(out)
def recordDeleteWhere(
self, model_class: type, recordFilter: Dict[str, Any]
) -> int:
"""Delete all records matching a simple equality filter, in one statement.
Replaces the N+1 pattern `for r in getRecordset(...): recordDelete(r.id)`.
Returns the number of rows actually deleted. If the table holds the
initial ID and that row gets deleted, the initial ID registration is
cleared so the next insert can re-register a fresh one.
"""
if not recordFilter:
raise ValueError("recordDeleteWhere requires a non-empty recordFilter (refusing to truncate)")
table = model_class.__name__
if not self._ensureTableExists(model_class):
return 0
fields = _get_model_fields(model_class)
clauses: List[str] = []
params: List[Any] = []
for key, val in recordFilter.items():
if key not in fields and key != "id":
raise ValueError(f"recordDeleteWhere: unknown column {table}.{key}")
clauses.append(f'"{key}" = %s')
params.append(val)
whereSql = " AND ".join(clauses)
initialId = self.getInitialId(model_class)
try:
self._ensure_connection()
with self.connection.cursor() as cursor:
if initialId is not None:
cursor.execute(
f'SELECT 1 FROM "{table}" WHERE "id" = %s AND ' + whereSql,
[initialId, *params],
)
initialIsAffected = cursor.fetchone() is not None
else:
initialIsAffected = False
cursor.execute(f'DELETE FROM "{table}" WHERE ' + whereSql, params)
deleted = cursor.rowcount or 0
self.connection.commit()
except Exception as e:
logger.error(f"Bulk delete from {table} failed (filter={recordFilter}): {e}")
try:
self.connection.rollback()
except Exception:
pass
raise
if deleted and initialIsAffected:
self._removeInitialId(table)
logger.info(f"Initial ID for table {table} cleared (bulk-delete removed it)")
if deleted:
logger.info(f"recordDeleteWhere: deleted {deleted} rows from {table} where {recordFilter}")
return deleted
def getInitialId(self, model_class: type) -> Optional[str]:
"""Returns the initial ID for a table."""
table = model_class.__name__

View file

@ -2,16 +2,27 @@
# All rights reserved.
"""Orchestrates importing accounting data from external systems into TrusteeData* tables.
Flow: load config resolve connector fetch data clear old records write new records compute balances.
Flow per phase:
1. async fetch from external system (HTTP, awaits cleanly on the event loop)
2. await asyncio.to_thread(...) for the DB write phase, so the heavy
synchronous psycopg2 calls do NOT block the FastAPI event loop
3. inside the worker thread we use bulk delete / bulk insert (single
transaction per phase) instead of N+1 single-row operations
Why this matters: a typical accounting sync is ~10k-100k rows. The old
implementation called ``recordCreate`` row-by-row on the event loop, which
froze every other request (chat, health-check, etc.) for minutes. See
``local/notes/changelog.txt`` for the original incident analysis.
"""
import asyncio
import json as _json
import logging
import os
import time
from collections import defaultdict
from pathlib import Path
from typing import Dict, Any, List, Optional
from typing import Callable, Dict, Any, List, Optional, Type
from .accountingConnectorBase import BaseAccountingConnector
from .accountingRegistry import _getAccountingRegistry
@ -19,30 +30,57 @@ from .accountingRegistry import _getAccountingRegistry
logger = logging.getLogger(__name__)
_DEBUG_SYNC_DIR = Path("D:/Athi/Local/Web/poweron/local/debug/sync")
_HEARTBEAT_EVERY = 500
def _debugSyncDir() -> Path:
_DEBUG_SYNC_DIR.mkdir(parents=True, exist_ok=True)
return _DEBUG_SYNC_DIR
def _isDebugDumpEnabled() -> bool:
"""Whether to write raw connector payloads to disk for offline inspection.
def _isDebugEnabled() -> bool:
Controlled by ``APP_DEBUG_ACCOUNTING_SYNC_ENABLED``. Default False so that
INT/PROD never spend disk/IO/RAM on dumping 7-figure JSON files. Mirrors
the existing ``APP_DEBUG_CHAT_WORKFLOW_ENABLED`` pattern.
"""
try:
from modules.shared.configuration import APP_CONFIG
return APP_CONFIG.get("APP_LOGGING_FILE_ENABLED", False) is True or str(APP_CONFIG.get("APP_LOGGING_FILE_ENABLED", "")).lower() == "true"
raw = APP_CONFIG.get("APP_DEBUG_ACCOUNTING_SYNC_ENABLED", False)
if isinstance(raw, bool):
return raw
return str(raw).strip().lower() in {"true", "1", "yes", "on"}
except Exception:
return False
def _dumpSyncData(tag: str, rows: list):
"""Write raw connector data to a timestamped JSON file in local/debug/sync/."""
if not _isDebugEnabled():
def _resolveDebugDumpDir() -> Optional[Path]:
"""Resolve the debug dump directory. Returns None if dumping is disabled
or the path could not be created."""
if not _isDebugDumpEnabled():
return None
try:
from modules.shared.configuration import APP_CONFIG
configured = APP_CONFIG.get("APP_DEBUG_ACCOUNTING_SYNC_DIR", None)
if not configured:
return None
path = Path(str(configured))
if not path.is_absolute():
gatewayDir = Path(__file__).resolve().parents[4]
path = gatewayDir / configured
path.mkdir(parents=True, exist_ok=True)
return path
except Exception as ex:
logger.warning(f"Could not resolve debug dump dir: {ex}")
return None
def _dumpSyncData(tag: str, rows: list) -> None:
"""Write raw connector data to a timestamped JSON file. No-op unless
``APP_DEBUG_ACCOUNTING_SYNC_ENABLED`` is true AND the configured dir
resolves."""
dumpDir = _resolveDebugDumpDir()
if dumpDir is None:
return
try:
d = _debugSyncDir()
ts = time.strftime("%Y%m%d-%H%M%S")
path = d / f"{ts}_{tag}.json"
path = dumpDir / f"{ts}_{tag}.json"
serializable = []
for r in rows:
if isinstance(r, dict):
@ -71,11 +109,25 @@ class AccountingDataSync:
mandateId: str,
dateFrom: Optional[str] = None,
dateTo: Optional[str] = None,
progressCb: Optional[Callable[[int, Optional[str]], None]] = None,
) -> Dict[str, Any]:
"""Run a full data import for a feature instance.
Returns a summary dict with counts per entity and any errors.
Returns a summary dict with counts per entity and any errors. All heavy
DB work is offloaded to a worker thread via ``asyncio.to_thread`` so
the event loop remains responsive for other requests.
``progressCb(percent, message)`` -- if provided, called at every phase
boundary so the UI poll on ``GET /api/jobs/{jobId}`` shows real
movement instead of jumping from 10 % to 100 %. Safe to omit.
"""
def _progress(pct: int, msg: str) -> None:
if progressCb is None:
return
try:
progressCb(pct, msg)
except Exception as ex:
logger.warning(f"progressCb failed at {pct}%: {ex}")
from modules.features.trustee.datamodelFeatureTrustee import (
TrusteeAccountingConfig,
TrusteeDataAccount,
@ -109,9 +161,8 @@ class AccountingDataSync:
encryptedConfig = cfgRecord.get("encryptedConfig", "")
try:
import json
plainJson = decryptValue(encryptedConfig)
connConfig = json.loads(plainJson) if plainJson else {}
connConfig = _json.loads(plainJson) if plainJson else {}
except Exception as e:
summary["errors"].append(f"Failed to decrypt config: {e}")
return summary
@ -122,97 +173,87 @@ class AccountingDataSync:
return summary
scope = {"featureInstanceId": featureInstanceId, "mandateId": mandateId}
logger.info(f"AccountingDataSync starting for {featureInstanceId}, connector={connectorType}, dateFrom={dateFrom}, dateTo={dateTo}")
logger.info(
f"AccountingDataSync starting for {featureInstanceId}, "
f"connector={connectorType}, dateFrom={dateFrom}, dateTo={dateTo}"
)
fetchedAccountNumbers: list = []
# 1) Chart of accounts
# ---- Phase 1: Chart of accounts ----
# Progress budget: 15-30 %. The fetch (15 %) is usually a single
# snappy API call; the persist step (30 %) is bulk-insert and finishes
# in <100 ms even for thousands of rows.
try:
_progress(15, "Lade Kontenplan...")
charts = await connector.getChartOfAccounts(connConfig)
_dumpSyncData("accounts", charts)
fetchedAccountNumbers = [acc.accountNumber for acc in charts if acc.accountNumber]
self._clearTable(TrusteeDataAccount, featureInstanceId)
for acc in charts:
self._if.db.recordCreate(TrusteeDataAccount, {
"accountNumber": acc.accountNumber,
"label": acc.label,
"accountType": acc.accountType or "",
"currency": "CHF",
"isActive": True,
**scope,
})
summary["accounts"] = len(charts)
_progress(25, f"Speichere {len(charts)} Konten...")
written = await asyncio.to_thread(
self._persistAccounts, charts, scope, featureInstanceId, TrusteeDataAccount
)
summary["accounts"] = written
_progress(30, f"{written} Konten gespeichert.")
except Exception as e:
logger.error(f"Import accounts failed: {e}", exc_info=True)
summary["errors"].append(f"Accounts: {e}")
# 2) Journal entries + lines (pass already-fetched chart to avoid redundant API call)
# ---- Phase 2: Journal entries + lines ----
# Progress budget: 35-65 %. Usually the longest phase; the external
# API often paginates per account, so the fetch alone can take 30+ s.
try:
rawEntries = await connector.getJournalEntries(connConfig, dateFrom=dateFrom, dateTo=dateTo, accountNumbers=fetchedAccountNumbers or None)
_progress(35, "Lade Journaleintraege vom Buchhaltungssystem...")
rawEntries = await connector.getJournalEntries(
connConfig,
dateFrom=dateFrom,
dateTo=dateTo,
accountNumbers=fetchedAccountNumbers or None,
)
_dumpSyncData("journalEntries", rawEntries)
self._clearTable(TrusteeDataJournalEntry, featureInstanceId)
self._clearTable(TrusteeDataJournalLine, featureInstanceId)
lineCount = 0
for raw in rawEntries:
import uuid
entryId = str(uuid.uuid4())
self._if.db.recordCreate(TrusteeDataJournalEntry, {
"id": entryId,
"externalId": raw.get("externalId"),
"bookingDate": raw.get("bookingDate"),
"reference": raw.get("reference"),
"description": raw.get("description", ""),
"currency": raw.get("currency", "CHF"),
"totalAmount": float(raw.get("totalAmount", 0)),
**scope,
})
for line in (raw.get("lines") or []):
self._if.db.recordCreate(TrusteeDataJournalLine, {
"journalEntryId": entryId,
"accountNumber": line.get("accountNumber", ""),
"debitAmount": float(line.get("debitAmount", 0)),
"creditAmount": float(line.get("creditAmount", 0)),
"currency": line.get("currency", "CHF"),
"taxCode": line.get("taxCode"),
"costCenter": line.get("costCenter"),
"description": line.get("description", ""),
**scope,
})
lineCount += 1
summary["journalEntries"] = len(rawEntries)
summary["journalLines"] = lineCount
_progress(60, f"Speichere {len(rawEntries)} Buchungssaetze...")
entriesCount, linesCount = await asyncio.to_thread(
self._persistJournal, rawEntries, scope, featureInstanceId,
TrusteeDataJournalEntry, TrusteeDataJournalLine,
)
summary["journalEntries"] = entriesCount
summary["journalLines"] = linesCount
_progress(65, f"{entriesCount} Saetze + {linesCount} Buchungszeilen gespeichert.")
except Exception as e:
logger.error(f"Import journal entries failed: {e}")
logger.error(f"Import journal entries failed: {e}", exc_info=True)
summary["errors"].append(f"Journal entries: {e}")
# 3) Contacts (customers + vendors)
# ---- Phase 3: Contacts (customers + vendors) ----
# Progress budget: 70-85 %. Two quick API calls + one bulk-insert.
try:
self._clearTable(TrusteeDataContact, featureInstanceId)
contactCount = 0
_progress(70, "Lade Kunden...")
customers = await connector.getCustomers(connConfig)
_dumpSyncData("customers", customers)
for c in customers:
self._if.db.recordCreate(TrusteeDataContact, self._mapContact(c, "customer", scope))
contactCount += 1
_progress(78, "Lade Lieferanten...")
vendors = await connector.getVendors(connConfig)
_dumpSyncData("vendors", vendors)
for v in vendors:
self._if.db.recordCreate(TrusteeDataContact, self._mapContact(v, "vendor", scope))
contactCount += 1
_progress(82, f"Speichere {len(customers) + len(vendors)} Kontakte...")
contactCount = await asyncio.to_thread(
self._persistContacts, customers, vendors, scope,
featureInstanceId, TrusteeDataContact,
)
summary["contacts"] = contactCount
_progress(85, f"{contactCount} Kontakte gespeichert.")
except Exception as e:
logger.error(f"Import contacts failed: {e}", exc_info=True)
summary["errors"].append(f"Contacts: {e}")
# 4) Compute account balances from journal lines
# ---- Phase 4: Compute account balances ----
# Progress budget: 90-95 %. Pure DB aggregation, no external calls.
try:
self._clearTable(TrusteeDataAccountBalance, featureInstanceId)
balanceCount = self._computeBalances(featureInstanceId, mandateId)
_progress(90, "Berechne Kontensaldi...")
balanceCount = await asyncio.to_thread(
self._persistBalances, featureInstanceId, mandateId,
TrusteeDataJournalEntry, TrusteeDataJournalLine, TrusteeDataAccountBalance,
)
summary["accountBalances"] = balanceCount
_progress(95, f"{balanceCount} Saldi berechnet.")
except Exception as e:
logger.error(f"Compute balances failed: {e}")
logger.error(f"Compute balances failed: {e}", exc_info=True)
summary["errors"].append(f"Balances: {e}")
cfgId = cfgRecord.get("id")
@ -255,6 +296,210 @@ class AccountingDataSync:
)
return summary
# ===== Sync persistence helpers (run inside asyncio.to_thread) =====
def _persistAccounts(self, charts: list, scope: Dict[str, Any],
featureInstanceId: str, modelAccount: Type) -> int:
"""Bulk-replace chart of accounts for one feature instance."""
t0 = time.time()
self._bulkClear(modelAccount, featureInstanceId)
rows = [{
"accountNumber": acc.accountNumber,
"label": acc.label,
"accountType": acc.accountType or "",
"currency": "CHF",
"isActive": True,
**scope,
} for acc in charts]
n = self._bulkCreate(modelAccount, rows)
logger.info(f"Persisted {n} accounts for {featureInstanceId} in {time.time() - t0:.1f}s")
return n
def _persistJournal(self, rawEntries: list, scope: Dict[str, Any],
featureInstanceId: str, modelEntry: Type, modelLine: Type) -> tuple:
"""Bulk-replace journal entries + journal lines for one feature instance.
We pre-build the line rows in memory keyed by the freshly minted entryId
so a single ``execute_values`` call can persist all of them.
"""
import uuid as _uuid
t0 = time.time()
self._bulkClear(modelEntry, featureInstanceId)
self._bulkClear(modelLine, featureInstanceId)
entryRows: List[Dict[str, Any]] = []
lineRows: List[Dict[str, Any]] = []
for raw in rawEntries:
entryId = str(_uuid.uuid4())
entryRows.append({
"id": entryId,
"externalId": raw.get("externalId"),
"bookingDate": raw.get("bookingDate"),
"reference": raw.get("reference"),
"description": raw.get("description", ""),
"currency": raw.get("currency", "CHF"),
"totalAmount": float(raw.get("totalAmount", 0)),
**scope,
})
for line in (raw.get("lines") or []):
lineRows.append({
"journalEntryId": entryId,
"accountNumber": line.get("accountNumber", ""),
"debitAmount": float(line.get("debitAmount", 0)),
"creditAmount": float(line.get("creditAmount", 0)),
"currency": line.get("currency", "CHF"),
"taxCode": line.get("taxCode"),
"costCenter": line.get("costCenter"),
"description": line.get("description", ""),
**scope,
})
if len(entryRows) % _HEARTBEAT_EVERY == 0:
logger.info(
f"Journal build progress: {len(entryRows)}/{len(rawEntries)} entries, "
f"{len(lineRows)} lines so far"
)
entriesCount = self._bulkCreate(modelEntry, entryRows)
linesCount = self._bulkCreate(modelLine, lineRows)
logger.info(
f"Persisted {entriesCount} entries + {linesCount} lines for "
f"{featureInstanceId} in {time.time() - t0:.1f}s"
)
return entriesCount, linesCount
def _persistContacts(self, customers: list, vendors: list, scope: Dict[str, Any],
featureInstanceId: str, modelContact: Type) -> int:
"""Bulk-replace contacts (customers + vendors) for one feature instance."""
t0 = time.time()
self._bulkClear(modelContact, featureInstanceId)
rows = [self._mapContact(c, "customer", scope) for c in customers]
rows += [self._mapContact(v, "vendor", scope) for v in vendors]
n = self._bulkCreate(modelContact, rows)
logger.info(f"Persisted {n} contacts for {featureInstanceId} in {time.time() - t0:.1f}s")
return n
def _persistBalances(self, featureInstanceId: str, mandateId: str,
modelEntry: Type, modelLine: Type, modelBalance: Type) -> int:
"""Re-aggregate journal lines into monthly + annual balances."""
t0 = time.time()
self._bulkClear(modelBalance, featureInstanceId)
entries = self._if.db.getRecordset(
modelEntry, recordFilter={"featureInstanceId": featureInstanceId},
) or []
entryDates: Dict[str, str] = {}
for e in entries:
eid = e.get("id") if isinstance(e, dict) else getattr(e, "id", None)
bdate = e.get("bookingDate") if isinstance(e, dict) else getattr(e, "bookingDate", None)
if eid and bdate:
entryDates[eid] = bdate
lines = self._if.db.getRecordset(
modelLine, recordFilter={"featureInstanceId": featureInstanceId},
) or []
buckets: Dict[tuple, Dict[str, float]] = defaultdict(lambda: {"debit": 0.0, "credit": 0.0})
for ln in lines:
if isinstance(ln, dict):
jeid = ln.get("journalEntryId", "")
accNo = ln.get("accountNumber", "")
debit = float(ln.get("debitAmount", 0))
credit = float(ln.get("creditAmount", 0))
else:
jeid = getattr(ln, "journalEntryId", "")
accNo = getattr(ln, "accountNumber", "")
debit = float(getattr(ln, "debitAmount", 0))
credit = float(getattr(ln, "creditAmount", 0))
bdate = entryDates.get(jeid, "")
if not accNo or not bdate:
continue
parts = bdate.split("-")
if len(parts) < 2:
continue
try:
year = int(parts[0])
month = int(parts[1])
except ValueError:
continue
buckets[(accNo, year, month)]["debit"] += debit
buckets[(accNo, year, month)]["credit"] += credit
buckets[(accNo, year, 0)]["debit"] += debit
buckets[(accNo, year, 0)]["credit"] += credit
scope = {"featureInstanceId": featureInstanceId, "mandateId": mandateId}
rows = [{
"accountNumber": accNo,
"periodYear": year,
"periodMonth": month,
"openingBalance": 0.0,
"debitTotal": round(totals["debit"], 2),
"creditTotal": round(totals["credit"], 2),
"closingBalance": round(totals["debit"] - totals["credit"], 2),
"currency": "CHF",
**scope,
} for (accNo, year, month), totals in buckets.items()]
n = self._bulkCreate(modelBalance, rows)
logger.info(
f"Persisted {n} balances for {featureInstanceId} in {time.time() - t0:.1f}s"
)
return n
# ===== Low-level bulk helpers =====
def _bulkClear(self, model: Type, featureInstanceId: str) -> int:
"""Delete every row for this feature instance in a single statement."""
try:
return self._if.db.recordDeleteWhere(
model, {"featureInstanceId": featureInstanceId}
)
except AttributeError:
# Backwards-compatible path if the connector hasn't been upgraded
# yet. Logs a warning so we notice in dev/CI.
logger.warning(
"DatabaseConnector.recordDeleteWhere missing — falling back to slow per-row delete for %s",
model.__name__,
)
records = self._if.db.getRecordset(
model, recordFilter={"featureInstanceId": featureInstanceId}
) or []
count = 0
for r in records:
rid = r.get("id") if isinstance(r, dict) else getattr(r, "id", None)
if rid:
try:
self._if.db.recordDelete(model, rid)
count += 1
except Exception:
pass
return count
def _bulkCreate(self, model: Type, rows: List[Dict[str, Any]]) -> int:
"""Insert all rows in a single transaction. Falls back to per-row
insert only if the connector lacks ``recordCreateBulk`` (legacy)."""
if not rows:
return 0
try:
return self._if.db.recordCreateBulk(model, rows)
except AttributeError:
logger.warning(
"DatabaseConnector.recordCreateBulk missing — falling back to slow per-row insert for %s",
model.__name__,
)
n = 0
for i, r in enumerate(rows, start=1):
try:
self._if.db.recordCreate(model, r)
n += 1
except Exception as ex:
logger.warning(f"Per-row insert failed at {i}/{len(rows)}: {ex}")
if i % _HEARTBEAT_EVERY == 0:
logger.info(f"Per-row insert progress: {i}/{len(rows)} rows ({model.__name__})")
return n
# ===== Field helpers =====
@staticmethod
def _safeStr(val: Any) -> str:
"""Convert a value to a safe string for DB storage, collapsing nested dicts/lists."""
@ -286,84 +531,3 @@ class AccountingDataSync:
"vatNumber": s(raw.get("vat_identifier") or raw.get("vatNumber") or ""),
**scope,
}
def _clearTable(self, model, featureInstanceId: str):
"""Delete all records for this feature instance from a TrusteeData* table."""
records = self._if.db.getRecordset(model, recordFilter={"featureInstanceId": featureInstanceId})
for r in (records or []):
rid = r.get("id") if isinstance(r, dict) else getattr(r, "id", None)
if rid:
try:
self._if.db.recordDelete(model, rid)
except Exception:
pass
def _computeBalances(self, featureInstanceId: str, mandateId: str) -> int:
"""Aggregate journal lines into monthly + annual account balances."""
from modules.features.trustee.datamodelFeatureTrustee import (
TrusteeDataJournalEntry,
TrusteeDataJournalLine,
TrusteeDataAccountBalance,
)
entries = self._if.db.getRecordset(
TrusteeDataJournalEntry,
recordFilter={"featureInstanceId": featureInstanceId},
) or []
entryDates = {}
for e in entries:
eid = e.get("id") if isinstance(e, dict) else getattr(e, "id", None)
bdate = e.get("bookingDate") if isinstance(e, dict) else getattr(e, "bookingDate", None)
if eid and bdate:
entryDates[eid] = bdate
lines = self._if.db.getRecordset(
TrusteeDataJournalLine,
recordFilter={"featureInstanceId": featureInstanceId},
) or []
# key: (accountNumber, year, month)
buckets: Dict[tuple, Dict[str, float]] = defaultdict(lambda: {"debit": 0.0, "credit": 0.0})
for ln in lines:
if isinstance(ln, dict):
jeid = ln.get("journalEntryId", "")
accNo = ln.get("accountNumber", "")
debit = float(ln.get("debitAmount", 0))
credit = float(ln.get("creditAmount", 0))
else:
jeid = getattr(ln, "journalEntryId", "")
accNo = getattr(ln, "accountNumber", "")
debit = float(getattr(ln, "debitAmount", 0))
credit = float(getattr(ln, "creditAmount", 0))
bdate = entryDates.get(jeid, "")
if not accNo or not bdate:
continue
parts = bdate.split("-")
if len(parts) < 2:
continue
year = int(parts[0])
month = int(parts[1])
buckets[(accNo, year, month)]["debit"] += debit
buckets[(accNo, year, month)]["credit"] += credit
buckets[(accNo, year, 0)]["debit"] += debit
buckets[(accNo, year, 0)]["credit"] += credit
count = 0
scope = {"featureInstanceId": featureInstanceId, "mandateId": mandateId}
for (accNo, year, month), totals in buckets.items():
closing = totals["debit"] - totals["credit"]
self._if.db.recordCreate(TrusteeDataAccountBalance, {
"accountNumber": accNo,
"periodYear": year,
"periodMonth": month,
"openingBalance": 0.0,
"debitTotal": round(totals["debit"], 2),
"creditTotal": round(totals["credit"], 2),
"closingBalance": round(closing, 2),
"currency": "CHF",
**scope,
})
count += 1
return count

View file

@ -1682,14 +1682,15 @@ async def _trusteeAccountingSyncJobHandler(job: Dict[str, Any], progressCb) -> D
progressCb(5, "Initialisiere Import...")
interface = getInterface(rootUser, mandateId=mandateId, featureInstanceId=instanceId)
sync = AccountingDataSync(interface)
progressCb(10, "Lese Daten vom Buchhaltungssystem...")
progressCb(10, "Verbinde mit Buchhaltungssystem...")
result = await sync.importData(
featureInstanceId=instanceId,
mandateId=mandateId,
dateFrom=payload.get("dateFrom"),
dateTo=payload.get("dateTo"),
progressCb=progressCb,
)
progressCb(100, "Import abgeschlossen")
progressCb(100, "Import abgeschlossen.")
return result
@ -1902,6 +1903,20 @@ def _validateInstanceAdmin(instanceId: str, context: RequestContext) -> str:
return mandateId
def _serializeRoleForApi(role) -> Dict[str, Any]:
"""Dump a Role and resolve the multilingual ``description`` to a plain string.
The Role.description field is a ``TextMultilingual`` (``{xx, de, en, ...}``).
The frontend expects a plain string, so we resolve via the request language
here (same pattern as ``getQuickActions``). Without this the React tree
crashes with "Objects are not valid as a React child".
"""
from modules.shared.i18nRegistry import resolveText
payload = role.model_dump()
payload["description"] = resolveText(payload.get("description"))
return payload
@router.get("/{instanceId}/instance-roles", response_model=PaginatedResponse)
@limiter.limit("30/minute")
def get_instance_roles(
@ -1921,7 +1936,7 @@ def get_instance_roles(
roles = rootInterface.getRolesByFeatureCode("trustee", featureInstanceId=instanceId)
return PaginatedResponse(
items=[r.model_dump() for r in roles],
items=[_serializeRoleForApi(r) for r in roles],
pagination=None
)
@ -1947,7 +1962,7 @@ def get_instance_role(
if str(role.featureInstanceId) != instanceId:
raise HTTPException(status_code=404, detail=f"Role {roleId} not found in this instance")
return role.model_dump()
return _serializeRoleForApi(role)
@router.get("/{instanceId}/instance-roles/{roleId}/rules", response_model=PaginatedResponse)

View file

@ -42,7 +42,11 @@ from modules.datamodels.datamodelNotification import NotificationType
from modules.interfaces.interfaceDbManagement import getInterface as getMgmtInterface
from modules.routes.routeNotifications import _createNotification
from modules.shared.configuration import APP_CONFIG
from modules.shared.i18nRegistry import _loadCache as _reloadI18nCache, apiRouteContext
from modules.shared.i18nRegistry import (
_enforceSourcePlaceholders,
_loadCache as _reloadI18nCache,
apiRouteContext,
)
from modules.shared.timeUtils import getUtcTimestamp
routeApiMsg = apiRouteContext("routeI18n")
@ -248,7 +252,12 @@ async def _translateBatch(
f"2. If the source is already in the target language, keep it (do not re-translate, "
f"do not paraphrase).\n"
f"3. KEEP the exact JSON keys from the input — do NOT translate or modify the keys.\n"
f"4. KEEP placeholders like {{variable}}, {{count}}, %s, %(name)s exactly as they are.\n"
f"4. PLACEHOLDERS ARE SACRED. Tokens of the form {{name}}, {{count}}, "
f"{{konten}}, {{anyWord}}, %s, %(name)s, %d MUST be copied character-for-"
f"character into the translation, EVEN IF the name inside the curly braces "
f"looks like a German or English word. Never translate, rename, reorder, "
f"add, or remove placeholders. Example: '{{konten}} Konten' translated to "
f"English MUST stay '{{konten}} accounts' — NEVER '{{accounts}} accounts'.\n"
f"5. Preserve leading/trailing whitespace, punctuation and capitalisation pattern.\n"
f"6. Answer ONLY with a JSON object mapping source-key -> translated value in "
f"{targetLanguageLabel}. No markdown fences, no comments, no explanations.\n"
@ -320,10 +329,31 @@ async def _translateBatch(
if batchIdx < totalBatches - 1:
await asyncio.sleep(_TRANSLATE_BATCH_PAUSE_S)
_enforcePlaceholdersOnBatch(result)
_matchCapitalization(keysToTranslate, result)
return result
def _enforcePlaceholdersOnBatch(translations: Dict[str, str]) -> None:
"""Ensure every translated value preserves the source key's placeholders.
See ``_enforceSourcePlaceholders`` for the detailed strategy. Mutates
``translations`` in place; logs a warning per repaired key.
"""
repaired = 0
for sourceKey, translatedValue in list(translations.items()):
fixed, changed = _enforceSourcePlaceholders(sourceKey, translatedValue)
if changed:
translations[sourceKey] = fixed
repaired += 1
logger.warning(
"i18n placeholder mismatch repaired: %r -> %r",
translatedValue, fixed,
)
if repaired:
logger.info("i18n batch: repaired placeholders in %d translations", repaired)
def _matchCapitalization(originals: Dict[str, str], translations: Dict[str, str]) -> None:
"""Ensure translations preserve the capitalisation pattern of the original key."""
for key, translated in translations.items():
@ -857,6 +887,87 @@ async def sync_xx_master(
return result
def _repairLanguageSetPlaceholders(db, code: str, userId: Optional[str]) -> dict:
"""Persistently fix placeholder mismatches in one language set.
Walks every entry, runs ``_enforceSourcePlaceholders(key, value)`` and
persists any changed values back to the row. Only saves if at least one
entry was modified.
"""
rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code})
if not rows:
raise HTTPException(status_code=404, detail=routeApiMsg("Sprachset nicht gefunden"))
row = dict(rows[0])
entries = _rowEntries(row)
repaired: List[Dict[str, str]] = []
for entry in entries:
key = entry.get("key", "")
val = entry.get("value", "")
fixed, changed = _enforceSourcePlaceholders(key, val)
if changed:
repaired.append({"key": key, "before": val, "after": fixed})
entry["value"] = fixed
if repaired:
row["entries"] = entries
if "keys" in row:
del row["keys"]
row["sysModifiedAt"] = getUtcTimestamp()
row["sysModifiedBy"] = userId
db.recordModify(UiLanguageSet, code, row)
return {
"code": code,
"checked": len(entries),
"repaired": len(repaired),
"examples": repaired[:10],
}
@router.post("/sets/{code}/repair-placeholders")
async def repair_language_set_placeholders(
code: str,
adminUser: User = Depends(requireSysAdmin),
):
"""SysAdmin: persistently restore placeholder tokens in one language set.
Use this once after the AI translator turned ``{konten}`` into
``{accounts}`` (or similar). Compares each entry's value against its
German source key; where the placeholder *names* differ but the *count*
matches, restores the source names positionally. Safe and idempotent.
"""
c = code.strip().lower()
if c == "xx":
raise HTTPException(status_code=400, detail=routeApiMsg("Das xx-Set hat keine Übersetzungen."))
db = getMgmtInterface(adminUser, mandateId=None).db
result = _repairLanguageSetPlaceholders(db, c, str(adminUser.id))
await _reloadI18nCache()
return result
@router.post("/sets/repair-placeholders-all")
async def repair_all_language_sets_placeholders(
adminUser: User = Depends(requireSysAdmin),
):
"""SysAdmin: persistently restore placeholder tokens in ALL language sets."""
db = getMgmtInterface(adminUser, mandateId=None).db
rows = db.getRecordset(UiLanguageSet)
summary: List[dict] = []
totalRepaired = 0
for row in rows:
code = row.get("id", "")
if not code or code == "xx":
continue
try:
res = _repairLanguageSetPlaceholders(db, code, str(adminUser.id))
summary.append(res)
totalRepaired += res["repaired"]
except HTTPException:
continue
await _reloadI18nCache()
return {"languages": len(summary), "totalRepaired": totalRepaired, "details": summary}
@router.get("/sets/{code}/sync-diff")
async def get_language_sync_diff(
code: str,

View file

@ -12,15 +12,59 @@ At runtime, t() returns the cached translation for the current request language.
from __future__ import annotations
import logging
import re
from contextvars import ContextVar
from dataclasses import dataclass, field as dataclass_field
from typing import Any, Dict, List, Optional, Type
from typing import Any, Dict, List, Optional, Tuple, Type
from pydantic import BaseModel
logger = logging.getLogger(__name__)
# Matches {placeholderName} tokens used by t(...) param substitution in the
# frontend (LanguageContext._applyParams) and the gateway. Allows ASCII
# identifiers and digits, no spaces.
_PLACEHOLDER_PATTERN = re.compile(r"\{[A-Za-z_][A-Za-z0-9_]*\}")
def _enforceSourcePlaceholders(sourceKey: str, translatedValue: str) -> Tuple[str, bool]:
"""Repair a translated value so its placeholder tokens match the source key.
Background: AI translators occasionally translate the *names* of
placeholders even when instructed not to (e.g. ``{konten}`` -> ``{accounts}``).
The frontend then cannot substitute params and the user sees raw
``{accounts}`` in the UI.
Strategy (positional, conservative):
- if the source has no placeholders -> nothing to do
- if source and translation have the same set of tokens -> nothing to do
- if both have the *same number* of tokens but different names -> swap
each translation token with the source token at the same position
- if counts differ -> leave the translation untouched (too risky to
guess; surfaced as a logger.warning by the caller if desired)
Returns ``(repairedValue, wasChanged)``.
"""
if not sourceKey or not translatedValue:
return translatedValue, False
sourceTokens = _PLACEHOLDER_PATTERN.findall(sourceKey)
if not sourceTokens:
return translatedValue, False
valueTokens = _PLACEHOLDER_PATTERN.findall(translatedValue)
if not valueTokens:
return translatedValue, False
if sourceTokens == valueTokens:
return translatedValue, False
if len(sourceTokens) != len(valueTokens):
return translatedValue, False
parts = _PLACEHOLDER_PATTERN.split(translatedValue)
rebuilt = parts[0]
for idx, srcTok in enumerate(sourceTokens):
rebuilt += srcTok + parts[idx + 1]
return rebuilt, True
def _extractRegistrySourceText(obj: Any) -> str:
"""Resolve a str or multilingual dict to one canonical registry key string."""
if isinstance(obj, str):
@ -492,6 +536,48 @@ def _registerNodeLabels():
logger.info("i18n node labels: %d new keys (node.*/port.* context)", added)
def _registerAccountingConnectorLabels():
"""Register all accounting connector configField labels (label) at boot time.
Connector ``getRequiredConfigFields()`` is normally invoked lazily at first
request, which is too late for the boot-sync. We discover the connectors
here so their ``t()`` calls register the keys before they are written to the
``xx`` set and AI-translated for every active language set.
"""
added = 0
try:
from modules.features.trustee.accounting.accountingRegistry import _getAccountingRegistry
except ImportError:
logger.debug("i18n accounting connectors: registry not importable")
return
try:
registry = _getAccountingRegistry()
except Exception as e:
logger.warning("i18n accounting connectors: registry init failed: %s", e)
return
for connectorType, connector in (registry._connectors or {}).items():
try:
for field in connector.getRequiredConfigFields():
key = getattr(field, "label", "") or ""
if not isinstance(key, str) or not key:
continue
if key not in _REGISTRY:
_REGISTRY[key] = _I18nRegistryEntry(
context=f"connector.accounting.{connectorType}",
value="",
)
added += 1
except Exception as e:
logger.warning(
"i18n accounting connector %s: failed to read fields: %s",
connectorType, e,
)
logger.info("i18n accounting connector labels: %d new keys", added)
def _registerDatamodelOptionLabels():
"""Register all frontend_options labels from Pydantic datamodels and subscription plans."""
added = 0
@ -568,6 +654,7 @@ async def _syncRegistryToDb():
_registerServiceCenterLabels()
_registerNodeLabels()
_registerDatamodelOptionLabels()
_registerAccountingConnectorLabels()
if not _REGISTRY:
logger.info("i18n registry: no keys to sync (empty registry)")
@ -668,6 +755,13 @@ async def _syncRegistryToDb():
async def _loadCache():
"""Boot hook: load all UiLanguageSets into the in-memory cache.
Also persistently repairs placeholder mismatches in the DB:
if an entry's value has placeholder *names* that differ from the
source key (typical AI translation mishap, e.g. ``{konten}`` ->
``{accounts}``), the source names are restored positionally and the
row is written back to the DB. Idempotent and safe -- only mutates
when the placeholder count matches and the names actually differ.
After this, t() lookups are O(1) dict access with no DB calls.
"""
from modules.datamodels.datamodelUiLanguage import UiLanguageSet
@ -686,6 +780,8 @@ async def _loadCache():
rows = db.getRecordset(UiLanguageSet)
_CACHE.clear()
repairedTotal = 0
persistedLanguages = 0
for row in rows:
code = row.get("id", "")
if code == "xx":
@ -694,13 +790,49 @@ async def _loadCache():
if not isinstance(entries, list):
continue
langDict: Dict[str, str] = {}
for e in entries:
key = e.get("key", "")
val = e.get("value", "")
if key and val:
langDict[key] = val
repairedInLang = 0
# Walk a mutable copy so we can write the corrected entries back to
# the row without re-reading from the DB.
for entry in entries:
key = entry.get("key", "")
val = entry.get("value", "")
if not key or not val:
continue
fixed, changed = _enforceSourcePlaceholders(key, val)
if changed:
entry["value"] = fixed
repairedInLang += 1
langDict[key] = fixed
if langDict:
_CACHE[code] = langDict
if repairedInLang:
repairedTotal += repairedInLang
try:
rowToSave = dict(row)
rowToSave["entries"] = entries
if "keys" in rowToSave:
del rowToSave["keys"]
db.recordModify(UiLanguageSet, code, rowToSave)
persistedLanguages += 1
logger.info(
"i18n boot repair: fixed and persisted %d placeholder mismatches in language '%s'",
repairedInLang, code,
)
except Exception as ex:
# Persistence is best-effort -- the in-memory cache is
# already correct (langDict above contains the fixed
# values), so the UI works either way. Log and move on.
logger.warning(
"i18n boot repair: in-memory fixed %d entries in '%s' but DB persist failed: %s",
repairedInLang, code, ex,
)
logger.info("i18n cache loaded: %d languages, %d total keys",
len(_CACHE), sum(len(v) for v in _CACHE.values()))
logger.info(
"i18n cache loaded: %d languages, %d total keys%s",
len(_CACHE), sum(len(v) for v in _CACHE.values()),
(
f" (boot-repaired {repairedTotal} placeholders, "
f"persisted to {persistedLanguages} language sets)"
if repairedTotal else ""
),
)