diff --git a/modules/interfaces/interfaceDbBilling.py b/modules/interfaces/interfaceDbBilling.py index 342c98c0..60261052 100644 --- a/modules/interfaces/interfaceDbBilling.py +++ b/modules/interfaces/interfaceDbBilling.py @@ -1540,16 +1540,40 @@ class BillingObjects: if not accountIds: return PaginatedResult(items=[], totalItems=0, totalPages=0) - recordFilter: Dict[str, Any] = {"accountId": accountIds} - if userId: - recordFilter["createdByUserId"] = userId + # Extract free-text search term and run a custom query that covers + # enriched columns (mandateName, userName) and the numeric amount + # column. The generic SQL search only covers TEXT columns of the + # BillingTransaction table, which excludes these fields. + searchTerm: Optional[str] = None + if mappedPagination and mappedPagination.filters: + raw = mappedPagination.filters.get("search") + if isinstance(raw, str) and raw.strip(): + searchTerm = raw.strip() - result = self.db.getRecordsetPaginated( - BillingTransaction, - pagination=mappedPagination, - recordFilter=recordFilter, - ) - pageItems = result.get("items", []) if isinstance(result, dict) else result.items + if searchTerm: + searchResult = self._searchTransactionsPaginated( + allAccounts=allAccounts, + accountIds=accountIds, + userId=userId, + searchTerm=searchTerm, + pagination=mappedPagination, + ) + pageItems = searchResult["items"] + totalItems = searchResult["totalItems"] + totalPages = searchResult["totalPages"] + else: + recordFilter: Dict[str, Any] = {"accountId": accountIds} + if userId: + recordFilter["createdByUserId"] = userId + + result = self.db.getRecordsetPaginated( + BillingTransaction, + pagination=mappedPagination, + recordFilter=recordFilter, + ) + pageItems = result.get("items", []) if isinstance(result, dict) else result.items + totalItems = result.get("totalItems", 0) if isinstance(result, dict) else result.totalItems + totalPages = result.get("totalPages", 0) if isinstance(result, dict) else result.totalPages accountMap = {a.get("id"): a for a in allAccounts} @@ -1592,15 +1616,186 @@ class BillingObjects: row["userName"] = userMap.get(txUserId, txUserId) if txUserId else None enriched.append(row) - totalItems = result.get("totalItems", 0) if isinstance(result, dict) else result.totalItems - totalPages = result.get("totalPages", 0) if isinstance(result, dict) else result.totalPages - return PaginatedResult(items=enriched, totalItems=totalItems, totalPages=totalPages) except Exception as e: logger.error(f"Error in getTransactionsForMandatesPaginated: {e}") return PaginatedResult(items=[], totalItems=0, totalPages=0) + def _searchTransactionsPaginated( + self, + allAccounts: List[Dict[str, Any]], + accountIds: List[str], + userId: Optional[str], + searchTerm: str, + pagination: PaginationParams, + ) -> Dict[str, Any]: + """ + Custom paginated search for BillingTransaction that also covers the + enriched columns `mandateName` and `userName` as well as the numeric + `amount` column. Resolves matching mandate/user IDs via the app DB + first, then builds a single SQL query with OR-combined conditions. + """ + import math + from modules.connectors.connectorDbPostgre import _get_model_fields, _parseRecordFields + from modules.datamodels.datamodelUam import UserInDB + from modules.interfaces.interfaceDbApp import getInterface as getAppInterface + + table = BillingTransaction.__name__ + fields = _get_model_fields(BillingTransaction) + pattern = f"%{searchTerm}%" + + # Resolve matching user / mandate IDs via the app DB (which is separate + # from the billing DB and hosts UserInDB / Mandate tables). + matchingUserIds: List[str] = [] + matchingMandateIds: List[str] = [] + try: + appInterface = getAppInterface(self.currentUser) + appInterface.db._ensure_connection() + with appInterface.db.connection.cursor() as cur: + if appInterface.db._ensureTableExists(UserInDB): + cur.execute( + 'SELECT "id" FROM "UserInDB" WHERE ' + 'COALESCE("username", \'\') ILIKE %s OR ' + 'COALESCE("fullName", \'\') ILIKE %s OR ' + 'COALESCE("email", \'\') ILIKE %s', + (pattern, pattern, pattern), + ) + matchingUserIds = [r["id"] for r in cur.fetchall() if r.get("id")] + + if appInterface.db._ensureTableExists(Mandate): + cur.execute( + 'SELECT "id" FROM "Mandate" WHERE ' + 'COALESCE("label", \'\') ILIKE %s OR ' + 'COALESCE("name", \'\') ILIKE %s', + (pattern, pattern), + ) + matchingMandateIds = [r["id"] for r in cur.fetchall() if r.get("id")] + except Exception as e: + logger.warning(f"_searchTransactionsPaginated: user/mandate resolution failed: {e}") + + matchingAccountIds = [ + a.get("id") for a in allAccounts + if a.get("id") and a.get("mandateId") in set(matchingMandateIds) + ] + + # Try to interpret the search term as a number for amount matching. + amountVal: Optional[float] = None + try: + amountVal = float(searchTerm.replace(",", ".")) + except Exception: + amountVal = None + + whereParts: List[str] = ['"accountId" = ANY(%s)'] + whereValues: List[Any] = [accountIds] + if userId: + whereParts.append('"createdByUserId" = %s') + whereValues.append(userId) + + # Apply non-search filters from pagination (reuse existing builder for + # everything except the `search` key which we handle explicitly). + import copy + paginationWithoutSearch = copy.deepcopy(pagination) if pagination else None + if paginationWithoutSearch and paginationWithoutSearch.filters: + paginationWithoutSearch.filters = { + k: v for k, v in paginationWithoutSearch.filters.items() if k != "search" + } + + orParts: List[str] = [] + orValues: List[Any] = [] + + textCols = [c for c, t in fields.items() if t == "TEXT"] + for col in textCols: + orParts.append(f'COALESCE("{col}"::TEXT, \'\') ILIKE %s') + orValues.append(pattern) + + if matchingUserIds: + orParts.append('"createdByUserId" = ANY(%s)') + orValues.append(matchingUserIds) + if matchingAccountIds: + orParts.append('"accountId" = ANY(%s)') + orValues.append(matchingAccountIds) + + orParts.append('"amount"::TEXT ILIKE %s') + orValues.append(pattern) + if amountVal is not None: + orParts.append('"amount" = %s') + orValues.append(amountVal) + + whereParts.append(f"({' OR '.join(orParts)})") + whereValues.extend(orValues) + + # Apply remaining structured filters via the generic helper by feeding + # it a dummy pagination that does NOT include LIMIT/OFFSET. We only + # need the WHERE contribution for the non-search filters here. + extraWhere = "" + extraValues: List[Any] = [] + if paginationWithoutSearch and paginationWithoutSearch.filters: + try: + fromPagination = copy.deepcopy(paginationWithoutSearch) + fromPagination.sort = [] + fromPagination.page = 1 + fromPagination.pageSize = 1 + ew, _, _, values, _ = self.db._buildPaginationClauses( + BillingTransaction, fromPagination, recordFilter=None + ) + if ew: + extraWhere = ew.replace(" WHERE ", " AND ", 1) + extraValues = list(values) + except Exception as e: + logger.warning(f"_searchTransactionsPaginated: extra-filter build failed: {e}") + + whereClause = " WHERE " + " AND ".join(whereParts) + extraWhere + whereValues.extend(extraValues) + + # Build ORDER BY from pagination.sort + validColumns = set(fields.keys()) + orderParts: List[str] = [] + if pagination and pagination.sort: + for sf in pagination.sort: + sfField = sf.get("field") if isinstance(sf, dict) else getattr(sf, "field", None) + sfDir = sf.get("direction", "asc") if isinstance(sf, dict) else getattr(sf, "direction", "asc") + if sfField and sfField in validColumns: + direction = "DESC" if str(sfDir).lower() == "desc" else "ASC" + colType = fields.get(sfField, "TEXT") + if colType == "BOOLEAN": + orderParts.append(f'COALESCE("{sfField}", FALSE) {direction}') + else: + orderParts.append(f'"{sfField}" {direction} NULLS LAST') + if not orderParts: + orderParts.append('"id"') + orderClause = " ORDER BY " + ", ".join(orderParts) + + pageSize = pagination.pageSize if pagination else 50 + page = pagination.page if pagination else 1 + offset = (page - 1) * pageSize + limitClause = f" LIMIT {pageSize} OFFSET {offset}" + + try: + self.db._ensure_connection() + with self.db.connection.cursor() as cur: + countSql = f'SELECT COUNT(*) FROM "{table}"{whereClause}' + cur.execute(countSql, whereValues) + totalItems = cur.fetchone()["count"] + + dataSql = f'SELECT * FROM "{table}"{whereClause}{orderClause}{limitClause}' + cur.execute(dataSql, whereValues) + records = [dict(row) for row in cur.fetchall()] + + for rec in records: + _parseRecordFields(rec, fields, f"search table {table}") + + totalPages = math.ceil(totalItems / pageSize) if totalItems > 0 else 0 + return {"items": records, "totalItems": totalItems, "totalPages": totalPages} + + except Exception as e: + logger.error(f"_searchTransactionsPaginated SQL error: {e}", exc_info=True) + try: + self.db.connection.rollback() + except Exception: + pass + return {"items": [], "totalItems": 0, "totalPages": 0} + def _buildScopeFilter( self, mandateIds: Optional[List[str]],