Added aggregate data for search page.
This commit is contained in:
parent
bac074599a
commit
6cc29589ba
|
|
@ -17,6 +17,7 @@ import account.api;
|
|||
import util.money;
|
||||
import util.pagination;
|
||||
import util.data;
|
||||
import transaction.search_filters;
|
||||
|
||||
// Transactions API
|
||||
|
||||
|
|
@ -32,12 +33,21 @@ void handleGetTransactions(ref ServerHttpRequest request, ref ServerHttpResponse
|
|||
|
||||
@GetMapping(PROFILE_PATH ~ "/transactions/search")
|
||||
void handleSearchTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
import transaction.search_filters : extractSearchParams;
|
||||
ProfileDataSource ds = getProfileDataSource(request);
|
||||
PageRequest pr = PageRequest.parse(request, DEFAULT_TRANSACTION_PAGE);
|
||||
auto page = ds.getTransactionRepository().search(pr, request);
|
||||
auto page = ds.getTransactionRepository().search(pr, extractSearchParams(request));
|
||||
writeJsonBody(response, page);
|
||||
}
|
||||
|
||||
@GetMapping(PROFILE_PATH ~ "/transactions/aggregate-data")
|
||||
void handleGetTransactionAggregateData(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
import transaction.search_filters : extractSearchParams;
|
||||
ProfileDataSource ds = getProfileDataSource(request);
|
||||
auto aggregateData = ds.getTransactionRepository().getAggregateData(extractSearchParams(request));
|
||||
writeJsonBody(response, aggregateData);
|
||||
}
|
||||
|
||||
@GetMapping(PROFILE_PATH ~ "/transactions/export")
|
||||
void handleExportTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
ProfileDataSource ds = getProfileDataSource(request);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
module transaction.data;
|
||||
|
||||
import handy_http_primitives : Optional, ServerHttpRequest;
|
||||
import handy_http_primitives : Optional, StringMultiValueMap;
|
||||
import std.datetime;
|
||||
|
||||
import transaction.model;
|
||||
|
|
@ -44,7 +44,8 @@ interface TransactionTagRepository {
|
|||
|
||||
interface TransactionRepository {
|
||||
Page!TransactionsListItem findAll(in PageRequest pr);
|
||||
Page!TransactionsListItem search(in PageRequest pr, in ServerHttpRequest request);
|
||||
Page!TransactionsListItem search(in PageRequest pr, in StringMultiValueMap searchParams);
|
||||
AggregateTransactionData getAggregateData(in StringMultiValueMap searchParams);
|
||||
Optional!TransactionDetail findById(ulong id);
|
||||
TransactionDetail insert(in AddTransactionPayload data);
|
||||
void linkAttachment(ulong transactionId, ulong attachmentId);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
module transaction.data_impl_sqlite;
|
||||
|
||||
import handy_http_primitives : Optional, ServerHttpRequest;
|
||||
import handy_http_primitives : Optional, StringMultiValueMap;
|
||||
import std.datetime;
|
||||
import std.typecons;
|
||||
import d2sqlite3;
|
||||
|
|
@ -293,7 +293,7 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
return Page!(TransactionsListItem).of(results, pr, totalCount);
|
||||
}
|
||||
|
||||
Page!TransactionsListItem search(in PageRequest pr, in ServerHttpRequest request) {
|
||||
Page!TransactionsListItem search(in PageRequest pr, in StringMultiValueMap searchParams) {
|
||||
import std.algorithm;
|
||||
import std.conv;
|
||||
import std.string : join;
|
||||
|
|
@ -303,7 +303,7 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
|
||||
// 1. Get the total count of transactions that match the search filters.
|
||||
qb.select("COUNT (DISTINCT txn.id)");
|
||||
applyFilters(qb, request, new SqliteTransactionCategoryRepository(db));
|
||||
applyFilters(qb, searchParams, new SqliteTransactionCategoryRepository(db));
|
||||
string countQuery = qb.build();
|
||||
Statement countStmt = db.prepare(countQuery);
|
||||
qb.applyArgBindings(countStmt);
|
||||
|
|
@ -335,6 +335,38 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
return Page!TransactionsListItem.of(results, pr, count);
|
||||
}
|
||||
|
||||
AggregateTransactionData getAggregateData(in StringMultiValueMap searchParams) {
|
||||
import transaction.search_filters;
|
||||
// Start by using the standard transactions-list query builder to apply filters.
|
||||
QueryBuilder qb = getBuilderForTransactionsList();
|
||||
qb.select("txn.currency AS currency, j_credit.amount AS credits, j_debit.amount AS debits");
|
||||
applyFilters(qb, searchParams, new SqliteTransactionCategoryRepository(db));
|
||||
string baseQuery = qb.build() ~ "\nGROUP BY txn.id";
|
||||
// Now wrap that in a separate query that aggregates credits & debits for each transaction.
|
||||
string aggregateQuery = QueryBuilder("(" ~ baseQuery ~ ") AS base")
|
||||
.select("base.currency")
|
||||
.select("SUM(base.credits)")
|
||||
.select("SUM(base.debits)")
|
||||
.build() ~ " GROUP BY base.currency";
|
||||
Statement stmt = db.prepare(aggregateQuery);
|
||||
// Use the base query's argument bindings to apply to the DB statement.
|
||||
qb.applyArgBindings(stmt);
|
||||
ResultRange result = stmt.execute();
|
||||
|
||||
// Collect the results for each currency into
|
||||
AggregateTransactionData aggregateData;
|
||||
while (!result.empty()) {
|
||||
AggregateTransactionData.CurrencyData currencyData;
|
||||
currencyData.credits = result.front.peek!ulong(1);
|
||||
currencyData.debits = result.front.peek!ulong(2);
|
||||
currencyData.balance = currencyData.debits - currencyData.credits;
|
||||
currencyData.currency = Currency.ofCode(result.front.peek!(string, PeekMode.slice)(0));
|
||||
aggregateData.currencies ~= currencyData;
|
||||
result.popFront();
|
||||
}
|
||||
return aggregateData;
|
||||
}
|
||||
|
||||
Optional!TransactionDetail findById(ulong id) {
|
||||
Optional!TransactionDetail item = util.sqlite.findOne(
|
||||
db,
|
||||
|
|
|
|||
|
|
@ -155,3 +155,18 @@ struct TransactionCategoryBalance {
|
|||
long balance;
|
||||
Currency currency;
|
||||
}
|
||||
|
||||
/**
|
||||
* A set of aggregate data computed from a set of transactions obtained in a
|
||||
* search. Data is grouped by currency.
|
||||
*/
|
||||
struct AggregateTransactionData {
|
||||
/// Aggregate data about all transactions with a common currency.
|
||||
static struct CurrencyData {
|
||||
ulong credits;
|
||||
ulong debits;
|
||||
long balance;
|
||||
Currency currency;
|
||||
}
|
||||
CurrencyData[] currencies;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,24 +20,24 @@ import transaction.data;
|
|||
* Applies a set of filters to a query builder for searching over transactions.
|
||||
* Params:
|
||||
* qb = The query builder to add WHERE clauses and argument bindings to.
|
||||
* request = The request to get filter options from.
|
||||
* searchParams = The set of search parameters provided by the user.
|
||||
* categoryRepo = Repository for fetching category data, which might be
|
||||
* needed if the user is filtering by a parent category.
|
||||
*/
|
||||
void applyFilters(
|
||||
ref QueryBuilder qb,
|
||||
in ServerHttpRequest request,
|
||||
in StringMultiValueMap searchParams,
|
||||
TransactionCategoryRepository categoryRepo
|
||||
) {
|
||||
applyPropertyInFilter!string(qb, request, "tags.tag", "tag");
|
||||
applyPropertyInFilter!ulong(qb, request, "vendor.id", "vendor");
|
||||
applyPropertyInFilter!string(qb, request, "txn.currency", "currency");
|
||||
applyPropertyInFilter!ulong(qb, request, "account_credit.id", "credited-account");
|
||||
applyPropertyInFilter!ulong(qb, request, "account_debit.id", "debited-account");
|
||||
applyPropertyInFilter!string(qb, searchParams, "tags.tag", "tag");
|
||||
applyPropertyInFilter!ulong(qb, searchParams, "vendor.id", "vendor");
|
||||
applyPropertyInFilter!string(qb, searchParams, "txn.currency", "currency");
|
||||
applyPropertyInFilter!ulong(qb, searchParams, "account_credit.id", "credited-account");
|
||||
applyPropertyInFilter!ulong(qb, searchParams, "account_debit.id", "debited-account");
|
||||
|
||||
// Separate filter that combines both credit and debit accounts.
|
||||
if (request.hasParam("account")) {
|
||||
ulong[] accountIds = request.getParamValues!ulong("account");
|
||||
if (searchParams.contains("account")) {
|
||||
ulong[] accountIds = searchParams.getAllValuesAs!ulong("account");
|
||||
string inStr = "(" ~ "?".repeat(accountIds.length).join(",") ~ ")";
|
||||
qb.where("(account_credit.id IN " ~ inStr ~ " OR account_debit.id IN " ~ inStr ~ ")");
|
||||
qb.withArgBinding((ref stmt, ref idx) {
|
||||
|
|
@ -53,8 +53,8 @@ void applyFilters(
|
|||
|
||||
// Separate filter that handles the hierarchical category relationship, so
|
||||
// if a parent category is filtered, all children are also included.
|
||||
if (request.hasParam("category")) {
|
||||
ulong[] categoryIds = request.getParamValues!ulong("category");
|
||||
if (searchParams.contains("category")) {
|
||||
ulong[] categoryIds = searchParams.getAllValuesAs!ulong("category");
|
||||
auto app = appender!(ulong[]);
|
||||
foreach (id; categoryIds) {
|
||||
app ~= getAllPossibleCategoryIds(id, categoryRepo);
|
||||
|
|
@ -70,8 +70,8 @@ void applyFilters(
|
|||
}
|
||||
}
|
||||
|
||||
if (request.hasParam("min-amount")) {
|
||||
ulong[] values = request.getParamValues!ulong("min-amount");
|
||||
if (searchParams.contains("min-amount")) {
|
||||
ulong[] values = searchParams.getAllValuesAs!ulong("min-amount");
|
||||
if (values.length > 0) {
|
||||
ulong minAmount = values[0];
|
||||
qb.where("txn.amount >= ?");
|
||||
|
|
@ -81,8 +81,8 @@ void applyFilters(
|
|||
}
|
||||
}
|
||||
|
||||
if (request.hasParam("max-amount")) {
|
||||
ulong[] values = request.getParamValues!ulong("max-amount");
|
||||
if (searchParams.contains("max-amount")) {
|
||||
ulong[] values = searchParams.getAllValuesAs!ulong("max-amount");
|
||||
if (values.length > 0) {
|
||||
ulong minAmount = values[0];
|
||||
qb.where("txn.amount <= ?");
|
||||
|
|
@ -93,10 +93,9 @@ void applyFilters(
|
|||
}
|
||||
|
||||
// Boolean filter for internal transfer.
|
||||
if (request.hasParam("internal-transfer")) {
|
||||
string value = request.getParamValues("internal-transfer")[0]
|
||||
.strip()
|
||||
.toUpper();
|
||||
if (searchParams.contains("internal-transfer")) {
|
||||
string value = searchParams.getFirst("internal-transfer").orElseThrow()
|
||||
.strip().toUpper();
|
||||
Optional!bool internalTransferFilter = Optional!bool.empty();
|
||||
if (value == "Y" || value == "YES" || value == "TRUE" || value == "1") {
|
||||
internalTransferFilter = Optional!bool.of(true);
|
||||
|
|
@ -112,8 +111,8 @@ void applyFilters(
|
|||
}
|
||||
|
||||
// Textual search query:
|
||||
if (request.hasParam("q")) {
|
||||
string searchQuery = request.getParamValues!string("q")[0];
|
||||
if (searchParams.contains("q")) {
|
||||
string searchQuery = searchParams.getFirst("q").orElseThrow();
|
||||
string likeStr = "%" ~ toUpper(strip(searchQuery)) ~ "%";
|
||||
const string[] conditions = [
|
||||
"UPPER(txn.description) LIKE ?",
|
||||
|
|
@ -132,14 +131,36 @@ void applyFilters(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to extract query parameters into a multi-value map to be
|
||||
* used more generally for other functions that require this format for search
|
||||
* parameters.
|
||||
* Params:
|
||||
* request = The HTTP request to extract parameters from.
|
||||
* Returns: A string-string multi-value map.
|
||||
*/
|
||||
StringMultiValueMap extractSearchParams(in ServerHttpRequest request) {
|
||||
auto searchParamsBuilder = StringMultiValueMap.Builder();
|
||||
foreach (queryParam; request.queryParams) {
|
||||
foreach (value; queryParam.values) {
|
||||
searchParamsBuilder.add(queryParam.key, value);
|
||||
}
|
||||
}
|
||||
return searchParamsBuilder.build();
|
||||
}
|
||||
|
||||
private T[] getAllValuesAs(T)(in StringMultiValueMap m, string key) {
|
||||
return m.getAll(key).map!(v => v.to!T).array;
|
||||
}
|
||||
|
||||
private void applyPropertyInFilter(T)(
|
||||
ref QueryBuilder qb,
|
||||
in ServerHttpRequest request,
|
||||
in StringMultiValueMap searchParams,
|
||||
string property,
|
||||
string key
|
||||
) {
|
||||
if (request.hasParam(key)) {
|
||||
T[] values = request.getParamValues!T(key);
|
||||
if (searchParams.contains(key)) {
|
||||
T[] values = searchParams.getAllValuesAs!T(key);
|
||||
qb.where(property ~ " IN (" ~ "?".repeat(values.length).join(",") ~ ")");
|
||||
qb.withArgBinding((ref stmt, ref idx) {
|
||||
foreach (value; values) {
|
||||
|
|
@ -150,22 +171,6 @@ private void applyPropertyInFilter(T)(
|
|||
}
|
||||
}
|
||||
|
||||
private bool hasParam(in ServerHttpRequest request, string key) {
|
||||
foreach (param; request.queryParams) {
|
||||
if (param.key == key && param.values.length > 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private T[] getParamValues(T = string)(in ServerHttpRequest request, string key) {
|
||||
foreach (param; request.queryParams) {
|
||||
if (param.key == key) {
|
||||
return param.values.map!(s => s.to!T).array;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private ulong[] getAllPossibleCategoryIds(ulong parentId, TransactionCategoryRepository categoryRepo) {
|
||||
auto app = appender!(ulong[]);
|
||||
app ~= parentId;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import transaction.api;
|
|||
import transaction.model;
|
||||
import transaction.data;
|
||||
import transaction.dto;
|
||||
import transaction.search_filters : extractSearchParams;
|
||||
import profile.data;
|
||||
import account.model;
|
||||
import account.data;
|
||||
|
|
@ -285,7 +286,7 @@ void exportTransactionsToFile(
|
|||
ref ServerHttpResponse response
|
||||
) {
|
||||
Page!TransactionsListItem data = ds.getTransactionRepository()
|
||||
.search(PageRequest.unpaged(), request);
|
||||
.search(PageRequest.unpaged(), extractSearchParams(request));
|
||||
ExportFileFormat fileFormat = getPreferredExportFileFormat(request);
|
||||
if (fileFormat == ExportFileFormat.JSON) {
|
||||
import handy_http_data : writeJsonBody;
|
||||
|
|
|
|||
|
|
@ -133,6 +133,17 @@ export interface CreateCategoryPayload {
|
|||
parentId: number | null
|
||||
}
|
||||
|
||||
export interface AggregateTransactionCurrencyData {
|
||||
credits: number
|
||||
debits: number
|
||||
balance: number
|
||||
currency: Currency
|
||||
}
|
||||
|
||||
export interface AggregateTransactionData {
|
||||
currencies: AggregateTransactionCurrencyData[]
|
||||
}
|
||||
|
||||
export class TransactionApiClient extends ApiClient {
|
||||
readonly path: string
|
||||
|
||||
|
|
@ -225,6 +236,10 @@ export class TransactionApiClient extends ApiClient {
|
|||
return super.getJson(this.path + '/transactions/search?' + params.toString())
|
||||
}
|
||||
|
||||
getAggregateTransactionData(params: URLSearchParams): Promise<AggregateTransactionData> {
|
||||
return super.getJson(this.path + '/transactions/aggregate-data?' + params.toString())
|
||||
}
|
||||
|
||||
exportTransactions(params: URLSearchParams): Promise<void> {
|
||||
return super.getFile(this.path + '/transactions/export?' + params.toString())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@
|
|||
margin-top: 0;
|
||||
}
|
||||
|
||||
.mb-1 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.mb-0 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import { type Account, AccountApiClient } from '@/api/account'
|
||||
import { formatMoney } from '@/api/data'
|
||||
import { defaultPage, type Page, type PageRequest, type SortDir } from '@/api/pagination'
|
||||
import { getSelectedProfile } from '@/api/profile'
|
||||
import {
|
||||
TransactionApiClient,
|
||||
type AggregateTransactionData,
|
||||
type TransactionCategory,
|
||||
type TransactionVendor,
|
||||
type TransactionsListItem,
|
||||
|
|
@ -36,6 +38,7 @@ const FETCH_DEBOUNCE_DELAY = 300
|
|||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const page: Ref<Page<TransactionsListItem>> = ref(defaultPage())
|
||||
const aggregateData: Ref<AggregateTransactionData | undefined> = ref()
|
||||
const lastFetchTime = ref(0)
|
||||
const fetchTimeoutId = ref<number | undefined>(undefined)
|
||||
|
||||
|
|
@ -134,6 +137,10 @@ async function fetchPage(pg: number, size: number) {
|
|||
const urlWithParams = params.size == 0 ? route.path : route.path + '?' + params.toString()
|
||||
const api = new TransactionApiClient(getSelectedProfile(route))
|
||||
try {
|
||||
// Update aggregate data (asynchronously from main fetching).
|
||||
aggregateData.value = undefined
|
||||
api.getAggregateTransactionData(params).then((data) => (aggregateData.value = data))
|
||||
// Fetch the main page of transactions.
|
||||
const startTime = performance.now()
|
||||
page.value = await api.searchTransactions(params, pageRequest)
|
||||
const endTime = performance.now()
|
||||
|
|
@ -348,11 +355,41 @@ function loadAllParamValues(key: string): string[] {
|
|||
@update="(pr) => fetchPage(pr.page, pr.size)"
|
||||
class="align-right"
|
||||
/>
|
||||
<AppBadge size="sm">
|
||||
{{ page.totalElements }} search
|
||||
{{ page.totalElements == 1 ? 'result' : 'results' }}
|
||||
in {{ lastFetchTime }} milliseconds
|
||||
</AppBadge>
|
||||
<div>
|
||||
<AppBadge size="sm">
|
||||
{{ page.totalElements }} search
|
||||
{{ page.totalElements == 1 ? 'result' : 'results' }}
|
||||
in {{ lastFetchTime }} milliseconds
|
||||
</AppBadge>
|
||||
</div>
|
||||
<div v-if="aggregateData">
|
||||
<div
|
||||
v-for="data in aggregateData.currencies"
|
||||
:key="data.currency.code"
|
||||
class="mt-1 mb-1"
|
||||
>
|
||||
<AppBadge size="sm">
|
||||
{{ data.currency.code }} Credits:
|
||||
<span class="font-mono text-muted">{{ formatMoney(data.credits, data.currency) }}</span>
|
||||
</AppBadge>
|
||||
<AppBadge size="sm">
|
||||
{{ data.currency.code }} Debits:
|
||||
<span class="font-mono text-muted">{{ formatMoney(data.debits, data.currency) }}</span>
|
||||
</AppBadge>
|
||||
<AppBadge size="sm">
|
||||
{{ data.currency.code }} Total:
|
||||
<span
|
||||
class="font-mono"
|
||||
:class="{
|
||||
'text-positive': data.balance > 0,
|
||||
'text-negative': data.balance < 0,
|
||||
}"
|
||||
>
|
||||
{{ formatMoney(data.balance, data.currency) }}
|
||||
</span>
|
||||
</AppBadge>
|
||||
</div>
|
||||
</div>
|
||||
<TransactionCard
|
||||
v-for="txn in page.items"
|
||||
:key="txn.id"
|
||||
|
|
|
|||
Loading…
Reference in New Issue